@vibe-cafe/vibe-usage 0.8.3 → 0.9.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/README.md CHANGED
@@ -4,30 +4,32 @@ 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](https://vibecafe.ai/usage), 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
32
34
  npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
33
35
  npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
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) {
@@ -164,10 +173,9 @@ export async function run(rawArgs) {
164
173
  vibe-usage - Vibe Usage Tracker by VibeCafé
165
174
 
166
175
  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
176
+ npx @vibe-cafe/vibe-usage Init (first run, browser login) or sync
177
+ npx @vibe-cafe/vibe-usage init Set up via browser login (default)
178
+ npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
171
179
  npx @vibe-cafe/vibe-usage sync Manually sync usage data
172
180
  npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
173
181
  npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
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`)}`);
62
- console.log(dim(' 浏览器会自动打开,登录后复制 Key 粘贴到下方。'));
63
- console.log();
64
- openBrowser(`${apiUrl}/usage`);
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
+ }