@prave/cli 1.0.7 → 1.0.9

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.
@@ -8,6 +8,7 @@ import { api, ApiError } from '../lib/api.js';
8
8
  import { requireAuth } from '../lib/credentials.js';
9
9
  import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
10
10
  import { CONFIG } from '../lib/config.js';
11
+ import { assertSlug, InvalidSlugError } from '../lib/slug.js';
11
12
  import { log } from '../utils/logger.js';
12
13
  function detectOsKey(detected) {
13
14
  if (detected === 'windows')
@@ -70,6 +71,17 @@ function buildDestPath(agent, basePath, os, slug) {
70
71
  };
71
72
  }
72
73
  export async function deployCommand(skillName, opts = {}) {
74
+ try {
75
+ assertSlug(skillName);
76
+ }
77
+ catch (err) {
78
+ if (err instanceof InvalidSlugError) {
79
+ log.error(err.message);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ throw err;
84
+ }
73
85
  const session = await requireAuth('prave deploy');
74
86
  if (!session)
75
87
  return;
@@ -76,6 +76,22 @@ export async function findCommand(query, opts = {}) {
76
76
  }
77
77
  catch (err) {
78
78
  spinner.stop();
79
+ if (err instanceof ApiError && err.status === 402) {
80
+ // Server returned the semantic-search upsell. Render it as a
81
+ // structured upgrade hint instead of a raw error so the message
82
+ // reads like guidance, not a crash.
83
+ log.error(err.message);
84
+ console.log();
85
+ console.log(chalk.dim(' Pro · $12/mo includes:'));
86
+ console.log(chalk.dim(' · `prave search "<natural language>"` ranked by intent'));
87
+ console.log(chalk.dim(' · Skill Intelligence audit + 30-day trigger telemetry'));
88
+ console.log(chalk.dim(' · Cross-machine sync · Tester · Authoring'));
89
+ console.log();
90
+ console.log(` ${chalk.bold('→ Upgrade:')} ${chalk.cyan('https://prave.app/#pricing')}`);
91
+ console.log(chalk.dim(' Or browse the registry without semantic search:'), chalk.cyan('https://prave.app/discover'));
92
+ process.exitCode = 1;
93
+ return;
94
+ }
79
95
  log.error(err instanceof ApiError ? err.message : err.message);
80
96
  process.exitCode = 1;
81
97
  }
@@ -7,6 +7,7 @@ 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
9
  import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
10
+ import { assertSlug, InvalidSlugError } from '../lib/slug.js';
10
11
  import { log } from '../utils/logger.js';
11
12
  /**
12
13
  * `prave install <slug>` — pulls SKILL.md to ~/.claude/skills/<slug>/.
@@ -19,6 +20,17 @@ import { log } from '../utils/logger.js';
19
20
  * instead of letting the API error bubble.
20
21
  */
21
22
  export async function installCommand(slug, opts = {}) {
23
+ try {
24
+ assertSlug(slug);
25
+ }
26
+ catch (err) {
27
+ if (err instanceof InvalidSlugError) {
28
+ log.error(err.message);
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+ throw err;
33
+ }
22
34
  // Auth gate — installs MUST be tracked, otherwise we can't bump counts,
23
35
  // serve recommendations, or surface "updates available" later. Block here
24
36
  // before any registry hit so the user gets a clear hint instead of a
@@ -1,8 +1,11 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
+ import readline from 'node:readline/promises';
3
4
  import { api, ApiError } from '../lib/api.js';
4
5
  import { requireAuth } from '../lib/credentials.js';
6
+ import { isValidSlug } from '../lib/slug.js';
5
7
  import { log } from '../utils/logger.js';
8
+ import { uninstallCommand } from './uninstall.js';
6
9
  function formatTokens(n) {
7
10
  if (n < 1000)
8
11
  return `~${n}`;
@@ -11,6 +14,41 @@ function formatTokens(n) {
11
14
  function nameOf(s) {
12
15
  return s.name ?? s.file_path;
13
16
  }
17
+ /**
18
+ * Map a skill_metadata row back to the registry slug. The canonical on-disk
19
+ * layout is `~/.claude/skills/<slug>/SKILL.md`, so the second-to-last path
20
+ * component is the slug. Falls back to a slugified `name` when the row was
21
+ * created by the slug-keyed self-heal path (no real file_path on disk).
22
+ */
23
+ function slugOf(s) {
24
+ const segs = s.file_path?.split('/').filter(Boolean) ?? [];
25
+ let candidate = null;
26
+ if (segs.length >= 2) {
27
+ const last = segs[segs.length - 1];
28
+ const parent = segs[segs.length - 2];
29
+ if (last.toLowerCase().endsWith('skill.md'))
30
+ candidate = parent;
31
+ }
32
+ if (!candidate && s.name) {
33
+ candidate = s.name.trim().toLowerCase().replace(/\s+/g, '-');
34
+ }
35
+ if (!candidate)
36
+ return null;
37
+ // Reject anything that doesn't pass the registry slug regex — protects
38
+ // the disk against `..`/path-traversal payloads that could have been
39
+ // injected via a malformed `file_path`.
40
+ return isValidSlug(candidate) ? candidate : null;
41
+ }
42
+ async function confirmYesNo(question) {
43
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
44
+ try {
45
+ const ans = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
46
+ return ans === 'y' || ans === 'yes';
47
+ }
48
+ finally {
49
+ rl.close();
50
+ }
51
+ }
14
52
  export async function optimizeCommand(opts = {}) {
15
53
  const _session = await requireAuth("prave optimize");
16
54
  if (!_session)
@@ -51,9 +89,36 @@ export async function optimizeCommand(opts = {}) {
51
89
  }
52
90
  console.log();
53
91
  console.log(chalk.bold(`Total estimated savings: ${formatTokens(data.total_savings_estimate_tokens)} tokens / session`));
54
- if (opts.apply) {
92
+ if (opts.removeUnused) {
93
+ console.log();
94
+ const candidates = data.underused
95
+ .map((s) => ({ skill: s, slug: slugOf(s) }))
96
+ .filter((c) => c.slug !== null);
97
+ if (candidates.length === 0) {
98
+ log.dim('Nothing to remove — no underused skills with a resolvable on-disk slug.');
99
+ return;
100
+ }
101
+ console.log(chalk.bold(`Will remove ${candidates.length} unused skill(s) from disk:`));
102
+ for (const c of candidates) {
103
+ console.log(` ${chalk.red('✗')} ${c.slug} ${chalk.dim(formatTokens(c.skill.estimated_tokens))}`);
104
+ }
105
+ console.log();
106
+ const proceed = opts.yes ? true : await confirmYesNo(chalk.yellow(`Delete ${candidates.length} skill folder(s) from ~/.claude/skills/? This cannot be undone.`));
107
+ if (!proceed) {
108
+ log.dim('Aborted — nothing removed.');
109
+ return;
110
+ }
111
+ for (const c of candidates) {
112
+ await uninstallCommand(c.slug);
113
+ }
114
+ console.log();
115
+ log.dim(`Removed ${candidates.length} skill(s). Run ${chalk.cyan('prave sync')} to update server-side metadata.`);
116
+ }
117
+ else if (opts.apply) {
55
118
  console.log();
56
- log.dim('Auto-apply not yet available — review the suggestions and adjust manually.');
119
+ log.dim('Auto-apply not available — use ' +
120
+ chalk.cyan('prave optimize --remove-unused') +
121
+ ' to delete underused skills, or review the suggestions and adjust manually.');
57
122
  }
58
123
  }
59
124
  catch (err) {
@@ -1,14 +1,37 @@
1
1
  import { rm } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, resolve, sep } from 'node:path';
3
3
  import ora from 'ora';
4
4
  import { CONFIG } from '../lib/config.js';
5
+ import { assertSlug, InvalidSlugError } from '../lib/slug.js';
5
6
  /**
6
7
  * `prave uninstall <slug>` — removes ~/.claude/skills/<slug> recursively.
7
8
  * No remote call: uninstalls are local-only; the install record stays
8
9
  * for analytics + history.
10
+ *
11
+ * Slug is validated against [a-z0-9][a-z0-9-]{0,63} before any path math
12
+ * so a hostile arg like `../../etc` cannot escape the skills directory.
9
13
  */
10
14
  export async function uninstallCommand(slug) {
15
+ try {
16
+ assertSlug(slug);
17
+ }
18
+ catch (err) {
19
+ if (err instanceof InvalidSlugError) {
20
+ console.error(err.message);
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ throw err;
25
+ }
11
26
  const dir = join(CONFIG.skillsDir, slug);
27
+ // Defence-in-depth: even with the regex above, refuse to remove anything
28
+ // outside the skills root (handles symlink edge-cases on Windows).
29
+ const root = resolve(CONFIG.skillsDir);
30
+ if (!resolve(dir).startsWith(root + sep)) {
31
+ console.error(`Refusing to remove ${dir} — outside ${root}`);
32
+ process.exitCode = 1;
33
+ return;
34
+ }
12
35
  const spinner = ora(`Removing ${slug}…`).start();
13
36
  try {
14
37
  await rm(dir, { recursive: true, force: true });
package/dist/index.js CHANGED
@@ -124,6 +124,8 @@ program
124
124
  .command('optimize')
125
125
  .description('Recommendations: underused, mergeable, and heavy skills')
126
126
  .option('--apply', 'placeholder for auto-apply')
127
+ .option('--remove-unused', 'interactively delete skills that have not fired in 30+ days from ~/.claude/skills/')
128
+ .option('--yes', 'with --remove-unused: skip the confirmation prompt')
127
129
  .action(optimizeCommand);
128
130
  const usage = program
129
131
  .command('usage')
@@ -197,6 +199,7 @@ program
197
199
  ' prave whatdoes <skill> # triggers, tokens, conflicts',
198
200
  ' prave conflicts # cross-skill collision check',
199
201
  ' prave optimize # heavy / underused / mergeable',
202
+ ' prave optimize --remove-unused # delete 30d-silent skills from disk',
200
203
  '',
201
204
  'Usage tracking (Pro+)',
202
205
  ' prave usage hook install # real-time PostToolUse hook',
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Skill slug validator.
3
+ *
4
+ * Slugs become path components on disk (`~/.claude/skills/<slug>/`) and on
5
+ * the registry (`/skills/<slug>`). Without validation a hostile or mistyped
6
+ * `slug` like `../../etc/passwd` would escape the skills root and let
7
+ * `prave uninstall` (or the new `prave optimize --remove-unused` flow)
8
+ * delete files outside the user's skill folder.
9
+ *
10
+ * Rules:
11
+ * - 1–64 chars, lowercase
12
+ * - first char alphanumeric
13
+ * - subsequent chars alphanumeric or `-`
14
+ * - no `/`, no `.`, no whitespace, no traversal sequences
15
+ *
16
+ * The same regex is enforced server-side via Zod, but defence-in-depth
17
+ * keeps the CLI safe even if a future endpoint is laxer.
18
+ */
19
+ export const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
20
+ export class InvalidSlugError extends Error {
21
+ constructor(slug) {
22
+ super(`Invalid slug "${slug}" — expected 1-64 lowercase alphanumeric characters and dashes (e.g. "markdown-pro").`);
23
+ this.name = 'InvalidSlugError';
24
+ }
25
+ }
26
+ export function isValidSlug(s) {
27
+ return SLUG_RE.test(s);
28
+ }
29
+ export function assertSlug(s) {
30
+ if (!isValidSlug(s))
31
+ throw new InvalidSlugError(s);
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
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.7"
19
+ "@prave/shared": "1.0.9"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.12.7",