@prave/cli 1.2.1 → 1.3.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/README.md CHANGED
@@ -172,7 +172,7 @@ API health and uptime: [status.prave.app](https://status.prave.app) — auto-ref
172
172
  - 📖 [**CLI Cheat Sheet**](https://prave.app/docs/cli/cheat-sheet) — every command on one page
173
173
  - 💚 [**status.prave.app**](https://status.prave.app) — real-time health
174
174
  - 🐛 [**GitHub Issues**](https://github.com/eppstudio/prave/issues) — bug reports & feature requests
175
- - ✉️ [info@epplab-studio.de](mailto:info@epplab-studio.de) — direct contact
175
+ - ✉️ [hello@epplab-studio.de](mailto:hello@epplab-studio.de) — direct contact
176
176
 
177
177
  ## License
178
178
 
@@ -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
- function describeContentForCursor(content, slug) {
45
- // Has frontmatter? best-effort rename triggers: globs:
46
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?/);
47
- if (fmMatch && fmMatch[1] !== undefined) {
48
- const body = content.slice(fmMatch[0].length);
49
- const fmInner = fmMatch[1].replace(/(^|\n)triggers:/g, '$1globs:');
50
- return `---\n${fmInner}\n---\n${body}`;
51
- }
52
- // No frontmatter synthesize
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
- if (agent === 'cursor') {
59
- // .cursor/rules/<slug>.mdc — the basePath already points at .cursor/rules
60
- return {
61
- dir: expanded,
62
- file: join(expanded, `${slug}.mdc`),
63
- converted: true,
64
- display: `${basePath.replace(/[\\/]+$/, '')}/${slug}.mdc`,
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: join(expanded, slug),
69
- file: join(expanded, slug, 'SKILL.md'),
70
- converted: false,
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
- const dest = buildDestPath(agent, basePath, os, skillName);
150
- const content = dest.converted
151
- ? describeContentForCursor(source, skillName)
152
- : source;
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 dest = buildDestPath(agent, basePath, os, skillName);
173
- const tag = dest.converted ? chalk.dim(' (converted)') : '';
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);
@@ -39,6 +39,17 @@ export async function loginCommand() {
39
39
  expires_at: data.expires_at ?? undefined,
40
40
  });
41
41
  spinner.succeed('Logged in.');
42
+ // Replay any Skill-invocation events the hook buffered while the
43
+ // user was offline / signed out. Silent when nothing's queued;
44
+ // prints "Syncing N events…" + a confirmation when the file has
45
+ // real backlog. Failures swallowed inside.
46
+ try {
47
+ const { flushBufferedTelemetry } = await import('../lib/flush-telemetry.js');
48
+ await flushBufferedTelemetry();
49
+ }
50
+ catch (flushErr) {
51
+ log.dim(`Telemetry sync skipped: ${flushErr.message}`);
52
+ }
42
53
  // Onboarding: prefill from the SaaS profile, let the user toggle
43
54
  // with space/enter, persist back, and offer to install hooks.
44
55
  // Failures here are non-fatal — login succeeded.
@@ -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
+ }
@@ -7,6 +7,7 @@ import { api, ApiError } from '../lib/api.js';
7
7
  import { CONFIG } from '../lib/config.js';
8
8
  import { requireAuth } from '../lib/credentials.js';
9
9
  import { HOOK_SUPPORTED, installHooksForAgents, uninstallHooksForAgents, } from '../lib/hook.js';
10
+ import { bufferEvent } from '../lib/telemetry-buffer.js';
10
11
  import { AGENT_REGISTRY } from '@prave/shared';
11
12
  import { loadCursor, saveCursor } from '../lib/usage-cursor.js';
12
13
  import { scanTranscriptsForUsage } from '../lib/usage-scanner.js';
@@ -159,12 +160,6 @@ export async function usageReportCommand(opts = {}) {
159
160
  }
160
161
  if (debug)
161
162
  await debugLog(`slug=${slug}`);
162
- const session = await requireAuthSilent();
163
- if (!session) {
164
- if (debug)
165
- await debugLog('no auth — skipping');
166
- return;
167
- }
168
163
  // Build the telemetry blob from whatever Claude Code shipped in the
169
164
  // payload. Everything is best-effort — missing fields just don't
170
165
  // appear in `meta`. The server schema (usageMetaSchema) validates
@@ -192,11 +187,30 @@ export async function usageReportCommand(opts = {}) {
192
187
  if (typeof obj.prompt === 'string')
193
188
  meta.prompt_chars = obj.prompt.length;
194
189
  }
190
+ const triggered_at = new Date().toISOString();
191
+ const session = await requireAuthSilent();
192
+ // No credentials? Don't drop the event — buffer it to disk. The next
193
+ // `prave login` (or any authenticated command that calls
194
+ // `flushBufferedTelemetry`) replays the file against the same
195
+ // by-slug endpoint, so no Skill invocation is ever lost just because
196
+ // the user happened to be signed out at hook-fire time.
197
+ if (!session) {
198
+ await bufferEvent({
199
+ slug,
200
+ agent_type: 'claude',
201
+ triggered_at,
202
+ meta: { ...meta, source },
203
+ });
204
+ await hookLog(`${source}:buffered slug=${slug}`);
205
+ if (debug)
206
+ await debugLog(`no auth — buffered to telemetry-queue`);
207
+ return;
208
+ }
195
209
  try {
196
210
  const { data } = await api.post('/api/v1/intelligence/usage/by-slug', {
197
211
  slug,
198
212
  agent_type: 'claude',
199
- triggered_at: new Date().toISOString(),
213
+ triggered_at,
200
214
  meta: { ...meta, source },
201
215
  }, true);
202
216
  await hookLog(`${source}:ok slug=${slug} recorded=${data.recorded} stub=${data.created_stub}`);
@@ -205,11 +219,18 @@ export async function usageReportCommand(opts = {}) {
205
219
  }
206
220
  }
207
221
  catch (err) {
222
+ // Network down or API error while authenticated — also buffer so
223
+ // the event isn't lost. The next successful command will replay.
224
+ await bufferEvent({
225
+ slug,
226
+ agent_type: 'claude',
227
+ triggered_at,
228
+ meta: { ...meta, source },
229
+ });
208
230
  const msg = err.message;
209
- await hookLog(`${source}:err slug=${slug} ${msg.slice(0, 120)}`);
231
+ await hookLog(`${source}:buffered-on-err slug=${slug} ${msg.slice(0, 80)}`);
210
232
  if (debug)
211
- await debugLog(`error: ${msg}`);
212
- /* silent — never break the host shell */
233
+ await debugLog(`api error, buffered: ${msg}`);
213
234
  }
214
235
  }
215
236
  /**
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { installCommand } from './commands/install.js';
12
12
  import { listCommand } from './commands/list.js';
13
13
  import { loginCommand } from './commands/login.js';
14
14
  import { logoutCommand } from './commands/logout.js';
15
+ import { mcpInstallCommand } from './commands/mcp-install.js';
16
+ import { mcpServerCommand } from './commands/mcp-server.js';
15
17
  import { optimizeCommand } from './commands/optimize.js';
16
18
  import { overviewCommand } from './commands/overview.js';
17
19
  import { searchCommand } from './commands/search.js';
@@ -161,6 +163,15 @@ program
161
163
  .option('--agent <agent>', 'restrict deploy to a single agent (claude-code | codex | cursor | gemini | cline | amp)')
162
164
  .option('--dry-run', 'log every destination path but write nothing — preview before committing')
163
165
  .action(deployCommand);
166
+ program
167
+ .command('mcp-server')
168
+ .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
+ .action(mcpServerCommand);
170
+ program
171
+ .command('mcp install')
172
+ .alias('mcp-install')
173
+ .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.')
174
+ .action(mcpInstallCommand);
164
175
  // ─── Help command — full quick-reference ───────────────────────────
165
176
  program
166
177
  .command('cheat')
@@ -0,0 +1,42 @@
1
+ import chalk from 'chalk';
2
+ import { api } from './api.js';
3
+ import { log } from '../utils/logger.js';
4
+ import { flushBuffered, pendingTelemetryCount, } from './telemetry-buffer.js';
5
+ /**
6
+ * Replay any offline-buffered Skill-invocation events. Called by
7
+ * `prave login` (right after credentials land on disk) and also by
8
+ * the API client on every successful authenticated request, so the
9
+ * queue drains opportunistically even without a fresh login.
10
+ *
11
+ * Behavior:
12
+ * - silent when the queue is empty (most invocations)
13
+ * - prints "Syncing N telemetry events…" + a success or partial
14
+ * line when there's a non-zero count
15
+ * - never throws — telemetry failures must not break the CLI
16
+ *
17
+ * `force` skips the early "empty?" check, useful right after login
18
+ * where we know we just succeeded and want the user to see whatever
19
+ * sync number lands (even if zero — feels reassuring).
20
+ */
21
+ export async function flushBufferedTelemetry(opts = {}) {
22
+ try {
23
+ const pending = await pendingTelemetryCount();
24
+ if (pending === 0 && !opts.force)
25
+ return;
26
+ if (pending === 0)
27
+ return; // even with force, no spinner for 0
28
+ log.info(`${chalk.cyan('●')} You have telemetry updates from offline sessions. Syncing ${chalk.bold(pending)} event${pending === 1 ? '' : 's'} to your dashboard…`);
29
+ const result = await flushBuffered(async (body) => {
30
+ await api.post('/api/v1/intelligence/usage/by-slug', body, true);
31
+ });
32
+ if (result.failed === 0) {
33
+ log.success(`Synced ${chalk.bold(result.sent)} telemetry event${result.sent === 1 ? '' : 's'}. Dashboard is up to date.`);
34
+ }
35
+ else {
36
+ log.warn(`Synced ${result.sent} of ${result.total} — ${result.failed} will retry on the next run.`);
37
+ }
38
+ }
39
+ catch {
40
+ /* swallow — telemetry sync is best-effort */
41
+ }
42
+ }
@@ -0,0 +1,131 @@
1
+ import { appendFile, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CONFIG } from './config.js';
4
+ /**
5
+ * Offline telemetry buffer.
6
+ *
7
+ * When the PostToolUse hook fires while the user is logged out (no
8
+ * credentials in ~/.prave/credentials.json), the Skill invocation would
9
+ * otherwise be lost — the hook can't reach the API. Instead we append
10
+ * the event as one JSON line to `~/.prave/telemetry-queue.jsonl` and
11
+ * replay the whole file the next time the user logs in (or runs any
12
+ * command that hits the API while authenticated).
13
+ *
14
+ * Each line is the exact payload the live hook would have POSTed to
15
+ * `/api/v1/intelligence/usage/by-slug`, so replay is "send each line
16
+ * through the same endpoint". Server-side dedup (per-minute bucket
17
+ * keyed by `skill_metadata_id + agent_type`) makes the flush idempotent
18
+ * — a flush that crashes mid-way can re-run with no double-counting.
19
+ *
20
+ * The queue file is hard-capped at 5000 events (~600 KB of JSONL).
21
+ * Beyond that we drop the oldest line per fire — telemetry is best-
22
+ * effort, never something that should grow unbounded on disk.
23
+ */
24
+ export const TELEMETRY_QUEUE_FILE = join(CONFIG.praveDir, 'telemetry-queue.jsonl');
25
+ const MAX_QUEUE_LINES = 5_000;
26
+ const QUEUE_ROTATE_AT_BYTES = 1_000_000; // 1 MB safety stop
27
+ /**
28
+ * Append one event to the queue. Best-effort: any FS failure is
29
+ * swallowed because the hook MUST NOT throw — failing telemetry can
30
+ * never break the user's editor.
31
+ */
32
+ export async function bufferEvent(event) {
33
+ try {
34
+ await mkdir(CONFIG.praveDir, { recursive: true });
35
+ // Rotate before append if the file got too big. We keep the
36
+ // newest half — recent telemetry is more valuable than ancient
37
+ // backlog the user probably doesn't care about anymore.
38
+ const info = await stat(TELEMETRY_QUEUE_FILE).catch(() => null);
39
+ if (info && info.size > QUEUE_ROTATE_AT_BYTES) {
40
+ const raw = await readFile(TELEMETRY_QUEUE_FILE, 'utf8').catch(() => '');
41
+ const lines = raw.split('\n').filter(Boolean);
42
+ if (lines.length > MAX_QUEUE_LINES) {
43
+ const trimmed = lines.slice(-Math.floor(MAX_QUEUE_LINES / 2));
44
+ await writeFile(TELEMETRY_QUEUE_FILE, trimmed.join('\n') + '\n', 'utf8');
45
+ }
46
+ }
47
+ await appendFile(TELEMETRY_QUEUE_FILE, JSON.stringify(event) + '\n', 'utf8');
48
+ }
49
+ catch {
50
+ /* swallow — telemetry is best-effort */
51
+ }
52
+ }
53
+ /**
54
+ * Read the queue. Returns parsed events plus the raw line count so
55
+ * callers can report "N events synced". Skips malformed lines silently
56
+ * — a single corrupt write must not kill an entire replay.
57
+ */
58
+ async function readQueue() {
59
+ const raw = await readFile(TELEMETRY_QUEUE_FILE, 'utf8').catch(() => null);
60
+ if (!raw)
61
+ return [];
62
+ const out = [];
63
+ for (const line of raw.split('\n')) {
64
+ const t = line.trim();
65
+ if (!t)
66
+ continue;
67
+ try {
68
+ const parsed = JSON.parse(t);
69
+ if (parsed.slug && parsed.triggered_at)
70
+ out.push(parsed);
71
+ }
72
+ catch {
73
+ /* skip malformed */
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * Replay the queue against the by-slug endpoint and delete the file on
80
+ * full success. Caller passes the already-authenticated POST helper so
81
+ * we don't introduce a circular dep on api.ts.
82
+ *
83
+ * On partial failure (network blip mid-flush), the remaining events
84
+ * are rewritten so a later attempt picks up where we left off. Order
85
+ * is preserved across rewrites.
86
+ */
87
+ export async function flushBuffered(postBySlug) {
88
+ const events = await readQueue();
89
+ if (!events.length)
90
+ return { sent: 0, failed: 0, total: 0 };
91
+ let sent = 0;
92
+ const remaining = [];
93
+ for (let i = 0; i < events.length; i++) {
94
+ const ev = events[i];
95
+ if (!ev)
96
+ continue;
97
+ try {
98
+ await postBySlug({ ...ev, source: 'buffered' });
99
+ sent += 1;
100
+ }
101
+ catch {
102
+ // Keep this one and every event after it for the next attempt.
103
+ // We don't push-and-continue because a network blip likely means
104
+ // every subsequent POST will fail too — better to stop fast.
105
+ remaining.push(...events.slice(i));
106
+ break;
107
+ }
108
+ }
109
+ if (remaining.length === 0) {
110
+ await unlink(TELEMETRY_QUEUE_FILE).catch(() => { });
111
+ }
112
+ else {
113
+ await writeFile(TELEMETRY_QUEUE_FILE, remaining.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8').catch(() => { });
114
+ }
115
+ return { sent, failed: remaining.length, total: events.length };
116
+ }
117
+ /**
118
+ * Cheap "is there anything to flush?" check — used by login/whoami to
119
+ * decide whether to print the "syncing pending telemetry" line at all.
120
+ * Returns 0 when the file doesn't exist OR is empty.
121
+ */
122
+ export async function pendingTelemetryCount() {
123
+ const raw = await readFile(TELEMETRY_QUEUE_FILE, 'utf8').catch(() => null);
124
+ if (!raw)
125
+ return 0;
126
+ let n = 0;
127
+ for (const line of raw.split('\n'))
128
+ if (line.trim())
129
+ n += 1;
130
+ return n;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.2.1",
3
+ "version": "1.3.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": [
@@ -26,14 +26,14 @@
26
26
  "homepage": "https://prave.app",
27
27
  "bugs": {
28
28
  "url": "https://github.com/eppstudio/prave/issues",
29
- "email": "info@epplab-studio.de"
29
+ "email": "hello@epplab-studio.de"
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
33
33
  "url": "git+https://github.com/eppstudio/prave.git",
34
34
  "directory": "apps/cli"
35
35
  },
36
- "author": "EppLab Studio <info@epplab-studio.de> (https://epplab-studio.de)",
36
+ "author": "EppLab Studio <hello@epplab-studio.de> (https://epplab-studio.de)",
37
37
  "license": "MIT",
38
38
  "engines": {
39
39
  "node": ">=18"
@@ -46,12 +46,13 @@
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",
53
54
  "undici": "^6.18.0",
54
- "@prave/shared": "1.2.1"
55
+ "@prave/shared": "1.3.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@types/node": "^20.12.7",