@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 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
- - βœ‰οΈ [info@epplab-studio.de](mailto:info@epplab-studio.de) β€” direct contact
175
+ - βœ‰οΈ [hello@epplab-studio.de](mailto:hello@epplab-studio.de) β€” direct contact
176
176
 
177
177
  ## License
178
178
 
@@ -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.
@@ -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: new Date().toISOString(),
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, 120)}`);
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.1",
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": "info@epplab-studio.de"
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 <info@epplab-studio.de> (https://epplab-studio.de)",
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.1"
54
+ "@prave/shared": "1.2.2"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^20.12.7",