@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.
- package/dist/commands/deploy.js +10 -0
- package/dist/commands/mcp-server.js +13 -2
- package/dist/commands/run.js +167 -14
- package/dist/index.js +9 -31
- package/dist/lib/api.js +55 -26
- package/package.json +3 -3
- package/dist/commands/export.js +0 -35
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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: (
|
|
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,
|
|
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
|
|
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
|
|
23
|
-
if (!
|
|
42
|
+
const initial = await loadCredentials();
|
|
43
|
+
if (!initial?.refresh_token)
|
|
24
44
|
return null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|
package/dist/commands/export.js
DELETED
|
@@ -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
|
-
}
|