@prave/cli 1.4.15 → 1.6.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 +1 -1
- package/dist/commands/hooks.js +234 -0
- package/dist/commands/sync.js +53 -21
- package/dist/index.js +33 -0
- package/dist/lib/agent-paths.js +56 -0
- package/dist/lib/hook.js +207 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ Syncing 12 Skills — this takes about 15 seconds.
|
|
|
63
63
|
Synced 12 · failed 0
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
The `last_sync_at` watermark persists at `~/.prave/state.json`.
|
|
66
|
+
The `last_sync_at` watermark persists at `~/.prave/state.json`.
|
|
67
67
|
|
|
68
68
|
## Commands
|
|
69
69
|
|
|
@@ -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
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -3,9 +3,9 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { createInterface } from 'node:readline/promises';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import ora from 'ora';
|
|
6
|
+
import { resolveAgentTargets } from '../lib/agent-paths.js';
|
|
6
7
|
import { track } from '../lib/analytics.js';
|
|
7
8
|
import { api, ApiError } from '../lib/api.js';
|
|
8
|
-
import { CONFIG } from '../lib/config.js';
|
|
9
9
|
import { requireAuth } from '../lib/credentials.js';
|
|
10
10
|
import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
|
|
11
11
|
import { readState, writeState } from '../lib/state.js';
|
|
@@ -111,28 +111,49 @@ export async function syncCommand() {
|
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
+
// Resolve every on-disk target the user has enabled in web settings.
|
|
115
|
+
// Pre-2026-06-05 sync was hard-pinned to ~/.claude/skills/ — users
|
|
116
|
+
// with Codex / Cline / Amp enabled saw nothing land in those dirs.
|
|
117
|
+
// Now we read the server-side agent settings and write to every
|
|
118
|
+
// enabled (non-conversion) path. Cursor is excluded for now because
|
|
119
|
+
// it needs format conversion.
|
|
120
|
+
const targets = await resolveAgentTargets();
|
|
121
|
+
const targetSummary = targets.map((t) => t.agent).join(', ');
|
|
114
122
|
const spinner = ora('Scanning local Skills…').start();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
// Union of slugs across all enabled targets — a skill that only
|
|
124
|
+
// exists in ~/.claude/skills is still "installed", and one we
|
|
125
|
+
// pulled into ~/.codex/skills the last sync should re-sync too.
|
|
126
|
+
const slugSet = new Set();
|
|
127
|
+
let scannedAny = false;
|
|
128
|
+
for (const target of targets) {
|
|
129
|
+
let entries = [];
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(target.dir);
|
|
132
|
+
scannedAny = true;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Missing dir → user enabled the agent in settings but never
|
|
136
|
+
// installed a skill there. Sync will create it on first write.
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const name of entries) {
|
|
140
|
+
const dir = join(target.dir, name);
|
|
141
|
+
if ((await stat(dir).catch(() => null))?.isDirectory())
|
|
142
|
+
slugSet.add(name);
|
|
143
|
+
}
|
|
118
144
|
}
|
|
119
|
-
|
|
120
|
-
spinner.warn(`No Skills directory
|
|
145
|
+
if (!scannedAny && slugSet.size === 0) {
|
|
146
|
+
spinner.warn(`No Skills directory found across ${targetSummary || 'claude'}.`);
|
|
121
147
|
return;
|
|
122
148
|
}
|
|
123
|
-
const slugs =
|
|
124
|
-
for (const name of entries) {
|
|
125
|
-
const dir = join(CONFIG.skillsDir, name);
|
|
126
|
-
if ((await stat(dir).catch(() => null))?.isDirectory())
|
|
127
|
-
slugs.push(name);
|
|
128
|
-
}
|
|
149
|
+
const slugs = Array.from(slugSet);
|
|
129
150
|
if (!slugs.length) {
|
|
130
151
|
spinner.warn('No installed Skills to sync.');
|
|
131
152
|
return;
|
|
132
153
|
}
|
|
133
|
-
spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'}.`);
|
|
154
|
+
spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'} across ${targetSummary || 'claude'}.`);
|
|
134
155
|
const estSeconds = estimateSeconds(slugs.length);
|
|
135
|
-
console.log(chalk.dim(`Syncing ${slugs.length} Skill${slugs.length === 1 ? '' : 's'} — this takes about ${estSeconds} seconds.`));
|
|
156
|
+
console.log(chalk.dim(`Syncing ${slugs.length} Skill${slugs.length === 1 ? '' : 's'} → ${targets.length} agent dir${targets.length === 1 ? '' : 's'} — this takes about ${estSeconds} seconds.`));
|
|
136
157
|
const progress = ora(`Synced 0 / ${slugs.length}`).start();
|
|
137
158
|
let updated = 0;
|
|
138
159
|
let paywalled = 0;
|
|
@@ -170,7 +191,10 @@ export async function syncCommand() {
|
|
|
170
191
|
// Track per-slug verdicts for the final summary line.
|
|
171
192
|
missing += response.missing.length;
|
|
172
193
|
missingSlugs.push(...response.missing);
|
|
173
|
-
// Write files in parallel, bounded.
|
|
194
|
+
// Write files in parallel, bounded. Each successful item gets
|
|
195
|
+
// written to EVERY target dir (claude + codex + …) — failures on
|
|
196
|
+
// a single target don't abort the others; we just count a skill
|
|
197
|
+
// as updated when at least one write succeeded.
|
|
174
198
|
await runWithConcurrency(response.items, WRITE_CONCURRENCY, async (item) => {
|
|
175
199
|
if (item.error === 'paid_unpurchased') {
|
|
176
200
|
paywalled += 1;
|
|
@@ -181,15 +205,23 @@ export async function syncCommand() {
|
|
|
181
205
|
noContent += 1;
|
|
182
206
|
return;
|
|
183
207
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
208
|
+
let anyWritten = false;
|
|
209
|
+
for (const target of targets) {
|
|
210
|
+
const dir = join(target.dir, item.slug);
|
|
211
|
+
try {
|
|
212
|
+
await mkdir(dir, { recursive: true });
|
|
213
|
+
await writeFile(join(dir, 'SKILL.md'), item.content, 'utf8');
|
|
214
|
+
anyWritten = true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* per-target write failure — continue with other targets */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (anyWritten) {
|
|
188
221
|
updated += 1;
|
|
189
222
|
progress.text = `Synced ${updated} / ${slugs.length} — ${item.slug}`;
|
|
190
223
|
}
|
|
191
|
-
|
|
192
|
-
// Disk-level failure — surface in the summary, don't crash.
|
|
224
|
+
else {
|
|
193
225
|
noContent += 1;
|
|
194
226
|
}
|
|
195
227
|
});
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Command } from 'commander';
|
|
|
6
6
|
import { conflictsCommand } from './commands/conflicts.js';
|
|
7
7
|
import { diffCommand } from './commands/diff.js';
|
|
8
8
|
import { docsCommand } from './commands/docs.js';
|
|
9
|
+
import { hooksAuditCommand, hooksInstallCommand, hooksListCommand, hooksRemoveCommand, hooksSyncCommand, hooksUpdateCommand, } from './commands/hooks.js';
|
|
9
10
|
import { importCommand } from './commands/import.js';
|
|
10
11
|
import { installCommand } from './commands/install.js';
|
|
11
12
|
import { listCommand } from './commands/list.js';
|
|
@@ -144,6 +145,38 @@ hook
|
|
|
144
145
|
.command('uninstall')
|
|
145
146
|
.description('Disable real-time invocation tracking')
|
|
146
147
|
.action(usageHookUninstallCommand);
|
|
148
|
+
/* ─── Hooks Manager — discover, install, sync registry hooks ─── */
|
|
149
|
+
const hooks = program
|
|
150
|
+
.command('hooks')
|
|
151
|
+
.description('Manage Claude Code hooks from the Prave registry (separate from `prave usage hook` which tracks Skill invocations)');
|
|
152
|
+
hooks
|
|
153
|
+
.command('install <slug>')
|
|
154
|
+
.description('Install a registry hook into ~/.claude/settings.json')
|
|
155
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
156
|
+
.action((slug, opts) => hooksInstallCommand(slug, opts));
|
|
157
|
+
hooks
|
|
158
|
+
.command('remove <slug>')
|
|
159
|
+
.description('Remove a registry hook (only the entry with this slug — never touches the tracking hook)')
|
|
160
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
161
|
+
.action((slug, opts) => hooksRemoveCommand(slug, opts));
|
|
162
|
+
hooks
|
|
163
|
+
.command('list')
|
|
164
|
+
.description('List prave-managed registry hooks installed locally')
|
|
165
|
+
.action(hooksListCommand);
|
|
166
|
+
hooks
|
|
167
|
+
.command('sync')
|
|
168
|
+
.description('Pull the latest content for every installed registry hook')
|
|
169
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
170
|
+
.action((opts) => hooksSyncCommand(opts));
|
|
171
|
+
hooks
|
|
172
|
+
.command('audit')
|
|
173
|
+
.description('List installed registry + foreign hooks and flag collisions')
|
|
174
|
+
.action(hooksAuditCommand);
|
|
175
|
+
hooks
|
|
176
|
+
.command('update [slug]')
|
|
177
|
+
.description('Refresh installed registry hooks against the server (one slug or all)')
|
|
178
|
+
.option('--dry-run', "Print the diff that would be applied, but don't write")
|
|
179
|
+
.action((slug, opts) => hooksUpdateCommand(slug, opts));
|
|
147
180
|
program
|
|
148
181
|
.command('mcp-server')
|
|
149
182
|
.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.')
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { AGENT_REGISTRY } from '@prave/shared';
|
|
3
|
+
import { api, ApiError } from './api.js';
|
|
4
|
+
import { CONFIG } from './config.js';
|
|
5
|
+
const isWindows = process.platform === 'win32';
|
|
6
|
+
function expandTilde(p) {
|
|
7
|
+
if (!p)
|
|
8
|
+
return p;
|
|
9
|
+
if (p === '~')
|
|
10
|
+
return homedir();
|
|
11
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
12
|
+
return homedir() + p.slice(1);
|
|
13
|
+
}
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
function pickOsPath(paths) {
|
|
17
|
+
return isWindows ? paths.windows : paths.mac;
|
|
18
|
+
}
|
|
19
|
+
export async function resolveAgentTargets() {
|
|
20
|
+
try {
|
|
21
|
+
const { data: settings } = await api.get('/api/v1/settings/agents', true);
|
|
22
|
+
const enabled = settings.enabled_agents ?? [];
|
|
23
|
+
if (enabled.length === 0) {
|
|
24
|
+
return [{ agent: 'claude', dir: CONFIG.skillsDir }];
|
|
25
|
+
}
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const targets = [];
|
|
28
|
+
for (const agent of enabled) {
|
|
29
|
+
const meta = AGENT_REGISTRY[agent];
|
|
30
|
+
if (!meta)
|
|
31
|
+
continue;
|
|
32
|
+
if (meta.conversionRequired)
|
|
33
|
+
continue;
|
|
34
|
+
const stored = settings.skill_paths?.[agent];
|
|
35
|
+
const raw = stored
|
|
36
|
+
? pickOsPath(stored)
|
|
37
|
+
: pickOsPath(meta.defaultPath);
|
|
38
|
+
const dir = expandTilde(raw).replace(/[\\/]+$/, '');
|
|
39
|
+
if (!dir || seen.has(dir))
|
|
40
|
+
continue;
|
|
41
|
+
seen.add(dir);
|
|
42
|
+
targets.push({ agent, dir });
|
|
43
|
+
}
|
|
44
|
+
if (targets.length === 0) {
|
|
45
|
+
return [{ agent: 'claude', dir: CONFIG.skillsDir }];
|
|
46
|
+
}
|
|
47
|
+
return targets;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// Fail-safe path: legacy single-claude behaviour. A 401 here just
|
|
51
|
+
// means the user isn't authed and the caller will surface that
|
|
52
|
+
// through requireAuth() anyway.
|
|
53
|
+
void (err instanceof ApiError);
|
|
54
|
+
return [{ agent: 'claude', dir: CONFIG.skillsDir }];
|
|
55
|
+
}
|
|
56
|
+
}
|
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.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": [
|
|
@@ -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.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^20.12.7",
|