@prave/cli 1.4.7 → 1.4.8

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.
@@ -123,17 +123,17 @@ export async function importCommand(opts) {
123
123
  const me = await fetchMyPlan();
124
124
  if (!me.limits.can_authoring_public && !me.limits.can_authoring_private) {
125
125
  log.warn('Uploading Skills requires the Pro plan or higher.');
126
- log.dim(formatUpgradeHint('explorer'));
126
+ log.dim(formatUpgradeHint('pro'));
127
127
  return;
128
128
  }
129
129
  if (visibility === 'private' && !me.limits.can_authoring_private) {
130
130
  log.warn('Private uploads require the Pro plan.');
131
- log.dim(formatUpgradeHint('explorer'));
131
+ log.dim(formatUpgradeHint('pro'));
132
132
  return;
133
133
  }
134
134
  if (visibility === 'public' && !me.limits.can_authoring_public) {
135
135
  log.warn('Public uploads require the Pro plan.');
136
- log.dim(formatUpgradeHint('explorer'));
136
+ log.dim(formatUpgradeHint('pro'));
137
137
  return;
138
138
  }
139
139
  // Clamp the queue to the caller's authoring ceiling. `null` = unlimited
@@ -142,7 +142,7 @@ export async function importCommand(opts) {
142
142
  if (me.limits.authoring_max_skills !== null &&
143
143
  queue.length > me.limits.authoring_max_skills) {
144
144
  log.warn(`Your ${me.limits.label} plan caps authored Skills at ${me.limits.authoring_max_skills}. Trimming queue from ${queue.length} → ${me.limits.authoring_max_skills}.`);
145
- log.dim(formatUpgradeHint('explorer'));
145
+ log.dim(formatUpgradeHint('pro'));
146
146
  queue = queue.slice(0, me.limits.authoring_max_skills);
147
147
  }
148
148
  const uploadSpinner = ora(`Uploading ${queue.length} skills as ${visibility}…`).start();
@@ -191,7 +191,7 @@ export async function importCommand(opts) {
191
191
  .join(' · ');
192
192
  uploadSpinner.succeed(tail);
193
193
  if (gated > 0)
194
- log.dim(formatUpgradeHint('explorer'));
194
+ log.dim(formatUpgradeHint('pro'));
195
195
  // Always analyze after upload — best effort, never blocks the user.
196
196
  const analyzeSpinner = ora(`Analyzing ${queue.length} skills…`).start();
197
197
  for (const s of queue) {
@@ -1,12 +1,12 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { createInterface } from 'node:readline/promises';
4
3
  import chalk from 'chalk';
5
4
  import ora from 'ora';
6
5
  import { track } from '../lib/analytics.js';
7
6
  import { api, ApiError } from '../lib/api.js';
8
7
  import { CONFIG } from '../lib/config.js';
9
8
  import { loadCredentials, requireAuth } from '../lib/credentials.js';
9
+ import { nudgeAfter } from '../lib/nudge.js';
10
10
  import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
11
11
  import { assertSlug, InvalidSlugError } from '../lib/slug.js';
12
12
  import { log } from '../utils/logger.js';
@@ -51,7 +51,7 @@ export async function installCommand(slug, opts = {}) {
51
51
  const remaining = data.installs.remaining;
52
52
  if (remaining !== null && remaining <= 0) {
53
53
  log.warn(`Your ${me.limits.label} plan caps installs at ${me.limits.install_monthly_limit}/month. You've used all of them.`);
54
- log.dim(formatUpgradeHint('explorer'));
54
+ log.dim(formatUpgradeHint('pro'));
55
55
  process.exitCode = 1;
56
56
  return;
57
57
  }
@@ -102,22 +102,13 @@ export async function installCommand(slug, opts = {}) {
102
102
  /* file unreadable — skip */
103
103
  }
104
104
  }
105
- // Offer to deploy to all configured agents unless the caller already
106
- // answered the question (e.g. `prave sync` collects the answer once for
107
- // the whole batch and threads it through `skipDeployPrompt`).
108
- if (opts.skipDeployPrompt)
109
- return;
110
- const rl = createInterface({ input: process.stdin, output: process.stdout });
111
- try {
112
- const ans = (await rl.question('\nDeploy to all configured agents? [y/N] ')).trim().toLowerCase();
113
- if (ans === 'y' || ans === 'yes') {
114
- const { deployCommand } = await import('./deploy.js');
115
- await deployCommand(slug, {});
116
- }
117
- }
118
- finally {
119
- rl.close();
120
- }
105
+ // `prave deploy` was retired install fans out to the user's
106
+ // configured agent targets directly (see multi_agent_targets in
107
+ // PLAN_LIMITS), so no follow-up prompt is needed.
108
+ // Defensive: requireAuth above already blocks unauth callers, so this
109
+ // is a no-op today. Keeps the nudge consistent if install ever opens
110
+ // to anonymous use (it's safe `nudgeAfter` self-gates on auth).
111
+ await nudgeAfter('install');
121
112
  }
122
113
  async function pullOne(slug, ctx) {
123
114
  const { data: skill } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, ctx.hasSession);
@@ -5,7 +5,7 @@ import { tokenTier } from '@prave/shared';
5
5
  import { track } from '../lib/analytics.js';
6
6
  import { api } from '../lib/api.js';
7
7
  import { CONFIG } from '../lib/config.js';
8
- import { nudgeIfAnonymous } from '../lib/nudge.js';
8
+ import { nudgeAfter } from '../lib/nudge.js';
9
9
  import { log } from '../utils/logger.js';
10
10
  const TIER_EMOJI = {
11
11
  lean: '🟢',
@@ -69,7 +69,7 @@ export async function listCommand(opts = {}) {
69
69
  console.log(` ${chalk.cyan('•')} ${name}`);
70
70
  }
71
71
  log.dim(`\n${localSlugs.length} local skill${localSlugs.length === 1 ? '' : 's'} in ${CONFIG.skillsDir}`);
72
- await nudgeIfAnonymous('list');
72
+ await nudgeAfter('list');
73
73
  return;
74
74
  }
75
75
  // Enriched path — pull intelligence and merge by slug (best-effort).
@@ -39,6 +39,7 @@ export async function loginCommand() {
39
39
  expires_at: data.expires_at ?? undefined,
40
40
  });
41
41
  spinner.succeed('Logged in.');
42
+ log.dim("Nudges disabled — you're all set.");
42
43
  // Replay any Skill-invocation events the hook buffered while the
43
44
  // user was offline / signed out. Silent when nothing's queued;
44
45
  // prints "Syncing N events…" + a confirmation when the file has
@@ -3,6 +3,7 @@ import ora from 'ora';
3
3
  import { track } from '../lib/analytics.js';
4
4
  import { api, ApiError } from '../lib/api.js';
5
5
  import { requireAuth } from '../lib/credentials.js';
6
+ import { nudgeAfter } from '../lib/nudge.js';
6
7
  import { log } from '../utils/logger.js';
7
8
  function formatThousands(n) {
8
9
  if (n < 1000)
@@ -71,5 +72,9 @@ export async function overviewCommand(opts = {}) {
71
72
  spinner.stop();
72
73
  log.error(err instanceof ApiError ? err.message : err.message);
73
74
  process.exitCode = 1;
75
+ return;
74
76
  }
77
+ // No-op for signed-in users (overview requires auth), but harmless and
78
+ // future-proof if we ever expose a public overview-equivalent.
79
+ await nudgeAfter('overview');
75
80
  }
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { track } from '../lib/analytics.js';
3
3
  import { api } from '../lib/api.js';
4
- import { nudgeIfAnonymous } from '../lib/nudge.js';
4
+ import { nudgeAfter } from '../lib/nudge.js';
5
5
  import { log } from '../utils/logger.js';
6
6
  const SLUG_COL = 32;
7
7
  function formatInstalls(n) {
@@ -42,5 +42,5 @@ export async function searchCommand(query) {
42
42
  if (skills.length > 0) {
43
43
  console.log(chalk.dim(' → prave install <slug>'));
44
44
  }
45
- await nudgeIfAnonymous('search');
45
+ await nudgeAfter('search');
46
46
  }
@@ -72,11 +72,11 @@ export async function syncCommand() {
72
72
  const session = await requireAuth('prave sync');
73
73
  if (!session)
74
74
  return;
75
- // Plan gate: sync requires Explorer or higher.
75
+ // Plan gate: sync requires Pro or higher.
76
76
  const me = await fetchMyPlan();
77
77
  if (!me.limits.can_cli_sync) {
78
- log.warn('Sync requires the Explorer plan or higher.');
79
- log.dim(formatUpgradeHint('explorer'));
78
+ log.warn('Sync requires the Pro plan or higher.');
79
+ log.dim(formatUpgradeHint('pro'));
80
80
  return;
81
81
  }
82
82
  // Last-sync nudge. We read state up-front because if the user bails on
@@ -134,19 +134,10 @@ export async function syncCommand() {
134
134
  // Pre-flight time estimate — sets expectations before the user hits Y.
135
135
  const estSeconds = estimateSeconds(slugs.length);
136
136
  console.log(chalk.dim(`Syncing ${slugs.length} Skill${slugs.length === 1 ? '' : 's'} — this takes about ${estSeconds} seconds.`));
137
- // Ask the deploy question ONCE for the whole batch.
138
- const rl = createInterface({ input: process.stdin, output: process.stdout });
139
- let deployAfter = false;
140
- try {
141
- const ans = (await rl.question(`\nAfter syncing, deploy all ${slugs.length} Skills to your configured agents? [y/N] `))
142
- .trim()
143
- .toLowerCase();
144
- deployAfter = ans === 'y' || ans === 'yes';
145
- }
146
- finally {
147
- rl.close();
148
- }
149
- // Parallel install with live progress counter.
137
+ // Parallel install with live progress counter. The legacy post-sync
138
+ // "deploy to all agents?" prompt is gone — `prave deploy` has been
139
+ // retired and `installCommand` already fans out to the user's
140
+ // configured agent targets directly.
150
141
  const progress = ora(`Installed 0 / ${slugs.length}`).start();
151
142
  let done = 0;
152
143
  let updated = 0;
@@ -174,18 +165,6 @@ export async function syncCommand() {
174
165
  // *did* attempt a sync, and we want the cooldown to apply to retries
175
166
  // just as much as to the happy path.
176
167
  await writeState({ last_sync_at: new Date().toISOString() }).catch(() => { });
177
- if (deployAfter && updated > 0) {
178
- const { deployCommand } = await import('./deploy.js');
179
- log.info(`\nDeploying ${updated} Skills to configured agents…`);
180
- for (const slug of slugs) {
181
- try {
182
- await deployCommand(slug, {});
183
- }
184
- catch (err) {
185
- log.warn(` ✗ deploy ${slug} — ${err.message}`);
186
- }
187
- }
188
- }
189
168
  // Tail end of sync: fire a quiet usage scan so the optimiser stays warm
190
169
  // without the user having to remember an extra command. Quiet mode means
191
170
  // we only log a one-liner ("Usage: 12 new, 88 known.") instead of taking
@@ -30,7 +30,7 @@ export async function updateCommand(slug, opts = {}) {
30
30
  const me = await fetchMyPlan();
31
31
  if (!me.limits.can_cli_update) {
32
32
  log.warn(`\`prave update\` requires the Pro plan or higher.`);
33
- log.dim(formatUpgradeHint('explorer'));
33
+ log.dim(formatUpgradeHint('pro'));
34
34
  process.exitCode = 1;
35
35
  return;
36
36
  }
@@ -3,7 +3,7 @@ import ora from 'ora';
3
3
  import { tokenTier } from '@prave/shared';
4
4
  import { track } from '../lib/analytics.js';
5
5
  import { api, ApiError } from '../lib/api.js';
6
- import { nudgeIfAnonymous } from '../lib/nudge.js';
6
+ import { nudgeAfter } from '../lib/nudge.js';
7
7
  import { log } from '../utils/logger.js';
8
8
  const TIER_BADGE = {
9
9
  lean: chalk.green('🟢 Lean'),
@@ -72,7 +72,7 @@ export async function whatdoesCommand(skillName) {
72
72
  console.log(`🔗 Requires: ${requires}`);
73
73
  console.log(`${data.conflicts.length > 0 ? chalk.yellow('⚠️ ') : '⚠️ '}Conflicts: ${conflicts}`);
74
74
  console.log(chalk.dim(RULE));
75
- await nudgeIfAnonymous('whatdoes');
75
+ await nudgeAfter('whatdoes');
76
76
  }
77
77
  catch (err) {
78
78
  spinner.stop();
@@ -11,7 +11,7 @@ import { log } from '../utils/logger.js';
11
11
  * is the only thing that ever surfaces in UI / CLI copy.
12
12
  */
13
13
  function planDisplay(plan) {
14
- if (plan === 'free' || plan === 'explorer' || plan === 'creator') {
14
+ if (plan === 'free' || plan === 'pro' || plan === 'max') {
15
15
  return planLabel(plan);
16
16
  }
17
17
  return plan;
package/dist/index.js CHANGED
@@ -24,6 +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
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
29
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
29
30
  initAnalytics(pkg.version);
@@ -203,6 +204,19 @@ program
203
204
  'Docs: https://prave.app/docs',
204
205
  ].join('\n'));
205
206
  });
207
+ // Global first-run banner. Fires once on the user's very first command
208
+ // regardless of which one it was — catches commands that don't have a
209
+ // per-action nudge wired in (e.g. `prave docs`, `prave conflicts`).
210
+ // Per-command `nudgeAfter` calls also invoke `nudgeFirstRun` first, so
211
+ // the once-per-process guard inside the nudge module prevents doubling.
212
+ program.hook('postAction', async () => {
213
+ try {
214
+ await nudgeFirstRun();
215
+ }
216
+ catch {
217
+ /* nudges are decorative — never block on them */
218
+ }
219
+ });
206
220
  program.parseAsync().catch((err) => {
207
221
  console.error(err.message);
208
222
  process.exit(1);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Centralized copy for every CLI conversion nudge.
3
+ *
4
+ * Tuning the funnel = editing this file. The renderer in nudge.ts is dumb
5
+ * and the wiring in command files is dumb; all the messaging lives here so
6
+ * a product person can reword without grokking node/chalk.
7
+ *
8
+ * Soft nudges = single short pitch under the command output. Strong nudges
9
+ * = double-line banner with a bulleted value-prop list. Use strong for
10
+ * commands where the gap between "what they just got locally" and "what
11
+ * they'd get signed-in" is biggest (overview/whatdoes/first-run).
12
+ */
13
+ /* --------------------------------------------------------------------- */
14
+ /* Soft nudges (Nudge 1–4) — throttled to ~1 in every 3 commands. */
15
+ /* --------------------------------------------------------------------- */
16
+ export const NUDGE_GENERIC = {
17
+ kind: 'soft',
18
+ icon: '💡',
19
+ title: 'Sign in to unlock the full Prave experience',
20
+ cta: 'prave.app/signup · takes 10 seconds',
21
+ };
22
+ export const NUDGE_LIST = {
23
+ kind: 'soft',
24
+ icon: '🔍',
25
+ title: 'Want AI-generated descriptions for each Skill?',
26
+ cta: 'Sign in free at prave.app — no credit card needed.',
27
+ };
28
+ export const NUDGE_SEARCH = {
29
+ kind: 'soft',
30
+ icon: '🚀',
31
+ title: 'Unlock semantic search and find Skills by intent.',
32
+ cta: 'Upgrade to Pro at prave.app — $12/mo',
33
+ };
34
+ export const NUDGE_INSTALL = {
35
+ kind: 'soft',
36
+ icon: '📊',
37
+ title: 'Track token costs and usage for this Skill at prave.app',
38
+ cta: 'Free account · takes 10 seconds · no credit card',
39
+ };
40
+ /* --------------------------------------------------------------------- */
41
+ /* Strong nudges (Nudge 5–6) — always shown to unauthenticated users. */
42
+ /* --------------------------------------------------------------------- */
43
+ export const NUDGE_DEEP = {
44
+ kind: 'strong',
45
+ icon: '✨',
46
+ title: "You're one step away from the full picture.",
47
+ bullets: [
48
+ 'AI-generated descriptions for all your Skills',
49
+ 'Conflict detection across your library',
50
+ '30-day usage history from PostToolUse hook',
51
+ 'Cross-machine sync',
52
+ ],
53
+ cta: 'prave.app/signup · free forever · 10 seconds',
54
+ };
55
+ export const NUDGE_FIRST_RUN = {
56
+ kind: 'strong',
57
+ icon: '👋',
58
+ title: "Welcome to Prave! You're running the CLI — nice.",
59
+ bullets: [
60
+ '4,300+ developers are already using it',
61
+ 'Discover 1,200+ Skills with semantic search',
62
+ 'Audit your token footprint',
63
+ 'Sync across all your machines',
64
+ ],
65
+ cta: 'prave login ← connect CLI to your account',
66
+ ctaSecondary: 'prave.app/signup ← create free account first',
67
+ };
68
+ /** Picks the right variant for a given context. */
69
+ export function nudgeFor(context) {
70
+ switch (context) {
71
+ case 'list':
72
+ return NUDGE_LIST;
73
+ case 'search':
74
+ return NUDGE_SEARCH;
75
+ case 'install':
76
+ return NUDGE_INSTALL;
77
+ case 'whatdoes':
78
+ case 'overview':
79
+ return NUDGE_DEEP;
80
+ case 'generic':
81
+ default:
82
+ return NUDGE_GENERIC;
83
+ }
84
+ }
85
+ /** True for nudges that bypass the 1-in-3 throttle. */
86
+ export function isAlwaysShow(context) {
87
+ return context === 'whatdoes' || context === 'overview';
88
+ }
package/dist/lib/nudge.js CHANGED
@@ -1,48 +1,157 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
2
  import chalk from 'chalk';
3
+ import { CONFIG } from './config.js';
2
4
  import { loadCredentials } from './credentials.js';
5
+ import { isAlwaysShow, NUDGE_FIRST_RUN, nudgeFor, } from './nudge-constants.js';
3
6
  let alreadyNudged = false;
4
- export async function nudgeIfAnonymous(context = 'generic') {
7
+ /* --------------------------------------------------------------------- */
8
+ /* Auth + state helpers */
9
+ /* --------------------------------------------------------------------- */
10
+ export async function isAuthenticated() {
11
+ const creds = await loadCredentials();
12
+ if (!creds?.access_token)
13
+ return false;
14
+ // expires_at is unix-seconds. If absent (pre-refresh-token creds) we
15
+ // assume valid — the API call itself will 401 and trigger re-login.
16
+ if (typeof creds.expires_at === 'number' && creds.expires_at < Math.floor(Date.now() / 1000)) {
17
+ return false;
18
+ }
19
+ return true;
20
+ }
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
+ /**
40
+ * Counter-based throttle for soft nudges. Reads the current count,
41
+ * increments, persists, and returns true when (count % 3 === 0) — so
42
+ * roughly every third invocation. The first invocation returns false:
43
+ * we'd rather skip than be loud on the user's first real command.
44
+ */
45
+ export async function shouldShowNudge() {
46
+ const cfg = await readConfig();
47
+ const current = typeof cfg.nudge_count === 'number' ? cfg.nudge_count : 0;
48
+ const next = current + 1;
49
+ await writeConfigPatch({ nudge_count: next });
50
+ return next % 3 === 0;
51
+ }
52
+ /* --------------------------------------------------------------------- */
53
+ /* Renderer */
54
+ /* --------------------------------------------------------------------- */
55
+ function makeRule(char, width = 60) {
56
+ return char.repeat(width);
57
+ }
58
+ function renderSoft(n) {
59
+ const rule = chalk.dim(makeRule('─'));
60
+ const title = chalk.dim(`${n.icon} ${n.title}`);
61
+ const cta = chalk.dim(` ${n.cta}`);
62
+ return [rule, title, cta, rule].join('\n');
63
+ }
64
+ function renderStrong(n) {
65
+ const rule = chalk.cyan(makeRule('═'));
66
+ const title = chalk.bold(`${n.icon} ${n.title}`);
67
+ const bullets = n.bullets.map((b) => chalk.dim(` → ${b}`)).join('\n');
68
+ const cta = chalk.cyan(` ${n.cta}`);
69
+ const cta2 = n.ctaSecondary ? '\n' + chalk.cyan(` ${n.ctaSecondary}`) : '';
70
+ return [rule, title, '', bullets, '', cta + cta2, rule].join('\n');
71
+ }
72
+ export function showNudge(nudge) {
73
+ console.log();
74
+ console.log(nudge.kind === 'strong' ? renderStrong(nudge) : renderSoft(nudge));
75
+ }
76
+ /* --------------------------------------------------------------------- */
77
+ /* Public entry points */
78
+ /* --------------------------------------------------------------------- */
79
+ /**
80
+ * Gate that all nudge entry points share. Skips when:
81
+ * - user is signed in
82
+ * - we've already nudged in this process
83
+ * - stdout isn't a TTY (pipes, CI)
84
+ * - PRAVE_QUIET=1 or PRAVE_TELEMETRY=0 is set
85
+ */
86
+ async function canNudge() {
5
87
  if (alreadyNudged)
6
- return;
88
+ return false;
89
+ if (!process.stdout.isTTY)
90
+ return false;
7
91
  if (process.env.PRAVE_QUIET === '1')
8
- return;
92
+ return false;
9
93
  if (process.env.PRAVE_TELEMETRY === '0')
10
- return; // user opted out of analytics
11
- // CI / non-interactive shells are unlikely to act on a nudge — and
12
- // the noise breaks pipe-parsing scripts.
13
- if (!process.stdout.isTTY)
94
+ return false;
95
+ if (await isAuthenticated())
96
+ return false;
97
+ return true;
98
+ }
99
+ /**
100
+ * Show the first-run welcome banner if it's the user's very first command
101
+ * (no nudge_count yet AND first_run not yet set to false). Always strong,
102
+ * always bypasses the throttle.
103
+ *
104
+ * Returns true when shown — call sites use this to skip the regular nudge
105
+ * on the same turn so we don't double-print.
106
+ */
107
+ export async function nudgeFirstRun() {
108
+ if (!(await canNudge()))
109
+ return false;
110
+ const cfg = await readConfig();
111
+ // first_run defaults to "yes, show it" unless we've already flipped
112
+ // the flag. A missing config file → first run.
113
+ const alreadyShown = cfg.first_run === false;
114
+ if (alreadyShown)
115
+ return false;
116
+ alreadyNudged = true;
117
+ showNudge(NUDGE_FIRST_RUN);
118
+ await writeConfigPatch({ first_run: false });
119
+ return true;
120
+ }
121
+ /**
122
+ * Main per-command nudge entry. Pick the most specific context — the
123
+ * renderer maps it to the matching constant. Strong contexts
124
+ * (overview/whatdoes) always show; soft ones go through the 1-in-3
125
+ * throttle.
126
+ */
127
+ export async function nudgeAfter(context = 'generic') {
128
+ // First-run handling lives here so every command path picks it up
129
+ // without needing its own boilerplate. If we showed the welcome banner,
130
+ // skip the regular nudge for this turn — too noisy otherwise.
131
+ const showedFirstRun = await nudgeFirstRun();
132
+ if (showedFirstRun)
133
+ return;
134
+ if (!(await canNudge()))
135
+ return;
136
+ if (isAlwaysShow(context)) {
137
+ alreadyNudged = true;
138
+ showNudge(nudgeFor(context));
139
+ return;
140
+ }
141
+ if (!(await shouldShowNudge()))
14
142
  return;
15
- const creds = await loadCredentials();
16
- if (creds)
17
- return; // logged-in → no nudge
18
143
  alreadyNudged = true;
19
- const cta = chalk.cyan('prave login');
20
- const url = chalk.dim('— takes 10 seconds, free forever');
21
- const message = (() => {
22
- switch (context) {
23
- case 'search':
24
- return `${chalk.dim('Save these to your library, get token costs + conflicts.')}\n${chalk.dim('→')} ${cta} ${url}`;
25
- case 'whatdoes':
26
- return `${chalk.dim('Want the full audit — triggers, conflicts, token cost?')}\n${chalk.dim('→')} ${cta} ${url}`;
27
- case 'list':
28
- return `${chalk.dim('Sign in to see AI descriptions, conflicts and token cost.')}\n${chalk.dim('→')} ${cta} ${url}`;
29
- case 'overview':
30
- return `${chalk.dim('Track this over time on prave.app.')}\n${chalk.dim('→')} ${cta} ${url}`;
31
- case 'generic':
32
- default:
33
- return `${chalk.dim('Get the full Skill Intelligence on prave.app.')}\n${chalk.dim('→')} ${cta} ${url}`;
34
- }
35
- })();
36
- // One blank line so the nudge isn't glued to the command's main
37
- // output. Two `console.log` calls so the chalk styles survive the
38
- // pipeline cleanly.
39
- console.log();
40
- console.log(message);
144
+ showNudge(nudgeFor(context));
41
145
  }
146
+ /**
147
+ * Back-compat alias. Older call sites used this name; new code should
148
+ * call `nudgeAfter` directly.
149
+ */
150
+ export const nudgeIfAnonymous = nudgeAfter;
42
151
  /**
43
152
  * Post-install banner used by package.json's `postinstall` script.
44
- * Runs once after `npm i -g @prave/cli`. Stays short — npm's install
45
- * log is already crowded.
153
+ * Runs once after `npm i -g @prave/cli`. Kept short — npm's install log
154
+ * is already crowded.
46
155
  */
47
156
  export function printPostInstallBanner() {
48
157
  if (process.env.CI)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.4.7",
3
+ "version": "1.4.8",
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.7"
57
+ "@prave/shared": "1.4.8"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",
@@ -1,189 +0,0 @@
1
- /**
2
- * Multi-agent fan-out helper.
3
- *
4
- * This module used to back a public `prave deploy <skill>` command, but
5
- * shipping that as a top-level CLI surface only duplicated what
6
- * `prave install` already prompts for ("Deploy to all configured agents?
7
- * [y/N]"). The standalone command was removed; `deployCommand` lives on
8
- * as an internal helper that `install.ts` and `sync.ts` import to do
9
- * the actual fan-out + Cursor `.mdc` rewrite.
10
- */
11
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
- import { homedir } from 'node:os';
13
- import { join } from 'node:path';
14
- import chalk from 'chalk';
15
- import ora from 'ora';
16
- import { AGENT_REGISTRY, compileSkill } from '@prave/shared';
17
- import { track } from '../lib/analytics.js';
18
- import { api, ApiError } from '../lib/api.js';
19
- import { requireAuth } from '../lib/credentials.js';
20
- import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
21
- import { CONFIG } from '../lib/config.js';
22
- import { assertSlug, InvalidSlugError } from '../lib/slug.js';
23
- import { log } from '../utils/logger.js';
24
- function detectOsKey(detected) {
25
- if (detected === 'windows')
26
- return 'windows';
27
- // mac + linux both use POSIX paths under "mac" in the registry.
28
- return 'mac';
29
- }
30
- function expandHome(p, os) {
31
- if (os === 'windows')
32
- return p;
33
- if (p.startsWith('~')) {
34
- return join(homedir(), p.slice(1).replace(/^[\\/]/, ''));
35
- }
36
- return p;
37
- }
38
- async function readLocalSkill(slug) {
39
- const path = join(CONFIG.skillsDir, slug, 'SKILL.md');
40
- try {
41
- return await readFile(path, 'utf8');
42
- }
43
- catch {
44
- return null;
45
- }
46
- }
47
- async function fetchRemoteSkill(slug) {
48
- const { data } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, true);
49
- if (!data.content) {
50
- throw new ApiError(`Remote skill "${slug}" has no content.`, 404);
51
- }
52
- return data.content;
53
- }
54
- /**
55
- * Build the on-disk write path for the given agent + slug. The
56
- * `compileSkill()` import from `@prave/shared` decides the *content*
57
- * transform (e.g. Cursor `.mdc` frontmatter rewrite); this function
58
- * only resolves the absolute filesystem location by combining the
59
- * agent's `basePath` from user settings with the relative path the
60
- * shared compiler returned.
61
- */
62
- function buildDestPath(agent, basePath, os, slug, relPath) {
63
- const expanded = expandHome(basePath, os);
64
- // The shared compiler returns POSIX-style relPaths
65
- // (e.g. `<slug>/SKILL.md` or `.cursor/rules/<slug>.mdc`). For
66
- // Cursor specifically the user's `basePath` already points at
67
- // `.cursor/rules/`, so we collapse the leading `.cursor/rules/`
68
- // to avoid the path duplicating to `.cursor/rules/.cursor/rules/`.
69
- const collapsed = agent === 'cursor' && relPath.startsWith('.cursor/rules/')
70
- ? relPath.slice('.cursor/rules/'.length)
71
- : relPath;
72
- const file = join(expanded, ...collapsed.split('/'));
73
- const dir = file.slice(0, file.length - collapsed.split('/').slice(-1)[0].length - 1);
74
- return {
75
- dir,
76
- file,
77
- display: `${basePath.replace(/[\\/]+$/, '')}/${collapsed}`,
78
- };
79
- }
80
- export async function deployCommand(skillName, opts = {}) {
81
- track('cli_deploy', { slug: skillName, agent: opts.agent ?? 'all', dry_run: !!opts.dryRun });
82
- try {
83
- assertSlug(skillName);
84
- }
85
- catch (err) {
86
- if (err instanceof InvalidSlugError) {
87
- log.error(err.message);
88
- process.exitCode = 1;
89
- return;
90
- }
91
- throw err;
92
- }
93
- const session = await requireAuth('prave deploy');
94
- if (!session)
95
- return;
96
- const start = Date.now();
97
- // Plan-gate the multi-agent target list. Free can only deploy to
98
- // Claude Code; Pro+ unlocks all 6. We compute the allowed set from
99
- // PLAN_LIMITS so the Stripe-side and CLI-side stay in lockstep.
100
- const me = await fetchMyPlan();
101
- const allowedAgents = new Set(me.limits.multi_agent_targets);
102
- if (allowedAgents.size === 0) {
103
- log.warn(`Your ${me.limits.label} plan can't deploy to any agent.`);
104
- log.dim(formatUpgradeHint('explorer'));
105
- process.exitCode = 1;
106
- return;
107
- }
108
- let settings;
109
- try {
110
- const { data } = await api.get('/api/v1/settings/agents', true);
111
- settings = data;
112
- }
113
- catch (err) {
114
- log.error(err instanceof ApiError ? err.message : err.message);
115
- process.exitCode = 1;
116
- return;
117
- }
118
- const targetFilter = opts.agent && opts.agent.toLowerCase() !== 'all'
119
- ? opts.agent.toLowerCase()
120
- : null;
121
- const targets = settings.enabled_agents
122
- .filter((a) => allowedAgents.has(a))
123
- .filter((a) => targetFilter === null || a === targetFilter);
124
- // If the user picked a target their plan can't reach, tell them why.
125
- if (targetFilter && !allowedAgents.has(targetFilter)) {
126
- log.warn(`Deploying to ${targetFilter} requires the Pro plan. Free can only deploy to Claude Code.`);
127
- log.dim(formatUpgradeHint('explorer'));
128
- process.exitCode = 1;
129
- return;
130
- }
131
- if (targets.length === 0) {
132
- log.warn('No matching agents enabled. Run `prave settings` to configure.');
133
- return;
134
- }
135
- // Load source content (local first, fall back to remote).
136
- let source = await readLocalSkill(skillName);
137
- if (!source) {
138
- try {
139
- source = await fetchRemoteSkill(skillName);
140
- }
141
- catch (err) {
142
- log.error(err instanceof ApiError ? err.message : err.message);
143
- process.exitCode = 1;
144
- return;
145
- }
146
- }
147
- const os = detectOsKey(settings.detected_os);
148
- console.log(`Deploying "${chalk.bold(skillName)}" to ${targets.length} agent${targets.length === 1 ? '' : 's'}${opts.dryRun ? chalk.dim(' (dry-run)') : ''}…`);
149
- const spinner = ora('Writing files…').start();
150
- let okCount = 0;
151
- for (const agent of targets) {
152
- const meta = AGENT_REGISTRY[agent];
153
- const paths = settings.skill_paths[agent] ?? meta.defaultPath;
154
- const basePath = os === 'windows' ? paths.windows : paths.mac;
155
- // Shared compileSkill() is the single source of truth — same
156
- // function the SaaS /dashboard/compile page calls, so the CLI
157
- // and the web zip produce byte-identical output for the same
158
- // SKILL.md.
159
- const artifact = compileSkill(source, skillName, agent);
160
- const dest = buildDestPath(agent, basePath, os, skillName, artifact.path);
161
- spinner.text = `→ ${meta.label}`;
162
- if (opts.dryRun) {
163
- okCount += 1;
164
- continue;
165
- }
166
- try {
167
- await mkdir(dest.dir, { recursive: true });
168
- await writeFile(dest.file, artifact.content, 'utf8');
169
- okCount += 1;
170
- }
171
- catch (err) {
172
- spinner.warn(`Failed: ${meta.label} — ${err.message}`);
173
- }
174
- }
175
- spinner.stop();
176
- for (const agent of targets) {
177
- const meta = AGENT_REGISTRY[agent];
178
- const paths = settings.skill_paths[agent] ?? meta.defaultPath;
179
- const basePath = os === 'windows' ? paths.windows : paths.mac;
180
- const artifact = compileSkill(source, skillName, agent);
181
- const dest = buildDestPath(agent, basePath, os, skillName, artifact.path);
182
- const tag = artifact.converted ? chalk.dim(' (converted)') : '';
183
- console.log(`${chalk.green('✓')} ${meta.label.padEnd(14)} → ${dest.display}${tag}`);
184
- }
185
- const elapsed = ((Date.now() - start) / 1000).toFixed(1);
186
- console.log(opts.dryRun
187
- ? chalk.dim(`Dry-run complete in ${elapsed}s — ${okCount} agents would receive the skill.`)
188
- : chalk.dim(`Deployed in ${elapsed}s`));
189
- }