@prave/cli 1.4.8 → 1.4.10

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.
@@ -89,14 +89,26 @@ export async function installCommand(slug, opts = {}) {
89
89
  process.exitCode = 1;
90
90
  return;
91
91
  }
92
- // Best-effort analyze pass on the freshly written files.
92
+ // Best-effort analyze pass on the freshly written files. Silently
93
+ // catching all errors here used to hide a real failure mode: when the
94
+ // user's CLI token had expired, every analyze call returned 401, the
95
+ // install ledger filled up, but skill_metadata stayed empty — so the
96
+ // web dashboard rendered honest zeros and looked broken. We still
97
+ // don't bail on transient errors (the install itself succeeded), but
98
+ // we DO surface auth failures once so the user knows to re-login.
99
+ let authWarned = false;
93
100
  for (const s of installedSlugs) {
94
101
  const path = join(CONFIG.skillsDir, s, 'SKILL.md');
95
102
  try {
96
103
  const content = await readFile(path, 'utf8');
97
104
  await api
98
105
  .post('/api/v1/intelligence/analyze', { content, file_path: path, agent_type: 'claude' }, true)
99
- .catch(() => { });
106
+ .catch((err) => {
107
+ if (!authWarned && err instanceof ApiError && err.status === 401) {
108
+ authWarned = true;
109
+ log.warn('Your CLI session expired — Skill intelligence will be out of sync until you run `prave login`.');
110
+ }
111
+ });
100
112
  }
101
113
  catch {
102
114
  /* file unreadable — skip */
@@ -5,6 +5,7 @@ import { track } from '../lib/analytics.js';
5
5
  import { api, ApiError } from '../lib/api.js';
6
6
  import { CONFIG } from '../lib/config.js';
7
7
  import { saveCredentials } from '../lib/credentials.js';
8
+ import { readLocalConfig, writeLocalConfigPatch } from '../lib/local-config.js';
8
9
  import { log } from '../utils/logger.js';
9
10
  /**
10
11
  * `prave login` — device-code flow against the Prave API / Supabase session.
@@ -51,12 +52,23 @@ export async function loginCommand() {
51
52
  catch (flushErr) {
52
53
  log.dim(`Telemetry sync skipped: ${flushErr.message}`);
53
54
  }
54
- // Onboarding: prefill from the SaaS profile, let the user toggle
55
- // with space/enter, persist back, and offer to install hooks.
56
- // Failures here are non-fatal login succeeded.
55
+ // First-login onboarding only. The agent picker + hook installer
56
+ // used to fire on EVERY login, which was annoying for users who
57
+ // re-login often (CI environments, multi-machine setups, after a
58
+ // token expiry). Now we gate on a persisted flag in
59
+ // ~/.prave/config.json — once they've completed the picker once,
60
+ // it never auto-fires again. Manual re-run available via
61
+ // `prave settings agent`.
57
62
  try {
58
- const { runAgentOnboarding } = await import('../lib/agent-onboarding.js');
59
- await runAgentOnboarding();
63
+ const cfg = await readLocalConfig();
64
+ if (cfg.agent_onboarding_done !== true) {
65
+ const { runAgentOnboarding } = await import('../lib/agent-onboarding.js');
66
+ await runAgentOnboarding();
67
+ await writeLocalConfigPatch({ agent_onboarding_done: true });
68
+ }
69
+ else {
70
+ log.dim('Welcome back — run `prave settings agent` to change your agent setup.');
71
+ }
60
72
  }
61
73
  catch (onboardErr) {
62
74
  log.dim(`Agent onboarding skipped: ${onboardErr.message}`);
@@ -1,10 +1,9 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
1
  import { createInterface } from 'node:readline/promises';
3
2
  import chalk from 'chalk';
4
3
  import { AGENT_REGISTRY } from '@prave/shared';
5
4
  import { api, ApiError } from '../lib/api.js';
6
5
  import { requireAuth } from '../lib/credentials.js';
7
- import { CONFIG } from '../lib/config.js';
6
+ import { writeLocalConfigPatch } from '../lib/local-config.js';
8
7
  import { log } from '../utils/logger.js';
9
8
  function detectOs() {
10
9
  if (process.platform === 'darwin')
@@ -13,20 +12,14 @@ function detectOs() {
13
12
  return 'windows';
14
13
  return 'linux';
15
14
  }
16
- async function loadLocalConfig() {
17
- try {
18
- const raw = await readFile(CONFIG.configPath, 'utf8');
19
- return JSON.parse(raw);
20
- }
21
- catch {
22
- return {};
23
- }
24
- }
25
15
  async function saveLocalConfig(settings) {
26
- await mkdir(CONFIG.praveDir, { recursive: true });
27
- const existing = await loadLocalConfig();
28
- const next = { ...existing, agentSettings: settings };
29
- await writeFile(CONFIG.configPath, JSON.stringify(next, null, 2), 'utf8');
16
+ // Mark onboarding done as a side effect — running `prave settings
17
+ // agent` manually counts as completing the picker, so the user won't
18
+ // get auto-prompted again on the next `prave login`.
19
+ await writeLocalConfigPatch({
20
+ agentSettings: settings,
21
+ agent_onboarding_done: true,
22
+ });
30
23
  }
31
24
  async function fetchSettings() {
32
25
  const { data } = await api.get('/api/v1/settings/agents', true);
@@ -432,8 +432,8 @@ export async function usageStatusCommand() {
432
432
  const checkmark = (ok) => (ok ? chalk.green('✓') : chalk.red('✗'));
433
433
  log.info(chalk.bold('Usage tracking status'));
434
434
  console.log();
435
- console.log(` ${checkmark(toolHookInstalled)} PostToolUse hook (Skill tool fires): ${toolHookInstalled ? chalk.green('installed') : chalk.yellow('missing — `prave usage hook install`')}`);
436
- console.log(` ${checkmark(promptHookInstalled)} UserPromptSubmit hook (slash commands like /graphify): ${promptHookInstalled ? chalk.green('installed') : chalk.yellow('missing — `prave usage hook install`')}`);
435
+ console.log(` ${checkmark(toolHookInstalled)} Skill invocation tracking: ${toolHookInstalled ? chalk.green('installed') : chalk.yellow('missing — `prave usage hook install`')}`);
436
+ console.log(` ${checkmark(promptHookInstalled)} Slash-command tracking (e.g. /graphify): ${promptHookInstalled ? chalk.green('installed') : chalk.yellow('missing — `prave usage hook install`')}`);
437
437
  console.log(` ${checkmark(apiReachable)} API reachable + auth valid: ${apiReachable ? chalk.green('yes') : chalk.red(apiMessage || 'no')}`);
438
438
  console.log(` ${checkmark(Boolean(lastScanAt))} Transcript scanner watermark: ${lastScanAt ?? chalk.dim('never run — `prave sync` includes it')}`);
439
439
  console.log(` ${checkmark(recent7 > 0)} Events in last 7 days: ${chalk.cyan(String(recent7))}`);
@@ -470,7 +470,7 @@ export async function usageStatusCommand() {
470
470
  console.log();
471
471
  log.warn('Telemetry may be incomplete. Suggested fixes:');
472
472
  if (!toolHookInstalled || !promptHookInstalled) {
473
- log.dim(' • Run `prave usage hook install` to wire BOTH PostToolUse and UserPromptSubmit.');
473
+ log.dim(' • Run `prave usage hook install` to enable both Skill-fire and slash-command tracking.');
474
474
  }
475
475
  if (!apiReachable) {
476
476
  log.dim(' • Run `prave login` — your access token may have expired (silently 401-ing).');
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand,
24
24
  import { whatdoesCommand } from './commands/whatdoes.js';
25
25
  import { whoamiCommand } from './commands/whoami.js';
26
26
  import { initAnalytics } from './lib/analytics.js';
27
- import { nudgeFirstRun } from './lib/nudge.js';
27
+ import { captureAuthSnapshot, nudgeFirstRun } from './lib/nudge.js';
28
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
29
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
30
30
  initAnalytics(pkg.version);
@@ -49,7 +49,7 @@ const program = new Command()
49
49
  .addHelpText('after', '\nRun `prave <cmd> --help` for command-specific options.');
50
50
  program
51
51
  .command('login')
52
- .description('Authenticate this machine via device-code (browser opens, no password is ever typed). Tokens are saved chmod-600 to ~/.prave/credentials.json and refresh automatically.')
52
+ .description('Sign this machine in (browser opens, no password typed). Credentials are stored securely on disk and refreshed automatically.')
53
53
  .action(loginCommand);
54
54
  program
55
55
  .command('logout')
@@ -130,17 +130,17 @@ const usage = program
130
130
  .description('Track which Skills you actually use (powers the optimiser)');
131
131
  usage
132
132
  .command('report')
133
- .description('Internal: invoked by the Claude Code PostToolUse / UserPromptSubmit hook (reads stdin)')
134
- .option('--source <kind>', 'hook channel that fired this report ("tool" or "prompt")', 'tool')
133
+ .description('Internal: invoked by the agent when a Skill fires (reads payload from stdin)')
134
+ .option('--source <kind>', 'event channel that fired this report', 'tool')
135
135
  .action((opts) => usageReportCommand(opts));
136
- const hook = usage.command('hook').description('Install/uninstall the Claude Code real-time usage hook');
136
+ const hook = usage.command('hook').description('Enable / disable real-time Skill invocation tracking');
137
137
  hook
138
138
  .command('install')
139
- .description('Add a PostToolUse hook to ~/.claude/settings.json')
139
+ .description('Enable real-time invocation tracking for your installed Skills')
140
140
  .action(usageHookInstallCommand);
141
141
  hook
142
142
  .command('uninstall')
143
- .description('Remove the Prave-managed hook from ~/.claude/settings.json')
143
+ .description('Disable real-time invocation tracking')
144
144
  .action(usageHookUninstallCommand);
145
145
  program
146
146
  .command('mcp-server')
@@ -188,7 +188,7 @@ program
188
188
  ' prave optimize --remove-unused # delete 30d-silent skills from disk',
189
189
  '',
190
190
  'Usage tracking (Pro+)',
191
- ' prave usage hook install # real-time PostToolUse hook',
191
+ ' prave usage hook install # enable real-time invocation tracking',
192
192
  ' prave usage hook uninstall',
193
193
  '',
194
194
  'Settings',
@@ -204,6 +204,19 @@ program
204
204
  'Docs: https://prave.app/docs',
205
205
  ].join('\n'));
206
206
  });
207
+ // Capture auth state BEFORE the command runs. Critical for the
208
+ // first-run welcome banner — if the user's very first command is
209
+ // `prave login`, the postAction hook fires AFTER credentials are
210
+ // saved, so a live auth check would read "signed in" and suppress
211
+ // the welcome. The preAction snapshot freezes the pre-command state.
212
+ program.hook('preAction', async () => {
213
+ try {
214
+ await captureAuthSnapshot();
215
+ }
216
+ catch {
217
+ /* never block the command on nudge bookkeeping */
218
+ }
219
+ });
207
220
  // Global first-run banner. Fires once on the user's very first command
208
221
  // regardless of which one it was — catches commands that don't have a
209
222
  // per-action nudge wired in (e.g. `prave docs`, `prave conflicts`).
@@ -0,0 +1,44 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { CONFIG } from './config.js';
3
+ /**
4
+ * Shared read / write for `~/.prave/config.json`.
5
+ *
6
+ * The same file holds three classes of state, all merged into one
7
+ * JSON object so callers can patch one key without clobbering the
8
+ * others:
9
+ *
10
+ * • `agentSettings` — written by `prave settings`
11
+ * • Nudge bookkeeping (`first_run`, `nudge_count`) — written by
12
+ * the conversion-nudge module
13
+ * • Onboarding flags (`agent_onboarding_done`) — written by
14
+ * `prave login` so we only walk the user through the agent
15
+ * picker once
16
+ *
17
+ * Previously each consumer reimplemented the same readFile / merge /
18
+ * writeFile dance — three copies in three files. Centralising here
19
+ * means a missing key in one consumer no longer wipes the keys
20
+ * another consumer just wrote.
21
+ */
22
+ export async function readLocalConfig() {
23
+ try {
24
+ const raw = await readFile(CONFIG.configPath, 'utf8');
25
+ const parsed = JSON.parse(raw);
26
+ if (parsed && typeof parsed === 'object')
27
+ return parsed;
28
+ return {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ /**
35
+ * Merge-write: reads the current config, shallow-merges `patch`, and
36
+ * writes the result. Creates `~/.prave/` with restrictive mode (0700)
37
+ * if it doesn't exist yet.
38
+ */
39
+ export async function writeLocalConfigPatch(patch) {
40
+ const existing = await readLocalConfig();
41
+ const next = { ...existing, ...patch };
42
+ await mkdir(CONFIG.praveDir, { recursive: true, mode: 0o700 });
43
+ await writeFile(CONFIG.configPath, JSON.stringify(next, null, 2), 'utf8');
44
+ }
@@ -47,7 +47,7 @@ export const NUDGE_DEEP = {
47
47
  bullets: [
48
48
  'AI-generated descriptions for all your Skills',
49
49
  'Conflict detection across your library',
50
- '30-day usage history from PostToolUse hook',
50
+ '30-day Skill invocation history',
51
51
  'Cross-machine sync',
52
52
  ],
53
53
  cta: 'prave.app/signup · free forever · 10 seconds',
package/dist/lib/nudge.js CHANGED
@@ -1,9 +1,45 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
1
  import chalk from 'chalk';
3
- import { CONFIG } from './config.js';
4
2
  import { loadCredentials } from './credentials.js';
3
+ import { readLocalConfig, writeLocalConfigPatch } from './local-config.js';
5
4
  import { isAlwaysShow, NUDGE_FIRST_RUN, nudgeFor, } from './nudge-constants.js';
5
+ /**
6
+ * Conversion nudges for anonymous CLI users.
7
+ *
8
+ * 4,300 npm-installs, 1 signed-in account. The CLI works for anonymous
9
+ * users by design (lower friction = more installs) but the handoff to
10
+ * the dashboard was missing. These nudges fix that.
11
+ *
12
+ * Rules baked in here, not in the call sites:
13
+ * • Silent for signed-in users. Always.
14
+ * • Soft nudges throttled to ~1 in every 3 commands via a counter
15
+ * persisted to ~/.prave/config.json (`nudge_count`).
16
+ * • Strong nudges (overview/whatdoes/first-run) bypass the throttle —
17
+ * they're the highest-intent moments and worth a fuller pitch.
18
+ * • First-run banner shows once on the user's very first command;
19
+ * `first_run: false` then locks it down forever.
20
+ * • One nudge per process. Multiple commands chained in a wrapper
21
+ * script don't spam.
22
+ * • Skipped when stdout isn't a TTY (piped output, CI) and when
23
+ * PRAVE_QUIET=1 / PRAVE_TELEMETRY=0.
24
+ */
6
25
  let alreadyNudged = false;
26
+ /**
27
+ * Auth snapshot captured at command START, used at command END.
28
+ *
29
+ * Without this, `prave login` (very common first-ever command) traps
30
+ * us: the postAction hook fires AFTER credentials are saved, so the
31
+ * auth check inside canNudge() reads "signed in" → first-run banner
32
+ * suppressed → `first_run: false` written → user never sees the
33
+ * welcome message on any future command. The snapshot freezes the
34
+ * pre-command auth state so the first-run welcome still fires for a
35
+ * user whose very first command is the login itself.
36
+ */
37
+ let wasAnonymousAtStart = null;
38
+ export async function captureAuthSnapshot() {
39
+ if (wasAnonymousAtStart !== null)
40
+ return;
41
+ wasAnonymousAtStart = !(await isAuthenticated());
42
+ }
7
43
  /* --------------------------------------------------------------------- */
8
44
  /* Auth + state helpers */
9
45
  /* --------------------------------------------------------------------- */
@@ -18,24 +54,6 @@ export async function isAuthenticated() {
18
54
  }
19
55
  return true;
20
56
  }
21
- async function readConfig() {
22
- try {
23
- const raw = await readFile(CONFIG.configPath, 'utf8');
24
- const parsed = JSON.parse(raw);
25
- if (parsed && typeof parsed === 'object')
26
- return parsed;
27
- return {};
28
- }
29
- catch {
30
- return {};
31
- }
32
- }
33
- async function writeConfigPatch(patch) {
34
- const existing = await readConfig();
35
- const next = { ...existing, ...patch };
36
- await mkdir(CONFIG.praveDir, { recursive: true, mode: 0o700 });
37
- await writeFile(CONFIG.configPath, JSON.stringify(next, null, 2), 'utf8');
38
- }
39
57
  /**
40
58
  * Counter-based throttle for soft nudges. Reads the current count,
41
59
  * increments, persists, and returns true when (count % 3 === 0) — so
@@ -43,10 +61,10 @@ async function writeConfigPatch(patch) {
43
61
  * we'd rather skip than be loud on the user's first real command.
44
62
  */
45
63
  export async function shouldShowNudge() {
46
- const cfg = await readConfig();
64
+ const cfg = await readLocalConfig();
47
65
  const current = typeof cfg.nudge_count === 'number' ? cfg.nudge_count : 0;
48
66
  const next = current + 1;
49
- await writeConfigPatch({ nudge_count: next });
67
+ await writeLocalConfigPatch({ nudge_count: next });
50
68
  return next % 3 === 0;
51
69
  }
52
70
  /* --------------------------------------------------------------------- */
@@ -101,13 +119,30 @@ async function canNudge() {
101
119
  * (no nudge_count yet AND first_run not yet set to false). Always strong,
102
120
  * always bypasses the throttle.
103
121
  *
122
+ * Uses the START-of-command auth snapshot, not live state. That way the
123
+ * banner still appears when the user's very first command is `prave
124
+ * login` itself — login completes, the postAction hook fires, and we
125
+ * still know they came in anonymous so the welcome is appropriate.
126
+ *
104
127
  * Returns true when shown — call sites use this to skip the regular nudge
105
128
  * on the same turn so we don't double-print.
106
129
  */
107
130
  export async function nudgeFirstRun() {
108
- if (!(await canNudge()))
131
+ if (alreadyNudged)
132
+ return false;
133
+ if (!process.stdout.isTTY)
134
+ return false;
135
+ if (process.env.PRAVE_QUIET === '1')
136
+ return false;
137
+ if (process.env.PRAVE_TELEMETRY === '0')
138
+ return false;
139
+ // Honour the auth-at-start snapshot. If the user opened the session
140
+ // already authenticated, the welcome conversion banner doesn't fit —
141
+ // they don't need a "create free account" pitch.
142
+ const startedAnon = wasAnonymousAtStart ?? !(await isAuthenticated());
143
+ if (!startedAnon)
109
144
  return false;
110
- const cfg = await readConfig();
145
+ const cfg = await readLocalConfig();
111
146
  // first_run defaults to "yes, show it" unless we've already flipped
112
147
  // the flag. A missing config file → first run.
113
148
  const alreadyShown = cfg.first_run === false;
@@ -115,7 +150,7 @@ export async function nudgeFirstRun() {
115
150
  return false;
116
151
  alreadyNudged = true;
117
152
  showNudge(NUDGE_FIRST_RUN);
118
- await writeConfigPatch({ first_run: false });
153
+ await writeLocalConfigPatch({ first_run: false });
119
154
  return true;
120
155
  }
121
156
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.4.8",
3
+ "version": "1.4.10",
4
4
  "description": "Prave CLI — discover, install, version, test, and ship Claude Skills. The developer platform for the complete Skill lifecycle.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -54,7 +54,7 @@
54
54
  "ora": "^8.0.1",
55
55
  "tar": "^7.4.3",
56
56
  "undici": "^6.18.0",
57
- "@prave/shared": "1.4.8"
57
+ "@prave/shared": "1.4.10"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",