@prave/cli 0.5.0 → 1.0.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.
@@ -33,6 +33,16 @@ export async function loginCommand() {
33
33
  user_id: data.user_id,
34
34
  });
35
35
  spinner.succeed('Logged in.');
36
+ // Onboarding: prefill from the SaaS profile, let the user toggle
37
+ // with space/enter, persist back, and offer to install hooks.
38
+ // Failures here are non-fatal — login succeeded.
39
+ try {
40
+ const { runAgentOnboarding } = await import('../lib/agent-onboarding.js');
41
+ await runAgentOnboarding();
42
+ }
43
+ catch (onboardErr) {
44
+ log.dim(`Agent onboarding skipped: ${onboardErr.message}`);
45
+ }
36
46
  return;
37
47
  }
38
48
  catch (err) {
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import chalk from 'chalk';
4
- import { AGENT_REGISTRY, AGENT_TYPES } from '@prave/shared';
4
+ import { AGENT_REGISTRY } from '@prave/shared';
5
5
  import { api, ApiError } from '../lib/api.js';
6
6
  import { requireAuth } from '../lib/credentials.js';
7
7
  import { CONFIG } from '../lib/config.js';
@@ -36,37 +36,15 @@ async function patchSettings(patch) {
36
36
  const { data } = await api.put('/api/v1/settings/agents', patch, true);
37
37
  return data;
38
38
  }
39
- function isAgentType(s) {
40
- return AGENT_TYPES.includes(s);
41
- }
42
- async function configureAgents(rl, current) {
43
- console.log();
44
- console.log(chalk.bold('Available agents:'));
45
- for (const id of AGENT_TYPES) {
46
- const meta = AGENT_REGISTRY[id];
47
- const enabled = current.enabled_agents.includes(id) ? chalk.green('✓') : ' ';
48
- console.log(` ${enabled} ${chalk.cyan(meta.id.padEnd(8))} ${meta.label} ${chalk.dim('— ' + meta.description)}`);
49
- }
50
- const answer = (await rl.question(`\nEnter comma-separated agents to enable (default: ${current.enabled_agents.join(',') || 'claude'}): `)).trim();
51
- let enabled = current.enabled_agents;
52
- if (answer) {
53
- const tokens = answer
54
- .split(',')
55
- .map((t) => t.trim().toLowerCase())
56
- .filter(Boolean);
57
- const invalid = tokens.filter((t) => !isAgentType(t));
58
- if (invalid.length > 0) {
59
- log.warn(`Unknown agents skipped: ${invalid.join(', ')}`);
60
- }
61
- enabled = tokens.filter(isAgentType);
62
- if (enabled.length === 0) {
63
- log.warn('No valid agents selected — keeping previous selection.');
64
- return current;
65
- }
66
- }
67
- const updated = await patchSettings({ enabled_agents: enabled });
39
+ async function configureAgents(_rl, current) {
40
+ // Reuse the shared onboarding flow. Same UX as `prave login`: space to
41
+ // toggle, enter to confirm, plus an opt-in hook install. Persists via
42
+ // PUT /api/v1/settings/agents so web + CLI stay in lockstep.
43
+ const { runAgentOnboarding } = await import('../lib/agent-onboarding.js');
44
+ const updated = await runAgentOnboarding();
45
+ if (!updated)
46
+ return current;
68
47
  await saveLocalConfig(updated);
69
- log.success(`Enabled agents: ${updated.enabled_agents.join(', ')}`);
70
48
  return updated;
71
49
  }
72
50
  async function configurePaths(rl, current) {
@@ -88,4 +88,15 @@ export async function syncCommand() {
88
88
  }
89
89
  }
90
90
  }
91
+ // Tail end of sync: fire a quiet usage scan so the optimiser stays warm
92
+ // without the user having to remember an extra command. Quiet mode means
93
+ // we only log a one-liner ("Usage: 12 new, 88 known.") instead of taking
94
+ // over the spinner. Failures are non-fatal — sync's primary job is done.
95
+ try {
96
+ const { usageScanCommand } = await import('./usage.js');
97
+ await usageScanCommand({ quiet: true });
98
+ }
99
+ catch {
100
+ /* swallow — usage tracking is best-effort */
101
+ }
91
102
  }
@@ -0,0 +1,267 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import { api, ApiError } from '../lib/api.js';
6
+ import { CONFIG } from '../lib/config.js';
7
+ import { requireAuth } from '../lib/credentials.js';
8
+ import { HOOK_SUPPORTED, installHooksForAgents, uninstallHooksForAgents, } from '../lib/hook.js';
9
+ import { AGENT_REGISTRY } from '@prave/shared';
10
+ import { loadCursor, saveCursor } from '../lib/usage-cursor.js';
11
+ import { scanTranscriptsForUsage } from '../lib/usage-scanner.js';
12
+ import { log } from '../utils/logger.js';
13
+ /**
14
+ * `prave usage scan` — read every recent JSONL transcript on disk, find
15
+ * Skill invocations, and POST them to `/api/v1/intelligence/usage/batch`.
16
+ * Hour-bucket dedup happens server-side, so re-runs are cheap.
17
+ *
18
+ * Use `--since=7d` to override the watermark (handy when you've manually
19
+ * cleared the cursor or want to backfill). `--quiet` suppresses output —
20
+ * used by `prave sync` to chain a scan without spamming the terminal.
21
+ */
22
+ export async function usageScanCommand(opts) {
23
+ const session = await requireAuth('prave usage scan');
24
+ if (!session)
25
+ return;
26
+ const since = opts.since ? parseSinceFlag(opts.since) : await loadCursor();
27
+ const spinner = opts.quiet ? null : ora('Scanning Claude Code transcripts…').start();
28
+ // 1. Gather installed slug list — this is our recognition allowlist.
29
+ const slugs = await listInstalledSlugs();
30
+ if (!slugs.length) {
31
+ spinner?.warn('No Skills installed locally — nothing to track.');
32
+ return;
33
+ }
34
+ // 2. Scan local JSONL transcripts.
35
+ let events = [];
36
+ try {
37
+ events = await scanTranscriptsForUsage(slugs, since);
38
+ }
39
+ catch (err) {
40
+ spinner?.fail(`Scan failed: ${err.message}`);
41
+ return;
42
+ }
43
+ if (!events.length) {
44
+ spinner?.succeed(`No new Skill invocations since ${since.toISOString()}.`);
45
+ await saveCursor();
46
+ return;
47
+ }
48
+ if (spinner)
49
+ spinner.text = `Mapping ${events.length} events to your Skill catalog…`;
50
+ // 3. Map slug → skill_metadata_id via the intelligence API.
51
+ let metadata = [];
52
+ try {
53
+ const { data } = await api.get('/api/v1/intelligence/skills', true);
54
+ metadata = data;
55
+ }
56
+ catch (err) {
57
+ spinner?.fail(err instanceof ApiError
58
+ ? `Couldn't fetch your Skill catalog: ${err.message}`
59
+ : 'Network error fetching your Skill catalog.');
60
+ return;
61
+ }
62
+ const slugToId = buildSlugMap(metadata);
63
+ const payload = events
64
+ .map((e) => {
65
+ const id = slugToId.get(e.slug);
66
+ if (!id)
67
+ return null;
68
+ return {
69
+ skill_metadata_id: id,
70
+ triggered_at: e.triggered_at,
71
+ trigger_phrase: e.trigger_phrase ?? null,
72
+ };
73
+ })
74
+ .filter((e) => Boolean(e));
75
+ if (!payload.length) {
76
+ spinner?.succeed('Found events, but none matched the Skills tracked on your account. Run `prave import --upload` first.');
77
+ return;
78
+ }
79
+ // 4. Send in chunks of 500 (server cap).
80
+ if (spinner)
81
+ spinner.text = `Reporting ${payload.length} events…`;
82
+ let inserted = 0;
83
+ let deduped = 0;
84
+ for (let i = 0; i < payload.length; i += 500) {
85
+ const slice = payload.slice(i, i + 500);
86
+ try {
87
+ const { data } = await api.post('/api/v1/intelligence/usage/batch', { events: slice }, true);
88
+ inserted += data.inserted;
89
+ deduped += data.deduped;
90
+ }
91
+ catch (err) {
92
+ spinner?.fail(`Upload failed mid-batch: ${err.message}`);
93
+ return;
94
+ }
95
+ }
96
+ await saveCursor();
97
+ if (spinner) {
98
+ spinner.succeed(`${chalk.bold(inserted)} new event${inserted === 1 ? '' : 's'} reported · ${deduped} already known.`);
99
+ }
100
+ else if (!opts.quiet) {
101
+ log.dim(`Usage: ${inserted} new, ${deduped} known.`);
102
+ }
103
+ }
104
+ /**
105
+ * `prave usage report` — invoked by the Claude Code `PostToolUse` hook.
106
+ * Reads the hook payload from stdin, extracts the Skill name, and fires
107
+ * a single-event POST. Errors are silent (we never want a hook failure
108
+ * to disturb the user's workflow).
109
+ */
110
+ export async function usageReportCommand() {
111
+ // Hook payload arrives on stdin. If we can't read it, exit silently.
112
+ const stdinPayload = await readStdin();
113
+ if (!stdinPayload)
114
+ return;
115
+ let parsed = {};
116
+ try {
117
+ parsed = JSON.parse(stdinPayload);
118
+ }
119
+ catch {
120
+ return;
121
+ }
122
+ const rawSlug = parsed.tool_input?.skill;
123
+ if (typeof rawSlug !== 'string' || !rawSlug.trim())
124
+ return;
125
+ const slug = rawSlug.toLowerCase().split(':').pop()?.trim();
126
+ if (!slug)
127
+ return;
128
+ // No auth → bail silently. The user is browsing logged-out; we're not
129
+ // going to bug them with a login prompt from a background hook.
130
+ const session = await requireAuthSilent();
131
+ if (!session)
132
+ return;
133
+ try {
134
+ const { data: metadata } = await api.get('/api/v1/intelligence/skills', true);
135
+ const id = buildSlugMap(metadata).get(slug);
136
+ if (!id)
137
+ return;
138
+ await api.post('/api/v1/intelligence/usage', { skill_metadata_id: id, trigger_phrase: null }, true);
139
+ }
140
+ catch {
141
+ /* silent — never break the host shell */
142
+ }
143
+ }
144
+ export async function usageHookInstallCommand() {
145
+ const session = await requireAuth('prave usage hook install');
146
+ if (!session)
147
+ return;
148
+ const agents = await fetchEnabledAgents();
149
+ if (!agents.length) {
150
+ log.warn('No agents enabled on your account. Run `prave settings` or `prave login` first.');
151
+ return;
152
+ }
153
+ const results = await installHooksForAgents(agents);
154
+ printAgentResults(results, 'install');
155
+ }
156
+ export async function usageHookUninstallCommand() {
157
+ const session = await requireAuth('prave usage hook uninstall');
158
+ if (!session)
159
+ return;
160
+ const agents = await fetchEnabledAgents();
161
+ if (!agents.length) {
162
+ // Nothing in the SaaS profile? Try removing from Claude anyway —
163
+ // the user might have installed manually.
164
+ const results = await uninstallHooksForAgents(['claude']);
165
+ printAgentResults(results, 'uninstall');
166
+ return;
167
+ }
168
+ const results = await uninstallHooksForAgents(agents);
169
+ printAgentResults(results, 'uninstall');
170
+ }
171
+ async function fetchEnabledAgents() {
172
+ try {
173
+ const { data } = await api.get('/api/v1/settings/agents', true);
174
+ return data.enabled_agents ?? [];
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ }
180
+ function printAgentResults(results, verb) {
181
+ for (const r of results) {
182
+ const label = AGENT_REGISTRY[r.agent].label;
183
+ if (r.status === 'installed') {
184
+ log.success(`${label} hook ${verb === 'install' ? 'installed' : 'removed'}`);
185
+ }
186
+ else if (r.status === 'already') {
187
+ log.dim(`${label}: ${verb === 'install' ? 'already installed' : 'nothing to remove'}`);
188
+ }
189
+ else if (r.status === 'unsupported') {
190
+ log.dim(`${label}: real-time hook not yet supported — usage scan still tracks via \`prave sync\`.`);
191
+ }
192
+ else {
193
+ log.warn(`${label}: ${r.message ?? 'failed'}`);
194
+ }
195
+ }
196
+ if (verb === 'install') {
197
+ const installed = results.filter((r) => r.status === 'installed' || r.status === 'already');
198
+ if (installed.length) {
199
+ log.dim(`Run \`prave usage hook uninstall\` to remove. Supported agents: ${HOOK_SUPPORTED.join(', ')}.`);
200
+ }
201
+ }
202
+ }
203
+ async function listInstalledSlugs() {
204
+ try {
205
+ const entries = await readdir(CONFIG.skillsDir);
206
+ const slugs = [];
207
+ for (const name of entries) {
208
+ if (name.startsWith('.'))
209
+ continue;
210
+ const dir = join(CONFIG.skillsDir, name);
211
+ if ((await stat(dir).catch(() => null))?.isDirectory())
212
+ slugs.push(name.toLowerCase());
213
+ }
214
+ return slugs;
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
220
+ function buildSlugMap(rows) {
221
+ const map = new Map();
222
+ for (const row of rows) {
223
+ if (!row.file_path)
224
+ continue;
225
+ // file_path looks like "<skillsDir>/<slug>/SKILL.md" — slug is the
226
+ // parent directory name. We also fall back to the row's `name` so a
227
+ // server that stored the slug in `name` still resolves.
228
+ const parts = row.file_path.split('/').filter(Boolean);
229
+ const skillIdx = parts.findIndex((p) => p === 'skills');
230
+ const slug = skillIdx >= 0 && parts[skillIdx + 1]
231
+ ? parts[skillIdx + 1].toLowerCase()
232
+ : (basename(row.file_path.replace(/\/SKILL\.md$/i, '')) || '').toLowerCase();
233
+ if (slug)
234
+ map.set(slug, row.id);
235
+ if (row.name)
236
+ map.set(row.name.toLowerCase(), row.id);
237
+ }
238
+ return map;
239
+ }
240
+ function parseSinceFlag(raw) {
241
+ const m = /^(\d+)([dh])$/.exec(raw.trim());
242
+ if (!m)
243
+ return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
244
+ const n = Number(m[1]);
245
+ const unit = m[2];
246
+ const ms = unit === 'h' ? n * 60 * 60 * 1000 : n * 24 * 60 * 60 * 1000;
247
+ return new Date(Date.now() - ms);
248
+ }
249
+ async function readStdin() {
250
+ if (process.stdin.isTTY)
251
+ return '';
252
+ const chunks = [];
253
+ for await (const chunk of process.stdin) {
254
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
255
+ if (Buffer.concat(chunks).length > 1_000_000)
256
+ break;
257
+ }
258
+ return Buffer.concat(chunks).toString('utf8');
259
+ }
260
+ /**
261
+ * Auth check with no UX side-effects. Used by the hook so a logged-out
262
+ * user never sees a stray "please log in" message during their workflow.
263
+ */
264
+ async function requireAuthSilent() {
265
+ const { loadCredentials } = await import('../lib/credentials.js');
266
+ return loadCredentials();
267
+ }
@@ -17,6 +17,17 @@ export async function whoamiCommand() {
17
17
  process.exitCode = 1;
18
18
  return;
19
19
  }
20
+ // Detect creds left over from older CLI versions that predate the
21
+ // refresh-token flow. Without a refresh_token we can't auto-rotate the
22
+ // expired access_token, so the user gets stuck in a "session expired"
23
+ // loop. Direct them to `prave login` immediately rather than letting
24
+ // the API call fail confusingly.
25
+ if (!creds.refresh_token) {
26
+ log.warn('Stored credentials predate the refresh-token flow.');
27
+ log.dim('Run `prave login` once to upgrade — refreshes will be automatic afterwards.');
28
+ process.exitCode = 1;
29
+ return;
30
+ }
20
31
  try {
21
32
  const { data } = await api.get('/api/v1/me', true);
22
33
  const handle = data.username || data.display_name || data.email || creds.email || 'unknown';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { settingsCommand } from './commands/settings.js';
20
20
  import { syncCommand } from './commands/sync.js';
21
21
  import { uninstallCommand } from './commands/uninstall.js';
22
22
  import { updateCommand } from './commands/update.js';
23
+ import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand, usageScanCommand, } from './commands/usage.js';
23
24
  import { whatdoesCommand } from './commands/whatdoes.js';
24
25
  import { whoamiCommand } from './commands/whoami.js';
25
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -100,6 +101,28 @@ program
100
101
  .description('Recommendations: underused, mergeable, and heavy skills')
101
102
  .option('--apply', 'placeholder for auto-apply')
102
103
  .action(optimizeCommand);
104
+ const usage = program
105
+ .command('usage')
106
+ .description('Track which Skills you actually use (powers the optimiser)');
107
+ usage
108
+ .command('scan')
109
+ .description('Scan local Claude Code transcripts and report invocations to Prave')
110
+ .option('--since <window>', 'override the watermark, e.g. "7d" or "12h"')
111
+ .option('--quiet', 'log a one-liner instead of a spinner')
112
+ .action(usageScanCommand);
113
+ usage
114
+ .command('report')
115
+ .description('Internal: invoked by the Claude Code PostToolUse hook (reads stdin)')
116
+ .action(usageReportCommand);
117
+ const hook = usage.command('hook').description('Install/uninstall the Claude Code real-time usage hook');
118
+ hook
119
+ .command('install')
120
+ .description('Add a PostToolUse hook to ~/.claude/settings.json')
121
+ .action(usageHookInstallCommand);
122
+ hook
123
+ .command('uninstall')
124
+ .description('Remove the Prave-managed hook from ~/.claude/settings.json')
125
+ .action(usageHookUninstallCommand);
103
126
  program
104
127
  .command('find <query>')
105
128
  .description('Smart skill search across local and marketplace')
@@ -0,0 +1,107 @@
1
+ import { AGENT_REGISTRY, AGENT_TYPES } from '@prave/shared';
2
+ import chalk from 'chalk';
3
+ import { api } from './api.js';
4
+ import { HOOK_SUPPORTED, installHooksForAgents } from './hook.js';
5
+ import { checkboxPrompt } from './prompt.js';
6
+ import { log } from '../utils/logger.js';
7
+ /**
8
+ * Shared "pick which agents you use" flow. Called by `prave login`
9
+ * (right after credentials are saved) and reused by
10
+ * `prave settings → Agent Configuration` so the user gets exactly the
11
+ * same UX in both places — no 1-5 numeric menus, no comma-separated
12
+ * typing.
13
+ *
14
+ * After the pick we also offer to install the real-time hook on every
15
+ * supported agent. Hook installation is opt-in: if the user just wants
16
+ * the agents enabled without hooks, they hit `n` at the second prompt.
17
+ */
18
+ export async function runAgentOnboarding() {
19
+ // 1. Pull whatever the SaaS profile already has so we prefill the
20
+ // selection from the user's web settings.
21
+ let current = null;
22
+ try {
23
+ const { data } = await api.get('/api/v1/settings/agents', true);
24
+ current = data;
25
+ }
26
+ catch {
27
+ /* fresh user with no saved prefs — fall through to defaults */
28
+ }
29
+ const items = AGENT_TYPES.map((id) => {
30
+ const meta = AGENT_REGISTRY[id];
31
+ const supportsHook = HOOK_SUPPORTED.includes(id);
32
+ return {
33
+ value: id,
34
+ label: meta.label,
35
+ hint: supportsHook ? `${meta.description} · real-time hook` : meta.description,
36
+ };
37
+ });
38
+ const initial = (current?.enabled_agents ?? ['claude']);
39
+ const picked = await checkboxPrompt('Which agents should Prave manage?', items, { initial, minSelected: 1 });
40
+ if (!picked) {
41
+ log.warn('No agents selected — keeping previous selection.');
42
+ return current;
43
+ }
44
+ // 2. Persist to SaaS so web + CLI stay in sync.
45
+ let updated = current ?? {
46
+ user_id: '',
47
+ enabled_agents: [],
48
+ skill_paths: {},
49
+ detected_os: detectOs(),
50
+ updated_at: new Date().toISOString(),
51
+ };
52
+ try {
53
+ const { data } = await api.put('/api/v1/settings/agents', { enabled_agents: picked, detected_os: detectOs() }, true);
54
+ updated = data;
55
+ log.info(`Enabled agents: ${chalk.cyan(updated.enabled_agents.join(', '))}`);
56
+ }
57
+ catch (err) {
58
+ log.warn(`Couldn't sync agents to your account: ${err.message}`);
59
+ return current;
60
+ }
61
+ // 3. Offer to install the real-time hook on every agent that supports
62
+ // it. We make this a single y/n — the hook itself is uniform across
63
+ // the chosen agents, so a per-agent confirmation would be friction.
64
+ const supportedPicks = picked.filter((a) => HOOK_SUPPORTED.includes(a));
65
+ if (!supportedPicks.length) {
66
+ log.dim('No real-time hook contract for the agents you picked yet — usage will still be tracked via `prave usage scan` (auto-runs on `prave sync`).');
67
+ return updated;
68
+ }
69
+ const wantHook = await yesNoPrompt(`Install the real-time usage hook for ${supportedPicks
70
+ .map((a) => AGENT_REGISTRY[a].label)
71
+ .join(', ')}? [y/N] `);
72
+ if (!wantHook) {
73
+ log.dim('Skipped. Run `prave usage hook install` later to enable.');
74
+ return updated;
75
+ }
76
+ const results = await installHooksForAgents(picked);
77
+ for (const r of results) {
78
+ const label = AGENT_REGISTRY[r.agent].label;
79
+ if (r.status === 'installed')
80
+ log.success(`${label} hook installed`);
81
+ else if (r.status === 'already')
82
+ log.dim(`${label} hook already present`);
83
+ else if (r.status === 'unsupported')
84
+ log.dim(`${label}: ${r.message}`);
85
+ else
86
+ log.warn(`${label}: ${r.message ?? 'install failed'}`);
87
+ }
88
+ return updated;
89
+ }
90
+ function detectOs() {
91
+ if (process.platform === 'darwin')
92
+ return 'mac';
93
+ if (process.platform === 'win32')
94
+ return 'windows';
95
+ return 'linux';
96
+ }
97
+ async function yesNoPrompt(question) {
98
+ const { createInterface } = await import('node:readline/promises');
99
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
100
+ try {
101
+ const ans = (await rl.question(question)).trim().toLowerCase();
102
+ return ans === 'y' || ans === 'yes';
103
+ }
104
+ finally {
105
+ rl.close();
106
+ }
107
+ }
@@ -0,0 +1,131 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ /**
5
+ * Real-time usage hook installer. Currently only Claude Code exposes a
6
+ * suitable hook contract (`PostToolUse` with stdin payload) — the other
7
+ * supported agents (Codex, Cursor, Gemini, Cline, Amp) don't yet ship
8
+ * a comparable mechanism, so for those we degrade gracefully and let
9
+ * the transcript scanner cover them once they do.
10
+ *
11
+ * The Claude block is wrapped with sentinel keys (`__prave_managed: true`)
12
+ * so the uninstaller can find and remove only the Prave entry without
13
+ * disturbing user-authored hooks. Idempotent: re-installing just refreshes.
14
+ */
15
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
16
+ const HOOK_MARKER = '__prave_managed';
17
+ /**
18
+ * Agents that currently support a real-time invocation hook. Adding a new
19
+ * agent to this list means implementing its `installFor<Agent>` branch
20
+ * below; until then we surface "not yet supported" so users are never
21
+ * misled about what's actually being instrumented.
22
+ */
23
+ export const HOOK_SUPPORTED = ['claude'];
24
+ const HOOK_COMMAND = 'prave usage report';
25
+ export async function installSkillHook() {
26
+ const settings = await readSettings();
27
+ settings.hooks ??= {};
28
+ 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 = {
32
+ matcher: 'Skill',
33
+ hooks: [{ type: 'command', command: HOOK_COMMAND, [HOOK_MARKER]: true }],
34
+ };
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 };
39
+ }
40
+ blocks[existingIdx] = fresh;
41
+ }
42
+ else {
43
+ blocks.push(fresh);
44
+ }
45
+ await writeSettings(settings);
46
+ return { installed: true, alreadyPresent: false, settingsPath: SETTINGS_PATH };
47
+ }
48
+ export async function installHooksForAgents(agents) {
49
+ const out = [];
50
+ for (const agent of agents) {
51
+ if (!HOOK_SUPPORTED.includes(agent)) {
52
+ out.push({
53
+ agent,
54
+ status: 'unsupported',
55
+ message: `${agent} doesn't expose a real-time hook contract yet — transcript scanner via \`prave sync\` will track usage when supported.`,
56
+ });
57
+ continue;
58
+ }
59
+ try {
60
+ const result = await installSkillHook();
61
+ out.push({
62
+ agent,
63
+ status: result.alreadyPresent ? 'already' : 'installed',
64
+ message: result.settingsPath,
65
+ });
66
+ }
67
+ catch (err) {
68
+ out.push({ agent, status: 'error', message: err.message });
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ export async function uninstallHooksForAgents(agents) {
74
+ const out = [];
75
+ for (const agent of agents) {
76
+ if (!HOOK_SUPPORTED.includes(agent)) {
77
+ out.push({ agent, status: 'unsupported' });
78
+ continue;
79
+ }
80
+ try {
81
+ const result = await uninstallSkillHook();
82
+ out.push({
83
+ agent,
84
+ status: result.removed ? 'installed' : 'already',
85
+ message: result.settingsPath,
86
+ });
87
+ }
88
+ catch (err) {
89
+ out.push({ agent, status: 'error', message: err.message });
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ export async function uninstallSkillHook() {
95
+ const settings = await readSettings();
96
+ const blocks = settings.hooks?.PostToolUse;
97
+ if (!blocks?.length)
98
+ 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)) {
107
+ 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;
115
+ }
116
+ await writeSettings(settings);
117
+ return { removed: true, settingsPath: SETTINGS_PATH };
118
+ }
119
+ async function readSettings() {
120
+ try {
121
+ const raw = await readFile(SETTINGS_PATH, 'utf8');
122
+ return JSON.parse(raw);
123
+ }
124
+ catch {
125
+ return {};
126
+ }
127
+ }
128
+ async function writeSettings(settings) {
129
+ await mkdir(dirname(SETTINGS_PATH), { recursive: true });
130
+ await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
131
+ }
@@ -0,0 +1,181 @@
1
+ import { stdin, stdout } from 'node:process';
2
+ import chalk from 'chalk';
3
+ export async function checkboxPrompt(question, items, opts = {}) {
4
+ if (!stdin.isTTY || !stdout.isTTY) {
5
+ return fallbackPrompt(question, items, opts);
6
+ }
7
+ const selected = new Set(opts.initial ?? []);
8
+ let cursor = 0;
9
+ const minSelected = opts.minSelected ?? 0;
10
+ let dataHandler = null;
11
+ const cleanup = () => {
12
+ if (typeof stdin.setRawMode === 'function')
13
+ stdin.setRawMode(false);
14
+ if (dataHandler)
15
+ stdin.off('data', dataHandler);
16
+ stdin.pause();
17
+ stdout.write(showCursor);
18
+ stdout.write('\n');
19
+ };
20
+ return new Promise((resolve) => {
21
+ if (typeof stdin.setRawMode === 'function')
22
+ stdin.setRawMode(true);
23
+ stdin.resume();
24
+ stdout.write(hideCursor);
25
+ let firstRender = true;
26
+ const render = () => {
27
+ if (!firstRender) {
28
+ // Move cursor up to the start of our last render and erase down.
29
+ stdout.write(`\x1b[${items.length + 2}A`);
30
+ stdout.write('\x1b[J');
31
+ }
32
+ firstRender = false;
33
+ stdout.write(`${chalk.bold(question)} ${chalk.dim('(space=toggle, a=all, enter=confirm, esc=cancel)')}\n`);
34
+ for (let i = 0; i < items.length; i++) {
35
+ const item = items[i];
36
+ const isCursor = i === cursor;
37
+ const isSelected = selected.has(item.value);
38
+ const box = isSelected ? chalk.green('◉') : chalk.dim('◯');
39
+ const arrow = isCursor ? chalk.cyan('›') : ' ';
40
+ const label = isCursor ? chalk.cyan(item.label) : item.label;
41
+ const hint = item.hint ? chalk.dim(` — ${item.hint}`) : '';
42
+ stdout.write(`${arrow} ${box} ${label}${hint}\n`);
43
+ }
44
+ const ok = selected.size >= minSelected;
45
+ stdout.write(`${chalk.dim(ok
46
+ ? `${selected.size} selected · enter to confirm`
47
+ : `at least ${minSelected} required · ${selected.size} selected`)}\n`);
48
+ };
49
+ /**
50
+ * Raw-byte parser. We listen to `data` directly instead of relying on
51
+ * `readline.emitKeypressEvents`, which is unreliable on macOS Terminal +
52
+ * iTerm under raw mode (the keypress event simply never fires for some
53
+ * users, leaving them stuck on whatever items the prompt prefilled).
54
+ *
55
+ * Sequences we care about:
56
+ * ESC [ A/B/C/D → arrow keys
57
+ * 0x0D / 0x0A → return
58
+ * 0x20 → space
59
+ * 0x03 → ctrl-c
60
+ * 0x1B (alone) → escape (heuristic: solo 0x1B with no follow-up)
61
+ * "k" / "j" / "a" → vim-style move + toggle-all
62
+ * "q" → cancel
63
+ */
64
+ const handle = (data) => {
65
+ // An entire keystroke can arrive as one chunk on most terminals, but
66
+ // we guard against multi-byte chunks by walking the buffer.
67
+ let i = 0;
68
+ while (i < data.length) {
69
+ const b = data[i];
70
+ // CSI escape sequences: ESC [ X
71
+ if (b === 0x1b && data[i + 1] === 0x5b && data.length > i + 2) {
72
+ const arrow = data[i + 2];
73
+ if (arrow === 0x41)
74
+ cursor = (cursor - 1 + items.length) % items.length;
75
+ else if (arrow === 0x42)
76
+ cursor = (cursor + 1) % items.length;
77
+ else if (arrow === 0x43) {
78
+ // right arrow → toggle (some users default to this)
79
+ const v = items[cursor].value;
80
+ if (selected.has(v))
81
+ selected.delete(v);
82
+ else
83
+ selected.add(v);
84
+ }
85
+ i += 3;
86
+ continue;
87
+ }
88
+ // Bare ESC = cancel.
89
+ if (b === 0x1b && (data.length === i + 1 || data[i + 1] === undefined)) {
90
+ cleanup();
91
+ resolve(null);
92
+ return;
93
+ }
94
+ // Ctrl-C.
95
+ if (b === 0x03) {
96
+ cleanup();
97
+ resolve(null);
98
+ return;
99
+ }
100
+ // Enter — CR or LF.
101
+ if (b === 0x0d || b === 0x0a) {
102
+ if (selected.size < minSelected) {
103
+ i += 1;
104
+ continue;
105
+ }
106
+ cleanup();
107
+ resolve([...selected]);
108
+ return;
109
+ }
110
+ // Space — toggle current.
111
+ if (b === 0x20) {
112
+ const v = items[cursor].value;
113
+ if (selected.has(v))
114
+ selected.delete(v);
115
+ else
116
+ selected.add(v);
117
+ i += 1;
118
+ continue;
119
+ }
120
+ // Letters: a (toggle all), j (down), k (up), q (cancel).
121
+ if (b === 0x61) {
122
+ if (selected.size === items.length)
123
+ selected.clear();
124
+ else
125
+ for (const it of items)
126
+ selected.add(it.value);
127
+ i += 1;
128
+ continue;
129
+ }
130
+ if (b === 0x6a) {
131
+ cursor = (cursor + 1) % items.length;
132
+ i += 1;
133
+ continue;
134
+ }
135
+ if (b === 0x6b) {
136
+ cursor = (cursor - 1 + items.length) % items.length;
137
+ i += 1;
138
+ continue;
139
+ }
140
+ if (b === 0x71) {
141
+ cleanup();
142
+ resolve(null);
143
+ return;
144
+ }
145
+ // Unknown byte — advance and ignore.
146
+ i += 1;
147
+ }
148
+ render();
149
+ };
150
+ dataHandler = handle;
151
+ stdin.on('data', handle);
152
+ render();
153
+ });
154
+ }
155
+ const hideCursor = '\x1b[?25l';
156
+ const showCursor = '\x1b[?25h';
157
+ async function fallbackPrompt(question, items, opts) {
158
+ const { createInterface } = await import('node:readline/promises');
159
+ const rl = createInterface({ input: stdin, output: stdout });
160
+ try {
161
+ stdout.write(`${question}\n`);
162
+ for (const i of items) {
163
+ stdout.write(` - ${i.value}${i.label !== i.value ? ` (${i.label})` : ''}${i.hint ? ` — ${i.hint}` : ''}\n`);
164
+ }
165
+ const def = (opts.initial ?? []).join(',');
166
+ const ans = (await rl.question(`Comma-separated values${def ? ` [${def}]` : ''}: `)).trim();
167
+ if (!ans && opts.initial)
168
+ return [...opts.initial];
169
+ if (!ans)
170
+ return [];
171
+ const valid = new Set(items.map((i) => i.value));
172
+ const tokens = ans
173
+ .split(',')
174
+ .map((s) => s.trim())
175
+ .filter((t) => valid.has(t));
176
+ return tokens;
177
+ }
178
+ finally {
179
+ rl.close();
180
+ }
181
+ }
@@ -0,0 +1,34 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { CONFIG } from './config.js';
4
+ /**
5
+ * Persistent watermark for the transcript scanner. Without it every
6
+ * `prave usage scan` would re-walk every JSONL we've ever recorded; with
7
+ * it we only re-process files modified since the last successful scan.
8
+ *
9
+ * Stored at `~/.prave/usage-cursor.json` (plain text, no secrets) so it
10
+ * survives across CLI versions and machine reboots.
11
+ */
12
+ const CURSOR_PATH = join(CONFIG.praveDir, 'usage-cursor.json');
13
+ export async function loadCursor() {
14
+ try {
15
+ const raw = await readFile(CURSOR_PATH, 'utf8');
16
+ const parsed = JSON.parse(raw);
17
+ if (parsed.lastScanAt) {
18
+ const ms = Date.parse(parsed.lastScanAt);
19
+ if (!Number.isNaN(ms))
20
+ return new Date(ms);
21
+ }
22
+ }
23
+ catch {
24
+ /* fall through to default */
25
+ }
26
+ // First run: look back 30 days. Matches the optimiser's "30+ days
27
+ // unused" window so the very first scan can fully populate it.
28
+ return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
29
+ }
30
+ export async function saveCursor(at = new Date()) {
31
+ await mkdir(dirname(CURSOR_PATH), { recursive: true });
32
+ const state = { lastScanAt: at.toISOString() };
33
+ await writeFile(CURSOR_PATH, JSON.stringify(state, null, 2), 'utf8');
34
+ }
@@ -0,0 +1,154 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
5
+ /**
6
+ * Scan every .jsonl transcript newer than `since` for Skill invocations.
7
+ *
8
+ * @param installedSlugs the slugs we recognise — anything outside this
9
+ * set is ignored to avoid noise from quoted text.
10
+ * @param since lower bound for transcript modification time. Defaults
11
+ * to 30 days ago on a fresh run; the CLI persists a watermark in
12
+ * `~/.prave/usage-cursor.json` so subsequent runs only re-scan recent
13
+ * transcripts.
14
+ */
15
+ export async function scanTranscriptsForUsage(installedSlugs, since) {
16
+ if (!installedSlugs.length)
17
+ return [];
18
+ const slugSet = new Set(installedSlugs);
19
+ const sinceMs = since.getTime();
20
+ const events = [];
21
+ const projectDirs = await safeReaddir(PROJECTS_DIR);
22
+ for (const projectName of projectDirs) {
23
+ const projectDir = join(PROJECTS_DIR, projectName);
24
+ const projectStat = await safeStat(projectDir);
25
+ if (!projectStat?.isDirectory())
26
+ continue;
27
+ const files = await safeReaddir(projectDir);
28
+ for (const file of files) {
29
+ if (!file.endsWith('.jsonl'))
30
+ continue;
31
+ const path = join(projectDir, file);
32
+ const fileStat = await safeStat(path);
33
+ if (!fileStat || fileStat.mtimeMs < sinceMs)
34
+ continue;
35
+ try {
36
+ const raw = await readFile(path, 'utf8');
37
+ const lines = raw.split('\n');
38
+ for (const line of lines) {
39
+ if (!line)
40
+ continue;
41
+ extractEventsFromLine(line, slugSet, sinceMs, events);
42
+ }
43
+ }
44
+ catch {
45
+ // Skip unreadable / corrupt JSONLs silently.
46
+ }
47
+ }
48
+ }
49
+ return events;
50
+ }
51
+ const COMMAND_NAME_RE = /<command-name>\s*\/?([a-z0-9][a-z0-9_-]{0,80})\s*<\/command-name>/gi;
52
+ /**
53
+ * Per-line extractor. The transcript is one JSON object per line, but
54
+ * we don't always need to parse the whole thing — slash commands land in
55
+ * the `<command-name>` string and a regex hit on the raw line is enough.
56
+ *
57
+ * For the canonical Skill-tool path we DO parse (only when the line
58
+ * actually mentions `"name":"Skill"`, so the parse cost stays bounded).
59
+ */
60
+ function extractEventsFromLine(line, slugSet, sinceMs, out) {
61
+ // Slash-command hits — cheap and don't require JSON.parse.
62
+ let m;
63
+ COMMAND_NAME_RE.lastIndex = 0;
64
+ while ((m = COMMAND_NAME_RE.exec(line))) {
65
+ const slug = m[1]?.toLowerCase();
66
+ if (!slug || !slugSet.has(slug))
67
+ continue;
68
+ const ts = sniffTimestamp(line, sinceMs);
69
+ if (!ts)
70
+ continue;
71
+ out.push({ slug, triggered_at: ts, trigger_phrase: `/${slug}` });
72
+ }
73
+ // Skill tool invocations — only parse when the line claims to have one.
74
+ if (!line.includes('"name":"Skill"'))
75
+ return;
76
+ try {
77
+ const obj = JSON.parse(line);
78
+ walkForSkillToolUse(obj, slugSet, sinceMs, out);
79
+ }
80
+ catch {
81
+ // Malformed line — ignore.
82
+ }
83
+ }
84
+ function walkForSkillToolUse(node, slugSet, sinceMs, out) {
85
+ if (!node)
86
+ return;
87
+ if (Array.isArray(node)) {
88
+ for (const item of node)
89
+ walkForSkillToolUse(item, slugSet, sinceMs, out);
90
+ return;
91
+ }
92
+ if (typeof node !== 'object')
93
+ return;
94
+ const obj = node;
95
+ // Detect a tool_use block at any level.
96
+ const block = obj;
97
+ if (block.type === 'tool_use' && block.name === 'Skill' && block.input?.skill) {
98
+ const slug = String(block.input.skill).toLowerCase().split(':').pop() ?? '';
99
+ if (slug && slugSet.has(slug)) {
100
+ const ts = sniffTimestampFromObject(obj, sinceMs);
101
+ if (ts)
102
+ out.push({ slug, triggered_at: ts, trigger_phrase: null });
103
+ }
104
+ }
105
+ // Recurse into known transcript fields. We don't blindly walk every
106
+ // value — that would re-parse user-prompt text trees we explicitly
107
+ // committed to NOT inspecting.
108
+ for (const key of ['message', 'content', 'children', 'tool_use', 'tool_results']) {
109
+ const child = obj[key];
110
+ if (child)
111
+ walkForSkillToolUse(child, slugSet, sinceMs, out);
112
+ }
113
+ }
114
+ /**
115
+ * Best-effort timestamp recovery from a JSONL line. Claude Code stamps
116
+ * each line with `"timestamp":"<iso>"` at the top level. If we can't
117
+ * find one, we drop the event (we'd rather lose a record than mis-date
118
+ * one).
119
+ */
120
+ function sniffTimestamp(line, sinceMs) {
121
+ const match = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
122
+ if (!match)
123
+ return null;
124
+ const ts = match[1];
125
+ const ms = Date.parse(ts);
126
+ if (Number.isNaN(ms) || ms < sinceMs)
127
+ return null;
128
+ return ts;
129
+ }
130
+ function sniffTimestampFromObject(obj, sinceMs) {
131
+ const ts = obj.timestamp;
132
+ if (typeof ts !== 'string')
133
+ return null;
134
+ const ms = Date.parse(ts);
135
+ if (Number.isNaN(ms) || ms < sinceMs)
136
+ return null;
137
+ return ts;
138
+ }
139
+ async function safeReaddir(path) {
140
+ try {
141
+ return await readdir(path);
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
147
+ async function safeStat(path) {
148
+ try {
149
+ return await stat(path);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "0.5.0",
3
+ "version": "1.0.1",
4
4
  "description": "Prave CLI — import, export, install, sync Claude Skills.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "open": "^10.1.0",
17
17
  "ora": "^8.0.1",
18
18
  "undici": "^6.18.0",
19
- "@prave/shared": "0.4.0"
19
+ "@prave/shared": "1.0.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.12.7",