@prave/cli 1.2.1 β 1.2.2
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 +1 -1
- package/dist/commands/login.js +11 -0
- package/dist/commands/usage.js +31 -10
- package/dist/lib/flush-telemetry.js +42 -0
- package/dist/lib/telemetry-buffer.js +131 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ API health and uptime: [status.prave.app](https://status.prave.app) β auto-ref
|
|
|
172
172
|
- π [**CLI Cheat Sheet**](https://prave.app/docs/cli/cheat-sheet) β every command on one page
|
|
173
173
|
- π [**status.prave.app**](https://status.prave.app) β real-time health
|
|
174
174
|
- π [**GitHub Issues**](https://github.com/eppstudio/prave/issues) β bug reports & feature requests
|
|
175
|
-
- βοΈ [
|
|
175
|
+
- βοΈ [hello@epplab-studio.de](mailto:hello@epplab-studio.de) β direct contact
|
|
176
176
|
|
|
177
177
|
## License
|
|
178
178
|
|
package/dist/commands/login.js
CHANGED
|
@@ -39,6 +39,17 @@ export async function loginCommand() {
|
|
|
39
39
|
expires_at: data.expires_at ?? undefined,
|
|
40
40
|
});
|
|
41
41
|
spinner.succeed('Logged in.');
|
|
42
|
+
// Replay any Skill-invocation events the hook buffered while the
|
|
43
|
+
// user was offline / signed out. Silent when nothing's queued;
|
|
44
|
+
// prints "Syncing N eventsβ¦" + a confirmation when the file has
|
|
45
|
+
// real backlog. Failures swallowed inside.
|
|
46
|
+
try {
|
|
47
|
+
const { flushBufferedTelemetry } = await import('../lib/flush-telemetry.js');
|
|
48
|
+
await flushBufferedTelemetry();
|
|
49
|
+
}
|
|
50
|
+
catch (flushErr) {
|
|
51
|
+
log.dim(`Telemetry sync skipped: ${flushErr.message}`);
|
|
52
|
+
}
|
|
42
53
|
// Onboarding: prefill from the SaaS profile, let the user toggle
|
|
43
54
|
// with space/enter, persist back, and offer to install hooks.
|
|
44
55
|
// Failures here are non-fatal β login succeeded.
|
package/dist/commands/usage.js
CHANGED
|
@@ -7,6 +7,7 @@ import { api, ApiError } from '../lib/api.js';
|
|
|
7
7
|
import { CONFIG } from '../lib/config.js';
|
|
8
8
|
import { requireAuth } from '../lib/credentials.js';
|
|
9
9
|
import { HOOK_SUPPORTED, installHooksForAgents, uninstallHooksForAgents, } from '../lib/hook.js';
|
|
10
|
+
import { bufferEvent } from '../lib/telemetry-buffer.js';
|
|
10
11
|
import { AGENT_REGISTRY } from '@prave/shared';
|
|
11
12
|
import { loadCursor, saveCursor } from '../lib/usage-cursor.js';
|
|
12
13
|
import { scanTranscriptsForUsage } from '../lib/usage-scanner.js';
|
|
@@ -159,12 +160,6 @@ export async function usageReportCommand(opts = {}) {
|
|
|
159
160
|
}
|
|
160
161
|
if (debug)
|
|
161
162
|
await debugLog(`slug=${slug}`);
|
|
162
|
-
const session = await requireAuthSilent();
|
|
163
|
-
if (!session) {
|
|
164
|
-
if (debug)
|
|
165
|
-
await debugLog('no auth β skipping');
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
163
|
// Build the telemetry blob from whatever Claude Code shipped in the
|
|
169
164
|
// payload. Everything is best-effort β missing fields just don't
|
|
170
165
|
// appear in `meta`. The server schema (usageMetaSchema) validates
|
|
@@ -192,11 +187,30 @@ export async function usageReportCommand(opts = {}) {
|
|
|
192
187
|
if (typeof obj.prompt === 'string')
|
|
193
188
|
meta.prompt_chars = obj.prompt.length;
|
|
194
189
|
}
|
|
190
|
+
const triggered_at = new Date().toISOString();
|
|
191
|
+
const session = await requireAuthSilent();
|
|
192
|
+
// No credentials? Don't drop the event β buffer it to disk. The next
|
|
193
|
+
// `prave login` (or any authenticated command that calls
|
|
194
|
+
// `flushBufferedTelemetry`) replays the file against the same
|
|
195
|
+
// by-slug endpoint, so no Skill invocation is ever lost just because
|
|
196
|
+
// the user happened to be signed out at hook-fire time.
|
|
197
|
+
if (!session) {
|
|
198
|
+
await bufferEvent({
|
|
199
|
+
slug,
|
|
200
|
+
agent_type: 'claude',
|
|
201
|
+
triggered_at,
|
|
202
|
+
meta: { ...meta, source },
|
|
203
|
+
});
|
|
204
|
+
await hookLog(`${source}:buffered slug=${slug}`);
|
|
205
|
+
if (debug)
|
|
206
|
+
await debugLog(`no auth β buffered to telemetry-queue`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
195
209
|
try {
|
|
196
210
|
const { data } = await api.post('/api/v1/intelligence/usage/by-slug', {
|
|
197
211
|
slug,
|
|
198
212
|
agent_type: 'claude',
|
|
199
|
-
triggered_at
|
|
213
|
+
triggered_at,
|
|
200
214
|
meta: { ...meta, source },
|
|
201
215
|
}, true);
|
|
202
216
|
await hookLog(`${source}:ok slug=${slug} recorded=${data.recorded} stub=${data.created_stub}`);
|
|
@@ -205,11 +219,18 @@ export async function usageReportCommand(opts = {}) {
|
|
|
205
219
|
}
|
|
206
220
|
}
|
|
207
221
|
catch (err) {
|
|
222
|
+
// Network down or API error while authenticated β also buffer so
|
|
223
|
+
// the event isn't lost. The next successful command will replay.
|
|
224
|
+
await bufferEvent({
|
|
225
|
+
slug,
|
|
226
|
+
agent_type: 'claude',
|
|
227
|
+
triggered_at,
|
|
228
|
+
meta: { ...meta, source },
|
|
229
|
+
});
|
|
208
230
|
const msg = err.message;
|
|
209
|
-
await hookLog(`${source}:err slug=${slug} ${msg.slice(0,
|
|
231
|
+
await hookLog(`${source}:buffered-on-err slug=${slug} ${msg.slice(0, 80)}`);
|
|
210
232
|
if (debug)
|
|
211
|
-
await debugLog(`error: ${msg}`);
|
|
212
|
-
/* silent β never break the host shell */
|
|
233
|
+
await debugLog(`api error, buffered: ${msg}`);
|
|
213
234
|
}
|
|
214
235
|
}
|
|
215
236
|
/**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { api } from './api.js';
|
|
3
|
+
import { log } from '../utils/logger.js';
|
|
4
|
+
import { flushBuffered, pendingTelemetryCount, } from './telemetry-buffer.js';
|
|
5
|
+
/**
|
|
6
|
+
* Replay any offline-buffered Skill-invocation events. Called by
|
|
7
|
+
* `prave login` (right after credentials land on disk) and also by
|
|
8
|
+
* the API client on every successful authenticated request, so the
|
|
9
|
+
* queue drains opportunistically even without a fresh login.
|
|
10
|
+
*
|
|
11
|
+
* Behavior:
|
|
12
|
+
* - silent when the queue is empty (most invocations)
|
|
13
|
+
* - prints "Syncing N telemetry eventsβ¦" + a success or partial
|
|
14
|
+
* line when there's a non-zero count
|
|
15
|
+
* - never throws β telemetry failures must not break the CLI
|
|
16
|
+
*
|
|
17
|
+
* `force` skips the early "empty?" check, useful right after login
|
|
18
|
+
* where we know we just succeeded and want the user to see whatever
|
|
19
|
+
* sync number lands (even if zero β feels reassuring).
|
|
20
|
+
*/
|
|
21
|
+
export async function flushBufferedTelemetry(opts = {}) {
|
|
22
|
+
try {
|
|
23
|
+
const pending = await pendingTelemetryCount();
|
|
24
|
+
if (pending === 0 && !opts.force)
|
|
25
|
+
return;
|
|
26
|
+
if (pending === 0)
|
|
27
|
+
return; // even with force, no spinner for 0
|
|
28
|
+
log.info(`${chalk.cyan('β')} You have telemetry updates from offline sessions. Syncing ${chalk.bold(pending)} event${pending === 1 ? '' : 's'} to your dashboardβ¦`);
|
|
29
|
+
const result = await flushBuffered(async (body) => {
|
|
30
|
+
await api.post('/api/v1/intelligence/usage/by-slug', body, true);
|
|
31
|
+
});
|
|
32
|
+
if (result.failed === 0) {
|
|
33
|
+
log.success(`Synced ${chalk.bold(result.sent)} telemetry event${result.sent === 1 ? '' : 's'}. Dashboard is up to date.`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
log.warn(`Synced ${result.sent} of ${result.total} β ${result.failed} will retry on the next run.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* swallow β telemetry sync is best-effort */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CONFIG } from './config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Offline telemetry buffer.
|
|
6
|
+
*
|
|
7
|
+
* When the PostToolUse hook fires while the user is logged out (no
|
|
8
|
+
* credentials in ~/.prave/credentials.json), the Skill invocation would
|
|
9
|
+
* otherwise be lost β the hook can't reach the API. Instead we append
|
|
10
|
+
* the event as one JSON line to `~/.prave/telemetry-queue.jsonl` and
|
|
11
|
+
* replay the whole file the next time the user logs in (or runs any
|
|
12
|
+
* command that hits the API while authenticated).
|
|
13
|
+
*
|
|
14
|
+
* Each line is the exact payload the live hook would have POSTed to
|
|
15
|
+
* `/api/v1/intelligence/usage/by-slug`, so replay is "send each line
|
|
16
|
+
* through the same endpoint". Server-side dedup (per-minute bucket
|
|
17
|
+
* keyed by `skill_metadata_id + agent_type`) makes the flush idempotent
|
|
18
|
+
* β a flush that crashes mid-way can re-run with no double-counting.
|
|
19
|
+
*
|
|
20
|
+
* The queue file is hard-capped at 5000 events (~600 KB of JSONL).
|
|
21
|
+
* Beyond that we drop the oldest line per fire β telemetry is best-
|
|
22
|
+
* effort, never something that should grow unbounded on disk.
|
|
23
|
+
*/
|
|
24
|
+
export const TELEMETRY_QUEUE_FILE = join(CONFIG.praveDir, 'telemetry-queue.jsonl');
|
|
25
|
+
const MAX_QUEUE_LINES = 5_000;
|
|
26
|
+
const QUEUE_ROTATE_AT_BYTES = 1_000_000; // 1 MB safety stop
|
|
27
|
+
/**
|
|
28
|
+
* Append one event to the queue. Best-effort: any FS failure is
|
|
29
|
+
* swallowed because the hook MUST NOT throw β failing telemetry can
|
|
30
|
+
* never break the user's editor.
|
|
31
|
+
*/
|
|
32
|
+
export async function bufferEvent(event) {
|
|
33
|
+
try {
|
|
34
|
+
await mkdir(CONFIG.praveDir, { recursive: true });
|
|
35
|
+
// Rotate before append if the file got too big. We keep the
|
|
36
|
+
// newest half β recent telemetry is more valuable than ancient
|
|
37
|
+
// backlog the user probably doesn't care about anymore.
|
|
38
|
+
const info = await stat(TELEMETRY_QUEUE_FILE).catch(() => null);
|
|
39
|
+
if (info && info.size > QUEUE_ROTATE_AT_BYTES) {
|
|
40
|
+
const raw = await readFile(TELEMETRY_QUEUE_FILE, 'utf8').catch(() => '');
|
|
41
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
42
|
+
if (lines.length > MAX_QUEUE_LINES) {
|
|
43
|
+
const trimmed = lines.slice(-Math.floor(MAX_QUEUE_LINES / 2));
|
|
44
|
+
await writeFile(TELEMETRY_QUEUE_FILE, trimmed.join('\n') + '\n', 'utf8');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await appendFile(TELEMETRY_QUEUE_FILE, JSON.stringify(event) + '\n', 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* swallow β telemetry is best-effort */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read the queue. Returns parsed events plus the raw line count so
|
|
55
|
+
* callers can report "N events synced". Skips malformed lines silently
|
|
56
|
+
* β a single corrupt write must not kill an entire replay.
|
|
57
|
+
*/
|
|
58
|
+
async function readQueue() {
|
|
59
|
+
const raw = await readFile(TELEMETRY_QUEUE_FILE, 'utf8').catch(() => null);
|
|
60
|
+
if (!raw)
|
|
61
|
+
return [];
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const line of raw.split('\n')) {
|
|
64
|
+
const t = line.trim();
|
|
65
|
+
if (!t)
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(t);
|
|
69
|
+
if (parsed.slug && parsed.triggered_at)
|
|
70
|
+
out.push(parsed);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* skip malformed */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Replay the queue against the by-slug endpoint and delete the file on
|
|
80
|
+
* full success. Caller passes the already-authenticated POST helper so
|
|
81
|
+
* we don't introduce a circular dep on api.ts.
|
|
82
|
+
*
|
|
83
|
+
* On partial failure (network blip mid-flush), the remaining events
|
|
84
|
+
* are rewritten so a later attempt picks up where we left off. Order
|
|
85
|
+
* is preserved across rewrites.
|
|
86
|
+
*/
|
|
87
|
+
export async function flushBuffered(postBySlug) {
|
|
88
|
+
const events = await readQueue();
|
|
89
|
+
if (!events.length)
|
|
90
|
+
return { sent: 0, failed: 0, total: 0 };
|
|
91
|
+
let sent = 0;
|
|
92
|
+
const remaining = [];
|
|
93
|
+
for (let i = 0; i < events.length; i++) {
|
|
94
|
+
const ev = events[i];
|
|
95
|
+
if (!ev)
|
|
96
|
+
continue;
|
|
97
|
+
try {
|
|
98
|
+
await postBySlug({ ...ev, source: 'buffered' });
|
|
99
|
+
sent += 1;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Keep this one and every event after it for the next attempt.
|
|
103
|
+
// We don't push-and-continue because a network blip likely means
|
|
104
|
+
// every subsequent POST will fail too β better to stop fast.
|
|
105
|
+
remaining.push(...events.slice(i));
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (remaining.length === 0) {
|
|
110
|
+
await unlink(TELEMETRY_QUEUE_FILE).catch(() => { });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
await writeFile(TELEMETRY_QUEUE_FILE, remaining.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8').catch(() => { });
|
|
114
|
+
}
|
|
115
|
+
return { sent, failed: remaining.length, total: events.length };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Cheap "is there anything to flush?" check β used by login/whoami to
|
|
119
|
+
* decide whether to print the "syncing pending telemetry" line at all.
|
|
120
|
+
* Returns 0 when the file doesn't exist OR is empty.
|
|
121
|
+
*/
|
|
122
|
+
export async function pendingTelemetryCount() {
|
|
123
|
+
const raw = await readFile(TELEMETRY_QUEUE_FILE, 'utf8').catch(() => null);
|
|
124
|
+
if (!raw)
|
|
125
|
+
return 0;
|
|
126
|
+
let n = 0;
|
|
127
|
+
for (const line of raw.split('\n'))
|
|
128
|
+
if (line.trim())
|
|
129
|
+
n += 1;
|
|
130
|
+
return n;
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prave/cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Prave CLI β discover, install, version, test, and ship Claude Skills. The developer platform for the complete Skill lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"homepage": "https://prave.app",
|
|
27
27
|
"bugs": {
|
|
28
28
|
"url": "https://github.com/eppstudio/prave/issues",
|
|
29
|
-
"email": "
|
|
29
|
+
"email": "hello@epplab-studio.de"
|
|
30
30
|
},
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
33
|
"url": "git+https://github.com/eppstudio/prave.git",
|
|
34
34
|
"directory": "apps/cli"
|
|
35
35
|
},
|
|
36
|
-
"author": "EppLab Studio <
|
|
36
|
+
"author": "EppLab Studio <hello@epplab-studio.de> (https://epplab-studio.de)",
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"engines": {
|
|
39
39
|
"node": ">=18"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"open": "^10.1.0",
|
|
52
52
|
"ora": "^8.0.1",
|
|
53
53
|
"undici": "^6.18.0",
|
|
54
|
-
"@prave/shared": "1.2.
|
|
54
|
+
"@prave/shared": "1.2.2"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^20.12.7",
|