@prave/cli 0.4.10 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/conflicts.js +4 -0
- package/dist/commands/deploy.js +4 -0
- package/dist/commands/import.js +24 -2
- package/dist/commands/install.js +14 -7
- package/dist/commands/login.js +10 -0
- package/dist/commands/optimize.js +4 -0
- package/dist/commands/overview.js +4 -0
- package/dist/commands/settings.js +13 -31
- package/dist/commands/sync.js +54 -12
- package/dist/commands/update.js +165 -0
- package/dist/commands/usage.js +267 -0
- package/dist/commands/whoami.js +31 -3
- package/dist/index.js +33 -2
- package/dist/lib/agent-onboarding.js +107 -0
- package/dist/lib/api.js +54 -2
- package/dist/lib/credentials.js +21 -0
- package/dist/lib/hook.js +131 -0
- package/dist/lib/prompt.js +124 -0
- package/dist/lib/usage-cursor.js +34 -0
- package/dist/lib/usage-scanner.js +154 -0
- package/package.json +2 -2
|
@@ -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
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { ApiError, api } from '../lib/api.js';
|
|
1
2
|
import { loadCredentials } from '../lib/credentials.js';
|
|
2
3
|
import { log } from '../utils/logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* `prave whoami` — quick check that the local credentials still authenticate
|
|
6
|
+
* against the API. Hits `GET /api/v1/me` so the displayed name is always the
|
|
7
|
+
* server-side truth (and any stale local data gets refreshed implicitly via
|
|
8
|
+
* the auto-refresh path on api.get).
|
|
9
|
+
*
|
|
10
|
+
* If we can't reach the API (offline, server down) we fall back to the
|
|
11
|
+
* locally cached email so the command stays useful in airplane mode.
|
|
12
|
+
*/
|
|
3
13
|
export async function whoamiCommand() {
|
|
4
14
|
const creds = await loadCredentials();
|
|
5
15
|
if (!creds) {
|
|
@@ -7,7 +17,25 @@ export async function whoamiCommand() {
|
|
|
7
17
|
process.exitCode = 1;
|
|
8
18
|
return;
|
|
9
19
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
try {
|
|
21
|
+
const { data } = await api.get('/api/v1/me', true);
|
|
22
|
+
const handle = data.username || data.display_name || data.email || creds.email || 'unknown';
|
|
23
|
+
log.kv('user', handle);
|
|
24
|
+
if (data.email)
|
|
25
|
+
log.kv('email', data.email);
|
|
26
|
+
log.kv('plan', data.plan);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
30
|
+
log.warn('Session expired. Run `prave login` again.');
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Offline or transient API error — fall back to local creds so the
|
|
35
|
+
// command still does *something* useful instead of crashing.
|
|
36
|
+
if (creds.email)
|
|
37
|
+
log.kv('user', creds.email);
|
|
38
|
+
else
|
|
39
|
+
log.kv('user', creds.user_id);
|
|
40
|
+
}
|
|
13
41
|
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,8 @@ import { searchCommand } from './commands/search.js';
|
|
|
19
19
|
import { settingsCommand } from './commands/settings.js';
|
|
20
20
|
import { syncCommand } from './commands/sync.js';
|
|
21
21
|
import { uninstallCommand } from './commands/uninstall.js';
|
|
22
|
+
import { updateCommand } from './commands/update.js';
|
|
23
|
+
import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand, usageScanCommand, } from './commands/usage.js';
|
|
22
24
|
import { whatdoesCommand } from './commands/whatdoes.js';
|
|
23
25
|
import { whoamiCommand } from './commands/whoami.js';
|
|
24
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -33,8 +35,9 @@ program.command('whoami').description('Show the signed-in user').action(whoamiCo
|
|
|
33
35
|
program
|
|
34
36
|
.command('import')
|
|
35
37
|
.description('Scan ~/.claude/skills/ — optionally upload to Prave')
|
|
36
|
-
.option('--upload', 'upload scanned skills')
|
|
37
|
-
.option('--
|
|
38
|
+
.option('--upload', 'upload scanned skills (requires --public or --private)')
|
|
39
|
+
.option('--public', 'upload as public (listed in the registry)')
|
|
40
|
+
.option('--private', 'upload as private (only you can see them)')
|
|
38
41
|
.action(importCommand);
|
|
39
42
|
program
|
|
40
43
|
.command('install <slug>')
|
|
@@ -50,6 +53,12 @@ program
|
|
|
50
53
|
.command('sync')
|
|
51
54
|
.description('Pull updates for every locally installed Skill')
|
|
52
55
|
.action(syncCommand);
|
|
56
|
+
program
|
|
57
|
+
.command('update [slug]')
|
|
58
|
+
.description('Diff installed Skills against the registry and pull anything outdated')
|
|
59
|
+
.option('--dry-run', "preview what would change, don't write")
|
|
60
|
+
.option('--yes', 'skip confirmation prompt')
|
|
61
|
+
.action((slug, opts) => updateCommand(slug, opts));
|
|
53
62
|
program
|
|
54
63
|
.command('list')
|
|
55
64
|
.description('List installed Skills (default) or remote ones')
|
|
@@ -92,6 +101,28 @@ program
|
|
|
92
101
|
.description('Recommendations: underused, mergeable, and heavy skills')
|
|
93
102
|
.option('--apply', 'placeholder for auto-apply')
|
|
94
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);
|
|
95
126
|
program
|
|
96
127
|
.command('find <query>')
|
|
97
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/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { request } from 'undici';
|
|
2
2
|
import { CONFIG } from './config.js';
|
|
3
|
-
import { loadCredentials } from './credentials.js';
|
|
3
|
+
import { loadCredentials, saveCredentials } from './credentials.js';
|
|
4
4
|
export class ApiError extends Error {
|
|
5
5
|
status;
|
|
6
6
|
constructor(message, status) {
|
|
@@ -9,7 +9,52 @@ export class ApiError extends Error {
|
|
|
9
9
|
this.name = 'ApiError';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Single-flight refresh — multiple parallel requests all hitting 401 share
|
|
14
|
+
* one refresh round-trip instead of stampeding the API and burning the
|
|
15
|
+
* Supabase rate limiter.
|
|
16
|
+
*/
|
|
17
|
+
let refreshing = null;
|
|
18
|
+
async function refreshTokens() {
|
|
19
|
+
if (refreshing)
|
|
20
|
+
return refreshing;
|
|
21
|
+
refreshing = (async () => {
|
|
22
|
+
const creds = await loadCredentials();
|
|
23
|
+
if (!creds?.refresh_token)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const { statusCode, body } = await request(`${CONFIG.apiUrl}/api/v1/cli/refresh`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ refresh_token: creds.refresh_token }),
|
|
30
|
+
});
|
|
31
|
+
const text = await body.text();
|
|
32
|
+
if (statusCode !== 200)
|
|
33
|
+
return null;
|
|
34
|
+
const payload = JSON.parse(text);
|
|
35
|
+
if (!payload.success || !payload.data)
|
|
36
|
+
return null;
|
|
37
|
+
const next = {
|
|
38
|
+
...creds,
|
|
39
|
+
access_token: payload.data.access_token,
|
|
40
|
+
refresh_token: payload.data.refresh_token ?? creds.refresh_token,
|
|
41
|
+
user_id: payload.data.user_id,
|
|
42
|
+
};
|
|
43
|
+
await saveCredentials(next);
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
try {
|
|
51
|
+
return await refreshing;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
refreshing = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function call(method, path, body, withAuth = false, attempt = 0) {
|
|
13
58
|
const headers = { 'Content-Type': 'application/json' };
|
|
14
59
|
if (withAuth) {
|
|
15
60
|
const creds = await loadCredentials();
|
|
@@ -26,6 +71,13 @@ async function call(method, path, body, withAuth = false) {
|
|
|
26
71
|
if (statusCode === 204)
|
|
27
72
|
return { data: undefined, status: statusCode };
|
|
28
73
|
const payload = text ? JSON.parse(text) : { success: true, data: null, error: null };
|
|
74
|
+
// 401 with a refresh token on file → swap the access token and retry once.
|
|
75
|
+
// Any other status, or a second 401, falls through to the normal error path.
|
|
76
|
+
if (statusCode === 401 && withAuth && attempt === 0) {
|
|
77
|
+
const refreshed = await refreshTokens();
|
|
78
|
+
if (refreshed)
|
|
79
|
+
return call(method, path, body, withAuth, attempt + 1);
|
|
80
|
+
}
|
|
29
81
|
if (statusCode >= 400 || payload.success === false) {
|
|
30
82
|
throw new ApiError(payload.error ?? `HTTP ${statusCode}`, statusCode);
|
|
31
83
|
}
|
package/dist/lib/credentials.js
CHANGED
|
@@ -23,3 +23,24 @@ export async function clearCredentials() {
|
|
|
23
23
|
/* already gone */
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Gate for CLI commands that mutate Prave-side state or whose effect we
|
|
28
|
+
* need to track (installs, deploys, settings, intelligence). Search and
|
|
29
|
+
* read-only Discover paths are intentionally NOT gated — anyone can
|
|
30
|
+
* browse the public registry from the terminal.
|
|
31
|
+
*
|
|
32
|
+
* Prints a friendly hint and sets a non-zero exit code when the caller
|
|
33
|
+
* isn't logged in.
|
|
34
|
+
*/
|
|
35
|
+
export async function requireAuth(commandName) {
|
|
36
|
+
const creds = await loadCredentials();
|
|
37
|
+
if (creds)
|
|
38
|
+
return creds;
|
|
39
|
+
// Lazy-import the chalk/log utilities here to avoid a circular dep on
|
|
40
|
+
// the credentials module.
|
|
41
|
+
const { log } = await import('../utils/logger.js');
|
|
42
|
+
log.warn(`\`${commandName}\` requires sign-in.`);
|
|
43
|
+
log.dim('Run `prave login` first — installs are tracked against your account so we can keep counts honest.');
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return null;
|
|
46
|
+
}
|