@prave/cli 1.4.1 → 1.4.3

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.
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Multi-agent fan-out helper.
3
+ *
4
+ * This module used to back a public `prave deploy <skill>` command, but
5
+ * shipping that as a top-level CLI surface only duplicated what
6
+ * `prave install` already prompts for ("Deploy to all configured agents?
7
+ * [y/N]"). The standalone command was removed; `deployCommand` lives on
8
+ * as an internal helper that `install.ts` and `sync.ts` import to do
9
+ * the actual fan-out + Cursor `.mdc` rewrite.
10
+ */
1
11
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
12
  import { homedir } from 'node:os';
3
13
  import { join } from 'node:path';
@@ -118,8 +118,16 @@ async function handleSearch(args) {
118
118
  if (args.category)
119
119
  params.set('category', args.category);
120
120
  params.set('limit', String(Math.min(20, Math.max(1, args.limit ?? 10))));
121
+ // The API's GET /skills returns a flat array as `data` (the
122
+ // controller calls `res.json(ok(skills))` with skills being
123
+ // `SkillSearchHit[]`). An earlier version of this MCP handler
124
+ // wrapped the response in `{items}` which is the shape the wizard
125
+ // uses, but never the public list endpoint — that mismatch silently
126
+ // turned every search into "No Skills matched" because `data.items`
127
+ // was always undefined. Normalise both shapes to stay forward-
128
+ // compatible with future API shifts.
121
129
  const { data } = await api.get(`/api/v1/skills?${params.toString()}`, true);
122
- const items = data.items ?? [];
130
+ const items = Array.isArray(data) ? data : (data?.items ?? []);
123
131
  const lines = items.map((s) => `• ${s.name} (${s.slug}) — ${s.description ?? 'no description'} · ↓${s.install_count}`);
124
132
  return mcpText(lines.length
125
133
  ? `Found ${lines.length} Skill${lines.length === 1 ? '' : 's'}:\n\n${lines.join('\n')}`
@@ -139,8 +147,11 @@ async function handleWhatdoes(args) {
139
147
  .join('\n'));
140
148
  }
141
149
  async function handleMySkills() {
150
+ // Same shape gotcha as handleSearch — /me/skills also returns a flat
151
+ // array via `ok(skills)` where skills is `SkillSearchHit[]`. Normalise
152
+ // both shapes so a future server-side refactor doesn't break the tool.
142
153
  const { data } = await api.get('/api/v1/me/skills', true);
143
- const items = data.items ?? [];
154
+ const items = Array.isArray(data) ? data : (data?.items ?? []);
144
155
  if (items.length === 0) {
145
156
  return mcpText('You haven\'t installed or authored any Skills yet. Try `prave_search_skills` to browse the catalogue.');
146
157
  }
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { resolve, relative, basename, sep } from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
3
4
  import { Buffer } from 'node:buffer';
4
5
  import chalk from 'chalk';
5
6
  import open from 'open';
@@ -46,7 +47,8 @@ const TAR_IGNORE = new Set([
46
47
  ]);
47
48
  const MAX_BUNDLE_BYTES = 20 * 1024 * 1024; // matches API + Supabase Storage cap
48
49
  const MAX_FILES = 200;
49
- export async function runDeployCommand(pathArg) {
50
+ export async function runDeployCommand(pathArg, options = {}) {
51
+ const isUpdate = Boolean(options.updateRunSlug);
50
52
  const root = resolve(pathArg ?? process.cwd());
51
53
  const rootStat = await stat(root).catch(() => null);
52
54
  if (!rootStat?.isDirectory()) {
@@ -62,11 +64,40 @@ export async function runDeployCommand(pathArg) {
62
64
  const creds0 = await requireAuth('prave run');
63
65
  if (!creds0)
64
66
  return;
65
- // 1. Local secret-scan BEFORE we ship anything. Cheap defence in
67
+ // 1a. Look for .env / .env.production / .env.local etc. The scanner
68
+ // would otherwise hard-reject them on the upload. Offer to lift the
69
+ // values out of the bundle and pass them to the wizard so they end
70
+ // up encrypted on the run row instead of in the tarball.
71
+ const envFiles = await findEnvFiles(root);
72
+ let envVars = {};
73
+ const stripPaths = new Set();
74
+ if (envFiles.length > 0) {
75
+ const totalKeys = envFiles.reduce((n, f) => n + Object.keys(f.values).length, 0);
76
+ console.log();
77
+ console.log(chalk.bold(`Found ${envFiles.length} env file${envFiles.length === 1 ? '' : 's'} with ${totalKeys} variable${totalKeys === 1 ? '' : 's'}:`));
78
+ for (const f of envFiles) {
79
+ console.log(` ${chalk.cyan('•')} ${f.path} ${chalk.dim(`(${Object.keys(f.values).length} keys)`)}`);
80
+ }
81
+ console.log(chalk.dim('\nIf you continue, Prave strips these from the upload and passes the\n' +
82
+ 'values to the browser wizard. They get AES-256-GCM-encrypted onto\n' +
83
+ 'the run, decrypted only inside the sandbox at run-time.\n' +
84
+ 'Decline to abort so you can clean up first.'));
85
+ const yes = await confirmYesNo('Strip & pass env vars to the wizard?');
86
+ if (!yes) {
87
+ log.error('Aborted. Remove the .env file(s) (or rename to .env.example) and re-run.');
88
+ process.exit(1);
89
+ }
90
+ for (const f of envFiles) {
91
+ stripPaths.add(f.path);
92
+ Object.assign(envVars, f.values);
93
+ }
94
+ }
95
+ // 1b. Local secret-scan BEFORE we ship anything. Cheap defence in
66
96
  // depth — even though the API scans again, surfacing the finding
67
97
  // pre-upload saves the user a round-trip and avoids briefly storing
68
- // a secret-bearing tarball in our bucket.
69
- const localFindings = await preflightScan(root);
98
+ // a secret-bearing tarball in our bucket. The env files we just
99
+ // accepted to lift out are excluded from the scan.
100
+ const localFindings = await preflightScan(root, stripPaths);
70
101
  if (localFindings.length > 0) {
71
102
  log.error('Bundle contains files that look like secrets:');
72
103
  for (const f of localFindings.slice(0, 10)) {
@@ -77,11 +108,16 @@ export async function runDeployCommand(pathArg) {
77
108
  'will prompt you for the real values during the wizard.'));
78
109
  process.exit(1);
79
110
  }
80
- // 2. Mint the deploy session
81
- const initSpinner = ora('Opening deploy session…').start();
111
+ // 2. Mint the deploy session. Update flows tag the session with
112
+ // the existing run's slug so the upload handler swaps that run's
113
+ // bundle pointer instead of creating a fresh row.
114
+ const initSpinner = ora(isUpdate ? `Opening update session for ${options.updateRunSlug}…` : 'Opening deploy session…').start();
82
115
  let session;
83
116
  try {
84
- const { data } = await api.post('/api/v1/deploy/init', {}, true);
117
+ const initPath = isUpdate
118
+ ? `/api/v1/deploy/init?run_slug=${encodeURIComponent(options.updateRunSlug)}`
119
+ : '/api/v1/deploy/init';
120
+ const { data } = await api.post(initPath, {}, true);
85
121
  session = data.session;
86
122
  initSpinner.succeed('Session opened');
87
123
  }
@@ -89,13 +125,28 @@ export async function runDeployCommand(pathArg) {
89
125
  initSpinner.fail(`Could not open deploy session: ${err.message}`);
90
126
  process.exit(1);
91
127
  }
128
+ // 2b. Ship env vars to the wizard before the upload so they're
129
+ // waiting when the browser opens. The wizard reads them once via
130
+ // /deploy/status and pre-fills its env-vars step.
131
+ if (Object.keys(envVars).length > 0) {
132
+ const envSpinner = ora('Sending env vars to the wizard…').start();
133
+ try {
134
+ await api.post(`/api/v1/deploy/env?session=${encodeURIComponent(session.session_id)}`, { env_vars: envVars }, true);
135
+ envSpinner.succeed(`Passed ${Object.keys(envVars).length} env var(s)`);
136
+ }
137
+ catch (err) {
138
+ envSpinner.fail(`Could not ship env vars: ${err.message}`);
139
+ process.exit(1);
140
+ }
141
+ }
92
142
  // 3. Pack the directory into a gzipped tar in memory. tar.create's
93
143
  // `cwd` option is critical — paths inside the archive must be
94
- // RELATIVE to the project root, not absolute.
144
+ // RELATIVE to the project root, not absolute. Stripped paths are
145
+ // env files we already shipped via /deploy/env above.
95
146
  const packSpinner = ora('Bundling project…').start();
96
147
  let tarball;
97
148
  try {
98
- tarball = await packDirectory(root);
149
+ tarball = await packDirectory(root, stripPaths);
99
150
  packSpinner.succeed(`Bundled ${formatBytes(tarball.length)}`);
100
151
  }
101
152
  catch (err) {
@@ -133,12 +184,23 @@ export async function runDeployCommand(pathArg) {
133
184
  process.exit(1);
134
185
  }
135
186
  uploadSpinner.succeed('Upload complete');
187
+ // Update flow: the server already swapped the run's bundle
188
+ // pointer. No wizard, no browser open — just confirm + exit.
189
+ const parsed = safeJson(text);
190
+ if (isUpdate || parsed?.updated_run_slug) {
191
+ console.log();
192
+ console.log(chalk.bold(`Updated ${parsed?.updated_run_slug ?? options.updateRunSlug}.`));
193
+ console.log(chalk.dim(' Next scheduled fire will use the freshly-uploaded bundle.\n' +
194
+ ' Open the dashboard:'));
195
+ console.log(chalk.cyan(' ' + session.wizard_url));
196
+ return;
197
+ }
136
198
  }
137
199
  catch (err) {
138
200
  uploadSpinner.fail(`Upload failed: ${err.message}`);
139
201
  process.exit(1);
140
202
  }
141
- // 5. Open the browser at the wizard
203
+ // 5. New-run flow: open the browser at the wizard.
142
204
  console.log();
143
205
  console.log(chalk.bold('Finish in the browser:'));
144
206
  console.log(chalk.cyan(' ' + session.wizard_url));
@@ -221,7 +283,7 @@ async function findSkillMd(root) {
221
283
  return null;
222
284
  }
223
285
  }
224
- async function preflightScan(root) {
286
+ async function preflightScan(root, stripPaths = new Set()) {
225
287
  const inputs = [];
226
288
  let files = 0;
227
289
  let bytes = 0;
@@ -243,8 +305,13 @@ async function preflightScan(root) {
243
305
  }
244
306
  if (!entry.isFile())
245
307
  continue;
246
- files++;
247
308
  const rel = relative(root, abs).split(sep).join('/');
309
+ // The user already accepted to lift this env file out of the
310
+ // bundle in the previous step. Don't scan it (we know it has
311
+ // secrets — that's why we're lifting it).
312
+ if (stripPaths.has(rel))
313
+ continue;
314
+ files++;
248
315
  if (!isLikelyTextPath(rel)) {
249
316
  inputs.push({ path: rel });
250
317
  continue;
@@ -262,12 +329,17 @@ async function preflightScan(root) {
262
329
  await visit(root);
263
330
  return scanForSecrets(inputs).findings;
264
331
  }
265
- async function packDirectory(root) {
332
+ async function packDirectory(root, stripPaths = new Set()) {
266
333
  // Top-level entries only — tar.create resolves them against `cwd`.
267
334
  const entries = (await readdir(root)).filter((n) => !TAR_IGNORE.has(n));
268
335
  if (entries.length === 0) {
269
336
  throw new Error('Project directory is empty.');
270
337
  }
338
+ // Normalise the strip-set against tar.create's relative-path format
339
+ // (POSIX forward slashes, no leading "./"). Paths sit one level
340
+ // beneath the archive's basename prefix, so we compare on the
341
+ // input-side relative path tar.create hands us.
342
+ const stripNormalised = new Set([...stripPaths].map((p) => p.replace(/\\/g, '/').replace(/^\.\//, '')));
271
343
  const stream = tar.create({
272
344
  gzip: true,
273
345
  cwd: root,
@@ -276,7 +348,10 @@ async function packDirectory(root) {
276
348
  // gets a `<project>/SKILL.md` shape, not a flat dump at the
277
349
  // archive root.
278
350
  prefix: basename(root),
279
- filter: (_path) => true,
351
+ filter: (path) => {
352
+ const norm = path.replace(/\\/g, '/').replace(/^\.\//, '');
353
+ return !stripNormalised.has(norm);
354
+ },
280
355
  }, entries);
281
356
  const chunks = [];
282
357
  for await (const chunk of stream) {
@@ -284,6 +359,84 @@ async function packDirectory(root) {
284
359
  }
285
360
  return Buffer.concat(chunks);
286
361
  }
362
+ /**
363
+ * Look for common `.env*` files at the project root. We don't scan
364
+ * deeper than top-level — a nested `.env` is almost certainly a
365
+ * shipping example, not the real secrets file. Returns an empty list
366
+ * when nothing's found.
367
+ */
368
+ async function findEnvFiles(root) {
369
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
370
+ const findings = [];
371
+ for (const entry of entries) {
372
+ if (!entry.isFile())
373
+ continue;
374
+ const name = entry.name;
375
+ if (!isRealEnvFile(name))
376
+ continue;
377
+ try {
378
+ const raw = await readFile(`${root}${sep}${name}`, 'utf8');
379
+ const values = parseDotenv(raw);
380
+ if (Object.keys(values).length === 0)
381
+ continue;
382
+ findings.push({ path: name, values });
383
+ }
384
+ catch {
385
+ /* unreadable — skip */
386
+ }
387
+ }
388
+ return findings;
389
+ }
390
+ /**
391
+ * `.env`, `.env.local`, `.env.production`, `.env.development`, `.envrc`
392
+ * count as "real env files". Templates (`.env.example`, `.env.sample`,
393
+ * `.env.template`) are explicitly NOT lifted — those are meant to be
394
+ * shipped.
395
+ */
396
+ function isRealEnvFile(name) {
397
+ if (name === '.env' || name === '.envrc')
398
+ return true;
399
+ if (!name.startsWith('.env.'))
400
+ return false;
401
+ const suffix = name.slice('.env.'.length);
402
+ if (/^(example|sample|template)$/i.test(suffix))
403
+ return false;
404
+ return true;
405
+ }
406
+ function parseDotenv(text) {
407
+ const out = {};
408
+ for (const raw of text.split('\n')) {
409
+ const line = raw.trim();
410
+ if (!line || line.startsWith('#'))
411
+ continue;
412
+ const eq = line.indexOf('=');
413
+ if (eq <= 0)
414
+ continue;
415
+ const key = line.slice(0, eq).trim();
416
+ let value = line.slice(eq + 1).trim();
417
+ if ((value.startsWith('"') && value.endsWith('"')) ||
418
+ (value.startsWith("'") && value.endsWith("'"))) {
419
+ value = value.slice(1, -1);
420
+ }
421
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
422
+ out[key] = value;
423
+ }
424
+ return out;
425
+ }
426
+ async function confirmYesNo(question) {
427
+ // Non-interactive shells (pipes, CI) can't prompt — default to "no"
428
+ // so we never silently ship secrets in a script.
429
+ if (!process.stdout.isTTY || !process.stdin.isTTY)
430
+ return false;
431
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
432
+ try {
433
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
434
+ return answer === '' || answer === 'y' || answer === 'yes';
435
+ }
436
+ finally {
437
+ rl.close();
438
+ }
439
+ }
287
440
  function formatBytes(n) {
288
441
  if (n < 1024)
289
442
  return `${n}B`;
package/dist/index.js CHANGED
@@ -4,10 +4,8 @@ import { dirname, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { Command } from 'commander';
6
6
  import { conflictsCommand } from './commands/conflicts.js';
7
- import { deployCommand } from './commands/deploy.js';
8
7
  import { diffCommand } from './commands/diff.js';
9
8
  import { docsCommand } from './commands/docs.js';
10
- import { exportCommand } from './commands/export.js';
11
9
  import { importCommand } from './commands/import.js';
12
10
  import { installCommand } from './commands/install.js';
13
11
  import { listCommand } from './commands/list.js';
@@ -23,7 +21,7 @@ import { settingsCommand } from './commands/settings.js';
23
21
  import { syncCommand } from './commands/sync.js';
24
22
  import { uninstallCommand } from './commands/uninstall.js';
25
23
  import { updateCommand } from './commands/update.js';
26
- import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand, usageScanCommand, usageStatusCommand, } from './commands/usage.js';
24
+ import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand, } from './commands/usage.js';
27
25
  import { whatdoesCommand } from './commands/whatdoes.js';
28
26
  import { whoamiCommand } from './commands/whoami.js';
29
27
  import { initAnalytics } from './lib/analytics.js';
@@ -36,12 +34,13 @@ const program = new Command()
36
34
  'Prave — Developer platform for Claude Skills.',
37
35
  '',
38
36
  ' Lifecycle: discover · install · author · sync · audit · ship.',
39
- ' Targets ~/.claude/skills/ by default; multi-agent via `prave deploy`.',
37
+ ' Targets ~/.claude/skills/ by default; multi-agent fan-out happens at install time.',
40
38
  '',
41
39
  ' Quick start:',
42
40
  ' prave login # device-code auth in your browser',
43
41
  ' prave search <q> # discover community skills',
44
- ' prave install <slug> # pull into ~/.claude/skills/',
42
+ ' prave install <slug> # pull into ~/.claude/skills/ (multi-agent prompt included)',
43
+ ' prave run deploy # bundle cwd, schedule on Prave\'s sandbox',
45
44
  ' prave usage hook install # real-time invocation tracking',
46
45
  '',
47
46
  ' Docs: https://prave.app/docs',
@@ -97,11 +96,6 @@ program
97
96
  .option('--heavy', 'narrow to Skills above the heavy-tier threshold (>5k estimated tokens)')
98
97
  .action(listCommand);
99
98
  program.command('search <query>').description('Search public Skills').action(searchCommand);
100
- program
101
- .command('export <slug>')
102
- .description('Print or save a Skill\'s SKILL.md without installing')
103
- .option('-o, --out <file>', 'write to file instead of stdout')
104
- .action(exportCommand);
105
99
  program
106
100
  .command('diff <slug>')
107
101
  .description('Show local vs registry diff for an installed Skill')
@@ -135,16 +129,6 @@ program
135
129
  const usage = program
136
130
  .command('usage')
137
131
  .description('Track which Skills you actually use (powers the optimiser)');
138
- usage
139
- .command('scan')
140
- .description('Scan local Claude Code transcripts and report invocations to Prave')
141
- .option('--since <window>', 'override the watermark, e.g. "7d" or "12h"')
142
- .option('--quiet', 'log a one-liner instead of a spinner')
143
- .action(usageScanCommand);
144
- usage
145
- .command('status')
146
- .description('Diagnose hook health, recent event counts, and most-used Skills')
147
- .action(usageStatusCommand);
148
132
  usage
149
133
  .command('report')
150
134
  .description('Internal: invoked by the Claude Code PostToolUse / UserPromptSubmit hook (reads stdin)')
@@ -159,12 +143,6 @@ hook
159
143
  .command('uninstall')
160
144
  .description('Remove the Prave-managed hook from ~/.claude/settings.json')
161
145
  .action(usageHookUninstallCommand);
162
- program
163
- .command('deploy <skillname>')
164
- .description('Deploy a Skill to every configured agent (Claude Code, Codex, Cursor, Gemini, Cline, Amp). Free plan deploys to Claude Code only; Pro and Max deploy across all six. The skill must already exist locally — use `prave install` first or point to a SKILL.md folder.')
165
- .option('--agent <agent>', 'restrict deploy to a single agent (claude-code | codex | cursor | gemini | cline | amp)')
166
- .option('--dry-run', 'log every destination path but write nothing — preview before committing')
167
- .action(deployCommand);
168
146
  program
169
147
  .command('mcp-server')
170
148
  .description('Run the Prave MCP server over stdio. Wire into Claude Desktop / Cursor MCP / Continue.dev via { "command": "npx", "args": ["-y", "@prave/cli", "mcp-server"] }. Exposes search, install, audit, my-skills, whatdoes as MCP tools.')
@@ -181,6 +159,10 @@ run
181
159
  .command('deploy [path]')
182
160
  .description('Bundle the current directory (or `path`), upload it to Prave, and open the browser wizard to pick the schedule + agent.')
183
161
  .action((path) => runDeployCommand(path));
162
+ run
163
+ .command('update <slug> [path]')
164
+ .description("Re-upload the bundle for an existing run. Same flow as `deploy`, but the run's schedule, agent and env vars stay intact — only the project files swap.")
165
+ .action((slug, path) => runDeployCommand(path, { updateRunSlug: slug }));
184
166
  run
185
167
  .command('list')
186
168
  .description('List your scheduled runs with next-fire time + last status.')
@@ -209,14 +191,12 @@ program
209
191
  '',
210
192
  'Discover & install',
211
193
  ' prave search <q> # public skill search',
212
- ' prave install <slug> [--no-deps] # install into ~/.claude/skills/',
194
+ ' prave install <slug> [--no-deps] # install into ~/.claude/skills/ (multi-agent prompt included)',
213
195
  ' prave uninstall <slug> # remove a local skill',
214
196
  ' prave list [--remote] [--verbose] # what is installed (or remote)',
215
- ' prave export <slug> -o file.md # dump SKILL.md without installing',
216
197
  '',
217
198
  'Authoring & sync',
218
199
  ' prave import [--upload --public] # publish ~/.claude/skills/ to Prave',
219
- ' prave deploy <skill> [--agent x] # mirror to other agents',
220
200
  ' prave sync # pull every installed update',
221
201
  ' prave update [slug] [--dry-run] # diff + pull outdated skills',
222
202
  ' prave diff <slug> # local vs registry diff',
@@ -231,8 +211,6 @@ program
231
211
  'Usage tracking (Pro+)',
232
212
  ' prave usage hook install # real-time PostToolUse hook',
233
213
  ' prave usage hook uninstall',
234
- ' prave usage scan [--since 7d] # transcript scanner',
235
- ' prave usage status # hook health + recent counts',
236
214
  '',
237
215
  'Settings',
238
216
  ' prave settings # configure agents + paths',
package/dist/lib/api.js CHANGED
@@ -15,39 +15,68 @@ export class ApiError extends Error {
15
15
  * Supabase rate limiter.
16
16
  */
17
17
  let refreshing = null;
18
+ /** One HTTP round-trip to /cli/refresh — returns null on any non-200. */
19
+ async function callRefresh(refresh_token) {
20
+ try {
21
+ const { statusCode, body } = await request(`${CONFIG.apiUrl}/api/v1/cli/refresh`, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify({ refresh_token }),
25
+ });
26
+ const text = await body.text();
27
+ if (statusCode !== 200)
28
+ return null;
29
+ const payload = JSON.parse(text);
30
+ if (!payload.success || !payload.data)
31
+ return null;
32
+ return payload.data;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
18
38
  async function refreshTokens() {
19
39
  if (refreshing)
20
40
  return refreshing;
21
41
  refreshing = (async () => {
22
- const creds = await loadCredentials();
23
- if (!creds?.refresh_token)
42
+ const initial = await loadCredentials();
43
+ if (!initial?.refresh_token)
24
44
  return null;
25
- try {
26
- const { statusCode, body } = await request(`${CONFIG.apiUrl}/api/v1/cli/refresh`, {
27
- method: 'POST',
28
- headers: { 'Content-Type': 'application/json' },
29
- body: JSON.stringify({ refresh_token: creds.refresh_token }),
30
- });
31
- const text = await body.text();
32
- if (statusCode !== 200)
33
- return null;
34
- const payload = JSON.parse(text);
35
- if (!payload.success || !payload.data)
36
- return null;
37
- const next = {
38
- ...creds,
39
- access_token: payload.data.access_token,
40
- refresh_token: payload.data.refresh_token ?? creds.refresh_token,
41
- user_id: payload.data.user_id,
42
- // Persist the new expiry so the next call's proactive check works.
43
- expires_at: payload.data.expires_at ?? undefined,
44
- };
45
- await saveCredentials(next);
46
- return next;
45
+ // First attempt: refresh with whatever's on disk right now.
46
+ let result = await callRefresh(initial.refresh_token);
47
+ let creds = initial;
48
+ // Supabase rotates the refresh_token on every successful refresh, so
49
+ // the old one is invalidated immediately. Two CLI processes can race
50
+ // here: the PostToolUse hook fires in the background while a
51
+ // foreground `prave whoami` (or anything else) is also alive. Each
52
+ // is its own Node process; the single-flight `refreshing` Promise
53
+ // only de-dupes within one process.
54
+ //
55
+ // When that race happens, the loser's stored refresh_token is now
56
+ // the already-used (and rejected) one. Re-read credentials.json
57
+ // once if the file has moved on (another process wrote newer
58
+ // tokens), retry with the fresh refresh_token before giving up.
59
+ if (!result) {
60
+ const fresh = await loadCredentials();
61
+ if (fresh?.refresh_token &&
62
+ fresh.refresh_token !== initial.refresh_token) {
63
+ result = await callRefresh(fresh.refresh_token);
64
+ if (result)
65
+ creds = fresh;
66
+ }
47
67
  }
48
- catch {
68
+ if (!result)
49
69
  return null;
50
- }
70
+ const next = {
71
+ ...creds,
72
+ access_token: result.access_token,
73
+ refresh_token: result.refresh_token ?? creds.refresh_token,
74
+ user_id: result.user_id,
75
+ // Persist the new expiry so the next call's proactive check works.
76
+ expires_at: result.expires_at ?? undefined,
77
+ };
78
+ await saveCredentials(next);
79
+ return next;
51
80
  })();
52
81
  try {
53
82
  return await refreshing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
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.1"
57
+ "@prave/shared": "1.4.3"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",
@@ -71,6 +71,6 @@
71
71
  "build": "tsc -p tsconfig.json && node scripts/inject-config.mjs",
72
72
  "typecheck": "tsc -p tsconfig.json --noEmit",
73
73
  "lint": "tsc -p tsconfig.json --noEmit",
74
- "postinstall": "node scripts/postinstall.mjs"
74
+ "postinstall": "node scripts/postinstall.mjs || true"
75
75
  }
76
76
  }
@@ -1,35 +0,0 @@
1
- import { writeFile } from 'node:fs/promises';
2
- import { api } from '../lib/api.js';
3
- import { loadCredentials } from '../lib/credentials.js';
4
- import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
5
- import { log } from '../utils/logger.js';
6
- /**
7
- * `prave export <slug>` — print or save a Skill's SKILL.md to disk
8
- * without touching ~/.claude/skills/. Useful for CI pipelines that want
9
- * to bundle Skills into other repos.
10
- */
11
- export async function exportCommand(slug, opts = {}) {
12
- const session = await loadCredentials();
13
- // Plan gate: export is part of the Pro+ authoring toolkit. Free can browse
14
- // and install, not extract for republishing.
15
- if (session) {
16
- const me = await fetchMyPlan();
17
- if (!me.limits.can_authoring_public && !me.limits.can_authoring_private) {
18
- log.warn('Export requires the Pro plan or higher.');
19
- log.dim(formatUpgradeHint('explorer'));
20
- return;
21
- }
22
- }
23
- const { data: skill } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, Boolean(session));
24
- const content = skill.content ?? '';
25
- if (!content.trim()) {
26
- log.warn(`${slug} has no content`);
27
- return;
28
- }
29
- if (opts.out) {
30
- await writeFile(opts.out, content, 'utf8');
31
- log.success(`Wrote ${opts.out} (${content.length} bytes)`);
32
- return;
33
- }
34
- process.stdout.write(content);
35
- }