@prave/cli 1.3.0 → 1.4.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/dist/commands/docs.js +32 -0
- package/dist/commands/list.js +2 -0
- package/dist/commands/run.js +301 -0
- package/dist/commands/search.js +2 -0
- package/dist/commands/uninstall.js +38 -7
- package/dist/commands/whatdoes.js +2 -0
- package/dist/index.js +31 -0
- package/dist/lib/nudge.js +61 -0
- package/package.json +8 -4
- package/scripts/postinstall.mjs +31 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import open from 'open';
|
|
3
|
+
import { CONFIG } from '../lib/config.js';
|
|
4
|
+
/**
|
|
5
|
+
* `prave docs [slug]` — open the docs in the user's browser.
|
|
6
|
+
*
|
|
7
|
+
* No `slug` → opens https://prave.app/docs
|
|
8
|
+
* Slug given → opens https://prave.app/docs/<slug>
|
|
9
|
+
*
|
|
10
|
+
* No auth needed — the docs are public. The command exists purely so
|
|
11
|
+
* users don't have to alt-tab to a browser, type a URL, and hunt for
|
|
12
|
+
* the right section. From the terminal:
|
|
13
|
+
*
|
|
14
|
+
* prave docs # docs home
|
|
15
|
+
* prave docs cli/run # the Runs CLI reference
|
|
16
|
+
* prave docs web/runs # the Runs dashboard guide
|
|
17
|
+
* prave docs pricing # the plans page
|
|
18
|
+
*/
|
|
19
|
+
export async function docsCommand(slug) {
|
|
20
|
+
const cleaned = (slug ?? '').replace(/^\/+|\/+$/g, '').trim();
|
|
21
|
+
// CONFIG.webUrl points at the SPA host (https://prave.app in prod,
|
|
22
|
+
// http://localhost:5173 in dev). Docs always live under /docs.
|
|
23
|
+
const base = CONFIG.webUrl?.replace(/\/$/, '') ?? 'https://prave.app';
|
|
24
|
+
const url = cleaned ? `${base}/docs/${cleaned}` : `${base}/docs`;
|
|
25
|
+
console.log(`${chalk.dim('Opening')} ${chalk.cyan(url)}`);
|
|
26
|
+
try {
|
|
27
|
+
await open(url);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
console.log(chalk.dim('(your browser did not open — copy the URL above)'));
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/commands/list.js
CHANGED
|
@@ -5,6 +5,7 @@ import { tokenTier } from '@prave/shared';
|
|
|
5
5
|
import { track } from '../lib/analytics.js';
|
|
6
6
|
import { api } from '../lib/api.js';
|
|
7
7
|
import { CONFIG } from '../lib/config.js';
|
|
8
|
+
import { nudgeIfAnonymous } from '../lib/nudge.js';
|
|
8
9
|
import { log } from '../utils/logger.js';
|
|
9
10
|
const TIER_EMOJI = {
|
|
10
11
|
lean: '🟢',
|
|
@@ -68,6 +69,7 @@ export async function listCommand(opts = {}) {
|
|
|
68
69
|
console.log(` ${chalk.cyan('•')} ${name}`);
|
|
69
70
|
}
|
|
70
71
|
log.dim(`\n${localSlugs.length} local skill${localSlugs.length === 1 ? '' : 's'} in ${CONFIG.skillsDir}`);
|
|
72
|
+
await nudgeIfAnonymous('list');
|
|
71
73
|
return;
|
|
72
74
|
}
|
|
73
75
|
// Enriched path — pull intelligence and merge by slug (best-effort).
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve, relative, basename, sep } from 'node:path';
|
|
3
|
+
import { Buffer } from 'node:buffer';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import * as tar from 'tar';
|
|
8
|
+
import { request } from 'undici';
|
|
9
|
+
import { isLikelyTextPath, scanForSecrets, } from '@prave/shared';
|
|
10
|
+
import { api, ApiError } from '../lib/api.js';
|
|
11
|
+
import { CONFIG } from '../lib/config.js';
|
|
12
|
+
import { loadCredentials, requireAuth } from '../lib/credentials.js';
|
|
13
|
+
import { log } from '../utils/logger.js';
|
|
14
|
+
/**
|
|
15
|
+
* `prave run` — scheduled server-side skill executions on Prave's
|
|
16
|
+
* infrastructure.
|
|
17
|
+
*
|
|
18
|
+
* prave run deploy [path] # bundle dir → upload → open wizard
|
|
19
|
+
* prave run list # list my deployed runs
|
|
20
|
+
* prave run logs <slug> # tail the most recent execution logs
|
|
21
|
+
*
|
|
22
|
+
* `deploy` is the headline action — the rest just expose the same data
|
|
23
|
+
* the dashboard already shows. The wizard handles schedule + agent
|
|
24
|
+
* selection in the browser because picking from an agent dropdown
|
|
25
|
+
* filtered to "which API keys do I have" is way clearer in a UI than
|
|
26
|
+
* an interactive prompt.
|
|
27
|
+
*/
|
|
28
|
+
// Sane-default ignore list for `tar.create` — none of these belong in
|
|
29
|
+
// a deployable bundle and dragging them up adds noise to the scan +
|
|
30
|
+
// bloats storage.
|
|
31
|
+
const TAR_IGNORE = new Set([
|
|
32
|
+
'.git',
|
|
33
|
+
'.github',
|
|
34
|
+
'.next',
|
|
35
|
+
'.turbo',
|
|
36
|
+
'.svelte-kit',
|
|
37
|
+
'.cache',
|
|
38
|
+
'node_modules',
|
|
39
|
+
'dist',
|
|
40
|
+
'build',
|
|
41
|
+
'coverage',
|
|
42
|
+
'.venv',
|
|
43
|
+
'venv',
|
|
44
|
+
'__pycache__',
|
|
45
|
+
'.DS_Store',
|
|
46
|
+
]);
|
|
47
|
+
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024; // matches API + Supabase Storage cap
|
|
48
|
+
const MAX_FILES = 200;
|
|
49
|
+
export async function runDeployCommand(pathArg) {
|
|
50
|
+
const root = resolve(pathArg ?? process.cwd());
|
|
51
|
+
const rootStat = await stat(root).catch(() => null);
|
|
52
|
+
if (!rootStat?.isDirectory()) {
|
|
53
|
+
log.error(`Not a directory: ${root}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
// Sanity check — the dir should *look* like a skill project. We don't
|
|
57
|
+
// hard-reject; we just warn if there's no SKILL.md anywhere.
|
|
58
|
+
const skillMd = await findSkillMd(root);
|
|
59
|
+
if (!skillMd) {
|
|
60
|
+
log.warn('No SKILL.md found in this directory. Deploys still work without it, but the runner will not have skill-shaped context for the agent.');
|
|
61
|
+
}
|
|
62
|
+
const creds0 = await requireAuth('prave run');
|
|
63
|
+
if (!creds0)
|
|
64
|
+
return;
|
|
65
|
+
// 1. Local secret-scan BEFORE we ship anything. Cheap defence in
|
|
66
|
+
// depth — even though the API scans again, surfacing the finding
|
|
67
|
+
// 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);
|
|
70
|
+
if (localFindings.length > 0) {
|
|
71
|
+
log.error('Bundle contains files that look like secrets:');
|
|
72
|
+
for (const f of localFindings.slice(0, 10)) {
|
|
73
|
+
console.error(` ${chalk.red('•')} ${f.rule} ${chalk.dim(f.path)}${f.line ? `:${f.line}` : ''}`);
|
|
74
|
+
}
|
|
75
|
+
console.error(chalk.dim('\nRemove these files (or scrub the values) and re-run.\n' +
|
|
76
|
+
'For env vars, ship a .env.example template instead — Prave\n' +
|
|
77
|
+
'will prompt you for the real values during the wizard.'));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
// 2. Mint the deploy session
|
|
81
|
+
const initSpinner = ora('Opening deploy session…').start();
|
|
82
|
+
let session;
|
|
83
|
+
try {
|
|
84
|
+
const { data } = await api.post('/api/v1/deploy/init', {}, true);
|
|
85
|
+
session = data.session;
|
|
86
|
+
initSpinner.succeed('Session opened');
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
initSpinner.fail(`Could not open deploy session: ${err.message}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
// 3. Pack the directory into a gzipped tar in memory. tar.create's
|
|
93
|
+
// `cwd` option is critical — paths inside the archive must be
|
|
94
|
+
// RELATIVE to the project root, not absolute.
|
|
95
|
+
const packSpinner = ora('Bundling project…').start();
|
|
96
|
+
let tarball;
|
|
97
|
+
try {
|
|
98
|
+
tarball = await packDirectory(root);
|
|
99
|
+
packSpinner.succeed(`Bundled ${formatBytes(tarball.length)}`);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
packSpinner.fail(`Bundle failed: ${err.message}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
if (tarball.length > MAX_BUNDLE_BYTES) {
|
|
106
|
+
log.error(`Bundle is ${formatBytes(tarball.length)}, cap is ${formatBytes(MAX_BUNDLE_BYTES)}. ` +
|
|
107
|
+
'Add large files to .gitignore-style noise (or place them in node_modules / .git which we already skip).');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
// 4. Stream the tarball to /deploy/upload. We use undici directly
|
|
111
|
+
// because api.ts only does JSON.
|
|
112
|
+
const uploadSpinner = ora('Uploading to Prave…').start();
|
|
113
|
+
const creds = await loadCredentials();
|
|
114
|
+
if (!creds) {
|
|
115
|
+
uploadSpinner.fail('Credentials expired mid-flight — please re-login.');
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
const uploadUrl = `${CONFIG.apiUrl}/api/v1/deploy/upload?session=${encodeURIComponent(session.session_id)}`;
|
|
119
|
+
try {
|
|
120
|
+
const { statusCode, body } = await request(uploadUrl, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/gzip',
|
|
124
|
+
Authorization: `Bearer ${creds.access_token}`,
|
|
125
|
+
},
|
|
126
|
+
body: tarball,
|
|
127
|
+
});
|
|
128
|
+
const text = await body.text();
|
|
129
|
+
if (statusCode >= 400) {
|
|
130
|
+
const parsed = safeJson(text);
|
|
131
|
+
const msg = parsed?.error ?? `HTTP ${statusCode}`;
|
|
132
|
+
uploadSpinner.fail(`Upload rejected: ${msg}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
uploadSpinner.succeed('Upload complete');
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
uploadSpinner.fail(`Upload failed: ${err.message}`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
// 5. Open the browser at the wizard
|
|
142
|
+
console.log();
|
|
143
|
+
console.log(chalk.bold('Finish in the browser:'));
|
|
144
|
+
console.log(chalk.cyan(' ' + session.wizard_url));
|
|
145
|
+
try {
|
|
146
|
+
await open(session.wizard_url);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* user can copy the URL manually */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export async function runListCommand() {
|
|
153
|
+
if (!(await requireAuth('prave run list')))
|
|
154
|
+
return;
|
|
155
|
+
const spinner = ora('Fetching runs…').start();
|
|
156
|
+
try {
|
|
157
|
+
const { data } = await api.get('/api/v1/runs', true);
|
|
158
|
+
spinner.stop();
|
|
159
|
+
if (!data.runs.length) {
|
|
160
|
+
console.log(chalk.dim('No runs yet. `prave run deploy` to schedule your first one.'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
for (const r of data.runs) {
|
|
164
|
+
const nextRun = r.next_run_at
|
|
165
|
+
? new Date(r.next_run_at).toLocaleString()
|
|
166
|
+
: chalk.dim('paused');
|
|
167
|
+
const status = r.status === 'active'
|
|
168
|
+
? chalk.green('active')
|
|
169
|
+
: r.status === 'paused'
|
|
170
|
+
? chalk.yellow('paused')
|
|
171
|
+
: chalk.red(r.status);
|
|
172
|
+
console.log(` ${chalk.bold(r.name)} ${chalk.dim(r.slug)}\n` +
|
|
173
|
+
` ${chalk.dim(`agent=${r.agent} schedule=${r.schedule_kind} status=${status} next=${nextRun}`)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
spinner.fail(err.message);
|
|
178
|
+
process.exit(err instanceof ApiError ? 1 : 1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function runLogsCommand(slug) {
|
|
182
|
+
if (!(await requireAuth('prave run logs')))
|
|
183
|
+
return;
|
|
184
|
+
const spinner = ora(`Fetching logs for ${slug}…`).start();
|
|
185
|
+
try {
|
|
186
|
+
const { data } = await api.get(`/api/v1/runs/${encodeURIComponent(slug)}/executions?limit=10`, true);
|
|
187
|
+
spinner.stop();
|
|
188
|
+
if (!data.executions.length) {
|
|
189
|
+
console.log(chalk.dim('No executions yet. The first one will appear after the next scheduled fire.'));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const latest = data.executions[0];
|
|
193
|
+
const status = latest.status === 'success'
|
|
194
|
+
? chalk.green(latest.status)
|
|
195
|
+
: latest.status === 'running'
|
|
196
|
+
? chalk.cyan(latest.status)
|
|
197
|
+
: chalk.red(latest.status);
|
|
198
|
+
console.log(`${chalk.bold(slug)} ${chalk.dim(latest.started_at)} ${status}` +
|
|
199
|
+
(latest.duration_ms !== null ? chalk.dim(` (${latest.duration_ms}ms)`) : ''));
|
|
200
|
+
console.log();
|
|
201
|
+
console.log(latest.log_text ?? chalk.dim('(no log captured)'));
|
|
202
|
+
if (latest.error_message) {
|
|
203
|
+
console.log();
|
|
204
|
+
console.log(chalk.red('Error: ') + latest.error_message);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
spinner.fail(err.message);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// ── helpers ──────────────────────────────────────────────────────────
|
|
213
|
+
async function findSkillMd(root) {
|
|
214
|
+
// Top-level only — most skill projects ship SKILL.md at the root.
|
|
215
|
+
// Deeper search would be wasted work for the warning we'd print.
|
|
216
|
+
try {
|
|
217
|
+
const entries = await readdir(root);
|
|
218
|
+
return entries.find((n) => n.toLowerCase() === 'skill.md') ?? null;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function preflightScan(root) {
|
|
225
|
+
const inputs = [];
|
|
226
|
+
let files = 0;
|
|
227
|
+
let bytes = 0;
|
|
228
|
+
const visit = async (dir) => {
|
|
229
|
+
if (files >= MAX_FILES)
|
|
230
|
+
return;
|
|
231
|
+
if (bytes >= MAX_BUNDLE_BYTES)
|
|
232
|
+
return;
|
|
233
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (TAR_IGNORE.has(entry.name))
|
|
236
|
+
continue;
|
|
237
|
+
if (entry.name.startsWith('._'))
|
|
238
|
+
continue;
|
|
239
|
+
const abs = `${dir}${sep}${entry.name}`;
|
|
240
|
+
if (entry.isDirectory()) {
|
|
241
|
+
await visit(abs);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!entry.isFile())
|
|
245
|
+
continue;
|
|
246
|
+
files++;
|
|
247
|
+
const rel = relative(root, abs).split(sep).join('/');
|
|
248
|
+
if (!isLikelyTextPath(rel)) {
|
|
249
|
+
inputs.push({ path: rel });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const content = await readFile(abs, 'utf8');
|
|
254
|
+
bytes += content.length;
|
|
255
|
+
inputs.push({ path: rel, content });
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
inputs.push({ path: rel });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
await visit(root);
|
|
263
|
+
return scanForSecrets(inputs).findings;
|
|
264
|
+
}
|
|
265
|
+
async function packDirectory(root) {
|
|
266
|
+
// Top-level entries only — tar.create resolves them against `cwd`.
|
|
267
|
+
const entries = (await readdir(root)).filter((n) => !TAR_IGNORE.has(n));
|
|
268
|
+
if (entries.length === 0) {
|
|
269
|
+
throw new Error('Project directory is empty.');
|
|
270
|
+
}
|
|
271
|
+
const stream = tar.create({
|
|
272
|
+
gzip: true,
|
|
273
|
+
cwd: root,
|
|
274
|
+
portable: true,
|
|
275
|
+
// Prefix all paths with the project's basename so the runner
|
|
276
|
+
// gets a `<project>/SKILL.md` shape, not a flat dump at the
|
|
277
|
+
// archive root.
|
|
278
|
+
prefix: basename(root),
|
|
279
|
+
filter: (_path) => true,
|
|
280
|
+
}, entries);
|
|
281
|
+
const chunks = [];
|
|
282
|
+
for await (const chunk of stream) {
|
|
283
|
+
chunks.push(Buffer.from(chunk));
|
|
284
|
+
}
|
|
285
|
+
return Buffer.concat(chunks);
|
|
286
|
+
}
|
|
287
|
+
function formatBytes(n) {
|
|
288
|
+
if (n < 1024)
|
|
289
|
+
return `${n}B`;
|
|
290
|
+
if (n < 1024 * 1024)
|
|
291
|
+
return `${(n / 1024).toFixed(1)}KB`;
|
|
292
|
+
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
293
|
+
}
|
|
294
|
+
function safeJson(text) {
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(text);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
package/dist/commands/search.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { track } from '../lib/analytics.js';
|
|
3
3
|
import { api } from '../lib/api.js';
|
|
4
|
+
import { nudgeIfAnonymous } from '../lib/nudge.js';
|
|
4
5
|
import { log } from '../utils/logger.js';
|
|
5
6
|
const SLUG_COL = 32;
|
|
6
7
|
function formatInstalls(n) {
|
|
@@ -41,4 +42,5 @@ export async function searchCommand(query) {
|
|
|
41
42
|
if (skills.length > 0) {
|
|
42
43
|
console.log(chalk.dim(' → prave install <slug>'));
|
|
43
44
|
}
|
|
45
|
+
await nudgeIfAnonymous('search');
|
|
44
46
|
}
|
|
@@ -2,15 +2,23 @@ import { rm } from 'node:fs/promises';
|
|
|
2
2
|
import { join, resolve, sep } from 'node:path';
|
|
3
3
|
import ora from 'ora';
|
|
4
4
|
import { track } from '../lib/analytics.js';
|
|
5
|
+
import { api, ApiError } from '../lib/api.js';
|
|
5
6
|
import { CONFIG } from '../lib/config.js';
|
|
7
|
+
import { loadCredentials } from '../lib/credentials.js';
|
|
6
8
|
import { assertSlug, InvalidSlugError } from '../lib/slug.js';
|
|
7
9
|
/**
|
|
8
|
-
* `prave uninstall <slug>` — removes ~/.claude/skills/<slug>
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* `prave uninstall <slug>` — removes ~/.claude/skills/<slug> AND tells
|
|
11
|
+
* the server to drop the corresponding install record so the
|
|
12
|
+
* dashboard counter goes down.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* The remote DELETE is best-effort: a network failure or "not logged
|
|
15
|
+
* in" state still lets the local rm succeed (so the user isn't stuck
|
|
16
|
+
* with dead files when offline). The mismatch then heals on the next
|
|
17
|
+
* `prave sync` or manual dashboard cleanup.
|
|
18
|
+
*
|
|
19
|
+
* Slug is validated against [a-z0-9][a-z0-9-]{0,63} before any path
|
|
20
|
+
* math so a hostile arg like `../../etc` cannot escape the skills
|
|
21
|
+
* directory.
|
|
14
22
|
*/
|
|
15
23
|
export async function uninstallCommand(slug) {
|
|
16
24
|
track('cli_uninstall', { slug });
|
|
@@ -37,10 +45,33 @@ export async function uninstallCommand(slug) {
|
|
|
37
45
|
const spinner = ora(`Removing ${slug}…`).start();
|
|
38
46
|
try {
|
|
39
47
|
await rm(dir, { recursive: true, force: true });
|
|
40
|
-
spinner.succeed(`Removed ${dir}`);
|
|
41
48
|
}
|
|
42
49
|
catch (err) {
|
|
43
|
-
spinner.fail(`Couldn
|
|
50
|
+
spinner.fail(`Couldn't remove ${slug}: ${err.message}`);
|
|
44
51
|
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Best-effort server-side ledger sweep. We only attempt it when the
|
|
55
|
+
// user is logged in — anonymous CLI usage (running before
|
|
56
|
+
// `prave login`) still does the local rm and exits cleanly.
|
|
57
|
+
const creds = await loadCredentials();
|
|
58
|
+
if (creds) {
|
|
59
|
+
try {
|
|
60
|
+
await api.del(`/api/v1/skills/${encodeURIComponent(slug)}/install`, true);
|
|
61
|
+
spinner.succeed(`Removed ${slug} (local + server install record)`);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
// 404 = install record already absent (matches our idempotent
|
|
65
|
+
// server behaviour). Anything else is a soft warning.
|
|
66
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
67
|
+
spinner.succeed(`Removed ${slug} (local; server had no install record)`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
spinner.warn(`Removed ${slug} locally — server ledger update failed (${err.message}). Run \`prave sync\` later to reconcile.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
spinner.succeed(`Removed ${slug} (local only — not signed in)`);
|
|
45
76
|
}
|
|
46
77
|
}
|
|
@@ -3,6 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import { tokenTier } from '@prave/shared';
|
|
4
4
|
import { track } from '../lib/analytics.js';
|
|
5
5
|
import { api, ApiError } from '../lib/api.js';
|
|
6
|
+
import { nudgeIfAnonymous } from '../lib/nudge.js';
|
|
6
7
|
import { log } from '../utils/logger.js';
|
|
7
8
|
const TIER_BADGE = {
|
|
8
9
|
lean: chalk.green('🟢 Lean'),
|
|
@@ -71,6 +72,7 @@ export async function whatdoesCommand(skillName) {
|
|
|
71
72
|
console.log(`🔗 Requires: ${requires}`);
|
|
72
73
|
console.log(`${data.conflicts.length > 0 ? chalk.yellow('⚠️ ') : '⚠️ '}Conflicts: ${conflicts}`);
|
|
73
74
|
console.log(chalk.dim(RULE));
|
|
75
|
+
await nudgeIfAnonymous('whatdoes');
|
|
74
76
|
}
|
|
75
77
|
catch (err) {
|
|
76
78
|
spinner.stop();
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Command } from 'commander';
|
|
|
6
6
|
import { conflictsCommand } from './commands/conflicts.js';
|
|
7
7
|
import { deployCommand } from './commands/deploy.js';
|
|
8
8
|
import { diffCommand } from './commands/diff.js';
|
|
9
|
+
import { docsCommand } from './commands/docs.js';
|
|
9
10
|
import { exportCommand } from './commands/export.js';
|
|
10
11
|
import { importCommand } from './commands/import.js';
|
|
11
12
|
import { installCommand } from './commands/install.js';
|
|
@@ -16,6 +17,7 @@ import { mcpInstallCommand } from './commands/mcp-install.js';
|
|
|
16
17
|
import { mcpServerCommand } from './commands/mcp-server.js';
|
|
17
18
|
import { optimizeCommand } from './commands/optimize.js';
|
|
18
19
|
import { overviewCommand } from './commands/overview.js';
|
|
20
|
+
import { runDeployCommand, runListCommand, runLogsCommand, } from './commands/run.js';
|
|
19
21
|
import { searchCommand } from './commands/search.js';
|
|
20
22
|
import { settingsCommand } from './commands/settings.js';
|
|
21
23
|
import { syncCommand } from './commands/sync.js';
|
|
@@ -167,6 +169,26 @@ program
|
|
|
167
169
|
.command('mcp-server')
|
|
168
170
|
.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.')
|
|
169
171
|
.action(mcpServerCommand);
|
|
172
|
+
program
|
|
173
|
+
.command('docs [slug]')
|
|
174
|
+
.description('Open the docs in your browser. Bare `prave docs` lands on the home page; `prave docs cli/run` or `prave docs web/runs` jumps straight to a section.')
|
|
175
|
+
.action((slug) => docsCommand(slug));
|
|
176
|
+
// ─── prave run — scheduled server-side executions (Runs) ──────────
|
|
177
|
+
const run = program
|
|
178
|
+
.command('run')
|
|
179
|
+
.description('Schedule a skill to fire on a cron, executed by your chosen AI agent on Prave\'s sandbox. Bring the whole project — SKILL.md, scripts, .env.');
|
|
180
|
+
run
|
|
181
|
+
.command('deploy [path]')
|
|
182
|
+
.description('Bundle the current directory (or `path`), upload it to Prave, and open the browser wizard to pick the schedule + agent.')
|
|
183
|
+
.action((path) => runDeployCommand(path));
|
|
184
|
+
run
|
|
185
|
+
.command('list')
|
|
186
|
+
.description('List your scheduled runs with next-fire time + last status.')
|
|
187
|
+
.action(runListCommand);
|
|
188
|
+
run
|
|
189
|
+
.command('logs <slug>')
|
|
190
|
+
.description('Print the latest execution log for a scheduled run.')
|
|
191
|
+
.action(runLogsCommand);
|
|
170
192
|
program
|
|
171
193
|
.command('mcp install')
|
|
172
194
|
.alias('mcp-install')
|
|
@@ -215,6 +237,15 @@ program
|
|
|
215
237
|
'Settings',
|
|
216
238
|
' prave settings # configure agents + paths',
|
|
217
239
|
'',
|
|
240
|
+
'Runs (scheduled cron on Prave)',
|
|
241
|
+
' prave run deploy # bundle cwd, upload, open wizard',
|
|
242
|
+
' prave run list # your scheduled runs',
|
|
243
|
+
' prave run logs <slug> # tail latest execution log',
|
|
244
|
+
'',
|
|
245
|
+
'Docs',
|
|
246
|
+
' prave docs # open the docs in your browser',
|
|
247
|
+
' prave docs <slug> # jump to a section, e.g. cli/run',
|
|
248
|
+
'',
|
|
218
249
|
'Telemetry',
|
|
219
250
|
' PRAVE_TELEMETRY=0 # opt out of CLI usage analytics',
|
|
220
251
|
'',
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadCredentials } from './credentials.js';
|
|
3
|
+
let alreadyNudged = false;
|
|
4
|
+
export async function nudgeIfAnonymous(context = 'generic') {
|
|
5
|
+
if (alreadyNudged)
|
|
6
|
+
return;
|
|
7
|
+
if (process.env.PRAVE_QUIET === '1')
|
|
8
|
+
return;
|
|
9
|
+
if (process.env.PRAVE_TELEMETRY === '0')
|
|
10
|
+
return; // user opted out of analytics
|
|
11
|
+
// CI / non-interactive shells are unlikely to act on a nudge — and
|
|
12
|
+
// the noise breaks pipe-parsing scripts.
|
|
13
|
+
if (!process.stdout.isTTY)
|
|
14
|
+
return;
|
|
15
|
+
const creds = await loadCredentials();
|
|
16
|
+
if (creds)
|
|
17
|
+
return; // logged-in → no nudge
|
|
18
|
+
alreadyNudged = true;
|
|
19
|
+
const cta = chalk.cyan('prave login');
|
|
20
|
+
const url = chalk.dim('— takes 10 seconds, free forever');
|
|
21
|
+
const message = (() => {
|
|
22
|
+
switch (context) {
|
|
23
|
+
case 'search':
|
|
24
|
+
return `${chalk.dim('Save these to your library, get token costs + conflicts.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
25
|
+
case 'whatdoes':
|
|
26
|
+
return `${chalk.dim('Want the full audit — triggers, conflicts, token cost?')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
27
|
+
case 'list':
|
|
28
|
+
return `${chalk.dim('Sign in to see AI descriptions, conflicts and token cost.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
29
|
+
case 'overview':
|
|
30
|
+
return `${chalk.dim('Track this over time on prave.app.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
31
|
+
case 'generic':
|
|
32
|
+
default:
|
|
33
|
+
return `${chalk.dim('Get the full Skill Intelligence on prave.app.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
// One blank line so the nudge isn't glued to the command's main
|
|
37
|
+
// output. Two `console.log` calls so the chalk styles survive the
|
|
38
|
+
// pipeline cleanly.
|
|
39
|
+
console.log();
|
|
40
|
+
console.log(message);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Post-install banner used by package.json's `postinstall` script.
|
|
44
|
+
* Runs once after `npm i -g @prave/cli`. Stays short — npm's install
|
|
45
|
+
* log is already crowded.
|
|
46
|
+
*/
|
|
47
|
+
export function printPostInstallBanner() {
|
|
48
|
+
if (process.env.CI)
|
|
49
|
+
return;
|
|
50
|
+
if (process.env.PRAVE_QUIET === '1')
|
|
51
|
+
return;
|
|
52
|
+
console.log();
|
|
53
|
+
console.log(chalk.bold(` Prave CLI installed.`));
|
|
54
|
+
console.log();
|
|
55
|
+
console.log(` ${chalk.cyan('prave login')} ${chalk.dim('— create your free account (browser)')}`);
|
|
56
|
+
console.log(` ${chalk.cyan('prave search <q>')} ${chalk.dim('— find any Claude Skill')}`);
|
|
57
|
+
console.log(` ${chalk.cyan('prave docs')} ${chalk.dim('— open the docs')}`);
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(chalk.dim(' Docs: https://prave.app/docs · Issues: github.com/eppstudio/prave'));
|
|
60
|
+
console.log();
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prave/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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": [
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"main": "dist/index.js",
|
|
45
45
|
"files": [
|
|
46
|
-
"dist"
|
|
46
|
+
"dist",
|
|
47
|
+
"scripts/postinstall.mjs"
|
|
47
48
|
],
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -51,11 +52,13 @@
|
|
|
51
52
|
"commander": "^12.1.0",
|
|
52
53
|
"open": "^10.1.0",
|
|
53
54
|
"ora": "^8.0.1",
|
|
55
|
+
"tar": "^7.4.3",
|
|
54
56
|
"undici": "^6.18.0",
|
|
55
|
-
"@prave/shared": "1.
|
|
57
|
+
"@prave/shared": "1.4.1"
|
|
56
58
|
},
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"@types/node": "^20.12.7",
|
|
61
|
+
"@types/tar": "^6.1.13",
|
|
59
62
|
"tsx": "^4.11.0",
|
|
60
63
|
"typescript": "^5.4.5"
|
|
61
64
|
},
|
|
@@ -67,6 +70,7 @@
|
|
|
67
70
|
"cli": "tsx src/index.ts",
|
|
68
71
|
"build": "tsc -p tsconfig.json && node scripts/inject-config.mjs",
|
|
69
72
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
70
|
-
"lint": "tsc -p tsconfig.json --noEmit"
|
|
73
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
74
|
+
"postinstall": "node scripts/postinstall.mjs"
|
|
71
75
|
}
|
|
72
76
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* npm postinstall hook. Runs once after `npm i -g @prave/cli` (and on
|
|
4
|
+
* upgrades). Prints a small "you're set — what now?" banner so the
|
|
5
|
+
* thousands of CLI installs we're seeing actually find the dashboard
|
|
6
|
+
* + the docs.
|
|
7
|
+
*
|
|
8
|
+
* Silent in CI / non-interactive shells / when PRAVE_QUIET=1 is set.
|
|
9
|
+
* Also silent on dependent installs — npm sets `npm_config_global` to
|
|
10
|
+
* 'false' or unset when we're being installed as a dependency, and
|
|
11
|
+
* the banner is only useful for the global-install user.
|
|
12
|
+
*/
|
|
13
|
+
if (process.env.CI) process.exit(0)
|
|
14
|
+
if (process.env.PRAVE_QUIET === '1') process.exit(0)
|
|
15
|
+
// `npm_config_global` is `'true'` only for `npm i -g`. Skip for local
|
|
16
|
+
// dependency installs (e.g. when our worker container builds).
|
|
17
|
+
if (process.env.npm_config_global !== 'true') process.exit(0)
|
|
18
|
+
|
|
19
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
20
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
21
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`
|
|
22
|
+
|
|
23
|
+
console.log()
|
|
24
|
+
console.log(` ${bold('Prave CLI installed.')}`)
|
|
25
|
+
console.log()
|
|
26
|
+
console.log(` ${cyan('prave login')} ${dim('— create your free account (browser)')}`)
|
|
27
|
+
console.log(` ${cyan('prave search <q>')} ${dim('— find any Claude Skill')}`)
|
|
28
|
+
console.log(` ${cyan('prave docs')} ${dim('— open the docs')}`)
|
|
29
|
+
console.log()
|
|
30
|
+
console.log(dim(' Docs: https://prave.app/docs · Issues: github.com/eppstudio/prave'))
|
|
31
|
+
console.log()
|