@prave/cli 1.0.1 → 1.0.2

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.
@@ -6,6 +6,7 @@ import ora from 'ora';
6
6
  import { AGENT_REGISTRY } from '@prave/shared';
7
7
  import { api, ApiError } from '../lib/api.js';
8
8
  import { requireAuth } from '../lib/credentials.js';
9
+ import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
9
10
  import { CONFIG } from '../lib/config.js';
10
11
  import { log } from '../utils/logger.js';
11
12
  function detectOsKey(detected) {
@@ -73,6 +74,17 @@ export async function deployCommand(skillName, opts = {}) {
73
74
  if (!session)
74
75
  return;
75
76
  const start = Date.now();
77
+ // Plan-gate the multi-agent target list. Free can only deploy to
78
+ // Claude Code; Pro+ unlocks all 6. We compute the allowed set from
79
+ // PLAN_LIMITS so the Stripe-side and CLI-side stay in lockstep.
80
+ const me = await fetchMyPlan();
81
+ const allowedAgents = new Set(me.limits.multi_agent_targets);
82
+ if (allowedAgents.size === 0) {
83
+ log.warn(`Your ${me.limits.label} plan can't deploy to any agent.`);
84
+ log.dim(formatUpgradeHint('explorer'));
85
+ process.exitCode = 1;
86
+ return;
87
+ }
76
88
  let settings;
77
89
  try {
78
90
  const { data } = await api.get('/api/v1/settings/agents', true);
@@ -86,7 +98,16 @@ export async function deployCommand(skillName, opts = {}) {
86
98
  const targetFilter = opts.agent && opts.agent.toLowerCase() !== 'all'
87
99
  ? opts.agent.toLowerCase()
88
100
  : null;
89
- const targets = settings.enabled_agents.filter((a) => targetFilter === null || a === targetFilter);
101
+ const targets = settings.enabled_agents
102
+ .filter((a) => allowedAgents.has(a))
103
+ .filter((a) => targetFilter === null || a === targetFilter);
104
+ // If the user picked a target their plan can't reach, tell them why.
105
+ if (targetFilter && !allowedAgents.has(targetFilter)) {
106
+ log.warn(`Deploying to ${targetFilter} requires the Pro plan. Free can only deploy to Claude Code.`);
107
+ log.dim(formatUpgradeHint('explorer'));
108
+ process.exitCode = 1;
109
+ return;
110
+ }
90
111
  if (targets.length === 0) {
91
112
  log.warn('No matching agents enabled. Run `prave settings` to configure.');
92
113
  return;
@@ -10,12 +10,12 @@ import { log } from '../utils/logger.js';
10
10
  */
11
11
  export async function exportCommand(slug, opts = {}) {
12
12
  const session = await loadCredentials();
13
- // Plan gate: Free can't export. Explorer caps at 50/mo (server-enforced
14
- // via per-user counter once that lands; CLI-side we only flag the gate).
13
+ // Plan gate: export is part of the Pro+ authoring toolkit. Free can browse
14
+ // and install, not extract for republishing.
15
15
  if (session) {
16
16
  const me = await fetchMyPlan();
17
- if (me.limits.cli_exports_monthly === 0) {
18
- log.warn('Export requires the Explorer plan or higher.');
17
+ if (!me.limits.can_authoring_public && !me.limits.can_authoring_private) {
18
+ log.warn('Export requires the Pro plan or higher.');
19
19
  log.dim(formatUpgradeHint('explorer'));
20
20
  return;
21
21
  }
@@ -79,19 +79,32 @@ export async function importCommand(opts) {
79
79
  return;
80
80
  }
81
81
  const visibility = opts.private ? 'private' : 'public';
82
- // Plan gate: clamp the queue to the caller's import / private quota so
83
- // we don't waste 70 round-trips against a Free account that maxes at 10.
82
+ // Plan gate: authoring (public + private) is Pro+. Free is read-only on
83
+ // the registry side discover, install, bookmark, but no upload.
84
84
  const me = await fetchMyPlan();
85
- if (visibility === 'private' && !me.limits.can_private_skills) {
86
- log.warn('Private imports require the Explorer plan.');
85
+ if (!me.limits.can_authoring_public && !me.limits.can_authoring_private) {
86
+ log.warn('Uploading Skills requires the Pro plan or higher.');
87
87
  log.dim(formatUpgradeHint('explorer'));
88
88
  return;
89
89
  }
90
+ if (visibility === 'private' && !me.limits.can_authoring_private) {
91
+ log.warn('Private uploads require the Pro plan.');
92
+ log.dim(formatUpgradeHint('explorer'));
93
+ return;
94
+ }
95
+ if (visibility === 'public' && !me.limits.can_authoring_public) {
96
+ log.warn('Public uploads require the Pro plan.');
97
+ log.dim(formatUpgradeHint('explorer'));
98
+ return;
99
+ }
100
+ // Clamp the queue to the caller's authoring ceiling. `null` = unlimited
101
+ // (Pro + Max), so the cap-check is a no-op for paid tiers.
90
102
  let queue = skills;
91
- if (me.limits.cli_max_imports !== null && queue.length > me.limits.cli_max_imports) {
92
- log.warn(`Your ${me.plan} plan caps imports at ${me.limits.cli_max_imports}. Trimming queue from ${queue.length} ${me.limits.cli_max_imports}.`);
103
+ if (me.limits.authoring_max_skills !== null &&
104
+ queue.length > me.limits.authoring_max_skills) {
105
+ 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}.`);
93
106
  log.dim(formatUpgradeHint('explorer'));
94
- queue = queue.slice(0, me.limits.cli_max_imports);
107
+ queue = queue.slice(0, me.limits.authoring_max_skills);
95
108
  }
96
109
  const uploadSpinner = ora(`Uploading ${queue.length} skills as ${visibility}…`).start();
97
110
  let ok = 0;
@@ -6,6 +6,7 @@ import ora from 'ora';
6
6
  import { api, ApiError } from '../lib/api.js';
7
7
  import { CONFIG } from '../lib/config.js';
8
8
  import { loadCredentials, requireAuth } from '../lib/credentials.js';
9
+ import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
9
10
  import { log } from '../utils/logger.js';
10
11
  /**
11
12
  * `prave install <slug>` — pulls SKILL.md to ~/.claude/skills/<slug>/.
@@ -25,6 +26,28 @@ export async function installCommand(slug, opts = {}) {
25
26
  const session = await requireAuth('prave install');
26
27
  if (!session)
27
28
  return;
29
+ // Plan gate — Free is capped at 5 installs / month. We surface the
30
+ // remaining count proactively so the user knows where they stand
31
+ // before hitting the wall on `install N+1`.
32
+ const me = await fetchMyPlan();
33
+ if (me.limits.install_monthly_limit !== null) {
34
+ try {
35
+ const { data } = await api.get('/api/v1/me/usage-snapshot', true);
36
+ const remaining = data.installs.remaining;
37
+ if (remaining !== null && remaining <= 0) {
38
+ log.warn(`Your ${me.limits.label} plan caps installs at ${me.limits.install_monthly_limit}/month. You've used all of them.`);
39
+ log.dim(formatUpgradeHint('explorer'));
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ if (remaining !== null && remaining <= 2) {
44
+ log.dim(`Heads up — ${remaining} install${remaining === 1 ? '' : 's'} left this month on the ${me.limits.label} plan.`);
45
+ }
46
+ }
47
+ catch {
48
+ /* snapshot endpoint unavailable — server still gates */
49
+ }
50
+ }
28
51
  const spinner = ora(`Resolving ${slug}…`).start();
29
52
  let installedSlugs = [];
30
53
  try {
@@ -6,6 +6,7 @@ import ora from 'ora';
6
6
  import { api } from '../lib/api.js';
7
7
  import { CONFIG } from '../lib/config.js';
8
8
  import { requireAuth } from '../lib/credentials.js';
9
+ import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
9
10
  import { log } from '../utils/logger.js';
10
11
  /**
11
12
  * `prave update [<slug>]` — diff every CLI-installed Skill against its
@@ -24,6 +25,13 @@ export async function updateCommand(slug, opts = {}) {
24
25
  const session = await requireAuth('prave update');
25
26
  if (!session)
26
27
  return;
28
+ const me = await fetchMyPlan();
29
+ if (!me.limits.can_cli_update) {
30
+ log.warn(`\`prave update\` requires the Pro plan or higher.`);
31
+ log.dim(formatUpgradeHint('explorer'));
32
+ process.exitCode = 1;
33
+ return;
34
+ }
27
35
  const spinner = ora(slug ? `Resolving ${slug}…` : 'Checking your installed skills…').start();
28
36
  let installs;
29
37
  try {
@@ -104,43 +104,93 @@ export async function usageScanCommand(opts) {
104
104
  /**
105
105
  * `prave usage report` — invoked by the Claude Code `PostToolUse` hook.
106
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).
107
+ * a single-event POST against the slug-keyed self-healing endpoint.
108
+ * Errors are silent (we never want a hook failure to disturb the user's
109
+ * workflow); set `PRAVE_DEBUG=1` to see what's happening in
110
+ * `~/.prave/usage.log`.
109
111
  */
110
112
  export async function usageReportCommand() {
111
- // Hook payload arrives on stdin. If we can't read it, exit silently.
113
+ const debug = process.env.PRAVE_DEBUG === '1' || process.env.PRAVE_DEBUG === 'true';
112
114
  const stdinPayload = await readStdin();
113
- if (!stdinPayload)
115
+ if (!stdinPayload) {
116
+ if (debug)
117
+ await debugLog('no stdin payload');
114
118
  return;
119
+ }
120
+ if (debug)
121
+ await debugLog(`stdin len=${stdinPayload.length}`);
122
+ // Claude Code's PostToolUse payload is documented as `{tool_name,
123
+ // tool_input, tool_response, ...}`, but we still defend against
124
+ // alternate shapes (capitalisation, future schema drift) so the hook
125
+ // doesn't silently break on a single field rename.
115
126
  let parsed = {};
116
127
  try {
117
128
  parsed = JSON.parse(stdinPayload);
118
129
  }
119
130
  catch {
131
+ if (debug)
132
+ await debugLog('payload is not valid JSON');
120
133
  return;
121
134
  }
122
- const rawSlug = parsed.tool_input?.skill;
123
- if (typeof rawSlug !== 'string' || !rawSlug.trim())
135
+ const rawSlug = extractSkillSlug(parsed);
136
+ if (!rawSlug) {
137
+ if (debug)
138
+ await debugLog(`no skill slug in payload: ${stdinPayload.slice(0, 200)}`);
124
139
  return;
125
- const slug = rawSlug.toLowerCase().split(':').pop()?.trim();
140
+ }
141
+ const slug = rawSlug.toLowerCase().split(':').pop()?.trim().replace(/[^a-z0-9_-]+/g, '-');
126
142
  if (!slug)
127
143
  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.
144
+ if (debug)
145
+ await debugLog(`slug=${slug}`);
130
146
  const session = await requireAuthSilent();
131
- if (!session)
147
+ if (!session) {
148
+ if (debug)
149
+ await debugLog('no auth — skipping');
132
150
  return;
151
+ }
133
152
  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);
153
+ const { data } = await api.post('/api/v1/intelligence/usage/by-slug', { slug, agent_type: 'claude', triggered_at: new Date().toISOString() }, true);
154
+ if (debug)
155
+ await debugLog(`ok recorded=${data.recorded} stub=${data.created_stub}`);
139
156
  }
140
- catch {
157
+ catch (err) {
158
+ if (debug)
159
+ await debugLog(`error: ${err.message}`);
141
160
  /* silent — never break the host shell */
142
161
  }
143
162
  }
163
+ /**
164
+ * Walk the hook payload looking for the invoked Skill's slug. Tries the
165
+ * documented Claude Code path first, then several plausible aliases so
166
+ * we don't break if the schema gains a new wrapper or rename.
167
+ */
168
+ function extractSkillSlug(payload) {
169
+ const candidates = [
170
+ payload.tool_input?.skill,
171
+ payload.toolInput?.skill,
172
+ payload.input?.skill,
173
+ payload.parameters?.skill,
174
+ payload.skill,
175
+ ];
176
+ for (const c of candidates) {
177
+ if (typeof c === 'string' && c.trim())
178
+ return c;
179
+ }
180
+ return null;
181
+ }
182
+ async function debugLog(line) {
183
+ try {
184
+ const { mkdir, appendFile } = await import('node:fs/promises');
185
+ const { join } = await import('node:path');
186
+ await mkdir(CONFIG.praveDir, { recursive: true });
187
+ const stamp = new Date().toISOString();
188
+ await appendFile(join(CONFIG.praveDir, 'usage.log'), `${stamp} ${line}\n`);
189
+ }
190
+ catch {
191
+ /* swallow — debug is best-effort */
192
+ }
193
+ }
144
194
  export async function usageHookInstallCommand() {
145
195
  const session = await requireAuth('prave usage hook install');
146
196
  if (!session)
@@ -168,6 +218,83 @@ export async function usageHookUninstallCommand() {
168
218
  const results = await uninstallHooksForAgents(agents);
169
219
  printAgentResults(results, 'uninstall');
170
220
  }
221
+ /**
222
+ * `prave usage status` — diagnostic. Reports hook health, recent event
223
+ * counts, and a tail of the debug log so users can troubleshoot why
224
+ * "unused for 30+ days" still shows up after running a Skill.
225
+ */
226
+ export async function usageStatusCommand() {
227
+ const session = await requireAuth('prave usage status');
228
+ if (!session)
229
+ return;
230
+ const { existsSync } = await import('node:fs');
231
+ const { readFile } = await import('node:fs/promises');
232
+ const { join } = await import('node:path');
233
+ const { homedir } = await import('node:os');
234
+ // 1. Hook installed?
235
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
236
+ let hookInstalled = false;
237
+ try {
238
+ const raw = await readFile(settingsPath, 'utf8');
239
+ hookInstalled = raw.includes('__prave_managed') && raw.includes('Skill');
240
+ }
241
+ catch {
242
+ /* no settings.json yet */
243
+ }
244
+ // 2. Last 7 days of recorded events from the API.
245
+ let recent7 = 0;
246
+ let topSlugs = [];
247
+ try {
248
+ const { data } = await api.get('/api/v1/intelligence/usage/recent?days=7', true);
249
+ recent7 = data.events?.length ?? 0;
250
+ topSlugs = data.by_skill ?? [];
251
+ }
252
+ catch {
253
+ /* endpoint optional — older servers may not have it */
254
+ }
255
+ // 3. Cursor watermark — when did the scanner last run?
256
+ const cursorPath = join(CONFIG.praveDir, 'usage-cursor.json');
257
+ let lastScanAt = null;
258
+ try {
259
+ const raw = await readFile(cursorPath, 'utf8');
260
+ lastScanAt = JSON.parse(raw).lastScanAt ?? null;
261
+ }
262
+ catch {
263
+ /* never scanned */
264
+ }
265
+ // 4. Debug log tail.
266
+ const logPath = join(CONFIG.praveDir, 'usage.log');
267
+ const debugAvailable = existsSync(logPath);
268
+ // Render.
269
+ const checkmark = (ok) => (ok ? chalk.green('✓') : chalk.dim('—'));
270
+ log.info(chalk.bold('Usage tracking status'));
271
+ console.log();
272
+ console.log(` ${checkmark(hookInstalled)} Real-time hook (Claude Code): ${hookInstalled ? chalk.green('installed') : chalk.yellow('not installed — `prave usage hook install`')}`);
273
+ console.log(` ${checkmark(Boolean(lastScanAt))} Transcript scanner watermark: ${lastScanAt ?? chalk.dim('never run — `prave sync` includes it')}`);
274
+ console.log(` ${checkmark(recent7 > 0)} Events in last 7 days: ${chalk.cyan(String(recent7))}`);
275
+ if (topSlugs.length) {
276
+ console.log();
277
+ console.log(chalk.dim(' Most active in last 7 days:'));
278
+ for (const s of topSlugs.slice(0, 5)) {
279
+ console.log(` ${chalk.cyan(s.count.toString().padStart(4))} ${s.name}`);
280
+ }
281
+ }
282
+ console.log();
283
+ if (debugAvailable) {
284
+ log.dim(`Debug log: ${logPath}`);
285
+ log.dim('Set PRAVE_DEBUG=1 in your shell to enable verbose hook logging.');
286
+ }
287
+ else {
288
+ log.dim('No debug log yet. Set PRAVE_DEBUG=1 to capture every hook fire to ~/.prave/usage.log');
289
+ }
290
+ if (recent7 === 0) {
291
+ console.log();
292
+ log.warn('No events recorded yet. Quick checks:');
293
+ log.dim(' 1. Is the hook installed? See checkmarks above.');
294
+ log.dim(' 2. Run a Skill in Claude Code, then `tail ~/.prave/usage.log` (with PRAVE_DEBUG=1)');
295
+ log.dim(' 3. Or run `prave usage scan --since 7d` to backfill from transcripts.');
296
+ }
297
+ }
171
298
  async function fetchEnabledAgents() {
172
299
  try {
173
300
  const { data } = await api.get('/api/v1/settings/agents', true);
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import { settingsCommand } from './commands/settings.js';
20
20
  import { syncCommand } from './commands/sync.js';
21
21
  import { uninstallCommand } from './commands/uninstall.js';
22
22
  import { updateCommand } from './commands/update.js';
23
- import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand, usageScanCommand, } from './commands/usage.js';
23
+ import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand, usageScanCommand, usageStatusCommand, } from './commands/usage.js';
24
24
  import { whatdoesCommand } from './commands/whatdoes.js';
25
25
  import { whoamiCommand } from './commands/whoami.js';
26
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -110,6 +110,10 @@ usage
110
110
  .option('--since <window>', 'override the watermark, e.g. "7d" or "12h"')
111
111
  .option('--quiet', 'log a one-liner instead of a spinner')
112
112
  .action(usageScanCommand);
113
+ usage
114
+ .command('status')
115
+ .description('Diagnose hook health, recent event counts, and most-used Skills')
116
+ .action(usageStatusCommand);
113
117
  usage
114
118
  .command('report')
115
119
  .description('Internal: invoked by the Claude Code PostToolUse hook (reads stdin)')
package/dist/lib/plan.js CHANGED
@@ -26,6 +26,14 @@ export const fetchMyPlan = async () => {
26
26
  return fallback;
27
27
  }
28
28
  };
29
- export const formatUpgradeHint = (required) => required === 'creator'
30
- ? 'Upgrade to Creator at https://prave.app/dashboard/settings'
31
- : 'Upgrade to Explorer (€4/mo) at https://prave.app/dashboard/settings';
29
+ /**
30
+ * Stable copy block surfaced by every plan-gated CLI command. Built from
31
+ * `PLAN_LIMITS` so prices/labels stay consistent with the SaaS pricing
32
+ * card automatically.
33
+ */
34
+ export const formatUpgradeHint = (required) => {
35
+ const limits = PLAN_LIMITS[required];
36
+ if (required === 'free')
37
+ return 'You\'re already on the Free plan.';
38
+ return `Upgrade to ${limits.label} ($${limits.price_usd_monthly}/mo or $${limits.price_usd_yearly}/yr) at https://prave.app/dashboard/settings/billing`;
39
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Prave CLI — import, export, install, sync Claude Skills.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "open": "^10.1.0",
17
17
  "ora": "^8.0.1",
18
18
  "undici": "^6.18.0",
19
- "@prave/shared": "1.0.1"
19
+ "@prave/shared": "1.0.2"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.12.7",