@prave/cli 1.4.13 → 1.4.15
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/dist/commands/sync.js +113 -62
- package/dist/lib/flush-telemetry.js +9 -2
- package/dist/lib/telemetry-buffer.js +19 -15
- package/package.json +2 -2
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
6
|
import { track } from '../lib/analytics.js';
|
|
7
|
+
import { api, ApiError } from '../lib/api.js';
|
|
7
8
|
import { CONFIG } from '../lib/config.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()
|
|
@@ -131,44 +131,95 @@ export async function syncCommand() {
|
|
|
131
131
|
return;
|
|
132
132
|
}
|
|
133
133
|
spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'}.`);
|
|
134
|
-
// Pre-flight time estimate — sets expectations before the user hits Y.
|
|
135
134
|
const estSeconds = estimateSeconds(slugs.length);
|
|
136
135
|
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;
|
|
136
|
+
const progress = ora(`Synced 0 / ${slugs.length}`).start();
|
|
143
137
|
let updated = 0;
|
|
144
|
-
let
|
|
145
|
-
|
|
138
|
+
let paywalled = 0;
|
|
139
|
+
let noContent = 0;
|
|
140
|
+
let missing = 0;
|
|
141
|
+
const missingSlugs = [];
|
|
142
|
+
const paywalledSlugs = [];
|
|
143
|
+
// Chunk + sequential bulk-fetch. Sequential between chunks is fine —
|
|
144
|
+
// one chunk covers ~80 skills in one round-trip, the wall-clock saving
|
|
145
|
+
// vs the old N-roundtrip loop is already 10–30x even without
|
|
146
|
+
// chunk-level parallelism. Going parallel adds complexity (and a
|
|
147
|
+
// bigger burst against the API) for negligible gain.
|
|
148
|
+
for (let i = 0; i < slugs.length; i += SYNC_CHUNK_SIZE) {
|
|
149
|
+
const chunk = slugs.slice(i, i + SYNC_CHUNK_SIZE);
|
|
150
|
+
let response;
|
|
146
151
|
try {
|
|
147
|
-
await
|
|
148
|
-
|
|
152
|
+
const { data } = await api.post('/api/v1/skills/bulk/sync', { slugs: chunk }, true);
|
|
153
|
+
response = data;
|
|
149
154
|
}
|
|
150
|
-
catch {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
catch (err) {
|
|
156
|
+
// Fatal-ish: if a whole chunk request fails, we still tried — log
|
|
157
|
+
// and skip the chunk so the user gets a partial sync rather than
|
|
158
|
+
// a 100% failure.
|
|
159
|
+
if (err instanceof ApiError) {
|
|
160
|
+
log.warn(`Chunk failed (${err.message}) — skipping ${chunk.length} Skill${chunk.length === 1 ? '' : 's'}.`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
log.warn(`Chunk failed — skipping ${chunk.length} Skill${chunk.length === 1 ? '' : 's'}.`);
|
|
164
|
+
}
|
|
165
|
+
missing += chunk.length;
|
|
166
|
+
missingSlugs.push(...chunk);
|
|
167
|
+
progress.text = `Synced ${updated} / ${slugs.length}`;
|
|
168
|
+
continue;
|
|
156
169
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
170
|
+
// Track per-slug verdicts for the final summary line.
|
|
171
|
+
missing += response.missing.length;
|
|
172
|
+
missingSlugs.push(...response.missing);
|
|
173
|
+
// Write files in parallel, bounded.
|
|
174
|
+
await runWithConcurrency(response.items, WRITE_CONCURRENCY, async (item) => {
|
|
175
|
+
if (item.error === 'paid_unpurchased') {
|
|
176
|
+
paywalled += 1;
|
|
177
|
+
paywalledSlugs.push(item.slug);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (item.error === 'no_content' || item.content === null) {
|
|
181
|
+
noContent += 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const targetDir = join(CONFIG.skillsDir, item.slug);
|
|
185
|
+
try {
|
|
186
|
+
await mkdir(targetDir, { recursive: true });
|
|
187
|
+
await writeFile(join(targetDir, 'SKILL.md'), item.content, 'utf8');
|
|
188
|
+
updated += 1;
|
|
189
|
+
progress.text = `Synced ${updated} / ${slugs.length} — ${item.slug}`;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Disk-level failure — surface in the summary, don't crash.
|
|
193
|
+
noContent += 1;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// Final status line. Distinguishes the four outcomes so the user can
|
|
198
|
+
// act on each (re-buy paywalled, re-import missing, file an issue on
|
|
199
|
+
// no-content).
|
|
200
|
+
const total = slugs.length;
|
|
201
|
+
if (updated === total) {
|
|
202
|
+
progress.succeed(`Synced ${updated} / ${total}`);
|
|
160
203
|
}
|
|
161
204
|
else {
|
|
162
|
-
progress.warn(`Synced ${updated}
|
|
205
|
+
progress.warn(`Synced ${updated} / ${total}` +
|
|
206
|
+
(paywalled ? ` · paywalled ${paywalled}` : '') +
|
|
207
|
+
(noContent ? ` · no content ${noContent}` : '') +
|
|
208
|
+
(missing ? ` · missing ${missing}` : ''));
|
|
209
|
+
}
|
|
210
|
+
if (paywalledSlugs.length) {
|
|
211
|
+
log.dim(` paywalled: ${paywalledSlugs.slice(0, 5).join(', ')}${paywalledSlugs.length > 5 ? `, +${paywalledSlugs.length - 5} more` : ''} — buy on prave.app to sync.`);
|
|
212
|
+
}
|
|
213
|
+
if (missingSlugs.length) {
|
|
214
|
+
log.dim(` not found: ${missingSlugs.slice(0, 5).join(', ')}${missingSlugs.length > 5 ? `, +${missingSlugs.length - 5} more` : ''}`);
|
|
163
215
|
}
|
|
164
216
|
// Persist last-sync timestamp regardless of partial failures — the user
|
|
165
217
|
// *did* attempt a sync, and we want the cooldown to apply to retries
|
|
166
218
|
// just as much as to the happy path.
|
|
167
219
|
await writeState({ last_sync_at: new Date().toISOString() }).catch(() => { });
|
|
168
220
|
// 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.
|
|
221
|
+
// without the user having to remember an extra command. Failures are
|
|
222
|
+
// non-fatal — sync's primary job is done.
|
|
172
223
|
try {
|
|
173
224
|
const { usageScanCommand } = await import('./usage.js');
|
|
174
225
|
await usageScanCommand({ quiet: true });
|
|
@@ -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.4.
|
|
3
|
+
"version": "1.4.15",
|
|
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.15"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^20.12.7",
|