@prave/cli 1.4.14 → 1.5.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 +1 -1
- package/dist/commands/sync.js +159 -76
- package/dist/lib/agent-paths.js +56 -0
- package/dist/lib/flush-telemetry.js +9 -2
- package/dist/lib/telemetry-buffer.js +19 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ Syncing 12 Skills — this takes about 15 seconds.
|
|
|
63
63
|
Synced 12 · failed 0
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
The `last_sync_at` watermark persists at `~/.prave/state.json`.
|
|
66
|
+
The `last_sync_at` watermark persists at `~/.prave/state.json`.
|
|
67
67
|
|
|
68
68
|
## Commands
|
|
69
69
|
|
package/dist/commands/sync.js
CHANGED
|
@@ -1,74 +1,76 @@
|
|
|
1
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline/promises';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import ora from 'ora';
|
|
6
|
+
import { resolveAgentTargets } from '../lib/agent-paths.js';
|
|
6
7
|
import { track } from '../lib/analytics.js';
|
|
7
|
-
import {
|
|
8
|
+
import { api, ApiError } from '../lib/api.js';
|
|
8
9
|
import { requireAuth } from '../lib/credentials.js';
|
|
9
10
|
import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
|
|
10
11
|
import { readState, writeState } from '../lib/state.js';
|
|
11
12
|
import { formatRelative, msSince } from '../lib/time.js';
|
|
12
13
|
import { log } from '../utils/logger.js';
|
|
13
|
-
import { installCommand } from './install.js';
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
* Bulk-fetch chunk size. The API caps `slugs` at 250 per request
|
|
16
|
+
* (see bulkSyncInputSchema). We chunk well below that so one slow
|
|
17
|
+
* skill in a chunk doesn't stall everything, and so total response
|
|
18
|
+
* size stays predictable on slow connections.
|
|
19
|
+
*/
|
|
20
|
+
const SYNC_CHUNK_SIZE = 80;
|
|
21
|
+
/**
|
|
22
|
+
* Per-skill local-write concurrency. Disk-only, no network — but
|
|
23
|
+
* spawning 200 concurrent fs.writeFile calls makes the OS unhappy on
|
|
24
|
+
* older Macs. Eight is a sweet spot that's basically instant for any
|
|
25
|
+
* library size we've seen.
|
|
26
|
+
*/
|
|
27
|
+
const WRITE_CONCURRENCY = 8;
|
|
28
|
+
/**
|
|
29
|
+
* Empirical sync time per skill is now ~0.02s (single bulk fetch
|
|
30
|
+
* amortized + parallel local writes). Multiplied by skill count and
|
|
31
|
+
* rounded to nearest 5s with a 2s floor.
|
|
21
32
|
*/
|
|
22
|
-
const SECONDS_PER_SKILL = 0.15;
|
|
23
|
-
const SYNC_CONCURRENCY = 4;
|
|
24
33
|
function estimateSeconds(count) {
|
|
25
|
-
const raw = count *
|
|
34
|
+
const raw = count * 0.02;
|
|
26
35
|
const rounded = Math.round(raw / 5) * 5;
|
|
27
|
-
return Math.max(
|
|
36
|
+
return Math.max(2, rounded);
|
|
28
37
|
}
|
|
29
38
|
/**
|
|
30
|
-
* Inline concurrency limiter
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* We deliberately avoid `Promise.all` over the full list to keep the agent
|
|
34
|
-
* gentle on the API and on the user's disk; ~3-4x throughput vs sequential
|
|
35
|
-
* is the goal, not "fire 200 requests at once".
|
|
39
|
+
* Inline concurrency limiter for the local disk writes. Avoids the
|
|
40
|
+
* `Promise.all` pattern over hundreds of file writes.
|
|
36
41
|
*/
|
|
37
42
|
async function runWithConcurrency(items, limit, fn) {
|
|
38
|
-
const results = new Array(items.length);
|
|
39
43
|
let cursor = 0;
|
|
40
44
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
41
45
|
while (true) {
|
|
42
46
|
const i = cursor++;
|
|
43
47
|
if (i >= items.length)
|
|
44
48
|
return;
|
|
45
|
-
|
|
49
|
+
await fn(items[i]);
|
|
46
50
|
}
|
|
47
51
|
});
|
|
48
52
|
await Promise.all(workers);
|
|
49
|
-
return results;
|
|
50
53
|
}
|
|
51
54
|
/**
|
|
52
55
|
* `prave sync` — re-pulls every locally installed Skill from the
|
|
53
|
-
* registry.
|
|
54
|
-
*
|
|
56
|
+
* registry.
|
|
57
|
+
*
|
|
58
|
+
* v2 implementation (Jun 2026) — earlier per-skill loop ran 3 separate
|
|
59
|
+
* authenticated requests per Skill (GET content, POST install, POST
|
|
60
|
+
* analyze), which for an 85-Skill library produced ~255 requests and
|
|
61
|
+
* burned through the 240/min/user default rate limit mid-way. The
|
|
62
|
+
* rewrite below makes ONE bulk request per chunk of up to 80 Skills.
|
|
63
|
+
* For a typical library that's 1 chunk = 1 request, and even the
|
|
64
|
+
* largest power-user library we've seen (200 Skills) needs only 3.
|
|
55
65
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* the previous sequential loop.
|
|
62
|
-
* • Per-skill progress: spinner text ticks "Installed N / M — slug" so
|
|
63
|
-
* the user can see actual liveness on slow connections.
|
|
64
|
-
* • The deploy-to-all-agents question is asked ONCE up-front for the
|
|
65
|
-
* entire queue and threaded into every `installCommand` invocation
|
|
66
|
-
* via `skipDeployPrompt: true`. A single batched deploy runs at the
|
|
67
|
-
* end.
|
|
66
|
+
* The per-skill `intelligence/analyze` POST is intentionally dropped
|
|
67
|
+
* from sync — the dashboard surfaces a "re-index your library" action
|
|
68
|
+
* the user can hit once, in bulk, when they want fresh intelligence.
|
|
69
|
+
* Running an LLM per Skill on every sync was wasteful regardless of
|
|
70
|
+
* the rate limit.
|
|
68
71
|
*/
|
|
69
72
|
export async function syncCommand() {
|
|
70
73
|
track('cli_sync');
|
|
71
|
-
// Auth gate — sync mutates the install ledger and intelligence cache.
|
|
72
74
|
const session = await requireAuth('prave sync');
|
|
73
75
|
if (!session)
|
|
74
76
|
return;
|
|
@@ -88,8 +90,6 @@ export async function syncCommand() {
|
|
|
88
90
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
89
91
|
let proceed = true;
|
|
90
92
|
try {
|
|
91
|
-
// Stronger nudge when the user just synced (<30 min). Default flips
|
|
92
|
-
// to "no" — they almost certainly hit the wrong command.
|
|
93
93
|
if (since !== null && since < 30 * 60 * 1000) {
|
|
94
94
|
const ans = (await rl.question(`Last sync: ${pretty}. Sync again? [y/N] `))
|
|
95
95
|
.trim()
|
|
@@ -111,64 +111,147 @@ export async function syncCommand() {
|
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
+
// Resolve every on-disk target the user has enabled in web settings.
|
|
115
|
+
// Pre-2026-06-05 sync was hard-pinned to ~/.claude/skills/ — users
|
|
116
|
+
// with Codex / Cline / Amp enabled saw nothing land in those dirs.
|
|
117
|
+
// Now we read the server-side agent settings and write to every
|
|
118
|
+
// enabled (non-conversion) path. Cursor is excluded for now because
|
|
119
|
+
// it needs format conversion.
|
|
120
|
+
const targets = await resolveAgentTargets();
|
|
121
|
+
const targetSummary = targets.map((t) => t.agent).join(', ');
|
|
114
122
|
const spinner = ora('Scanning local Skills…').start();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
// Union of slugs across all enabled targets — a skill that only
|
|
124
|
+
// exists in ~/.claude/skills is still "installed", and one we
|
|
125
|
+
// pulled into ~/.codex/skills the last sync should re-sync too.
|
|
126
|
+
const slugSet = new Set();
|
|
127
|
+
let scannedAny = false;
|
|
128
|
+
for (const target of targets) {
|
|
129
|
+
let entries = [];
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(target.dir);
|
|
132
|
+
scannedAny = true;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Missing dir → user enabled the agent in settings but never
|
|
136
|
+
// installed a skill there. Sync will create it on first write.
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const name of entries) {
|
|
140
|
+
const dir = join(target.dir, name);
|
|
141
|
+
if ((await stat(dir).catch(() => null))?.isDirectory())
|
|
142
|
+
slugSet.add(name);
|
|
143
|
+
}
|
|
118
144
|
}
|
|
119
|
-
|
|
120
|
-
spinner.warn(`No Skills directory
|
|
145
|
+
if (!scannedAny && slugSet.size === 0) {
|
|
146
|
+
spinner.warn(`No Skills directory found across ${targetSummary || 'claude'}.`);
|
|
121
147
|
return;
|
|
122
148
|
}
|
|
123
|
-
const slugs =
|
|
124
|
-
for (const name of entries) {
|
|
125
|
-
const dir = join(CONFIG.skillsDir, name);
|
|
126
|
-
if ((await stat(dir).catch(() => null))?.isDirectory())
|
|
127
|
-
slugs.push(name);
|
|
128
|
-
}
|
|
149
|
+
const slugs = Array.from(slugSet);
|
|
129
150
|
if (!slugs.length) {
|
|
130
151
|
spinner.warn('No installed Skills to sync.');
|
|
131
152
|
return;
|
|
132
153
|
}
|
|
133
|
-
spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'}.`);
|
|
134
|
-
// Pre-flight time estimate — sets expectations before the user hits Y.
|
|
154
|
+
spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'} across ${targetSummary || 'claude'}.`);
|
|
135
155
|
const estSeconds = estimateSeconds(slugs.length);
|
|
136
|
-
console.log(chalk.dim(`Syncing ${slugs.length} Skill${slugs.length === 1 ? '' : 's'} — this takes about ${estSeconds} seconds.`));
|
|
137
|
-
|
|
138
|
-
// "deploy to all agents?" prompt is gone — `prave deploy` has been
|
|
139
|
-
// retired and `installCommand` already fans out to the user's
|
|
140
|
-
// configured agent targets directly.
|
|
141
|
-
const progress = ora(`Installed 0 / ${slugs.length}`).start();
|
|
142
|
-
let done = 0;
|
|
156
|
+
console.log(chalk.dim(`Syncing ${slugs.length} Skill${slugs.length === 1 ? '' : 's'} → ${targets.length} agent dir${targets.length === 1 ? '' : 's'} — this takes about ${estSeconds} seconds.`));
|
|
157
|
+
const progress = ora(`Synced 0 / ${slugs.length}`).start();
|
|
143
158
|
let updated = 0;
|
|
144
|
-
let
|
|
145
|
-
|
|
159
|
+
let paywalled = 0;
|
|
160
|
+
let noContent = 0;
|
|
161
|
+
let missing = 0;
|
|
162
|
+
const missingSlugs = [];
|
|
163
|
+
const paywalledSlugs = [];
|
|
164
|
+
// Chunk + sequential bulk-fetch. Sequential between chunks is fine —
|
|
165
|
+
// one chunk covers ~80 skills in one round-trip, the wall-clock saving
|
|
166
|
+
// vs the old N-roundtrip loop is already 10–30x even without
|
|
167
|
+
// chunk-level parallelism. Going parallel adds complexity (and a
|
|
168
|
+
// bigger burst against the API) for negligible gain.
|
|
169
|
+
for (let i = 0; i < slugs.length; i += SYNC_CHUNK_SIZE) {
|
|
170
|
+
const chunk = slugs.slice(i, i + SYNC_CHUNK_SIZE);
|
|
171
|
+
let response;
|
|
146
172
|
try {
|
|
147
|
-
await
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
failed++;
|
|
173
|
+
const { data } = await api.post('/api/v1/skills/bulk/sync', { slugs: chunk }, true);
|
|
174
|
+
response = data;
|
|
152
175
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
176
|
+
catch (err) {
|
|
177
|
+
// Fatal-ish: if a whole chunk request fails, we still tried — log
|
|
178
|
+
// and skip the chunk so the user gets a partial sync rather than
|
|
179
|
+
// a 100% failure.
|
|
180
|
+
if (err instanceof ApiError) {
|
|
181
|
+
log.warn(`Chunk failed (${err.message}) — skipping ${chunk.length} Skill${chunk.length === 1 ? '' : 's'}.`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
log.warn(`Chunk failed — skipping ${chunk.length} Skill${chunk.length === 1 ? '' : 's'}.`);
|
|
185
|
+
}
|
|
186
|
+
missing += chunk.length;
|
|
187
|
+
missingSlugs.push(...chunk);
|
|
188
|
+
progress.text = `Synced ${updated} / ${slugs.length}`;
|
|
189
|
+
continue;
|
|
156
190
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
191
|
+
// Track per-slug verdicts for the final summary line.
|
|
192
|
+
missing += response.missing.length;
|
|
193
|
+
missingSlugs.push(...response.missing);
|
|
194
|
+
// Write files in parallel, bounded. Each successful item gets
|
|
195
|
+
// written to EVERY target dir (claude + codex + …) — failures on
|
|
196
|
+
// a single target don't abort the others; we just count a skill
|
|
197
|
+
// as updated when at least one write succeeded.
|
|
198
|
+
await runWithConcurrency(response.items, WRITE_CONCURRENCY, async (item) => {
|
|
199
|
+
if (item.error === 'paid_unpurchased') {
|
|
200
|
+
paywalled += 1;
|
|
201
|
+
paywalledSlugs.push(item.slug);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (item.error === 'no_content' || item.content === null) {
|
|
205
|
+
noContent += 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let anyWritten = false;
|
|
209
|
+
for (const target of targets) {
|
|
210
|
+
const dir = join(target.dir, item.slug);
|
|
211
|
+
try {
|
|
212
|
+
await mkdir(dir, { recursive: true });
|
|
213
|
+
await writeFile(join(dir, 'SKILL.md'), item.content, 'utf8');
|
|
214
|
+
anyWritten = true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* per-target write failure — continue with other targets */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (anyWritten) {
|
|
221
|
+
updated += 1;
|
|
222
|
+
progress.text = `Synced ${updated} / ${slugs.length} — ${item.slug}`;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
noContent += 1;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Final status line. Distinguishes the four outcomes so the user can
|
|
230
|
+
// act on each (re-buy paywalled, re-import missing, file an issue on
|
|
231
|
+
// no-content).
|
|
232
|
+
const total = slugs.length;
|
|
233
|
+
if (updated === total) {
|
|
234
|
+
progress.succeed(`Synced ${updated} / ${total}`);
|
|
160
235
|
}
|
|
161
236
|
else {
|
|
162
|
-
progress.warn(`Synced ${updated}
|
|
237
|
+
progress.warn(`Synced ${updated} / ${total}` +
|
|
238
|
+
(paywalled ? ` · paywalled ${paywalled}` : '') +
|
|
239
|
+
(noContent ? ` · no content ${noContent}` : '') +
|
|
240
|
+
(missing ? ` · missing ${missing}` : ''));
|
|
241
|
+
}
|
|
242
|
+
if (paywalledSlugs.length) {
|
|
243
|
+
log.dim(` paywalled: ${paywalledSlugs.slice(0, 5).join(', ')}${paywalledSlugs.length > 5 ? `, +${paywalledSlugs.length - 5} more` : ''} — buy on prave.app to sync.`);
|
|
244
|
+
}
|
|
245
|
+
if (missingSlugs.length) {
|
|
246
|
+
log.dim(` not found: ${missingSlugs.slice(0, 5).join(', ')}${missingSlugs.length > 5 ? `, +${missingSlugs.length - 5} more` : ''}`);
|
|
163
247
|
}
|
|
164
248
|
// Persist last-sync timestamp regardless of partial failures — the user
|
|
165
249
|
// *did* attempt a sync, and we want the cooldown to apply to retries
|
|
166
250
|
// just as much as to the happy path.
|
|
167
251
|
await writeState({ last_sync_at: new Date().toISOString() }).catch(() => { });
|
|
168
252
|
// Tail end of sync: fire a quiet usage scan so the optimiser stays warm
|
|
169
|
-
// without the user having to remember an extra command.
|
|
170
|
-
//
|
|
171
|
-
// over the spinner. Failures are non-fatal — sync's primary job is done.
|
|
253
|
+
// without the user having to remember an extra command. Failures are
|
|
254
|
+
// non-fatal — sync's primary job is done.
|
|
172
255
|
try {
|
|
173
256
|
const { usageScanCommand } = await import('./usage.js');
|
|
174
257
|
await usageScanCommand({ quiet: true });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { AGENT_REGISTRY } from '@prave/shared';
|
|
3
|
+
import { api, ApiError } from './api.js';
|
|
4
|
+
import { CONFIG } from './config.js';
|
|
5
|
+
const isWindows = process.platform === 'win32';
|
|
6
|
+
function expandTilde(p) {
|
|
7
|
+
if (!p)
|
|
8
|
+
return p;
|
|
9
|
+
if (p === '~')
|
|
10
|
+
return homedir();
|
|
11
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
12
|
+
return homedir() + p.slice(1);
|
|
13
|
+
}
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
function pickOsPath(paths) {
|
|
17
|
+
return isWindows ? paths.windows : paths.mac;
|
|
18
|
+
}
|
|
19
|
+
export async function resolveAgentTargets() {
|
|
20
|
+
try {
|
|
21
|
+
const { data: settings } = await api.get('/api/v1/settings/agents', true);
|
|
22
|
+
const enabled = settings.enabled_agents ?? [];
|
|
23
|
+
if (enabled.length === 0) {
|
|
24
|
+
return [{ agent: 'claude', dir: CONFIG.skillsDir }];
|
|
25
|
+
}
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const targets = [];
|
|
28
|
+
for (const agent of enabled) {
|
|
29
|
+
const meta = AGENT_REGISTRY[agent];
|
|
30
|
+
if (!meta)
|
|
31
|
+
continue;
|
|
32
|
+
if (meta.conversionRequired)
|
|
33
|
+
continue;
|
|
34
|
+
const stored = settings.skill_paths?.[agent];
|
|
35
|
+
const raw = stored
|
|
36
|
+
? pickOsPath(stored)
|
|
37
|
+
: pickOsPath(meta.defaultPath);
|
|
38
|
+
const dir = expandTilde(raw).replace(/[\\/]+$/, '');
|
|
39
|
+
if (!dir || seen.has(dir))
|
|
40
|
+
continue;
|
|
41
|
+
seen.add(dir);
|
|
42
|
+
targets.push({ agent, dir });
|
|
43
|
+
}
|
|
44
|
+
if (targets.length === 0) {
|
|
45
|
+
return [{ agent: 'claude', dir: CONFIG.skillsDir }];
|
|
46
|
+
}
|
|
47
|
+
return targets;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// Fail-safe path: legacy single-claude behaviour. A 401 here just
|
|
51
|
+
// means the user isn't authed and the caller will surface that
|
|
52
|
+
// through requireAuth() anyway.
|
|
53
|
+
void (err instanceof ApiError);
|
|
54
|
+
return [{ agent: 'claude', dir: CONFIG.skillsDir }];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -26,8 +26,15 @@ export async function flushBufferedTelemetry(opts = {}) {
|
|
|
26
26
|
if (pending === 0)
|
|
27
27
|
return; // even with force, no spinner for 0
|
|
28
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 (
|
|
30
|
-
|
|
29
|
+
const result = await flushBuffered(async (chunk) => {
|
|
30
|
+
// Map to the API shape — server tolerates a missing source.
|
|
31
|
+
const events = chunk.map((e) => ({
|
|
32
|
+
slug: e.slug,
|
|
33
|
+
agent_type: e.agent_type,
|
|
34
|
+
triggered_at: e.triggered_at,
|
|
35
|
+
meta: e.meta,
|
|
36
|
+
}));
|
|
37
|
+
await api.post('/api/v1/intelligence/usage/by-slug/batch', { events }, true);
|
|
31
38
|
});
|
|
32
39
|
if (result.failed === 0) {
|
|
33
40
|
log.success(`Synced ${chalk.bold(result.sent)} telemetry event${result.sent === 1 ? '' : 's'}. Dashboard is up to date.`);
|
|
@@ -76,33 +76,37 @@ async function readQueue() {
|
|
|
76
76
|
return out;
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
|
-
* Replay the queue
|
|
80
|
-
*
|
|
81
|
-
* we don't introduce a circular dep on api.ts.
|
|
79
|
+
* Replay the queue in chunked batch calls and delete the file on full
|
|
80
|
+
* success. Caller passes the already-authenticated batch POST helper
|
|
81
|
+
* so we don't introduce a circular dep on api.ts.
|
|
82
|
+
*
|
|
83
|
+
* Pre-2026-06-05 this loop fired ONE POST per event. With ~300 events
|
|
84
|
+
* buffered after a week offline that meant 300 sequential calls that
|
|
85
|
+
* blew through the per-user 240/min limit mid-replay. The chunked
|
|
86
|
+
* batch path collapses N events into Math.ceil(N / 250) requests.
|
|
82
87
|
*
|
|
83
88
|
* On partial failure (network blip mid-flush), the remaining events
|
|
84
89
|
* are rewritten so a later attempt picks up where we left off. Order
|
|
85
90
|
* is preserved across rewrites.
|
|
86
91
|
*/
|
|
87
|
-
|
|
92
|
+
const FLUSH_CHUNK_SIZE = 250; // matches the server's recordSkillUsageBySlugBatchSchema cap (500), with headroom
|
|
93
|
+
export async function flushBuffered(postBatch) {
|
|
88
94
|
const events = await readQueue();
|
|
89
95
|
if (!events.length)
|
|
90
96
|
return { sent: 0, failed: 0, total: 0 };
|
|
91
97
|
let sent = 0;
|
|
92
|
-
|
|
93
|
-
for (let i = 0; i < events.length; i
|
|
94
|
-
const
|
|
95
|
-
if (!ev)
|
|
96
|
-
continue;
|
|
98
|
+
let remaining = events.slice();
|
|
99
|
+
for (let i = 0; i < events.length; i += FLUSH_CHUNK_SIZE) {
|
|
100
|
+
const chunk = events.slice(i, i + FLUSH_CHUNK_SIZE);
|
|
97
101
|
try {
|
|
98
|
-
await
|
|
99
|
-
sent +=
|
|
102
|
+
await postBatch(chunk);
|
|
103
|
+
sent += chunk.length;
|
|
104
|
+
remaining = events.slice(i + chunk.length);
|
|
100
105
|
}
|
|
101
106
|
catch {
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
remaining.push(...events.slice(i));
|
|
107
|
+
// Stop on first failed chunk — keep the failed chunk and
|
|
108
|
+
// everything after it for the next attempt.
|
|
109
|
+
remaining = events.slice(i);
|
|
106
110
|
break;
|
|
107
111
|
}
|
|
108
112
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prave/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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": [
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"ora": "^8.0.1",
|
|
55
55
|
"tar": "^7.4.3",
|
|
56
56
|
"undici": "^6.18.0",
|
|
57
|
-
"@prave/shared": "1.4.
|
|
57
|
+
"@prave/shared": "1.4.16"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^20.12.7",
|