@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 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`. Pass `--yes` to skip the prompt in CI scripts.
66
+ The `last_sync_at` watermark persists at `~/.prave/state.json`.
67
67
 
68
68
  ## Commands
69
69
 
@@ -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 { CONFIG } from '../lib/config.js';
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
- * Empirical per-skill timing `installCommand` does ~1 registry GET +
16
- * ~1 file write + ~1 best-effort POST analyze, which clocks in around
17
- * 0.4s on a warm connection. With concurrency=4 we approximate effective
18
- * ~0.4s per skill / 4 ≈ 0.1s, but include a safety multiplier so the
19
- * estimate doesn't oversell. End result: estimate ~ 0.15s/skill, rounded
20
- * to nearest 5s with a 5s floor.
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 * SECONDS_PER_SKILL;
34
+ const raw = count * 0.02;
26
35
  const rounded = Math.round(raw / 5) * 5;
27
- return Math.max(5, rounded);
36
+ return Math.max(2, rounded);
28
37
  }
29
38
  /**
30
- * Inline concurrency limiter runs `fn(item)` for each item, never letting
31
- * more than `limit` promises be in-flight at once. Returns when all settle.
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
- results[i] = await fn(items[i], i);
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. Picks up SKILL.md edits without the user having to remember
54
- * each slug.
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
- * Behaviours layered on top of the basic loop:
57
- * Last-sync timestamp persisted to ~/.prave/state.json used to nudge
58
- * against accidental rapid re-syncs (<30 min) and to skip the prompt
59
- * entirely on the first ever run.
60
- * Concurrency-limited parallel pulls (4 in flight) — ~3-4x faster than
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
- let entries = [];
116
- try {
117
- entries = await readdir(CONFIG.skillsDir);
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
- catch {
120
- spinner.warn(`No Skills directory at ${CONFIG.skillsDir}`);
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
- // Parallel install with live progress counter. The legacy post-sync
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 failed = 0;
145
- await runWithConcurrency(slugs, SYNC_CONCURRENCY, async (slug) => {
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 installCommand(slug, { noDeps: true, skipDeployPrompt: true });
148
- updated++;
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
- finally {
154
- done++;
155
- progress.text = `Installed ${done} / ${slugs.length} ${slug}`;
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
- if (failed === 0) {
159
- progress.succeed(`Synced ${updated} / ${slugs.length}`);
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} · failed ${failed}`);
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. Quiet mode means
170
- // we only log a one-liner ("Usage: 12 new, 88 known.") instead of taking
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 (body) => {
30
- await api.post('/api/v1/intelligence/usage/by-slug', body, true);
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 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.
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
- export async function flushBuffered(postBySlug) {
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
- const remaining = [];
93
- for (let i = 0; i < events.length; i++) {
94
- const ev = events[i];
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 postBySlug({ ...ev, source: 'buffered' });
99
- sent += 1;
102
+ await postBatch(chunk);
103
+ sent += chunk.length;
104
+ remaining = events.slice(i + chunk.length);
100
105
  }
101
106
  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));
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.14",
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.14"
57
+ "@prave/shared": "1.4.16"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",