@prave/cli 1.0.8 → 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.
- package/dist/commands/deploy.js +12 -0
- package/dist/commands/install.js +12 -0
- package/dist/commands/optimize.js +67 -2
- package/dist/commands/uninstall.js +24 -1
- package/dist/index.js +3 -0
- package/dist/lib/slug.js +32 -0
- package/package.json +2 -2
package/dist/commands/deploy.js
CHANGED
|
@@ -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;
|
package/dist/commands/install.js
CHANGED
|
@@ -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.
|
|
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
|
|
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',
|
package/dist/lib/slug.js
ADDED
|
@@ -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.
|
|
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.
|
|
19
|
+
"@prave/shared": "1.0.9"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^20.12.7",
|