@prave/cli 0.1.0
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/diff.js +56 -0
- package/dist/commands/export.js +24 -0
- package/dist/commands/import.js +64 -0
- package/dist/commands/install.js +91 -0
- package/dist/commands/list.js +34 -0
- package/dist/commands/login.js +47 -0
- package/dist/commands/logout.js +6 -0
- package/dist/commands/search.js +13 -0
- package/dist/commands/sync.js +48 -0
- package/dist/commands/uninstall.js +21 -0
- package/dist/commands/whoami.js +13 -0
- package/dist/index.js +59 -0
- package/dist/lib/api.js +39 -0
- package/dist/lib/config.js +12 -0
- package/dist/lib/credentials.js +25 -0
- package/dist/utils/logger.js +9 -0
- package/package.json +36 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { api } from '../lib/api.js';
|
|
5
|
+
import { CONFIG } from '../lib/config.js';
|
|
6
|
+
import { loadCredentials } from '../lib/credentials.js';
|
|
7
|
+
import { log } from '../utils/logger.js';
|
|
8
|
+
/**
|
|
9
|
+
* `prave diff <slug>` — line-level comparison between the local
|
|
10
|
+
* ~/.claude/skills/<slug>/SKILL.md and the registry's current
|
|
11
|
+
* version. We use a tiny LCS-based diff so the CLI stays dep-light.
|
|
12
|
+
*/
|
|
13
|
+
export async function diffCommand(slug) {
|
|
14
|
+
const localPath = join(CONFIG.skillsDir, slug, 'SKILL.md');
|
|
15
|
+
let local = '';
|
|
16
|
+
try {
|
|
17
|
+
local = await readFile(localPath, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
log.warn(`No local Skill at ${localPath} — run \`prave install ${slug}\` first.`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const session = await loadCredentials();
|
|
24
|
+
const { data: skill } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, Boolean(session));
|
|
25
|
+
const remote = skill.content ?? '';
|
|
26
|
+
if (local === remote) {
|
|
27
|
+
log.success(`${slug} is in sync with the registry.`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
console.log(chalk.bold(`--- local ${localPath}`));
|
|
31
|
+
console.log(chalk.bold(`+++ remote prave://${slug}`));
|
|
32
|
+
for (const line of unifiedDiff(local, remote))
|
|
33
|
+
console.log(line);
|
|
34
|
+
}
|
|
35
|
+
/** Tiny line diff: not a full Myers, but enough to spot drift visually. */
|
|
36
|
+
function unifiedDiff(a, b) {
|
|
37
|
+
const aLines = a.split('\n');
|
|
38
|
+
const bLines = b.split('\n');
|
|
39
|
+
const out = [];
|
|
40
|
+
const max = Math.max(aLines.length, bLines.length);
|
|
41
|
+
for (let i = 0; i < max; i++) {
|
|
42
|
+
const l = aLines[i];
|
|
43
|
+
const r = bLines[i];
|
|
44
|
+
if (l === r) {
|
|
45
|
+
if (l !== undefined)
|
|
46
|
+
out.push(` ${l}`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (l !== undefined)
|
|
50
|
+
out.push(chalk.red(`- ${l}`));
|
|
51
|
+
if (r !== undefined)
|
|
52
|
+
out.push(chalk.green(`+ ${r}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { api } from '../lib/api.js';
|
|
3
|
+
import { loadCredentials } from '../lib/credentials.js';
|
|
4
|
+
import { log } from '../utils/logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* `prave export <slug>` — print or save a Skill's SKILL.md to disk
|
|
7
|
+
* without touching ~/.claude/skills/. Useful for CI pipelines that want
|
|
8
|
+
* to bundle Skills into other repos.
|
|
9
|
+
*/
|
|
10
|
+
export async function exportCommand(slug, opts = {}) {
|
|
11
|
+
const session = await loadCredentials();
|
|
12
|
+
const { data: skill } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, Boolean(session));
|
|
13
|
+
const content = skill.content ?? '';
|
|
14
|
+
if (!content.trim()) {
|
|
15
|
+
log.warn(`${slug} has no content`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (opts.out) {
|
|
19
|
+
await writeFile(opts.out, content, 'utf8');
|
|
20
|
+
log.success(`Wrote ${opts.out} (${content.length} bytes)`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
process.stdout.write(content);
|
|
24
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { api } from '../lib/api.js';
|
|
6
|
+
import { CONFIG } from '../lib/config.js';
|
|
7
|
+
import { log } from '../utils/logger.js';
|
|
8
|
+
/**
|
|
9
|
+
* Scans CONFIG.skillsDir for SKILL.md files. Without --upload, only prints
|
|
10
|
+
* the overview — no network call, no consent required.
|
|
11
|
+
*/
|
|
12
|
+
export async function importCommand(opts) {
|
|
13
|
+
const spinner = ora(`Scanning ${CONFIG.skillsDir}…`).start();
|
|
14
|
+
let entries = [];
|
|
15
|
+
try {
|
|
16
|
+
entries = await readdir(CONFIG.skillsDir);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
spinner.fail(`Skills directory not found: ${CONFIG.skillsDir}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const skills = [];
|
|
23
|
+
for (const name of entries) {
|
|
24
|
+
const dir = join(CONFIG.skillsDir, name);
|
|
25
|
+
if (!(await stat(dir).catch(() => null))?.isDirectory())
|
|
26
|
+
continue;
|
|
27
|
+
const skillFile = join(dir, 'SKILL.md');
|
|
28
|
+
try {
|
|
29
|
+
const content = await readFile(skillFile, 'utf8');
|
|
30
|
+
const { size } = await stat(skillFile);
|
|
31
|
+
skills.push({ slug: name, path: skillFile, content, sizeBytes: size });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* no SKILL.md here */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
spinner.succeed(`Found ${skills.length} local skills.`);
|
|
38
|
+
for (const s of skills) {
|
|
39
|
+
console.log(` ${chalk.cyan('•')} ${s.slug} ${chalk.dim(`(${s.sizeBytes}B)`)}`);
|
|
40
|
+
}
|
|
41
|
+
if (!opts.upload) {
|
|
42
|
+
log.dim('\nRe-run with --upload to push these to your Prave account.');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const visibility = opts.private ? 'private' : 'public';
|
|
46
|
+
const uploadSpinner = ora(`Uploading ${skills.length} skills as ${visibility}…`).start();
|
|
47
|
+
let ok = 0;
|
|
48
|
+
for (const s of skills) {
|
|
49
|
+
try {
|
|
50
|
+
await api.post('/api/v1/skills', {
|
|
51
|
+
name: s.slug,
|
|
52
|
+
slug: s.slug,
|
|
53
|
+
content: s.content,
|
|
54
|
+
visibility,
|
|
55
|
+
license: 'MIT',
|
|
56
|
+
}, true);
|
|
57
|
+
ok += 1;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
uploadSpinner.warn(`Failed: ${s.slug} — ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
uploadSpinner.succeed(`Uploaded ${ok}/${skills.length} skills.`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { api, ApiError } from '../lib/api.js';
|
|
6
|
+
import { CONFIG } from '../lib/config.js';
|
|
7
|
+
import { loadCredentials } from '../lib/credentials.js';
|
|
8
|
+
import { log } from '../utils/logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* `prave install <slug>` — pulls SKILL.md to ~/.claude/skills/<slug>/.
|
|
11
|
+
*
|
|
12
|
+
* • Resolves the dependency tree by default and installs each leaf
|
|
13
|
+
* before the parent (deepest first). `--no-deps` skips this.
|
|
14
|
+
* • Records an `installs` row server-side when a session is present
|
|
15
|
+
* so the dashboard "Installs" tab + trending sort stay accurate.
|
|
16
|
+
* • Detects paywall errors (402) early and prints a friendly hint
|
|
17
|
+
* instead of letting the API error bubble.
|
|
18
|
+
*/
|
|
19
|
+
export async function installCommand(slug, opts = {}) {
|
|
20
|
+
const spinner = ora(`Resolving ${slug}…`).start();
|
|
21
|
+
try {
|
|
22
|
+
const slugs = opts.noDeps ? [slug] : await resolveOrder(slug);
|
|
23
|
+
spinner.text = `Installing ${slugs.length} skill${slugs.length === 1 ? '' : 's'}…`;
|
|
24
|
+
const session = await loadCredentials();
|
|
25
|
+
for (const s of slugs) {
|
|
26
|
+
spinner.text = `↓ ${s}`;
|
|
27
|
+
await pullOne(s, { hasSession: Boolean(session), force: Boolean(opts.force) });
|
|
28
|
+
}
|
|
29
|
+
spinner.succeed(`Installed ${slugs.length} skill${slugs.length === 1 ? '' : 's'} → ${CONFIG.skillsDir}`);
|
|
30
|
+
if (slugs.length > 1) {
|
|
31
|
+
log.dim(` chain: ${slugs.join(' → ')}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
spinner.fail(formatError(err));
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function pullOne(slug, ctx) {
|
|
40
|
+
const { data: skill } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, ctx.hasSession);
|
|
41
|
+
if (skill.price_cents > 0 && skill.purchased === false && skill.is_owner === false) {
|
|
42
|
+
throw new ApiError(`${chalk.bold(skill.slug)} is paid (${formatPrice(skill.price_cents, skill.currency)}). Buy it on prave.app first.`, 402);
|
|
43
|
+
}
|
|
44
|
+
if (!skill.content && !ctx.force) {
|
|
45
|
+
throw new ApiError(`${skill.slug} has no SKILL.md content yet`, 400);
|
|
46
|
+
}
|
|
47
|
+
const targetDir = join(CONFIG.skillsDir, skill.slug);
|
|
48
|
+
await mkdir(targetDir, { recursive: true });
|
|
49
|
+
await writeFile(join(targetDir, 'SKILL.md'), skill.content ?? '', 'utf8');
|
|
50
|
+
if (ctx.hasSession) {
|
|
51
|
+
await api
|
|
52
|
+
.post(`/api/v1/skills/${encodeURIComponent(skill.slug)}/install`, {}, true)
|
|
53
|
+
.catch(() => { });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function resolveOrder(rootSlug) {
|
|
57
|
+
const session = await loadCredentials();
|
|
58
|
+
let tree = null;
|
|
59
|
+
try {
|
|
60
|
+
const res = await api.get(`/api/v1/skills/${encodeURIComponent(rootSlug)}/dependencies/tree`, Boolean(session));
|
|
61
|
+
tree = res.data;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [rootSlug];
|
|
65
|
+
}
|
|
66
|
+
if (!tree?.nodes.length)
|
|
67
|
+
return [rootSlug];
|
|
68
|
+
if (tree.conflicts.length) {
|
|
69
|
+
log.warn(`${tree.conflicts.length} dependency conflict(s) — installing latest available`);
|
|
70
|
+
}
|
|
71
|
+
const ordered = tree.nodes
|
|
72
|
+
.filter((n) => !n.missing)
|
|
73
|
+
.sort((a, b) => b.depth - a.depth)
|
|
74
|
+
.map((n) => n.slug);
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const s of ordered)
|
|
78
|
+
if (!seen.has(s))
|
|
79
|
+
(seen.add(s), out.push(s));
|
|
80
|
+
if (!out.includes(rootSlug))
|
|
81
|
+
out.push(rootSlug);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function formatPrice(cents, currency) {
|
|
85
|
+
return `${(cents / 100).toFixed(2)} ${currency}`;
|
|
86
|
+
}
|
|
87
|
+
function formatError(err) {
|
|
88
|
+
if (err instanceof ApiError)
|
|
89
|
+
return err.message;
|
|
90
|
+
return err instanceof Error ? err.message : 'Install failed';
|
|
91
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { api } from '../lib/api.js';
|
|
5
|
+
import { CONFIG } from '../lib/config.js';
|
|
6
|
+
import { log } from '../utils/logger.js';
|
|
7
|
+
export async function listCommand(opts) {
|
|
8
|
+
if (opts.remote) {
|
|
9
|
+
const { data: skills } = await api.get('/api/v1/skills?limit=50', true);
|
|
10
|
+
if (skills.length === 0) {
|
|
11
|
+
log.dim('No skills in your Prave account yet.');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const s of skills) {
|
|
15
|
+
console.log(` ${chalk.cyan('•')} ${s.slug} ${chalk.dim(`(${s.visibility})`)}`);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const entries = await readdir(CONFIG.skillsDir);
|
|
21
|
+
let count = 0;
|
|
22
|
+
for (const name of entries) {
|
|
23
|
+
const dir = join(CONFIG.skillsDir, name);
|
|
24
|
+
if ((await stat(dir).catch(() => null))?.isDirectory()) {
|
|
25
|
+
console.log(` ${chalk.cyan('•')} ${name}`);
|
|
26
|
+
count += 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
log.dim(`\n${count} local skill${count === 1 ? '' : 's'} in ${CONFIG.skillsDir}`);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
log.warn(`No skills directory at ${CONFIG.skillsDir}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import open from 'open';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { api, ApiError } from '../lib/api.js';
|
|
5
|
+
import { CONFIG } from '../lib/config.js';
|
|
6
|
+
import { saveCredentials } from '../lib/credentials.js';
|
|
7
|
+
import { log } from '../utils/logger.js';
|
|
8
|
+
/**
|
|
9
|
+
* `prave login` — device-code flow against the Prave API / Supabase session.
|
|
10
|
+
*/
|
|
11
|
+
export async function loginCommand() {
|
|
12
|
+
const { data: start } = await api.post('/api/v1/cli/login');
|
|
13
|
+
const url = `${CONFIG.webUrl}${start.verification_url}`;
|
|
14
|
+
log.info('Opening browser to authorize this device…');
|
|
15
|
+
log.dim(url);
|
|
16
|
+
try {
|
|
17
|
+
await open(url);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
log.warn('Could not auto-open browser. Open the URL above manually.');
|
|
21
|
+
}
|
|
22
|
+
const spinner = ora('Waiting for authorization…').start();
|
|
23
|
+
const deadline = Date.now() + CONFIG.pollTimeoutMs;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
await sleep(CONFIG.pollIntervalMs);
|
|
26
|
+
try {
|
|
27
|
+
const { data, status } = await api.get(`/api/v1/cli/token?device_code=${start.device_code}`);
|
|
28
|
+
if (status === 202)
|
|
29
|
+
continue;
|
|
30
|
+
await saveCredentials({
|
|
31
|
+
access_token: data.access_token,
|
|
32
|
+
refresh_token: data.refresh_token,
|
|
33
|
+
user_id: data.user_id,
|
|
34
|
+
});
|
|
35
|
+
spinner.succeed('Logged in.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof ApiError && err.status === 410) {
|
|
40
|
+
spinner.fail('Device code expired. Run `prave login` again.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Any other error: keep polling until the deadline.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
spinner.fail('Authorization timed out.');
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { api } from '../lib/api.js';
|
|
3
|
+
import { log } from '../utils/logger.js';
|
|
4
|
+
export async function searchCommand(query) {
|
|
5
|
+
const { data: skills } = await api.get(`/api/v1/skills?q=${encodeURIComponent(query)}&limit=25`);
|
|
6
|
+
if (skills.length === 0) {
|
|
7
|
+
log.dim(`No skills match "${query}".`);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
for (const s of skills) {
|
|
11
|
+
console.log(` ${chalk.cyan('•')} ${s.slug} ${chalk.dim(s.description ?? '')} ${chalk.magenta(`↓ ${s.install_count}`)}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { CONFIG } from '../lib/config.js';
|
|
6
|
+
import { log } from '../utils/logger.js';
|
|
7
|
+
import { installCommand } from './install.js';
|
|
8
|
+
/**
|
|
9
|
+
* `prave sync` — re-pulls every locally installed Skill from the
|
|
10
|
+
* registry. Picks up SKILL.md edits without the user having to remember
|
|
11
|
+
* each slug. Skips deps (each top-level Skill already has its tree
|
|
12
|
+
* resolved on the original install).
|
|
13
|
+
*/
|
|
14
|
+
export async function syncCommand() {
|
|
15
|
+
const spinner = ora('Scanning local Skills…').start();
|
|
16
|
+
let entries = [];
|
|
17
|
+
try {
|
|
18
|
+
entries = await readdir(CONFIG.skillsDir);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
spinner.warn(`No Skills directory at ${CONFIG.skillsDir}`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const slugs = [];
|
|
25
|
+
for (const name of entries) {
|
|
26
|
+
const dir = join(CONFIG.skillsDir, name);
|
|
27
|
+
if ((await stat(dir).catch(() => null))?.isDirectory())
|
|
28
|
+
slugs.push(name);
|
|
29
|
+
}
|
|
30
|
+
if (!slugs.length) {
|
|
31
|
+
spinner.warn('No installed Skills to sync.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'}.`);
|
|
35
|
+
let updated = 0;
|
|
36
|
+
let failed = 0;
|
|
37
|
+
for (const slug of slugs) {
|
|
38
|
+
try {
|
|
39
|
+
await installCommand(slug, { noDeps: true });
|
|
40
|
+
updated++;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
failed++;
|
|
44
|
+
console.log(chalk.red(` ✗ ${slug}`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
log.dim(`\nSynced ${updated} · failed ${failed}`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { CONFIG } from '../lib/config.js';
|
|
5
|
+
/**
|
|
6
|
+
* `prave uninstall <slug>` — removes ~/.claude/skills/<slug> recursively.
|
|
7
|
+
* No remote call: uninstalls are local-only; the install record stays
|
|
8
|
+
* for analytics + history.
|
|
9
|
+
*/
|
|
10
|
+
export async function uninstallCommand(slug) {
|
|
11
|
+
const dir = join(CONFIG.skillsDir, slug);
|
|
12
|
+
const spinner = ora(`Removing ${slug}…`).start();
|
|
13
|
+
try {
|
|
14
|
+
await rm(dir, { recursive: true, force: true });
|
|
15
|
+
spinner.succeed(`Removed ${dir}`);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
spinner.fail(`Couldn’t remove ${slug}: ${err.message}`);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { loadCredentials } from '../lib/credentials.js';
|
|
2
|
+
import { log } from '../utils/logger.js';
|
|
3
|
+
export async function whoamiCommand() {
|
|
4
|
+
const creds = await loadCredentials();
|
|
5
|
+
if (!creds) {
|
|
6
|
+
log.warn('Not logged in. Run `prave login`.');
|
|
7
|
+
process.exitCode = 1;
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
log.kv('user_id', creds.user_id);
|
|
11
|
+
if (creds.email)
|
|
12
|
+
log.kv('email', creds.email);
|
|
13
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { diffCommand } from './commands/diff.js';
|
|
4
|
+
import { exportCommand } from './commands/export.js';
|
|
5
|
+
import { importCommand } from './commands/import.js';
|
|
6
|
+
import { installCommand } from './commands/install.js';
|
|
7
|
+
import { listCommand } from './commands/list.js';
|
|
8
|
+
import { loginCommand } from './commands/login.js';
|
|
9
|
+
import { logoutCommand } from './commands/logout.js';
|
|
10
|
+
import { searchCommand } from './commands/search.js';
|
|
11
|
+
import { syncCommand } from './commands/sync.js';
|
|
12
|
+
import { uninstallCommand } from './commands/uninstall.js';
|
|
13
|
+
import { whoamiCommand } from './commands/whoami.js';
|
|
14
|
+
const program = new Command()
|
|
15
|
+
.name('prave')
|
|
16
|
+
.description('Prave — Developer platform for Claude Skills')
|
|
17
|
+
.version('0.1.0');
|
|
18
|
+
program.command('login').description('Authenticate this machine').action(loginCommand);
|
|
19
|
+
program.command('logout').description('Remove stored credentials').action(logoutCommand);
|
|
20
|
+
program.command('whoami').description('Show the signed-in user').action(whoamiCommand);
|
|
21
|
+
program
|
|
22
|
+
.command('import')
|
|
23
|
+
.description('Scan ~/.claude/skills/ — optionally upload to Prave')
|
|
24
|
+
.option('--upload', 'upload scanned skills')
|
|
25
|
+
.option('--private', 'upload as private (requires --upload)')
|
|
26
|
+
.action(importCommand);
|
|
27
|
+
program
|
|
28
|
+
.command('install <slug>')
|
|
29
|
+
.description('Install a Skill into ~/.claude/skills/ (resolves deps)')
|
|
30
|
+
.option('--no-deps', 'skip transitive dependency resolution')
|
|
31
|
+
.option('--force', 'install even if SKILL.md is empty')
|
|
32
|
+
.action(installCommand);
|
|
33
|
+
program
|
|
34
|
+
.command('uninstall <slug>')
|
|
35
|
+
.description('Remove a locally installed Skill')
|
|
36
|
+
.action(uninstallCommand);
|
|
37
|
+
program
|
|
38
|
+
.command('sync')
|
|
39
|
+
.description('Pull updates for every locally installed Skill')
|
|
40
|
+
.action(syncCommand);
|
|
41
|
+
program
|
|
42
|
+
.command('list')
|
|
43
|
+
.description('List installed Skills (default) or remote ones')
|
|
44
|
+
.option('--remote', 'list Skills from your Prave account')
|
|
45
|
+
.action(listCommand);
|
|
46
|
+
program.command('search <query>').description('Search public Skills').action(searchCommand);
|
|
47
|
+
program
|
|
48
|
+
.command('export <slug>')
|
|
49
|
+
.description('Print or save a Skill\'s SKILL.md without installing')
|
|
50
|
+
.option('-o, --out <file>', 'write to file instead of stdout')
|
|
51
|
+
.action(exportCommand);
|
|
52
|
+
program
|
|
53
|
+
.command('diff <slug>')
|
|
54
|
+
.description('Show local vs registry diff for an installed Skill')
|
|
55
|
+
.action(diffCommand);
|
|
56
|
+
program.parseAsync().catch((err) => {
|
|
57
|
+
console.error(err.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { request } from 'undici';
|
|
2
|
+
import { CONFIG } from './config.js';
|
|
3
|
+
import { loadCredentials } from './credentials.js';
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = 'ApiError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function call(method, path, body, withAuth = false) {
|
|
13
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
14
|
+
if (withAuth) {
|
|
15
|
+
const creds = await loadCredentials();
|
|
16
|
+
if (!creds)
|
|
17
|
+
throw new ApiError('Not logged in. Run `prave login`.', 401);
|
|
18
|
+
headers.Authorization = `Bearer ${creds.access_token}`;
|
|
19
|
+
}
|
|
20
|
+
const { statusCode, body: resBody } = await request(`${CONFIG.apiUrl}${path}`, {
|
|
21
|
+
method,
|
|
22
|
+
headers,
|
|
23
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
24
|
+
});
|
|
25
|
+
const text = await resBody.text();
|
|
26
|
+
if (statusCode === 204)
|
|
27
|
+
return { data: undefined, status: statusCode };
|
|
28
|
+
const payload = text ? JSON.parse(text) : { success: true, data: null, error: null };
|
|
29
|
+
if (statusCode >= 400 || payload.success === false) {
|
|
30
|
+
throw new ApiError(payload.error ?? `HTTP ${statusCode}`, statusCode);
|
|
31
|
+
}
|
|
32
|
+
return { data: payload.data, status: statusCode };
|
|
33
|
+
}
|
|
34
|
+
export const api = {
|
|
35
|
+
get: (path, withAuth = false) => call('GET', path, undefined, withAuth),
|
|
36
|
+
post: (path, body, withAuth = false) => call('POST', path, body, withAuth),
|
|
37
|
+
put: (path, body, withAuth = false) => call('PUT', path, body, withAuth),
|
|
38
|
+
del: (path, withAuth = false) => call('DELETE', path, undefined, withAuth),
|
|
39
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const CONFIG = {
|
|
4
|
+
apiUrl: process.env.PRAVE_API_URL ?? 'https://api.prave.app',
|
|
5
|
+
webUrl: process.env.PRAVE_WEB_URL ?? 'https://prave.app',
|
|
6
|
+
skillsDir: process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), '.claude', 'skills'),
|
|
7
|
+
praveDir: join(homedir(), '.prave'),
|
|
8
|
+
credentialsPath: join(homedir(), '.prave', 'credentials.json'),
|
|
9
|
+
configPath: join(homedir(), '.prave', 'config.json'),
|
|
10
|
+
pollIntervalMs: 2_000,
|
|
11
|
+
pollTimeoutMs: 10 * 60_000,
|
|
12
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { CONFIG } from './config.js';
|
|
4
|
+
export async function saveCredentials(creds) {
|
|
5
|
+
await mkdir(dirname(CONFIG.credentialsPath), { recursive: true });
|
|
6
|
+
await writeFile(CONFIG.credentialsPath, JSON.stringify(creds, null, 2), 'utf8');
|
|
7
|
+
await chmod(CONFIG.credentialsPath, 0o600);
|
|
8
|
+
}
|
|
9
|
+
export async function loadCredentials() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(CONFIG.credentialsPath, 'utf8');
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function clearCredentials() {
|
|
19
|
+
try {
|
|
20
|
+
await unlink(CONFIG.credentialsPath);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* already gone */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
export const log = {
|
|
3
|
+
info: (msg) => console.log(msg),
|
|
4
|
+
success: (msg) => console.log(chalk.green('✓'), msg),
|
|
5
|
+
warn: (msg) => console.log(chalk.yellow('!'), msg),
|
|
6
|
+
error: (msg) => console.error(chalk.red('✗'), msg),
|
|
7
|
+
dim: (msg) => console.log(chalk.dim(msg)),
|
|
8
|
+
kv: (k, v) => console.log(chalk.dim(`${k.padEnd(14)}`), v),
|
|
9
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prave/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Prave CLI — import, export, install, sync Claude Skills.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"prave": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"chalk": "^5.3.0",
|
|
15
|
+
"commander": "^12.1.0",
|
|
16
|
+
"open": "^10.1.0",
|
|
17
|
+
"ora": "^8.0.1",
|
|
18
|
+
"undici": "^6.18.0",
|
|
19
|
+
"@prave/shared": "0.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.12.7",
|
|
23
|
+
"tsx": "^4.11.0",
|
|
24
|
+
"typescript": "^5.4.5"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "tsx src/index.ts --help",
|
|
31
|
+
"cli": "tsx src/index.ts",
|
|
32
|
+
"build": "tsc -p tsconfig.json",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
34
|
+
"lint": "tsc -p tsconfig.json --noEmit"
|
|
35
|
+
}
|
|
36
|
+
}
|