@prave/cli 1.2.2 → 1.4.0
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 +33 -34
- package/dist/commands/docs.js +32 -0
- package/dist/commands/mcp-install.js +197 -0
- package/dist/commands/mcp-server.js +211 -0
- package/dist/commands/run.js +301 -0
- package/dist/index.js +42 -0
- package/package.json +5 -2
package/dist/commands/deploy.js
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import ora from 'ora';
|
|
6
|
-
import { AGENT_REGISTRY } from '@prave/shared';
|
|
6
|
+
import { AGENT_REGISTRY, compileSkill } from '@prave/shared';
|
|
7
7
|
import { track } from '../lib/analytics.js';
|
|
8
8
|
import { api, ApiError } from '../lib/api.js';
|
|
9
9
|
import { requireAuth } from '../lib/credentials.js';
|
|
@@ -41,34 +41,30 @@ async function fetchRemoteSkill(slug) {
|
|
|
41
41
|
}
|
|
42
42
|
return data.content;
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const trimmed = content.trim().replace(/\s+/g, ' ').slice(0, 200);
|
|
54
|
-
return `---\nname: ${slug}\ndescription: ${trimmed}\n---\n${content}`;
|
|
55
|
-
}
|
|
56
|
-
function buildDestPath(agent, basePath, os, slug) {
|
|
44
|
+
/**
|
|
45
|
+
* Build the on-disk write path for the given agent + slug. The
|
|
46
|
+
* `compileSkill()` import from `@prave/shared` decides the *content*
|
|
47
|
+
* transform (e.g. Cursor `.mdc` frontmatter rewrite); this function
|
|
48
|
+
* only resolves the absolute filesystem location by combining the
|
|
49
|
+
* agent's `basePath` from user settings with the relative path the
|
|
50
|
+
* shared compiler returned.
|
|
51
|
+
*/
|
|
52
|
+
function buildDestPath(agent, basePath, os, slug, relPath) {
|
|
57
53
|
const expanded = expandHome(basePath, os);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
54
|
+
// The shared compiler returns POSIX-style relPaths
|
|
55
|
+
// (e.g. `<slug>/SKILL.md` or `.cursor/rules/<slug>.mdc`). For
|
|
56
|
+
// Cursor specifically the user's `basePath` already points at
|
|
57
|
+
// `.cursor/rules/`, so we collapse the leading `.cursor/rules/`
|
|
58
|
+
// to avoid the path duplicating to `.cursor/rules/.cursor/rules/`.
|
|
59
|
+
const collapsed = agent === 'cursor' && relPath.startsWith('.cursor/rules/')
|
|
60
|
+
? relPath.slice('.cursor/rules/'.length)
|
|
61
|
+
: relPath;
|
|
62
|
+
const file = join(expanded, ...collapsed.split('/'));
|
|
63
|
+
const dir = file.slice(0, file.length - collapsed.split('/').slice(-1)[0].length - 1);
|
|
67
64
|
return {
|
|
68
|
-
dir
|
|
69
|
-
file
|
|
70
|
-
|
|
71
|
-
display: `${basePath.replace(/[\\/]+$/, '')}/${slug}/SKILL.md`,
|
|
65
|
+
dir,
|
|
66
|
+
file,
|
|
67
|
+
display: `${basePath.replace(/[\\/]+$/, '')}/${collapsed}`,
|
|
72
68
|
};
|
|
73
69
|
}
|
|
74
70
|
export async function deployCommand(skillName, opts = {}) {
|
|
@@ -146,10 +142,12 @@ export async function deployCommand(skillName, opts = {}) {
|
|
|
146
142
|
const meta = AGENT_REGISTRY[agent];
|
|
147
143
|
const paths = settings.skill_paths[agent] ?? meta.defaultPath;
|
|
148
144
|
const basePath = os === 'windows' ? paths.windows : paths.mac;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
145
|
+
// Shared compileSkill() is the single source of truth — same
|
|
146
|
+
// function the SaaS /dashboard/compile page calls, so the CLI
|
|
147
|
+
// and the web zip produce byte-identical output for the same
|
|
148
|
+
// SKILL.md.
|
|
149
|
+
const artifact = compileSkill(source, skillName, agent);
|
|
150
|
+
const dest = buildDestPath(agent, basePath, os, skillName, artifact.path);
|
|
153
151
|
spinner.text = `→ ${meta.label}`;
|
|
154
152
|
if (opts.dryRun) {
|
|
155
153
|
okCount += 1;
|
|
@@ -157,7 +155,7 @@ export async function deployCommand(skillName, opts = {}) {
|
|
|
157
155
|
}
|
|
158
156
|
try {
|
|
159
157
|
await mkdir(dest.dir, { recursive: true });
|
|
160
|
-
await writeFile(dest.file, content, 'utf8');
|
|
158
|
+
await writeFile(dest.file, artifact.content, 'utf8');
|
|
161
159
|
okCount += 1;
|
|
162
160
|
}
|
|
163
161
|
catch (err) {
|
|
@@ -169,8 +167,9 @@ export async function deployCommand(skillName, opts = {}) {
|
|
|
169
167
|
const meta = AGENT_REGISTRY[agent];
|
|
170
168
|
const paths = settings.skill_paths[agent] ?? meta.defaultPath;
|
|
171
169
|
const basePath = os === 'windows' ? paths.windows : paths.mac;
|
|
172
|
-
const
|
|
173
|
-
const
|
|
170
|
+
const artifact = compileSkill(source, skillName, agent);
|
|
171
|
+
const dest = buildDestPath(agent, basePath, os, skillName, artifact.path);
|
|
172
|
+
const tag = artifact.converted ? chalk.dim(' (converted)') : '';
|
|
174
173
|
console.log(`${chalk.green('✓')} ${meta.label.padEnd(14)} → ${dest.display}${tag}`);
|
|
175
174
|
}
|
|
176
175
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { track } from '../lib/analytics.js';
|
|
8
|
+
import { log } from '../utils/logger.js';
|
|
9
|
+
const ENTRY_NPX = {
|
|
10
|
+
command: 'npx',
|
|
11
|
+
args: ['-y', '@prave/cli', 'mcp-server'],
|
|
12
|
+
};
|
|
13
|
+
const ENTRY_GLOBAL = {
|
|
14
|
+
command: 'prave',
|
|
15
|
+
args: ['mcp-server'],
|
|
16
|
+
};
|
|
17
|
+
export async function mcpInstallCommand() {
|
|
18
|
+
track('cli_mcp_install');
|
|
19
|
+
const target = resolveClaudeDesktopConfigPath();
|
|
20
|
+
if (!target) {
|
|
21
|
+
log.error(`This OS (${platform()}) isn't supported by Claude Desktop yet. Use the manual JSON snippet from /dashboard/settings/mcp instead.`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Prefer the global `prave` binary when it's on PATH — it's faster
|
|
26
|
+
// (no npx resolution at every Claude Desktop launch), version-pinned
|
|
27
|
+
// by the user (no surprise updates mid-session), and the config
|
|
28
|
+
// entry is two tokens shorter. Fall back to the npx form when the
|
|
29
|
+
// user is on a one-shot `npx @prave/cli mcp install` path.
|
|
30
|
+
const hasGlobalPrave = await detectGlobalPrave();
|
|
31
|
+
const PRAVE_ENTRY = hasGlobalPrave ? ENTRY_GLOBAL : ENTRY_NPX;
|
|
32
|
+
// Read existing config, tolerate the "file doesn't exist yet" case
|
|
33
|
+
// — Claude Desktop creates the file on first launch, but users often
|
|
34
|
+
// run our setup BEFORE opening the app for the first time.
|
|
35
|
+
let existing = {};
|
|
36
|
+
let fileExisted = false;
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(target, 'utf8');
|
|
39
|
+
fileExisted = true;
|
|
40
|
+
try {
|
|
41
|
+
existing = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
log.error(`Couldn't parse ${target} as JSON (${err.message}). Fix it manually or delete it and re-run.`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code !== 'ENOENT') {
|
|
51
|
+
log.error(`Couldn't read ${target}: ${err.message}`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Idempotency check — if a `prave` entry with our exact command +
|
|
57
|
+
// args already exists, don't churn the file.
|
|
58
|
+
const current = existing.mcpServers?.prave;
|
|
59
|
+
if (current &&
|
|
60
|
+
current.command === PRAVE_ENTRY.command &&
|
|
61
|
+
JSON.stringify(current.args ?? []) === JSON.stringify(PRAVE_ENTRY.args ?? [])) {
|
|
62
|
+
log.success('Prave MCP server is already wired into Claude Desktop.');
|
|
63
|
+
log.dim(`Config: ${target}`);
|
|
64
|
+
log.dim('Restart Claude Desktop if you haven\'t since the last edit.');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Backup before mutating. Only when the file existed — there's
|
|
68
|
+
// nothing to back up otherwise.
|
|
69
|
+
if (fileExisted) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await readFile(target, 'utf8');
|
|
72
|
+
await writeFile(`${target}.bak`, raw, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
log.warn(`Couldn't write ${target}.bak — proceeding anyway: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Merge our entry, preserving everything else (including any other
|
|
79
|
+
// MCP servers the user has configured).
|
|
80
|
+
const nextConfig = {
|
|
81
|
+
...existing,
|
|
82
|
+
mcpServers: {
|
|
83
|
+
...(existing.mcpServers ?? {}),
|
|
84
|
+
prave: PRAVE_ENTRY,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await mkdir(dirname(target), { recursive: true });
|
|
89
|
+
await writeFile(target, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8');
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
log.error(`Couldn't write ${target}: ${err.message}`);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
log.success('Prave MCP server installed for Claude Desktop.');
|
|
97
|
+
console.log();
|
|
98
|
+
console.log(` ${chalk.dim('Config:')} ${target}`);
|
|
99
|
+
if (fileExisted) {
|
|
100
|
+
console.log(` ${chalk.dim('Backup:')} ${target}.bak`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.log(` ${chalk.dim('Note:')} New config file created.`);
|
|
104
|
+
}
|
|
105
|
+
console.log(` ${chalk.dim('Mode:')} ${hasGlobalPrave
|
|
106
|
+
? 'global `prave` binary (fast, no npx hop)'
|
|
107
|
+
: 'npx (no global install needed)'}`);
|
|
108
|
+
console.log();
|
|
109
|
+
log.info('Next:');
|
|
110
|
+
log.dim(' 1. Restart Claude Desktop.');
|
|
111
|
+
log.dim(' 2. Prave\'s tools appear in the MCP panel.');
|
|
112
|
+
log.dim(' 3. Try: "Find me a skill for React testing".');
|
|
113
|
+
if (!hasGlobalPrave) {
|
|
114
|
+
console.log();
|
|
115
|
+
log.dim(`Tip: ${chalk.bold('npm i -g @prave/cli')} once, and Claude Desktop boots Prave a second faster (no npx resolution).`);
|
|
116
|
+
}
|
|
117
|
+
// Ping the heartbeat so the SaaS Settings → MCP card shows
|
|
118
|
+
// "Connection wired" even before the user fires the first real
|
|
119
|
+
// tool call. Best-effort; failure swallowed.
|
|
120
|
+
void pingHeartbeatBestEffort();
|
|
121
|
+
}
|
|
122
|
+
/* ─── internals ─────────────────────────────────────────────────── */
|
|
123
|
+
function resolveClaudeDesktopConfigPath() {
|
|
124
|
+
const home = homedir();
|
|
125
|
+
switch (platform()) {
|
|
126
|
+
case 'darwin':
|
|
127
|
+
return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
128
|
+
case 'win32': {
|
|
129
|
+
const appData = process.env.APPDATA;
|
|
130
|
+
if (!appData)
|
|
131
|
+
return null;
|
|
132
|
+
return join(appData, 'Claude', 'claude_desktop_config.json');
|
|
133
|
+
}
|
|
134
|
+
case 'linux':
|
|
135
|
+
return join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
136
|
+
default:
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function pingHeartbeatBestEffort() {
|
|
141
|
+
try {
|
|
142
|
+
const { api } = await import('../lib/api.js');
|
|
143
|
+
await api.post('/api/v1/me/mcp-heartbeat', {}, true);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// No creds yet, or API unreachable — fine. The first real tool
|
|
147
|
+
// call from Claude Desktop will set the heartbeat anyway.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Is the `prave` binary globally installed and on PATH?
|
|
152
|
+
*
|
|
153
|
+
* Uses `which` (POSIX) / `where` (Windows) to probe. Returns true only
|
|
154
|
+
* when the resolved path is NOT inside an npx temp directory — that
|
|
155
|
+
* way running this command via `npx @prave/cli mcp install` doesn't
|
|
156
|
+
* fool the detector into thinking the user has a permanent install
|
|
157
|
+
* (the resolved binary in that case is in `~/.npm/_npx/...`).
|
|
158
|
+
*
|
|
159
|
+
* Timeout: 1s. If `which` hangs (shouldn't ever, but networked
|
|
160
|
+
* filesystems are weird), we fall back to the npx form. Worst case
|
|
161
|
+
* is one extra npx hop per Claude Desktop launch — not a correctness
|
|
162
|
+
* issue.
|
|
163
|
+
*/
|
|
164
|
+
async function detectGlobalPrave() {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const cmd = platform() === 'win32' ? 'where' : 'which';
|
|
167
|
+
const child = spawn(cmd, ['prave'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
168
|
+
let output = '';
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
child.kill();
|
|
171
|
+
resolve(false);
|
|
172
|
+
}, 1_000);
|
|
173
|
+
child.stdout?.on('data', (chunk) => {
|
|
174
|
+
output += chunk.toString('utf8');
|
|
175
|
+
});
|
|
176
|
+
child.on('error', () => {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
resolve(false);
|
|
179
|
+
});
|
|
180
|
+
child.on('close', (code) => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
if (code !== 0 || !output.trim()) {
|
|
183
|
+
resolve(false);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Filter out npx-temp paths so an in-flight `npx @prave/cli` run
|
|
187
|
+
// doesn't get mistaken for a global install.
|
|
188
|
+
const firstLine = output.split('\n')[0]?.trim() ?? '';
|
|
189
|
+
const looksLikeNpxTemp = /[/\\](_npx|\.npm[/\\]_npx|npm-cache[/\\]_npx)[/\\]/i.test(firstLine);
|
|
190
|
+
resolve(!looksLikeNpxTemp);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
// Silences the unused-import lint when no other branch reads `stat`.
|
|
195
|
+
// The function is kept here intentionally for future expansion (e.g.
|
|
196
|
+
// staleness check on `.bak` files) without re-adding the import.
|
|
197
|
+
void stat;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { api, ApiError } from '../lib/api.js';
|
|
7
|
+
import { loadCredentials } from '../lib/credentials.js';
|
|
8
|
+
const TOOLS = [
|
|
9
|
+
{
|
|
10
|
+
name: 'prave_search_skills',
|
|
11
|
+
description: 'Search the public Prave catalogue of Claude Skills. Returns up to 20 results. Use this when the user is exploring which Skill to install, or asking what Skills exist for a domain.',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
query: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Keyword or phrase to search. Searches name + description + tags.',
|
|
18
|
+
},
|
|
19
|
+
category: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Optional category filter (e.g. "testing", "design", "deployment"). See the Discover page for the full list.',
|
|
22
|
+
},
|
|
23
|
+
limit: {
|
|
24
|
+
type: 'integer',
|
|
25
|
+
minimum: 1,
|
|
26
|
+
maximum: 20,
|
|
27
|
+
default: 10,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ['query'],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'prave_whatdoes',
|
|
35
|
+
description: 'Look up one Skill by slug. Returns the canonical name, description, tags, install count, and rating. Useful as a confirmation step before `prave_install_skill`.',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
slug: { type: 'string', description: 'Skill slug, e.g. "vercel-labs-agent-skills-react-best-practices".' },
|
|
40
|
+
},
|
|
41
|
+
required: ['slug'],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'prave_my_skills',
|
|
46
|
+
description: 'List Skills the signed-in user has installed locally or authored. Useful when the user asks "what Skills do I already have?".',
|
|
47
|
+
inputSchema: { type: 'object', properties: {} },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'prave_audit_library',
|
|
51
|
+
description: 'Return the Skill Intelligence overview for the signed-in user: total Skill count, library token footprint, trigger count over the last 30 days, conflicts detected, and the 5 token-heaviest Skills. Use this when the user asks about token cost, performance, or which Skills to trim.',
|
|
52
|
+
inputSchema: { type: 'object', properties: {} },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'prave_install_skill',
|
|
56
|
+
description: 'Install a Skill from the catalogue into the user\'s local agent directory (~/.claude/skills/). Spawns the `prave install <slug>` CLI subprocess and streams its output back. Use only after confirming the slug with `prave_whatdoes`.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
slug: { type: 'string', description: 'Skill slug to install.' },
|
|
61
|
+
},
|
|
62
|
+
required: ['slug'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
export async function mcpServerCommand() {
|
|
67
|
+
// Auth check up-front. If the user runs `prave mcp-server` without
|
|
68
|
+
// logging in, the MCP frame would otherwise look healthy but every
|
|
69
|
+
// tool call would fail with 401 — confusing. Emit a clear stderr
|
|
70
|
+
// message and exit, so Claude Desktop's MCP UI surfaces a bad-config
|
|
71
|
+
// error instead of letting the user wonder.
|
|
72
|
+
const creds = await loadCredentials();
|
|
73
|
+
if (!creds) {
|
|
74
|
+
process.stderr.write('prave mcp-server: not logged in. Run `prave login` first.\n');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const server = new Server({ name: 'prave-mcp', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
78
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
79
|
+
tools: TOOLS,
|
|
80
|
+
}));
|
|
81
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
82
|
+
const { name, arguments: args = {} } = req.params;
|
|
83
|
+
// Best-effort heartbeat — every successful tool call pings the
|
|
84
|
+
// server so the Settings → MCP card knows the user is wired in.
|
|
85
|
+
// Fired-and-forgotten; failures don't break the tool response.
|
|
86
|
+
void pingHeartbeat();
|
|
87
|
+
try {
|
|
88
|
+
switch (name) {
|
|
89
|
+
case 'prave_search_skills':
|
|
90
|
+
return await handleSearch(args);
|
|
91
|
+
case 'prave_whatdoes':
|
|
92
|
+
return await handleWhatdoes(args);
|
|
93
|
+
case 'prave_my_skills':
|
|
94
|
+
return await handleMySkills();
|
|
95
|
+
case 'prave_audit_library':
|
|
96
|
+
return await handleAuditLibrary();
|
|
97
|
+
case 'prave_install_skill':
|
|
98
|
+
return await handleInstallSkill(args);
|
|
99
|
+
default:
|
|
100
|
+
return mcpError(`Unknown tool: ${name}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const msg = err instanceof ApiError ? err.message : err.message;
|
|
105
|
+
return mcpError(msg);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const transport = new StdioServerTransport();
|
|
109
|
+
await server.connect(transport);
|
|
110
|
+
// Block forever — the transport keeps the event loop alive while
|
|
111
|
+
// stdio is open. Process exits cleanly when the parent (Claude
|
|
112
|
+
// Desktop) closes the pipe.
|
|
113
|
+
}
|
|
114
|
+
/* ─── tool handlers ────────────────────────────────────────────── */
|
|
115
|
+
async function handleSearch(args) {
|
|
116
|
+
const params = new URLSearchParams();
|
|
117
|
+
params.set('q', args.query);
|
|
118
|
+
if (args.category)
|
|
119
|
+
params.set('category', args.category);
|
|
120
|
+
params.set('limit', String(Math.min(20, Math.max(1, args.limit ?? 10))));
|
|
121
|
+
const { data } = await api.get(`/api/v1/skills?${params.toString()}`, true);
|
|
122
|
+
const items = data.items ?? [];
|
|
123
|
+
const lines = items.map((s) => `• ${s.name} (${s.slug}) — ${s.description ?? 'no description'} · ↓${s.install_count}`);
|
|
124
|
+
return mcpText(lines.length
|
|
125
|
+
? `Found ${lines.length} Skill${lines.length === 1 ? '' : 's'}:\n\n${lines.join('\n')}`
|
|
126
|
+
: `No Skills matched "${args.query}".`);
|
|
127
|
+
}
|
|
128
|
+
async function handleWhatdoes(args) {
|
|
129
|
+
const { data } = await api.get(`/api/v1/skills/${encodeURIComponent(args.slug)}`, true);
|
|
130
|
+
return mcpText([
|
|
131
|
+
`${data.name} (${data.slug})`,
|
|
132
|
+
data.description ?? '(no description)',
|
|
133
|
+
'',
|
|
134
|
+
`Tags: ${data.tags.length ? data.tags.join(', ') : '(none)'}`,
|
|
135
|
+
`Installs: ${data.install_count}`,
|
|
136
|
+
data.rating != null ? `Rating: ${data.rating.toFixed(1)}/5` : '',
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join('\n'));
|
|
140
|
+
}
|
|
141
|
+
async function handleMySkills() {
|
|
142
|
+
const { data } = await api.get('/api/v1/me/skills', true);
|
|
143
|
+
const items = data.items ?? [];
|
|
144
|
+
if (items.length === 0) {
|
|
145
|
+
return mcpText('You haven\'t installed or authored any Skills yet. Try `prave_search_skills` to browse the catalogue.');
|
|
146
|
+
}
|
|
147
|
+
const lines = items.map((s) => `• ${s.name} (${s.slug})`);
|
|
148
|
+
return mcpText(`You have ${items.length} Skill${items.length === 1 ? '' : 's'}:\n\n${lines.join('\n')}`);
|
|
149
|
+
}
|
|
150
|
+
async function handleAuditLibrary() {
|
|
151
|
+
const { data } = await api.get('/api/v1/intelligence/overview', true);
|
|
152
|
+
const heaviestLines = data.heaviest
|
|
153
|
+
.slice(0, 5)
|
|
154
|
+
.map((s) => ` • ${s.name ?? s.slug ?? '(unnamed)'} — ${s.estimated_tokens.toLocaleString()} tokens`);
|
|
155
|
+
return mcpText([
|
|
156
|
+
`Skill Intelligence overview`,
|
|
157
|
+
``,
|
|
158
|
+
`Total Skills indexed: ${data.total_skills}`,
|
|
159
|
+
`Library token footprint: ${data.total_estimated_tokens.toLocaleString()} tokens`,
|
|
160
|
+
`Triggered in last 30 days: ${data.triggered_30d}`,
|
|
161
|
+
`Conflicts detected: ${data.conflict_count}`,
|
|
162
|
+
``,
|
|
163
|
+
`Top 5 token-heaviest:`,
|
|
164
|
+
...(heaviestLines.length ? heaviestLines : [' (none indexed yet — run `prave sync`)']),
|
|
165
|
+
].join('\n'));
|
|
166
|
+
}
|
|
167
|
+
async function handleInstallSkill(args) {
|
|
168
|
+
// Run the existing CLI install command as a subprocess. Identical
|
|
169
|
+
// behavior to a user typing `prave install <slug>` — same plan
|
|
170
|
+
// checks, same dependency resolution, same on-disk effects. Tail
|
|
171
|
+
// the output for the MCP response.
|
|
172
|
+
return await new Promise((resolve) => {
|
|
173
|
+
const child = spawn(process.argv[0] ?? 'node', [process.argv[1] ?? '', 'install', args.slug], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
174
|
+
let stdout = '';
|
|
175
|
+
let stderr = '';
|
|
176
|
+
child.stdout?.on('data', (chunk) => {
|
|
177
|
+
stdout += chunk.toString('utf8');
|
|
178
|
+
});
|
|
179
|
+
child.stderr?.on('data', (chunk) => {
|
|
180
|
+
stderr += chunk.toString('utf8');
|
|
181
|
+
});
|
|
182
|
+
child.on('error', (err) => {
|
|
183
|
+
resolve(mcpError(`Failed to spawn install: ${err.message}`));
|
|
184
|
+
});
|
|
185
|
+
child.on('close', (code) => {
|
|
186
|
+
const output = (stdout + (stderr ? `\n${stderr}` : '')).trim() ||
|
|
187
|
+
(code === 0 ? `Installed ${args.slug}.` : `Install exited with code ${code}.`);
|
|
188
|
+
resolve(code === 0
|
|
189
|
+
? mcpText(output)
|
|
190
|
+
: mcpError(output));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/* ─── helpers ──────────────────────────────────────────────────── */
|
|
195
|
+
function mcpText(text) {
|
|
196
|
+
return { content: [{ type: 'text', text }] };
|
|
197
|
+
}
|
|
198
|
+
function mcpError(text) {
|
|
199
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
200
|
+
}
|
|
201
|
+
async function pingHeartbeat() {
|
|
202
|
+
try {
|
|
203
|
+
await api.post('/api/v1/me/mcp-heartbeat', {}, true);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Heartbeat is fire-and-forget. The MCP tool still works even
|
|
207
|
+
// when the heartbeat endpoint is unreachable (e.g. brief API
|
|
208
|
+
// outage); the SaaS Settings card just won't update its
|
|
209
|
+
// last-seen timestamp.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -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/index.js
CHANGED
|
@@ -6,14 +6,18 @@ 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';
|
|
12
13
|
import { listCommand } from './commands/list.js';
|
|
13
14
|
import { loginCommand } from './commands/login.js';
|
|
14
15
|
import { logoutCommand } from './commands/logout.js';
|
|
16
|
+
import { mcpInstallCommand } from './commands/mcp-install.js';
|
|
17
|
+
import { mcpServerCommand } from './commands/mcp-server.js';
|
|
15
18
|
import { optimizeCommand } from './commands/optimize.js';
|
|
16
19
|
import { overviewCommand } from './commands/overview.js';
|
|
20
|
+
import { runDeployCommand, runListCommand, runLogsCommand, } from './commands/run.js';
|
|
17
21
|
import { searchCommand } from './commands/search.js';
|
|
18
22
|
import { settingsCommand } from './commands/settings.js';
|
|
19
23
|
import { syncCommand } from './commands/sync.js';
|
|
@@ -161,6 +165,35 @@ program
|
|
|
161
165
|
.option('--agent <agent>', 'restrict deploy to a single agent (claude-code | codex | cursor | gemini | cline | amp)')
|
|
162
166
|
.option('--dry-run', 'log every destination path but write nothing — preview before committing')
|
|
163
167
|
.action(deployCommand);
|
|
168
|
+
program
|
|
169
|
+
.command('mcp-server')
|
|
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.')
|
|
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);
|
|
192
|
+
program
|
|
193
|
+
.command('mcp install')
|
|
194
|
+
.alias('mcp-install')
|
|
195
|
+
.description('One-command setup: patches Claude Desktop\'s config to wire the Prave MCP server. Idempotent, takes a `.bak` of any existing config, preserves other mcpServers entries. Restart Claude Desktop after running.')
|
|
196
|
+
.action(mcpInstallCommand);
|
|
164
197
|
// ─── Help command — full quick-reference ───────────────────────────
|
|
165
198
|
program
|
|
166
199
|
.command('cheat')
|
|
@@ -204,6 +237,15 @@ program
|
|
|
204
237
|
'Settings',
|
|
205
238
|
' prave settings # configure agents + paths',
|
|
206
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
|
+
'',
|
|
207
249
|
'Telemetry',
|
|
208
250
|
' PRAVE_TELEMETRY=0 # opt out of CLI usage analytics',
|
|
209
251
|
'',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prave/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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": [
|
|
@@ -46,15 +46,18 @@
|
|
|
46
46
|
"dist"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
50
|
"chalk": "^5.3.0",
|
|
50
51
|
"commander": "^12.1.0",
|
|
51
52
|
"open": "^10.1.0",
|
|
52
53
|
"ora": "^8.0.1",
|
|
54
|
+
"tar": "^7.4.3",
|
|
53
55
|
"undici": "^6.18.0",
|
|
54
|
-
"@prave/shared": "1.
|
|
56
|
+
"@prave/shared": "1.4.0"
|
|
55
57
|
},
|
|
56
58
|
"devDependencies": {
|
|
57
59
|
"@types/node": "^20.12.7",
|
|
60
|
+
"@types/tar": "^6.1.13",
|
|
58
61
|
"tsx": "^4.11.0",
|
|
59
62
|
"typescript": "^5.4.5"
|
|
60
63
|
},
|