@prave/cli 1.2.0 โ†’ 1.2.2

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
 
@@ -1,9 +1,47 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
1
3
  import chalk from 'chalk';
2
4
  import ora from 'ora';
3
5
  import { track } from '../lib/analytics.js';
4
6
  import { api, ApiError } from '../lib/api.js';
7
+ import { CONFIG } from '../lib/config.js';
5
8
  import { requireAuth } from '../lib/credentials.js';
9
+ import { checkboxPrompt } from '../lib/prompt.js';
10
+ import { isValidSlug } from '../lib/slug.js';
6
11
  import { log } from '../utils/logger.js';
12
+ import { uninstallCommand } from './uninstall.js';
13
+ /**
14
+ * Resolve a `skill_metadata` row back to its on-disk slug, mirroring
15
+ * the canonical `~/.claude/skills/<slug>/SKILL.md` layout used by
16
+ * `prave optimize --remove-unused`. Returns `null` when the slug
17
+ * fails the registry regex (defence against `..` injection via a
18
+ * malformed `file_path`).
19
+ */
20
+ function slugOf(s) {
21
+ const segs = s.file_path?.split('/').filter(Boolean) ?? [];
22
+ let candidate = null;
23
+ if (segs.length >= 2) {
24
+ const last = segs[segs.length - 1];
25
+ const parent = segs[segs.length - 2];
26
+ if (last.toLowerCase().endsWith('skill.md'))
27
+ candidate = parent;
28
+ }
29
+ if (!candidate && s.name) {
30
+ candidate = s.name.trim().toLowerCase().replace(/\s+/g, '-');
31
+ }
32
+ if (!candidate)
33
+ return null;
34
+ return isValidSlug(candidate) ? candidate : null;
35
+ }
36
+ async function existsOnDisk(slug) {
37
+ try {
38
+ const st = await stat(join(CONFIG.skillsDir, slug));
39
+ return st.isDirectory();
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
7
45
  function describe(c) {
8
46
  const a = c.skill_a_name ?? c.skill_a_id;
9
47
  const b = c.skill_b_name ?? c.skill_b_id;
@@ -44,8 +82,70 @@ export async function conflictsCommand(opts = {}) {
44
82
  console.log(`${chalk.yellow('โš ๏ธ ')} ${describe(c)}`);
45
83
  }
46
84
  if (opts.fix) {
85
+ // Interactive resolution: the most common (and safest) fix for a
86
+ // conflict is uninstalling one of the two competing Skills. We
87
+ // build a checkbox list of the unique slugs that appear in the
88
+ // conflict set, restricted to ones we can resolve back to a real
89
+ // on-disk folder (so we never offer to "uninstall" something the
90
+ // user hasn't actually got installed).
91
+ let metadata = [];
92
+ try {
93
+ const { data } = await api.get('/api/v1/intelligence/skills', true);
94
+ metadata = data;
95
+ }
96
+ catch (err) {
97
+ log.warn(`Could not fetch skill metadata โ€” ${err.message}`);
98
+ }
99
+ // Tally how many conflicts each Skill participates in. Heuristic:
100
+ // a skill that conflicts with two others is the strongest fix
101
+ // candidate (uninstalling it resolves multiple rows at once), so
102
+ // we surface it first.
103
+ const conflictCount = new Map();
104
+ for (const c of conflicts) {
105
+ conflictCount.set(c.skill_a_id, (conflictCount.get(c.skill_a_id) ?? 0) + 1);
106
+ conflictCount.set(c.skill_b_id, (conflictCount.get(c.skill_b_id) ?? 0) + 1);
107
+ }
108
+ const candidates = [];
109
+ const seen = new Set();
110
+ for (const m of metadata) {
111
+ if (!conflictCount.has(m.id))
112
+ continue;
113
+ const slug = slugOf(m);
114
+ if (!slug || seen.has(slug))
115
+ continue;
116
+ if (!(await existsOnDisk(slug)))
117
+ continue;
118
+ seen.add(slug);
119
+ candidates.push({
120
+ slug,
121
+ name: m.name ?? slug,
122
+ count: conflictCount.get(m.id) ?? 1,
123
+ });
124
+ }
125
+ candidates.sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug));
126
+ if (candidates.length === 0) {
127
+ console.log();
128
+ log.dim('Nothing to auto-fix โ€” no conflicting Skills are installed under ~/.claude/skills/. ' +
129
+ 'Run `prave whatdoes <skill>` to inspect frontmatter and resolve manually.');
130
+ return;
131
+ }
132
+ console.log();
133
+ const picks = await checkboxPrompt('Select Skills to uninstall:', candidates.map((c) => ({
134
+ value: c.slug,
135
+ label: c.name,
136
+ hint: c.count > 1
137
+ ? `${c.count} conflicts ยท ${c.slug}`
138
+ : `1 conflict ยท ${c.slug}`,
139
+ })));
140
+ if (!picks || picks.length === 0) {
141
+ log.dim('Aborted โ€” nothing removed.');
142
+ return;
143
+ }
144
+ for (const slug of picks) {
145
+ await uninstallCommand(slug);
146
+ }
47
147
  console.log();
48
- log.dim('Interactive fix coming soon โ€” for now run `prave whatdoes <skill>` and adjust frontmatter manually.');
148
+ log.dim(`Removed ${picks.length} skill(s). Run ${chalk.cyan('prave sync')} to refresh server-side metadata.`);
49
149
  }
50
150
  }
51
151
  catch (err) {
@@ -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.
@@ -1,9 +1,13 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import readline from 'node:readline/promises';
1
4
  import chalk from 'chalk';
2
5
  import ora from 'ora';
3
- import readline from 'node:readline/promises';
4
6
  import { track } from '../lib/analytics.js';
5
7
  import { api, ApiError } from '../lib/api.js';
8
+ import { CONFIG } from '../lib/config.js';
6
9
  import { requireAuth } from '../lib/credentials.js';
10
+ import { checkboxPrompt } from '../lib/prompt.js';
7
11
  import { isValidSlug } from '../lib/slug.js';
8
12
  import { log } from '../utils/logger.js';
9
13
  import { uninstallCommand } from './uninstall.js';
@@ -40,6 +44,15 @@ function slugOf(s) {
40
44
  // injected via a malformed `file_path`.
41
45
  return isValidSlug(candidate) ? candidate : null;
42
46
  }
47
+ async function existsOnDisk(slug) {
48
+ try {
49
+ const st = await stat(join(CONFIG.skillsDir, slug));
50
+ return st.isDirectory();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
43
56
  async function confirmYesNo(question) {
44
57
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
45
58
  try {
@@ -121,10 +134,57 @@ export async function optimizeCommand(opts = {}) {
121
134
  log.dim(`Removed ${candidates.length} skill(s). Run ${chalk.cyan('prave sync')} to update server-side metadata.`);
122
135
  }
123
136
  else if (opts.apply) {
137
+ // `--apply` is the unified interactive cleanup: pick from the
138
+ // *underused* + *heavy* lists at once and uninstall whatever the
139
+ // user agrees to. This is strictly safer than blanket-deleting
140
+ // because every selection is opt-in.
141
+ //
142
+ // Merge candidates are intentionally NOT auto-actionable โ€” merging
143
+ // two Skills is a content-rewrite that the user has to do by hand.
144
+ // We surface them above and leave them for the suggestions panel.
145
+ const offer = [];
146
+ const seen = new Set();
147
+ const push = (skill, bucket) => {
148
+ const slug = slugOf(skill);
149
+ if (!slug || seen.has(slug))
150
+ return;
151
+ seen.add(slug);
152
+ offer.push({ slug, skill, bucket });
153
+ };
154
+ for (const s of data.underused)
155
+ push(s, 'underused');
156
+ for (const s of data.heavy)
157
+ push(s, 'heavy');
158
+ // Filter to ones actually present on disk โ€” the API returns
159
+ // metadata for slug-keyed self-heal stubs that may not have a
160
+ // matching folder.
161
+ const installed = [];
162
+ for (const item of offer) {
163
+ if (await existsOnDisk(item.slug))
164
+ installed.push(item);
165
+ }
166
+ if (installed.length === 0) {
167
+ console.log();
168
+ log.dim('Nothing on disk to clean up โ€” every flagged skill is either uninstalled already or only relevant to merge candidates (which need a manual rewrite).');
169
+ return;
170
+ }
171
+ console.log();
172
+ const picks = await checkboxPrompt('Pick skills to uninstall:', installed.map((i) => ({
173
+ value: i.slug,
174
+ label: nameOf(i.skill),
175
+ hint: i.bucket === 'underused'
176
+ ? `underused ยท ${formatTokens(i.skill.estimated_tokens)} ยท ${i.slug}`
177
+ : `heavy ยท ${formatTokens(i.skill.estimated_tokens)} ยท ${i.slug}`,
178
+ })));
179
+ if (!picks || picks.length === 0) {
180
+ log.dim('Aborted โ€” nothing removed.');
181
+ return;
182
+ }
183
+ for (const slug of picks) {
184
+ await uninstallCommand(slug);
185
+ }
124
186
  console.log();
125
- log.dim('Auto-apply not available โ€” use ' +
126
- chalk.cyan('prave optimize --remove-unused') +
127
- ' to delete underused skills, or review the suggestions and adjust manually.');
187
+ log.dim(`Removed ${picks.length} skill(s). Run ${chalk.cyan('prave sync')} to refresh server-side metadata.`);
128
188
  }
129
189
  }
130
190
  catch (err) {
@@ -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';
@@ -104,53 +105,61 @@ export async function usageScanCommand(opts) {
104
105
  }
105
106
  }
106
107
  /**
107
- * `prave usage report` โ€” invoked by the Claude Code `PostToolUse` hook.
108
+ * `prave usage report` โ€” invoked by the Claude Code `PostToolUse` hook
109
+ * (and, when `--source=prompt`, the companion `UserPromptSubmit` hook).
108
110
  * Reads the hook payload from stdin, extracts the Skill name, and fires
109
111
  * a single-event POST against the slug-keyed self-healing endpoint.
110
112
  * Errors are silent (we never want a hook failure to disturb the user's
111
- * workflow); set `PRAVE_DEBUG=1` to see what's happening in
112
- * `~/.prave/usage.log`.
113
+ * workflow); a rotated `~/.prave/hook.log` always captures the last 200
114
+ * fires so users can verify telemetry without flipping `PRAVE_DEBUG=1`.
115
+ *
116
+ * Hook discipline:
117
+ * - reads stdin with a 250 ms cap so we never block Claude Code
118
+ * - one POST attempt with a hard 4 s deadline (background-friendly)
119
+ * - on auth/network failure we log + bail; never throw
113
120
  */
114
- export async function usageReportCommand() {
121
+ export async function usageReportCommand(opts = {}) {
115
122
  const debug = process.env.PRAVE_DEBUG === '1' || process.env.PRAVE_DEBUG === 'true';
123
+ const source = opts.source === 'prompt' ? 'prompt' : 'tool';
124
+ await rotateHookLog();
116
125
  const stdinPayload = await readStdin();
117
126
  if (!stdinPayload) {
127
+ await hookLog(`${source}:no-stdin`);
118
128
  if (debug)
119
129
  await debugLog('no stdin payload');
120
130
  return;
121
131
  }
122
132
  if (debug)
123
- await debugLog(`stdin len=${stdinPayload.length}`);
133
+ await debugLog(`stdin len=${stdinPayload.length} source=${source}`);
124
134
  // Claude Code's PostToolUse payload is documented as `{tool_name,
125
- // tool_input, tool_response, ...}`, but we still defend against
126
- // alternate shapes (capitalisation, future schema drift) so the hook
127
- // doesn't silently break on a single field rename.
135
+ // tool_input, tool_response, ...}`. UserPromptSubmit ships
136
+ // `{ prompt, ... }`. We defend against alternate shapes (capitalisation,
137
+ // future schema drift) so the hook doesn't silently break on a single
138
+ // field rename.
128
139
  let parsed = {};
129
140
  try {
130
141
  parsed = JSON.parse(stdinPayload);
131
142
  }
132
143
  catch {
144
+ await hookLog(`${source}:bad-json`);
133
145
  if (debug)
134
146
  await debugLog('payload is not valid JSON');
135
147
  return;
136
148
  }
137
- const rawSlug = extractSkillSlug(parsed);
149
+ const rawSlug = source === 'prompt' ? extractSlashCommand(parsed) : extractSkillSlug(parsed);
138
150
  if (!rawSlug) {
151
+ await hookLog(`${source}:no-slug`);
139
152
  if (debug)
140
153
  await debugLog(`no skill slug in payload: ${stdinPayload.slice(0, 200)}`);
141
154
  return;
142
155
  }
143
156
  const slug = rawSlug.toLowerCase().split(':').pop()?.trim().replace(/[^a-z0-9_-]+/g, '-');
144
- if (!slug)
157
+ if (!slug) {
158
+ await hookLog(`${source}:empty-slug`);
145
159
  return;
160
+ }
146
161
  if (debug)
147
162
  await debugLog(`slug=${slug}`);
148
- const session = await requireAuthSilent();
149
- if (!session) {
150
- if (debug)
151
- await debugLog('no auth โ€” skipping');
152
- return;
153
- }
154
163
  // Build the telemetry blob from whatever Claude Code shipped in the
155
164
  // payload. Everything is best-effort โ€” missing fields just don't
156
165
  // appear in `meta`. The server schema (usageMetaSchema) validates
@@ -178,23 +187,77 @@ export async function usageReportCommand() {
178
187
  if (typeof obj.prompt === 'string')
179
188
  meta.prompt_chars = obj.prompt.length;
180
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
+ }
181
209
  try {
182
210
  const { data } = await api.post('/api/v1/intelligence/usage/by-slug', {
183
211
  slug,
184
212
  agent_type: 'claude',
185
- triggered_at: new Date().toISOString(),
186
- meta,
213
+ triggered_at,
214
+ meta: { ...meta, source },
187
215
  }, true);
216
+ await hookLog(`${source}:ok slug=${slug} recorded=${data.recorded} stub=${data.created_stub}`);
188
217
  if (debug) {
189
218
  await debugLog(`ok recorded=${data.recorded} stub=${data.created_stub} meta=${JSON.stringify(meta)}`);
190
219
  }
191
220
  }
192
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
+ });
230
+ const msg = err.message;
231
+ await hookLog(`${source}:buffered-on-err slug=${slug} ${msg.slice(0, 80)}`);
193
232
  if (debug)
194
- await debugLog(`error: ${err.message}`);
195
- /* silent โ€” never break the host shell */
233
+ await debugLog(`api error, buffered: ${msg}`);
196
234
  }
197
235
  }
236
+ /**
237
+ * Extract a slash-command name from a UserPromptSubmit payload. Claude
238
+ * Code ships `{ prompt: '/graphify ...' }` (and tolerates leading
239
+ * whitespace). We treat the first whitespace-separated token after `/`
240
+ * as the slug. Returns `null` for non-slash prompts so we don't waste
241
+ * an API hop on every keystroke.
242
+ */
243
+ function extractSlashCommand(payload) {
244
+ const candidates = [
245
+ payload.prompt,
246
+ payload.user_prompt,
247
+ payload.input,
248
+ ];
249
+ for (const c of candidates) {
250
+ if (typeof c !== 'string')
251
+ continue;
252
+ const trimmed = c.trimStart();
253
+ if (!trimmed.startsWith('/'))
254
+ continue;
255
+ const token = trimmed.slice(1).split(/[\s\n\r]/, 1)[0] ?? '';
256
+ if (token)
257
+ return token;
258
+ }
259
+ return null;
260
+ }
198
261
  /**
199
262
  * Walk the hook payload looking for the invoked Skill's slug. Tries the
200
263
  * documented Claude Code path first, then several plausible aliases so
@@ -226,6 +289,45 @@ async function debugLog(line) {
226
289
  /* swallow โ€” debug is best-effort */
227
290
  }
228
291
  }
292
+ /**
293
+ * Always-on per-fire breadcrumb log so users can answer "did the hook
294
+ * run?" without flipping `PRAVE_DEBUG=1`. Capped at 200 lines (rotated
295
+ * lazily by `rotateHookLog`). Lives at `~/.prave/hook.log`.
296
+ */
297
+ async function hookLog(line) {
298
+ try {
299
+ const { mkdir, appendFile } = await import('node:fs/promises');
300
+ const { join } = await import('node:path');
301
+ await mkdir(CONFIG.praveDir, { recursive: true });
302
+ const stamp = new Date().toISOString();
303
+ await appendFile(join(CONFIG.praveDir, 'hook.log'), `${stamp} ${line}\n`);
304
+ }
305
+ catch {
306
+ /* swallow */
307
+ }
308
+ }
309
+ const HOOK_LOG_MAX_LINES = 200;
310
+ async function rotateHookLog() {
311
+ try {
312
+ const { stat, readFile, writeFile } = await import('node:fs/promises');
313
+ const { join } = await import('node:path');
314
+ const path = join(CONFIG.praveDir, 'hook.log');
315
+ const info = await stat(path).catch(() => null);
316
+ // Only do the read-rewrite dance occasionally โ€” when the file gets
317
+ // big enough that we suspect overflow. 32 KB โ‰ˆ 200 short lines.
318
+ if (!info || info.size < 32 * 1024)
319
+ return;
320
+ const raw = await readFile(path, 'utf8');
321
+ const lines = raw.split('\n');
322
+ if (lines.length <= HOOK_LOG_MAX_LINES)
323
+ return;
324
+ const trimmed = lines.slice(-HOOK_LOG_MAX_LINES).join('\n');
325
+ await writeFile(path, trimmed, 'utf8');
326
+ }
327
+ catch {
328
+ /* swallow */
329
+ }
330
+ }
229
331
  export async function usageHookInstallCommand() {
230
332
  track('cli_usage_hook_install');
231
333
  const session = await requireAuth('prave usage hook install');
@@ -269,16 +371,22 @@ export async function usageStatusCommand() {
269
371
  const { readFile } = await import('node:fs/promises');
270
372
  const { join } = await import('node:path');
271
373
  const { homedir } = await import('node:os');
272
- // 1. Hook installed?
374
+ // 1. Hook installed? Check both channels we manage.
273
375
  const settingsPath = join(homedir(), '.claude', 'settings.json');
274
- let hookInstalled = false;
376
+ let toolHookInstalled = false;
377
+ let promptHookInstalled = false;
275
378
  try {
276
379
  const raw = await readFile(settingsPath, 'utf8');
277
- hookInstalled = raw.includes('__prave_managed') && raw.includes('Skill');
380
+ const parsed = JSON.parse(raw);
381
+ toolHookInstalled =
382
+ (parsed.hooks?.PostToolUse ?? []).some((b) => b.matcher === 'Skill' && (b.hooks ?? []).some((h) => h.__prave_managed === true));
383
+ promptHookInstalled =
384
+ (parsed.hooks?.UserPromptSubmit ?? []).some((b) => (b.hooks ?? []).some((h) => h.__prave_managed === true));
278
385
  }
279
386
  catch {
280
387
  /* no settings.json yet */
281
388
  }
389
+ const hookInstalled = toolHookInstalled && promptHookInstalled;
282
390
  // 2. Last 7 days of recorded events from the API.
283
391
  let recent7 = 0;
284
392
  let topSlugs = [];
@@ -300,14 +408,33 @@ export async function usageStatusCommand() {
300
408
  catch {
301
409
  /* never scanned */
302
410
  }
303
- // 4. Debug log tail.
304
- const logPath = join(CONFIG.praveDir, 'usage.log');
305
- const debugAvailable = existsSync(logPath);
411
+ // 4. Debug log + hook log tails.
412
+ const debugLogPath = join(CONFIG.praveDir, 'usage.log');
413
+ const hookLogPath = join(CONFIG.praveDir, 'hook.log');
414
+ const debugAvailable = existsSync(debugLogPath);
415
+ const hookLogAvailable = existsSync(hookLogPath);
416
+ // 5. API reachability โ€” token-validating ping. We already passed
417
+ // `requireAuth` above so the credentials file exists; this round-trip
418
+ // just confirms the access_token is still accepted server-side. If the
419
+ // hook has been silently 401-ing for a week, this is the only way the
420
+ // user finds out without flipping `PRAVE_DEBUG=1`.
421
+ let apiReachable = false;
422
+ let apiMessage = '';
423
+ try {
424
+ await api.get('/api/v1/intelligence/usage/recent?days=1', true);
425
+ apiReachable = true;
426
+ }
427
+ catch (err) {
428
+ apiMessage =
429
+ err instanceof ApiError ? `${err.status} ${err.message}` : err.message;
430
+ }
306
431
  // Render.
307
- const checkmark = (ok) => (ok ? chalk.green('โœ“') : chalk.dim('โ€”'));
432
+ const checkmark = (ok) => (ok ? chalk.green('โœ“') : chalk.red('โœ—'));
308
433
  log.info(chalk.bold('Usage tracking status'));
309
434
  console.log();
310
- console.log(` ${checkmark(hookInstalled)} Real-time hook (Claude Code): ${hookInstalled ? chalk.green('installed') : chalk.yellow('not installed โ€” `prave usage hook install`')}`);
435
+ console.log(` ${checkmark(toolHookInstalled)} PostToolUse hook (Skill tool fires): ${toolHookInstalled ? chalk.green('installed') : chalk.yellow('missing โ€” `prave usage hook install`')}`);
436
+ console.log(` ${checkmark(promptHookInstalled)} UserPromptSubmit hook (slash commands like /graphify): ${promptHookInstalled ? chalk.green('installed') : chalk.yellow('missing โ€” `prave usage hook install`')}`);
437
+ console.log(` ${checkmark(apiReachable)} API reachable + auth valid: ${apiReachable ? chalk.green('yes') : chalk.red(apiMessage || 'no')}`);
311
438
  console.log(` ${checkmark(Boolean(lastScanAt))} Transcript scanner watermark: ${lastScanAt ?? chalk.dim('never run โ€” `prave sync` includes it')}`);
312
439
  console.log(` ${checkmark(recent7 > 0)} Events in last 7 days: ${chalk.cyan(String(recent7))}`);
313
440
  if (topSlugs.length) {
@@ -317,20 +444,41 @@ export async function usageStatusCommand() {
317
444
  console.log(` ${chalk.cyan(s.count.toString().padStart(4))} ${s.name}`);
318
445
  }
319
446
  }
320
- console.log();
321
- if (debugAvailable) {
322
- log.dim(`Debug log: ${logPath}`);
323
- log.dim('Set PRAVE_DEBUG=1 in your shell to enable verbose hook logging.');
324
- }
325
- else {
326
- log.dim('No debug log yet. Set PRAVE_DEBUG=1 to capture every hook fire to ~/.prave/usage.log');
447
+ // Last 5 hook fires โ€” the breadcrumb trail that answers "did the hook
448
+ // even run when I typed /graphify?". Read the bottom of hook.log.
449
+ if (hookLogAvailable) {
450
+ try {
451
+ const raw = await readFile(hookLogPath, 'utf8');
452
+ const tail = raw.trimEnd().split('\n').slice(-5);
453
+ if (tail.length) {
454
+ console.log();
455
+ console.log(chalk.dim(' Last hook fires:'));
456
+ for (const line of tail)
457
+ console.log(` ${chalk.dim(line)}`);
458
+ }
459
+ }
460
+ catch {
461
+ /* swallow */
462
+ }
327
463
  }
328
- if (recent7 === 0) {
464
+ console.log();
465
+ log.dim(`Hook breadcrumb log: ${hookLogPath}`);
466
+ if (debugAvailable)
467
+ log.dim(`Verbose debug log: ${debugLogPath}`);
468
+ log.dim('Set PRAVE_DEBUG=1 in your shell to enable verbose hook logging.');
469
+ if (!hookInstalled || !apiReachable || recent7 === 0) {
329
470
  console.log();
330
- log.warn('No events recorded yet. Quick checks:');
331
- log.dim(' 1. Is the hook installed? See checkmarks above.');
332
- log.dim(' 2. Run a Skill in Claude Code, then `tail ~/.prave/usage.log` (with PRAVE_DEBUG=1)');
333
- log.dim(' 3. Or run `prave usage scan --since 7d` to backfill from transcripts.');
471
+ log.warn('Telemetry may be incomplete. Suggested fixes:');
472
+ if (!toolHookInstalled || !promptHookInstalled) {
473
+ log.dim(' โ€ข Run `prave usage hook install` to wire BOTH PostToolUse and UserPromptSubmit.');
474
+ }
475
+ if (!apiReachable) {
476
+ log.dim(' โ€ข Run `prave login` โ€” your access token may have expired (silently 401-ing).');
477
+ }
478
+ if (recent7 === 0 && hookInstalled && apiReachable) {
479
+ log.dim(' โ€ข Run a Skill in Claude Code, then re-run `prave usage status`.');
480
+ log.dim(' โ€ข Or run `prave usage scan --since 7d` to backfill from transcripts.');
481
+ }
334
482
  }
335
483
  }
336
484
  async function fetchEnabledAgents() {
@@ -414,13 +562,37 @@ function parseSinceFlag(raw) {
414
562
  async function readStdin() {
415
563
  if (process.stdin.isTTY)
416
564
  return '';
417
- const chunks = [];
418
- for await (const chunk of process.stdin) {
419
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
420
- if (Buffer.concat(chunks).length > 1_000_000)
421
- break;
422
- }
423
- return Buffer.concat(chunks).toString('utf8');
565
+ // Hard 1.5 s cap so a stuck pipe never blocks Claude Code's tool flow.
566
+ // 1 MB payload cap so a runaway stream can't OOM the hook either.
567
+ return new Promise((resolve) => {
568
+ const chunks = [];
569
+ let total = 0;
570
+ let settled = false;
571
+ const finish = () => {
572
+ if (settled)
573
+ return;
574
+ settled = true;
575
+ resolve(Buffer.concat(chunks).toString('utf8'));
576
+ };
577
+ const timer = setTimeout(finish, 1500);
578
+ process.stdin.on('data', (chunk) => {
579
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
580
+ chunks.push(buf);
581
+ total += buf.length;
582
+ if (total > 1_000_000) {
583
+ clearTimeout(timer);
584
+ finish();
585
+ }
586
+ });
587
+ process.stdin.once('end', () => {
588
+ clearTimeout(timer);
589
+ finish();
590
+ });
591
+ process.stdin.once('error', () => {
592
+ clearTimeout(timer);
593
+ finish();
594
+ });
595
+ });
424
596
  }
425
597
  /**
426
598
  * Auth check with no UX side-effects. Used by the hook so a logged-out
package/dist/index.js CHANGED
@@ -119,12 +119,12 @@ program
119
119
  program
120
120
  .command('conflicts')
121
121
  .description('Detect overlap, collisions, and missing dependencies')
122
- .option('--fix', 'placeholder for interactive fix flow')
122
+ .option('--fix', 'after listing conflicts, interactively pick installed Skills to uninstall to resolve them')
123
123
  .action(conflictsCommand);
124
124
  program
125
125
  .command('optimize')
126
126
  .description('Recommendations: underused, mergeable, and heavy skills')
127
- .option('--apply', 'placeholder for auto-apply')
127
+ .option('--apply', 'after listing recommendations, interactively pick underused + heavy skills to uninstall')
128
128
  .option('--remove-unused', 'interactively delete skills that have not fired in 30+ days from ~/.claude/skills/')
129
129
  .option('--yes', 'with --remove-unused: skip the confirmation prompt')
130
130
  .action(optimizeCommand);
@@ -143,8 +143,9 @@ usage
143
143
  .action(usageStatusCommand);
144
144
  usage
145
145
  .command('report')
146
- .description('Internal: invoked by the Claude Code PostToolUse hook (reads stdin)')
147
- .action(usageReportCommand);
146
+ .description('Internal: invoked by the Claude Code PostToolUse / UserPromptSubmit hook (reads stdin)')
147
+ .option('--source <kind>', 'hook channel that fired this report ("tool" or "prompt")', 'tool')
148
+ .action((opts) => usageReportCommand(opts));
148
149
  const hook = usage.command('hook').description('Install/uninstall the Claude Code real-time usage hook');
149
150
  hook
150
151
  .command('install')
@@ -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
+ }
package/dist/lib/hook.js CHANGED
@@ -22,25 +22,50 @@ const HOOK_MARKER = '__prave_managed';
22
22
  */
23
23
  export const HOOK_SUPPORTED = ['claude'];
24
24
  const HOOK_COMMAND = 'prave usage report';
25
+ // Companion command for the UserPromptSubmit channel so a typed-slash
26
+ // `/graphify` is captured even when the Skill tool path doesn't fire a
27
+ // matching PostToolUse with a populated `tool_input.skill` field.
28
+ const PROMPT_HOOK_COMMAND = 'prave usage report --source=prompt';
29
+ /**
30
+ * Install on BOTH `PostToolUse` (matcher `Skill`) and `UserPromptSubmit`
31
+ * (catches slash commands like `/graphify` before tool dispatch). The two
32
+ * channels are deduped server-side by per-minute bucket, so double-fires
33
+ * for the same Skill in the same minute land as a single row โ€” but every
34
+ * additional minute of activity is preserved. Idempotent + atomic.
35
+ */
25
36
  export async function installSkillHook() {
26
37
  const settings = await readSettings();
27
38
  settings.hooks ??= {};
28
39
  settings.hooks.PostToolUse ??= [];
29
- const blocks = settings.hooks.PostToolUse;
30
- const existingIdx = blocks.findIndex((b) => b.matcher === 'Skill' && b.hooks?.some((h) => h[HOOK_MARKER]));
31
- const fresh = {
40
+ settings.hooks.UserPromptSubmit ??= [];
41
+ const postBlocks = settings.hooks.PostToolUse;
42
+ const promptBlocks = settings.hooks.UserPromptSubmit;
43
+ const postFresh = {
32
44
  matcher: 'Skill',
33
45
  hooks: [{ type: 'command', command: HOOK_COMMAND, [HOOK_MARKER]: true }],
34
46
  };
35
- if (existingIdx >= 0) {
36
- const existingCmd = blocks[existingIdx]?.hooks?.[0]?.command;
37
- if (existingCmd === HOOK_COMMAND) {
38
- return { installed: false, alreadyPresent: true, settingsPath: SETTINGS_PATH };
47
+ const promptFresh = {
48
+ // UserPromptSubmit has no matcher concept in Claude Code today โ€” the
49
+ // hook script itself filters for `prompt.startsWith('/')` before
50
+ // doing any work.
51
+ hooks: [{ type: 'command', command: PROMPT_HOOK_COMMAND, [HOOK_MARKER]: true }],
52
+ };
53
+ const upsert = (blocks, fresh, expectedCmd) => {
54
+ const idx = blocks.findIndex((b) => b.matcher === fresh.matcher && b.hooks?.some((h) => h[HOOK_MARKER]));
55
+ if (idx >= 0) {
56
+ const existingCmd = blocks[idx]?.hooks?.[0]?.command;
57
+ if (existingCmd === expectedCmd)
58
+ return false;
59
+ blocks[idx] = fresh;
60
+ return true;
39
61
  }
40
- blocks[existingIdx] = fresh;
41
- }
42
- else {
43
62
  blocks.push(fresh);
63
+ return true;
64
+ };
65
+ const changedPost = upsert(postBlocks, postFresh, HOOK_COMMAND);
66
+ const changedPrompt = upsert(promptBlocks, promptFresh, PROMPT_HOOK_COMMAND);
67
+ if (!changedPost && !changedPrompt) {
68
+ return { installed: false, alreadyPresent: true, settingsPath: SETTINGS_PATH };
44
69
  }
45
70
  await writeSettings(settings);
46
71
  return { installed: true, alreadyPresent: false, settingsPath: SETTINGS_PATH };
@@ -93,25 +118,35 @@ export async function uninstallHooksForAgents(agents) {
93
118
  }
94
119
  export async function uninstallSkillHook() {
95
120
  const settings = await readSettings();
96
- const blocks = settings.hooks?.PostToolUse;
97
- if (!blocks?.length)
121
+ if (!settings.hooks)
98
122
  return { removed: false, settingsPath: SETTINGS_PATH };
99
- const before = blocks.length;
100
- const filtered = blocks
101
- .map((b) => ({
102
- ...b,
103
- hooks: b.hooks?.filter((h) => !h[HOOK_MARKER]),
104
- }))
105
- .filter((b) => (b.hooks?.length ?? 0) > 0);
106
- if (filtered.length === before && filtered.every((b, i) => b.hooks?.length === blocks[i]?.hooks?.length)) {
123
+ let touched = false;
124
+ const stripChannel = (channel) => {
125
+ const blocks = settings.hooks?.[channel];
126
+ if (!blocks?.length)
127
+ return;
128
+ const beforeCounts = blocks.map((b) => b.hooks?.length ?? 0);
129
+ const filtered = blocks
130
+ .map((b) => ({ ...b, hooks: b.hooks?.filter((h) => !h[HOOK_MARKER]) }))
131
+ .filter((b) => (b.hooks?.length ?? 0) > 0);
132
+ const lengthChanged = filtered.length !== blocks.length;
133
+ const innerChanged = !lengthChanged &&
134
+ filtered.some((b, i) => (b.hooks?.length ?? 0) !== beforeCounts[i]);
135
+ if (!lengthChanged && !innerChanged)
136
+ return;
137
+ touched = true;
138
+ if (settings.hooks) {
139
+ settings.hooks[channel] = filtered.length ? filtered : undefined;
140
+ if (!filtered.length)
141
+ delete settings.hooks[channel];
142
+ }
143
+ };
144
+ stripChannel('PostToolUse');
145
+ stripChannel('UserPromptSubmit');
146
+ if (!touched)
107
147
  return { removed: false, settingsPath: SETTINGS_PATH };
108
- }
109
- if (settings.hooks) {
110
- settings.hooks.PostToolUse = filtered;
111
- if (!filtered.length)
112
- delete settings.hooks.PostToolUse;
113
- if (Object.keys(settings.hooks).length === 0)
114
- delete settings.hooks;
148
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
149
+ delete settings.hooks;
115
150
  }
116
151
  await writeSettings(settings);
117
152
  return { removed: true, settingsPath: SETTINGS_PATH };
@@ -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.0",
3
+ "version": "1.2.2",
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"
@@ -51,7 +51,7 @@
51
51
  "open": "^10.1.0",
52
52
  "ora": "^8.0.1",
53
53
  "undici": "^6.18.0",
54
- "@prave/shared": "1.2.0"
54
+ "@prave/shared": "1.2.2"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^20.12.7",