@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.
- package/dist/commands/login.js +10 -0
- package/dist/commands/settings.js +9 -31
- package/dist/commands/sync.js +11 -0
- package/dist/commands/usage.js +267 -0
- package/dist/commands/whoami.js +11 -0
- package/dist/index.js +23 -0
- package/dist/lib/agent-onboarding.js +107 -0
- package/dist/lib/hook.js +131 -0
- package/dist/lib/prompt.js +181 -0
- package/dist/lib/usage-cursor.js +34 -0
- package/dist/lib/usage-scanner.js +154 -0
- package/package.json +2 -2
package/dist/commands/login.js
CHANGED
|
@@ -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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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) {
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/hook.js
ADDED
|
@@ -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.
|
|
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.
|
|
19
|
+
"@prave/shared": "1.0.1"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^20.12.7",
|