@prave/cli 1.5.1 → 1.6.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/advisor.js +68 -0
- package/dist/commands/hooks.js +234 -0
- package/dist/commands/mcp-server.js +40 -0
- package/dist/index.js +39 -0
- package/dist/lib/hook.js +207 -1
- package/package.json +2 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { track } from '../lib/analytics.js';
|
|
4
|
+
import { api, ApiError } from '../lib/api.js';
|
|
5
|
+
import { requireAuth } from '../lib/credentials.js';
|
|
6
|
+
import { log } from '../utils/logger.js';
|
|
7
|
+
const RULE = '─────────────────────────────────────────';
|
|
8
|
+
export async function advisorCommand(task, opts = {}) {
|
|
9
|
+
const mode = opts.auto ? 'auto' : 'manual';
|
|
10
|
+
track('cli_advisor', { auto_mode: opts.auto === true, task_length: task?.length ?? 0 });
|
|
11
|
+
if (mode === 'manual') {
|
|
12
|
+
if (!task || task.trim().length < 8) {
|
|
13
|
+
log.error('Pass a task description of at least 8 characters, or use --auto.');
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
await requireAuth('advisor');
|
|
19
|
+
const spinner = ora(mode === 'auto' ? 'Thinking…' : `Thinking about "${truncate(task, 60)}"…`).start();
|
|
20
|
+
try {
|
|
21
|
+
const { data } = await api.post('/api/v1/advisor', { mode, task: mode === 'manual' ? task : undefined }, true);
|
|
22
|
+
spinner.stop();
|
|
23
|
+
console.log(chalk.dim(RULE));
|
|
24
|
+
console.log(chalk.bold('Recommended for you'));
|
|
25
|
+
console.log(chalk.dim(RULE));
|
|
26
|
+
console.log(wrap(data.prose, 78));
|
|
27
|
+
console.log();
|
|
28
|
+
if (data.recommendations.length === 0) {
|
|
29
|
+
log.dim('No specific Skills to recommend yet.');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
for (const rec of data.recommendations) {
|
|
33
|
+
console.log(` ${chalk.cyan('▸')} ${chalk.bold(rec.slug)}`);
|
|
34
|
+
console.log(` ${chalk.dim('Why:')} ${wrap(rec.reason, 70).split('\n').join('\n ')}`);
|
|
35
|
+
console.log(` ${chalk.dim(`prave install ${rec.slug}`)}`);
|
|
36
|
+
console.log();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
console.log(chalk.dim(`Quota: ${data.quota.used} / ${data.quota.limit} today on the ${data.quota.plan} plan.`));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
spinner.stop();
|
|
43
|
+
log.error(err instanceof ApiError ? err.message : err.message);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function truncate(s, n) {
|
|
48
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
|
|
49
|
+
}
|
|
50
|
+
function wrap(text, width) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const para of text.split('\n')) {
|
|
53
|
+
const words = para.trim().split(/\s+/);
|
|
54
|
+
let line = '';
|
|
55
|
+
for (const w of words) {
|
|
56
|
+
if ((line + ' ' + w).trim().length > width) {
|
|
57
|
+
out.push(line.trim());
|
|
58
|
+
line = w;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
line = `${line} ${w}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (line.trim())
|
|
65
|
+
out.push(line.trim());
|
|
66
|
+
}
|
|
67
|
+
return out.join('\n');
|
|
68
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { track } from '../lib/analytics.js';
|
|
4
|
+
import { api, ApiError } from '../lib/api.js';
|
|
5
|
+
import { requireAuth } from '../lib/credentials.js';
|
|
6
|
+
import { detectCollisions, installRegistryHook, listForeignHooks, listRegistryHooks, removeRegistryHook, } from '../lib/hook.js';
|
|
7
|
+
import { assertSlug, InvalidSlugError } from '../lib/slug.js';
|
|
8
|
+
import { log } from '../utils/logger.js';
|
|
9
|
+
/* ─── install ─────────────────────────────────────────────────── */
|
|
10
|
+
export async function hooksInstallCommand(slug, opts = {}) {
|
|
11
|
+
track('cli_hooks_install', { slug, dry_run: !!opts.dryRun });
|
|
12
|
+
try {
|
|
13
|
+
assertSlug(slug);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
if (err instanceof InvalidSlugError) {
|
|
17
|
+
log.error(err.message);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
await requireAuth('hooks');
|
|
24
|
+
const spinner = ora(`Fetching hook ${chalk.cyan(slug)}…`).start();
|
|
25
|
+
let hook;
|
|
26
|
+
try {
|
|
27
|
+
const { data } = await api.get(`/api/v1/hooks/${encodeURIComponent(slug)}`);
|
|
28
|
+
hook = data;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
spinner.fail(`Hook ${chalk.cyan(slug)} not found`);
|
|
32
|
+
if (err instanceof ApiError && err.status !== 404)
|
|
33
|
+
log.error(err.message);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
spinner.succeed(`Fetched ${chalk.cyan(hook.name)}`);
|
|
38
|
+
const def = {
|
|
39
|
+
slug: hook.slug,
|
|
40
|
+
event: hook.event,
|
|
41
|
+
matcher: hook.matcher,
|
|
42
|
+
command: hook.command,
|
|
43
|
+
timeout_seconds: hook.timeout_seconds,
|
|
44
|
+
};
|
|
45
|
+
const result = await installRegistryHook(def, { dryRun: opts.dryRun });
|
|
46
|
+
if (opts.dryRun) {
|
|
47
|
+
printDryRunDiff(result.before, result.after);
|
|
48
|
+
log.info(`(dry-run) ${result.changed ? 'Would install' : 'No change needed for'} ${chalk.cyan(slug)}.`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!result.changed) {
|
|
52
|
+
log.info(`Already installed: ${chalk.cyan(slug)} — no change.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Record the install receipt so the dashboard count + trending sort
|
|
56
|
+
// stay accurate. Best-effort: a network blip here must not undo the
|
|
57
|
+
// local install.
|
|
58
|
+
try {
|
|
59
|
+
await api.post(`/api/v1/hooks/${encodeURIComponent(slug)}/install`, {}, true);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
log.warn(`Local install succeeded but server receipt failed: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
log.success(`Installed ${chalk.cyan(slug)} → ${result.settingsPath}`);
|
|
65
|
+
}
|
|
66
|
+
/* ─── remove ──────────────────────────────────────────────────── */
|
|
67
|
+
export async function hooksRemoveCommand(slug, opts = {}) {
|
|
68
|
+
track('cli_hooks_remove', { slug, dry_run: !!opts.dryRun });
|
|
69
|
+
try {
|
|
70
|
+
assertSlug(slug);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof InvalidSlugError) {
|
|
74
|
+
log.error(err.message);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
const result = await removeRegistryHook(slug, { dryRun: opts.dryRun });
|
|
81
|
+
if (opts.dryRun) {
|
|
82
|
+
printDryRunDiff(result.before, result.after);
|
|
83
|
+
log.info(`(dry-run) ${result.changed ? 'Would remove' : 'No matching hook for'} ${chalk.cyan(slug)}.`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!result.changed) {
|
|
87
|
+
log.info(`No installed hook with slug ${chalk.cyan(slug)} — nothing to remove.`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
log.success(`Removed ${chalk.cyan(slug)} from ${result.settingsPath}`);
|
|
91
|
+
// Soft uninstall on the server so the user's dashboard count drops.
|
|
92
|
+
try {
|
|
93
|
+
await requireAuth('hooks');
|
|
94
|
+
await api.del(`/api/v1/hooks/${encodeURIComponent(slug)}/install`, true);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* offline / not logged in — local state is what matters */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/* ─── list ────────────────────────────────────────────────────── */
|
|
101
|
+
export async function hooksListCommand() {
|
|
102
|
+
track('cli_hooks_list', {});
|
|
103
|
+
const installed = await listRegistryHooks();
|
|
104
|
+
if (installed.length === 0) {
|
|
105
|
+
log.info('No prave-managed hooks installed locally.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
log.info(`${chalk.bold(installed.length)} prave-managed hook${installed.length === 1 ? '' : 's'} installed:\n`);
|
|
109
|
+
for (const h of installed) {
|
|
110
|
+
console.log(` ${chalk.cyan(h.slug)} ${chalk.dim(h.channel + (h.matcher ? `:${h.matcher}` : ''))}` +
|
|
111
|
+
(h.timeout ? chalk.dim(` (${h.timeout}s)`) : ''));
|
|
112
|
+
console.log(` ${chalk.gray(h.command)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/* ─── sync ────────────────────────────────────────────────────── */
|
|
116
|
+
export async function hooksSyncCommand(opts = {}) {
|
|
117
|
+
track('cli_hooks_sync', { dry_run: !!opts.dryRun });
|
|
118
|
+
await requireAuth('hooks');
|
|
119
|
+
const installed = await listRegistryHooks();
|
|
120
|
+
if (installed.length === 0) {
|
|
121
|
+
log.info('No local prave-managed hooks to sync.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const slugs = installed.map((h) => h.slug);
|
|
125
|
+
const spinner = ora(`Syncing ${slugs.length} hook${slugs.length === 1 ? '' : 's'}…`).start();
|
|
126
|
+
let bulk;
|
|
127
|
+
try {
|
|
128
|
+
const { data } = await api.post('/api/v1/hooks/bulk/sync', { slugs }, true);
|
|
129
|
+
bulk = data;
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
spinner.fail('Sync failed');
|
|
133
|
+
log.error(err.message);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
spinner.succeed('Sync received');
|
|
138
|
+
let updated = 0;
|
|
139
|
+
for (const item of bulk.items) {
|
|
140
|
+
if (!item.hook)
|
|
141
|
+
continue;
|
|
142
|
+
const def = {
|
|
143
|
+
slug: item.hook.slug,
|
|
144
|
+
event: item.hook.event,
|
|
145
|
+
matcher: item.hook.matcher,
|
|
146
|
+
command: item.hook.command,
|
|
147
|
+
timeout_seconds: item.hook.timeout_seconds,
|
|
148
|
+
};
|
|
149
|
+
const result = await installRegistryHook(def, { dryRun: opts.dryRun });
|
|
150
|
+
if (result.changed)
|
|
151
|
+
updated++;
|
|
152
|
+
}
|
|
153
|
+
if (bulk.missing.length) {
|
|
154
|
+
log.warn(`Skipped ${bulk.missing.length} unknown slug${bulk.missing.length === 1 ? '' : 's'}: ${bulk.missing.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
log.success(`${opts.dryRun ? '(dry-run) ' : ''}Updated ${updated} of ${slugs.length} hook${slugs.length === 1 ? '' : 's'}.`);
|
|
157
|
+
}
|
|
158
|
+
/* ─── audit ───────────────────────────────────────────────────── */
|
|
159
|
+
export async function hooksAuditCommand() {
|
|
160
|
+
track('cli_hooks_audit', {});
|
|
161
|
+
const installed = await listRegistryHooks();
|
|
162
|
+
const foreign = await listForeignHooks();
|
|
163
|
+
const collisions = detectCollisions(installed);
|
|
164
|
+
log.info(chalk.bold(`Prave-managed registry hooks: ${installed.length}`));
|
|
165
|
+
for (const h of installed) {
|
|
166
|
+
console.log(` ${chalk.cyan(h.slug)} ${chalk.dim(h.channel + (h.matcher ? `:${h.matcher}` : ''))}`);
|
|
167
|
+
}
|
|
168
|
+
if (foreign.length) {
|
|
169
|
+
console.log();
|
|
170
|
+
log.info(chalk.bold(`User-owned hooks (untouched): ${foreign.length}`));
|
|
171
|
+
for (const h of foreign) {
|
|
172
|
+
console.log(` ${chalk.dim(h.channel + (h.matcher ? `:${h.matcher}` : ''))} ${chalk.gray(h.command)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (collisions.length) {
|
|
176
|
+
console.log();
|
|
177
|
+
log.warn(`${collisions.length} collision${collisions.length === 1 ? '' : 's'} detected — multiple registry hooks share an event+matcher pair:`);
|
|
178
|
+
for (const c of collisions) {
|
|
179
|
+
console.log(` ${chalk.yellow(`${c.channel}:${c.matcher ?? '*'}`)} → ${c.slugs.map((s) => chalk.cyan(s)).join(', ')}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
console.log();
|
|
184
|
+
log.success('No collisions detected.');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/* ─── update ──────────────────────────────────────────────────── */
|
|
188
|
+
export async function hooksUpdateCommand(slug, opts = {}) {
|
|
189
|
+
track('cli_hooks_update', { slug: slug ?? null, dry_run: !!opts.dryRun });
|
|
190
|
+
await requireAuth('hooks');
|
|
191
|
+
const installed = await listRegistryHooks();
|
|
192
|
+
const targets = slug ? installed.filter((h) => h.slug === slug) : installed;
|
|
193
|
+
if (targets.length === 0) {
|
|
194
|
+
log.info(slug ? `${chalk.cyan(slug)} is not installed.` : 'No installed registry hooks to update.');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
let updated = 0;
|
|
198
|
+
for (const local of targets) {
|
|
199
|
+
let remote;
|
|
200
|
+
try {
|
|
201
|
+
const { data } = await api.get(`/api/v1/hooks/${encodeURIComponent(local.slug)}`);
|
|
202
|
+
remote = data;
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
log.warn(`Skipping ${chalk.cyan(local.slug)} — ${err.message}`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const def = {
|
|
209
|
+
slug: remote.slug,
|
|
210
|
+
event: remote.event,
|
|
211
|
+
matcher: remote.matcher,
|
|
212
|
+
command: remote.command,
|
|
213
|
+
timeout_seconds: remote.timeout_seconds,
|
|
214
|
+
};
|
|
215
|
+
const result = await installRegistryHook(def, { dryRun: opts.dryRun });
|
|
216
|
+
if (result.changed) {
|
|
217
|
+
updated++;
|
|
218
|
+
log.info(`${opts.dryRun ? '(dry-run) ' : ''}Updated ${chalk.cyan(local.slug)} → v${remote.version}`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
log.info(`${chalk.cyan(local.slug)} already current.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
log.success(`${opts.dryRun ? '(dry-run) ' : ''}${updated} hook${updated === 1 ? '' : 's'} updated.`);
|
|
225
|
+
}
|
|
226
|
+
/* ─── helpers ─────────────────────────────────────────────────── */
|
|
227
|
+
function printDryRunDiff(before, after) {
|
|
228
|
+
const b = JSON.stringify(before, null, 2);
|
|
229
|
+
const a = JSON.stringify(after, null, 2);
|
|
230
|
+
console.log(chalk.gray('--- before ---'));
|
|
231
|
+
console.log(chalk.red(b));
|
|
232
|
+
console.log(chalk.gray('--- after ---'));
|
|
233
|
+
console.log(chalk.green(a));
|
|
234
|
+
}
|
|
@@ -62,6 +62,23 @@ const TOOLS = [
|
|
|
62
62
|
required: ['slug'],
|
|
63
63
|
},
|
|
64
64
|
},
|
|
65
|
+
{
|
|
66
|
+
name: 'prave_advisor',
|
|
67
|
+
description: "Contextual Skill advisor. Given a user task (e.g. 'I'm building a Next.js app with Stripe and auth'), returns a prose recommendation explaining which Skills genuinely fit and why. Tiered freemium quota — Free 3/day, Pro 10/day, Max 30/day — enforced server-side. Use this when the user asks 'what Skills should I install for X?' or 'help me pick Skills for my project'.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
task: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: "The user's task / context. Min 8 chars. Omit to run in auto mode.",
|
|
74
|
+
},
|
|
75
|
+
auto: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
description: "When true, ignore `task` and synthesise context from the user's installed Skills + recent usage.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
65
82
|
];
|
|
66
83
|
export async function mcpServerCommand() {
|
|
67
84
|
// Auth check up-front. If the user runs `prave mcp-server` without
|
|
@@ -96,6 +113,8 @@ export async function mcpServerCommand() {
|
|
|
96
113
|
return await handleAuditLibrary();
|
|
97
114
|
case 'prave_install_skill':
|
|
98
115
|
return await handleInstallSkill(args);
|
|
116
|
+
case 'prave_advisor':
|
|
117
|
+
return await handleAdvisor(args);
|
|
99
118
|
default:
|
|
100
119
|
return mcpError(`Unknown tool: ${name}`);
|
|
101
120
|
}
|
|
@@ -202,6 +221,27 @@ async function handleInstallSkill(args) {
|
|
|
202
221
|
});
|
|
203
222
|
});
|
|
204
223
|
}
|
|
224
|
+
async function handleAdvisor(args) {
|
|
225
|
+
const mode = args.auto ? 'auto' : 'manual';
|
|
226
|
+
if (mode === 'manual' && (!args.task || args.task.trim().length < 8)) {
|
|
227
|
+
return mcpError("Pass a `task` of at least 8 characters describing what the user is building, or set `auto: true` to use their installed Skills as context.");
|
|
228
|
+
}
|
|
229
|
+
const { data } = await api.post('/api/v1/advisor', { mode, task: mode === 'manual' ? args.task : undefined }, true);
|
|
230
|
+
const lines = [data.prose, ''];
|
|
231
|
+
if (data.recommendations.length === 0) {
|
|
232
|
+
lines.push('(No specific Skills recommended for this context.)');
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
for (const rec of data.recommendations) {
|
|
236
|
+
lines.push(`• ${rec.slug}`);
|
|
237
|
+
lines.push(` Why: ${rec.reason}`);
|
|
238
|
+
lines.push(` Install: prave install ${rec.slug}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
lines.push('');
|
|
242
|
+
lines.push(`Advisor quota: ${data.quota.used} / ${data.quota.limit} today on the ${data.quota.plan} plan.`);
|
|
243
|
+
return mcpText(lines.join('\n'));
|
|
244
|
+
}
|
|
205
245
|
/* ─── helpers ──────────────────────────────────────────────────── */
|
|
206
246
|
function mcpText(text) {
|
|
207
247
|
return { content: [{ type: 'text', text }] };
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { Command } from 'commander';
|
|
6
6
|
import { conflictsCommand } from './commands/conflicts.js';
|
|
7
7
|
import { diffCommand } from './commands/diff.js';
|
|
8
|
+
import { advisorCommand } from './commands/advisor.js';
|
|
8
9
|
import { docsCommand } from './commands/docs.js';
|
|
10
|
+
import { hooksAuditCommand, hooksInstallCommand, hooksListCommand, hooksRemoveCommand, hooksSyncCommand, hooksUpdateCommand, } from './commands/hooks.js';
|
|
9
11
|
import { importCommand } from './commands/import.js';
|
|
10
12
|
import { installCommand } from './commands/install.js';
|
|
11
13
|
import { listCommand } from './commands/list.js';
|
|
@@ -109,6 +111,11 @@ program
|
|
|
109
111
|
.command('whatdoes <skillname>')
|
|
110
112
|
.description("Inspect a skill's triggers, tokens, conflicts")
|
|
111
113
|
.action(whatdoesCommand);
|
|
114
|
+
program
|
|
115
|
+
.command('advisor [task...]')
|
|
116
|
+
.description('Prose recommendation from the contextual Skill advisor — pass a task description, or use --auto to base it on your installed Skills.')
|
|
117
|
+
.option('--auto', 'use your installed Skills + recent usage as context (no task argument)')
|
|
118
|
+
.action((taskWords, opts) => advisorCommand(taskWords.length ? taskWords.join(' ') : undefined, opts));
|
|
112
119
|
program
|
|
113
120
|
.command('overview')
|
|
114
121
|
.description('Summary of your skill set, conflicts, and token cost')
|
|
@@ -144,6 +151,38 @@ hook
|
|
|
144
151
|
.command('uninstall')
|
|
145
152
|
.description('Disable real-time invocation tracking')
|
|
146
153
|
.action(usageHookUninstallCommand);
|
|
154
|
+
/* ─── Hooks Manager — discover, install, sync registry hooks ─── */
|
|
155
|
+
const hooks = program
|
|
156
|
+
.command('hooks')
|
|
157
|
+
.description('Manage Claude Code hooks from the Prave registry (separate from `prave usage hook` which tracks Skill invocations)');
|
|
158
|
+
hooks
|
|
159
|
+
.command('install <slug>')
|
|
160
|
+
.description('Install a registry hook into ~/.claude/settings.json')
|
|
161
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
162
|
+
.action((slug, opts) => hooksInstallCommand(slug, opts));
|
|
163
|
+
hooks
|
|
164
|
+
.command('remove <slug>')
|
|
165
|
+
.description('Remove a registry hook (only the entry with this slug — never touches the tracking hook)')
|
|
166
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
167
|
+
.action((slug, opts) => hooksRemoveCommand(slug, opts));
|
|
168
|
+
hooks
|
|
169
|
+
.command('list')
|
|
170
|
+
.description('List prave-managed registry hooks installed locally')
|
|
171
|
+
.action(hooksListCommand);
|
|
172
|
+
hooks
|
|
173
|
+
.command('sync')
|
|
174
|
+
.description('Pull the latest content for every installed registry hook')
|
|
175
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
176
|
+
.action((opts) => hooksSyncCommand(opts));
|
|
177
|
+
hooks
|
|
178
|
+
.command('audit')
|
|
179
|
+
.description('List installed registry + foreign hooks and flag collisions')
|
|
180
|
+
.action(hooksAuditCommand);
|
|
181
|
+
hooks
|
|
182
|
+
.command('update [slug]')
|
|
183
|
+
.description('Refresh installed registry hooks against the server (one slug or all)')
|
|
184
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
185
|
+
.action((slug, opts) => hooksUpdateCommand(slug, opts));
|
|
147
186
|
program
|
|
148
187
|
.command('mcp-server')
|
|
149
188
|
.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.')
|
package/dist/lib/hook.js
CHANGED
|
@@ -14,6 +14,18 @@ import { dirname, join } from 'node:path';
|
|
|
14
14
|
*/
|
|
15
15
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
16
16
|
const HOOK_MARKER = '__prave_managed';
|
|
17
|
+
/**
|
|
18
|
+
* Per-entry slug marker added in the Hooks Manager (V1, migration
|
|
19
|
+
* 060). The tracking hook installed by `prave usage hook install`
|
|
20
|
+
* deliberately leaves this field undefined — every "registry" hook
|
|
21
|
+
* installed via `prave hooks install <slug>` stamps its slug here.
|
|
22
|
+
*
|
|
23
|
+
* That discrimination is what makes `prave hooks remove <slug>` safe:
|
|
24
|
+
* it only targets entries with a matching `__prave_slug`, so the
|
|
25
|
+
* tracking hook can never be removed by mistake. Tests in
|
|
26
|
+
* `hook.test.ts` pin this invariant.
|
|
27
|
+
*/
|
|
28
|
+
const HOOK_SLUG_MARKER = '__prave_slug';
|
|
17
29
|
/**
|
|
18
30
|
* Agents that currently support a real-time invocation hook. Adding a new
|
|
19
31
|
* agent to this list means implementing its `installFor<Agent>` branch
|
|
@@ -21,6 +33,16 @@ const HOOK_MARKER = '__prave_managed';
|
|
|
21
33
|
* misled about what's actually being instrumented.
|
|
22
34
|
*/
|
|
23
35
|
export const HOOK_SUPPORTED = ['claude'];
|
|
36
|
+
const REGISTRY_CHANNELS = [
|
|
37
|
+
'PreToolUse',
|
|
38
|
+
'PostToolUse',
|
|
39
|
+
'UserPromptSubmit',
|
|
40
|
+
'Stop',
|
|
41
|
+
'SubagentStop',
|
|
42
|
+
'Notification',
|
|
43
|
+
'PreCompact',
|
|
44
|
+
'SessionStart',
|
|
45
|
+
];
|
|
24
46
|
const HOOK_COMMAND = 'prave usage report';
|
|
25
47
|
// Companion command for the UserPromptSubmit channel so a typed-slash
|
|
26
48
|
// `/graphify` is captured even when the Skill tool path doesn't fire a
|
|
@@ -127,7 +149,15 @@ export async function uninstallSkillHook() {
|
|
|
127
149
|
return;
|
|
128
150
|
const beforeCounts = blocks.map((b) => b.hooks?.length ?? 0);
|
|
129
151
|
const filtered = blocks
|
|
130
|
-
.map((b) => ({
|
|
152
|
+
.map((b) => ({
|
|
153
|
+
...b,
|
|
154
|
+
// ONLY strip tracking-hook entries — those with
|
|
155
|
+
// `__prave_managed: true` AND no `__prave_slug`. Registry hooks
|
|
156
|
+
// installed via `prave hooks install <slug>` carry a
|
|
157
|
+
// `__prave_slug` and are guarded here so this code path can
|
|
158
|
+
// never remove them by accident.
|
|
159
|
+
hooks: b.hooks?.filter((h) => !(h[HOOK_MARKER] && !h[HOOK_SLUG_MARKER])),
|
|
160
|
+
}))
|
|
131
161
|
.filter((b) => (b.hooks?.length ?? 0) > 0);
|
|
132
162
|
const lengthChanged = filtered.length !== blocks.length;
|
|
133
163
|
const innerChanged = !lengthChanged &&
|
|
@@ -164,3 +194,179 @@ async function writeSettings(settings) {
|
|
|
164
194
|
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
|
|
165
195
|
await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
|
166
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Install / upsert a registry hook into `~/.claude/settings.json`.
|
|
199
|
+
* Read-modify-write is atomic-ish (single fs write) and guarded by
|
|
200
|
+
* the `__prave_slug` marker so we can:
|
|
201
|
+
* • Update an existing entry for the same slug in-place.
|
|
202
|
+
* • Never collide with the tracking hook (which has no slug
|
|
203
|
+
* marker) or with a hand-written user hook (which has neither
|
|
204
|
+
* marker).
|
|
205
|
+
*
|
|
206
|
+
* Returns the unified diff representation when `dryRun` is true so
|
|
207
|
+
* `prave hooks install --dry-run` can show what would change without
|
|
208
|
+
* actually writing.
|
|
209
|
+
*/
|
|
210
|
+
export async function installRegistryHook(def, opts = {}) {
|
|
211
|
+
const before = await readSettings();
|
|
212
|
+
// Defensive deep clone — operating on a copy keeps `before` clean
|
|
213
|
+
// for the diff output.
|
|
214
|
+
const after = JSON.parse(JSON.stringify(before));
|
|
215
|
+
after.hooks ??= {};
|
|
216
|
+
const channelBlocks = (after.hooks[def.event] ??= []);
|
|
217
|
+
const entry = {
|
|
218
|
+
type: 'command',
|
|
219
|
+
command: def.command,
|
|
220
|
+
[HOOK_MARKER]: true,
|
|
221
|
+
[HOOK_SLUG_MARKER]: def.slug,
|
|
222
|
+
};
|
|
223
|
+
if (def.timeout_seconds != null)
|
|
224
|
+
entry.timeout = def.timeout_seconds;
|
|
225
|
+
// Look for an existing block matching (channel, matcher) AND
|
|
226
|
+
// already carrying our slug. Upsert vs append accordingly.
|
|
227
|
+
const matcherKey = def.matcher ?? undefined;
|
|
228
|
+
const blockIdx = channelBlocks.findIndex((b) => (b.matcher ?? undefined) === matcherKey);
|
|
229
|
+
let changed = false;
|
|
230
|
+
if (blockIdx >= 0) {
|
|
231
|
+
const block = channelBlocks[blockIdx];
|
|
232
|
+
block.hooks ??= [];
|
|
233
|
+
const entryIdx = block.hooks.findIndex((h) => h[HOOK_SLUG_MARKER] === def.slug);
|
|
234
|
+
if (entryIdx >= 0) {
|
|
235
|
+
const prev = block.hooks[entryIdx];
|
|
236
|
+
if (prev.command !== entry.command || prev.timeout !== entry.timeout) {
|
|
237
|
+
block.hooks[entryIdx] = entry;
|
|
238
|
+
changed = true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
block.hooks.push(entry);
|
|
243
|
+
changed = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
channelBlocks.push({
|
|
248
|
+
matcher: matcherKey,
|
|
249
|
+
hooks: [entry],
|
|
250
|
+
});
|
|
251
|
+
changed = true;
|
|
252
|
+
}
|
|
253
|
+
if (changed && !opts.dryRun)
|
|
254
|
+
await writeSettings(after);
|
|
255
|
+
return { changed, settingsPath: SETTINGS_PATH, before, after };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Remove the registry hook with the given slug. Filters by
|
|
259
|
+
* `__prave_slug === slug` so the tracking hook (no slug) and any
|
|
260
|
+
* foreign user hook (no marker) are NEVER touched.
|
|
261
|
+
*/
|
|
262
|
+
export async function removeRegistryHook(slug, opts = {}) {
|
|
263
|
+
const before = await readSettings();
|
|
264
|
+
const after = JSON.parse(JSON.stringify(before));
|
|
265
|
+
if (!after.hooks)
|
|
266
|
+
return { changed: false, settingsPath: SETTINGS_PATH, before, after };
|
|
267
|
+
let touched = false;
|
|
268
|
+
for (const channel of REGISTRY_CHANNELS) {
|
|
269
|
+
const blocks = after.hooks[channel];
|
|
270
|
+
if (!blocks?.length)
|
|
271
|
+
continue;
|
|
272
|
+
const filtered = blocks
|
|
273
|
+
.map((b) => ({
|
|
274
|
+
...b,
|
|
275
|
+
hooks: b.hooks?.filter((h) => h[HOOK_SLUG_MARKER] !== slug),
|
|
276
|
+
}))
|
|
277
|
+
.filter((b) => (b.hooks?.length ?? 0) > 0);
|
|
278
|
+
if (filtered.length !== blocks.length ||
|
|
279
|
+
filtered.some((b, i) => (b.hooks?.length ?? 0) !== (blocks[i]?.hooks?.length ?? 0))) {
|
|
280
|
+
touched = true;
|
|
281
|
+
if (after.hooks) {
|
|
282
|
+
if (filtered.length)
|
|
283
|
+
after.hooks[channel] = filtered;
|
|
284
|
+
else
|
|
285
|
+
delete after.hooks[channel];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (after.hooks && Object.keys(after.hooks).length === 0)
|
|
290
|
+
delete after.hooks;
|
|
291
|
+
if (touched && !opts.dryRun)
|
|
292
|
+
await writeSettings(after);
|
|
293
|
+
return { changed: touched, settingsPath: SETTINGS_PATH, before, after };
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Enumerate the registry hooks currently installed locally — used by
|
|
297
|
+
* `prave hooks list` and `prave hooks audit`.
|
|
298
|
+
*/
|
|
299
|
+
export async function listRegistryHooks() {
|
|
300
|
+
const settings = await readSettings();
|
|
301
|
+
const out = [];
|
|
302
|
+
if (!settings.hooks)
|
|
303
|
+
return out;
|
|
304
|
+
for (const channel of REGISTRY_CHANNELS) {
|
|
305
|
+
const blocks = settings.hooks[channel];
|
|
306
|
+
if (!blocks?.length)
|
|
307
|
+
continue;
|
|
308
|
+
for (const b of blocks) {
|
|
309
|
+
for (const h of b.hooks ?? []) {
|
|
310
|
+
if (!h[HOOK_SLUG_MARKER])
|
|
311
|
+
continue;
|
|
312
|
+
out.push({
|
|
313
|
+
channel,
|
|
314
|
+
matcher: b.matcher ?? null,
|
|
315
|
+
slug: h[HOOK_SLUG_MARKER],
|
|
316
|
+
command: h.command,
|
|
317
|
+
timeout: h.timeout ?? null,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Foreign (user-authored) hooks living alongside our managed entries.
|
|
326
|
+
* Surfaced by `prave hooks audit` as informational context — never
|
|
327
|
+
* touched by any of the write helpers above.
|
|
328
|
+
*/
|
|
329
|
+
export async function listForeignHooks() {
|
|
330
|
+
const settings = await readSettings();
|
|
331
|
+
const out = [];
|
|
332
|
+
if (!settings.hooks)
|
|
333
|
+
return out;
|
|
334
|
+
for (const channel of REGISTRY_CHANNELS) {
|
|
335
|
+
const blocks = settings.hooks[channel];
|
|
336
|
+
if (!blocks?.length)
|
|
337
|
+
continue;
|
|
338
|
+
for (const b of blocks) {
|
|
339
|
+
for (const h of b.hooks ?? []) {
|
|
340
|
+
if (h[HOOK_MARKER])
|
|
341
|
+
continue;
|
|
342
|
+
out.push({ channel, matcher: b.matcher ?? null, command: h.command });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Detect duplicate-coverage collisions inside the registry: two
|
|
350
|
+
* different prave-managed slugs writing to the same
|
|
351
|
+
* (channel, matcher) pair. The audit command surfaces these so the
|
|
352
|
+
* user can manually decide which one to keep.
|
|
353
|
+
*/
|
|
354
|
+
export function detectCollisions(installed) {
|
|
355
|
+
const buckets = new Map();
|
|
356
|
+
for (const h of installed) {
|
|
357
|
+
const key = `${h.channel}|${h.matcher ?? '*'}`;
|
|
358
|
+
const list = buckets.get(key) ?? [];
|
|
359
|
+
list.push(h.slug);
|
|
360
|
+
buckets.set(key, list);
|
|
361
|
+
}
|
|
362
|
+
const out = [];
|
|
363
|
+
for (const [key, slugs] of buckets) {
|
|
364
|
+
if (slugs.length < 2)
|
|
365
|
+
continue;
|
|
366
|
+
const parts = key.split('|');
|
|
367
|
+
const channel = parts[0];
|
|
368
|
+
const matcher = parts[1] === '*' ? null : (parts[1] ?? null);
|
|
369
|
+
out.push({ channel, matcher, slugs });
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prave/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": [
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"ora": "^8.0.1",
|
|
55
55
|
"tar": "^7.4.3",
|
|
56
56
|
"undici": "^6.18.0",
|
|
57
|
-
"@prave/shared": "1.
|
|
57
|
+
"@prave/shared": "1.5.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^20.12.7",
|