@vibe-cafe/vibe-usage 0.8.4 → 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 +14 -12
- package/package.json +1 -1
- package/src/api.js +58 -0
- package/src/index.js +13 -5
- package/src/init.js +80 -12
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/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
|
-
|
|
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
|
|
30
|
-
npx @vibe-cafe/vibe-usage init
|
|
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
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
|
-
|
|
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
|
|
169
|
-
npx @vibe-cafe/vibe-usage init
|
|
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
|
-
|
|
62
|
-
|
|
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:
|
|
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
|
+
}
|