@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.
@@ -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
+ }
@@ -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
- log.kv('user_id', creds.user_id);
11
- if (creds.email)
12
- log.kv('email', creds.email);
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('--private', 'upload as private (requires --upload)')
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
- async function call(method, path, body, withAuth = false) {
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
  }
@@ -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
+ }