@prave/cli 1.4.2 → 1.4.4

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
  }
@@ -47,7 +47,8 @@ const TAR_IGNORE = new Set([
47
47
  ]);
48
48
  const MAX_BUNDLE_BYTES = 20 * 1024 * 1024; // matches API + Supabase Storage cap
49
49
  const MAX_FILES = 200;
50
- export async function runDeployCommand(pathArg) {
50
+ export async function runDeployCommand(pathArg, options = {}) {
51
+ const isUpdate = Boolean(options.updateRunSlug);
51
52
  const root = resolve(pathArg ?? process.cwd());
52
53
  const rootStat = await stat(root).catch(() => null);
53
54
  if (!rootStat?.isDirectory()) {
@@ -107,11 +108,16 @@ export async function runDeployCommand(pathArg) {
107
108
  'will prompt you for the real values during the wizard.'));
108
109
  process.exit(1);
109
110
  }
110
- // 2. Mint the deploy session
111
- 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();
112
115
  let session;
113
116
  try {
114
- 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);
115
121
  session = data.session;
116
122
  initSpinner.succeed('Session opened');
117
123
  }
@@ -178,12 +184,23 @@ export async function runDeployCommand(pathArg) {
178
184
  process.exit(1);
179
185
  }
180
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
+ }
181
198
  }
182
199
  catch (err) {
183
200
  uploadSpinner.fail(`Upload failed: ${err.message}`);
184
201
  process.exit(1);
185
202
  }
186
- // 5. Open the browser at the wizard
203
+ // 5. New-run flow: open the browser at the wizard.
187
204
  console.log();
188
205
  console.log(chalk.bold('Finish in the browser:'));
189
206
  console.log(chalk.cyan(' ' + session.wizard_url));
@@ -435,3 +452,30 @@ function safeJson(text) {
435
452
  return null;
436
453
  }
437
454
  }
455
+ /**
456
+ * `prave run trigger <slug>` — fire a single ad-hoc execution outside
457
+ * the cron schedule. Refused with 409 when another execution is in
458
+ * flight (same single-flight guard the dashboard's "Run now" button
459
+ * uses). Returns immediately after enqueueing — tail with
460
+ * `prave run logs <slug>` after a few seconds.
461
+ */
462
+ export async function runTriggerCommand(slug) {
463
+ if (!(await requireAuth('prave run trigger')))
464
+ return;
465
+ const spinner = ora(`Triggering ${slug}…`).start();
466
+ try {
467
+ await api.post(`/api/v1/runs/${encodeURIComponent(slug)}/trigger`, undefined, true);
468
+ spinner.succeed(`Triggered ${chalk.bold(slug)} — log will appear in a few seconds.`);
469
+ console.log(chalk.dim(` Tail it with `) +
470
+ chalk.cyan(`prave run logs ${slug}`) +
471
+ chalk.dim(` (or watch the dashboard).`));
472
+ }
473
+ catch (err) {
474
+ if (err instanceof ApiError && err.status === 409) {
475
+ spinner.warn(`An execution is already in flight for ${slug} — wait for it to finish, then re-run.`);
476
+ return;
477
+ }
478
+ spinner.fail(err.message);
479
+ process.exit(1);
480
+ }
481
+ }
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';
@@ -17,13 +15,13 @@ import { mcpInstallCommand } from './commands/mcp-install.js';
17
15
  import { mcpServerCommand } from './commands/mcp-server.js';
18
16
  import { optimizeCommand } from './commands/optimize.js';
19
17
  import { overviewCommand } from './commands/overview.js';
20
- import { runDeployCommand, runListCommand, runLogsCommand, } from './commands/run.js';
18
+ import { runDeployCommand, runListCommand, runLogsCommand, runTriggerCommand, } from './commands/run.js';
21
19
  import { searchCommand } from './commands/search.js';
22
20
  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.')
@@ -189,6 +171,10 @@ run
189
171
  .command('logs <slug>')
190
172
  .description('Print the latest execution log for a scheduled run.')
191
173
  .action(runLogsCommand);
174
+ run
175
+ .command('trigger <slug>')
176
+ .description('Fire a one-shot execution outside the cron schedule. Refused with 409 if another execution is already in flight. Tail with `prave run logs <slug>` afterwards.')
177
+ .action(runTriggerCommand);
192
178
  program
193
179
  .command('mcp install')
194
180
  .alias('mcp-install')
@@ -209,14 +195,12 @@ program
209
195
  '',
210
196
  'Discover & install',
211
197
  ' prave search <q> # public skill search',
212
- ' prave install <slug> [--no-deps] # install into ~/.claude/skills/',
198
+ ' prave install <slug> [--no-deps] # install into ~/.claude/skills/ (multi-agent prompt included)',
213
199
  ' prave uninstall <slug> # remove a local skill',
214
200
  ' prave list [--remote] [--verbose] # what is installed (or remote)',
215
- ' prave export <slug> -o file.md # dump SKILL.md without installing',
216
201
  '',
217
202
  'Authoring & sync',
218
203
  ' prave import [--upload --public] # publish ~/.claude/skills/ to Prave',
219
- ' prave deploy <skill> [--agent x] # mirror to other agents',
220
204
  ' prave sync # pull every installed update',
221
205
  ' prave update [slug] [--dry-run] # diff + pull outdated skills',
222
206
  ' prave diff <slug> # local vs registry diff',
@@ -231,14 +215,14 @@ program
231
215
  'Usage tracking (Pro+)',
232
216
  ' prave usage hook install # real-time PostToolUse hook',
233
217
  ' prave usage hook uninstall',
234
- ' prave usage scan [--since 7d] # transcript scanner',
235
- ' prave usage status # hook health + recent counts',
236
218
  '',
237
219
  'Settings',
238
220
  ' prave settings # configure agents + paths',
239
221
  '',
240
222
  'Runs (scheduled cron on Prave)',
241
223
  ' prave run deploy # bundle cwd, upload, open wizard',
224
+ ' prave run update <slug> # re-upload bundle for an existing run',
225
+ ' prave run trigger <slug> # fire one execution outside the cron',
242
226
  ' prave run list # your scheduled runs',
243
227
  ' prave run logs <slug> # tail latest execution log',
244
228
  '',
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.2",
3
+ "version": "1.4.4",
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.2"
57
+ "@prave/shared": "1.4.4"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",
@@ -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
- }