@prave/cli 0.4.8 → 0.5.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.
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { api, ApiError } from '../lib/api.js';
4
+ import { requireAuth } from '../lib/credentials.js';
4
5
  import { log } from '../utils/logger.js';
5
6
  function describe(c) {
6
7
  const a = c.skill_a_name ?? c.skill_a_id;
@@ -17,6 +18,9 @@ function describe(c) {
17
18
  }
18
19
  }
19
20
  export async function conflictsCommand(opts = {}) {
21
+ const _session = await requireAuth("prave conflicts");
22
+ if (!_session)
23
+ return;
20
24
  const spinner = ora('Detecting conflicts…').start();
21
25
  try {
22
26
  const { data: conflicts } = await api.get('/api/v1/intelligence/conflicts?refresh=true', true);
@@ -5,6 +5,7 @@ import chalk from 'chalk';
5
5
  import ora from 'ora';
6
6
  import { AGENT_REGISTRY } from '@prave/shared';
7
7
  import { api, ApiError } from '../lib/api.js';
8
+ import { requireAuth } from '../lib/credentials.js';
8
9
  import { CONFIG } from '../lib/config.js';
9
10
  import { log } from '../utils/logger.js';
10
11
  function detectOsKey(detected) {
@@ -68,6 +69,9 @@ function buildDestPath(agent, basePath, os, slug) {
68
69
  };
69
70
  }
70
71
  export async function deployCommand(skillName, opts = {}) {
72
+ const session = await requireAuth('prave deploy');
73
+ if (!session)
74
+ return;
71
75
  const start = Date.now();
72
76
  let settings;
73
77
  try {
@@ -4,14 +4,27 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import { api } from '../lib/api.js';
6
6
  import { CONFIG } from '../lib/config.js';
7
- import { loadCredentials } from '../lib/credentials.js';
7
+ import { loadCredentials, requireAuth } from '../lib/credentials.js';
8
8
  import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
9
9
  import { log } from '../utils/logger.js';
10
10
  /**
11
11
  * Scans CONFIG.skillsDir for SKILL.md files. Without --upload, only prints
12
12
  * the overview — no network call, no consent required.
13
13
  */
14
+ /**
15
+ * Visibility is mandatory on uploads — no silent defaults. Users must pass
16
+ * either `--public` or `--private`. This avoids the embarrassment of
17
+ * accidentally publishing private notes to the registry.
18
+ */
14
19
  export async function importCommand(opts) {
20
+ // `prave import` (without --upload) only inspects local files and posts
21
+ // to /intelligence/analyze if logged in — uploads, however, must always
22
+ // be tracked, so we gate on auth whenever --upload is set.
23
+ if (opts.upload) {
24
+ const session = await requireAuth('prave import --upload');
25
+ if (!session)
26
+ return;
27
+ }
15
28
  const spinner = ora(`Scanning ${CONFIG.skillsDir}…`).start();
16
29
  let entries = [];
17
30
  try {
@@ -56,10 +69,19 @@ export async function importCommand(opts) {
56
69
  }
57
70
  return;
58
71
  }
72
+ // Visibility is required on every upload — no silent default.
73
+ if (opts.public === opts.private) {
74
+ log.warn('Specify visibility: --public or --private (one is required on upload).');
75
+ log.dim('Examples:');
76
+ log.dim(' prave import --upload --private # only you can see them');
77
+ log.dim(' prave import --upload --public # listed in the public registry');
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ const visibility = opts.private ? 'private' : 'public';
59
82
  // Plan gate: clamp the queue to the caller's import / private quota so
60
83
  // we don't waste 70 round-trips against a Free account that maxes at 10.
61
84
  const me = await fetchMyPlan();
62
- const visibility = opts.private ? 'private' : 'public';
63
85
  if (visibility === 'private' && !me.limits.can_private_skills) {
64
86
  log.warn('Private imports require the Explorer plan.');
65
87
  log.dim(formatUpgradeHint('explorer'));
@@ -5,7 +5,7 @@ import chalk from 'chalk';
5
5
  import ora from 'ora';
6
6
  import { api, ApiError } from '../lib/api.js';
7
7
  import { CONFIG } from '../lib/config.js';
8
- import { loadCredentials } from '../lib/credentials.js';
8
+ import { loadCredentials, requireAuth } from '../lib/credentials.js';
9
9
  import { log } from '../utils/logger.js';
10
10
  /**
11
11
  * `prave install <slug>` — pulls SKILL.md to ~/.claude/skills/<slug>/.
@@ -18,15 +18,21 @@ import { log } from '../utils/logger.js';
18
18
  * instead of letting the API error bubble.
19
19
  */
20
20
  export async function installCommand(slug, opts = {}) {
21
+ // Auth gate — installs MUST be tracked, otherwise we can't bump counts,
22
+ // serve recommendations, or surface "updates available" later. Block here
23
+ // before any registry hit so the user gets a clear hint instead of a
24
+ // silently-untracked install.
25
+ const session = await requireAuth('prave install');
26
+ if (!session)
27
+ return;
21
28
  const spinner = ora(`Resolving ${slug}…`).start();
22
29
  let installedSlugs = [];
23
30
  try {
24
31
  const slugs = opts.noDeps ? [slug] : await resolveOrder(slug);
25
32
  spinner.text = `Installing ${slugs.length} skill${slugs.length === 1 ? '' : 's'}…`;
26
- const session = await loadCredentials();
27
33
  for (const s of slugs) {
28
34
  spinner.text = `↓ ${s}`;
29
- await pullOne(s, { hasSession: Boolean(session), force: Boolean(opts.force) });
35
+ await pullOne(s, { hasSession: true, force: Boolean(opts.force) });
30
36
  }
31
37
  installedSlugs = slugs;
32
38
  spinner.succeed(`Installed ${slugs.length} skill${slugs.length === 1 ? '' : 's'} → ${CONFIG.skillsDir}`);
@@ -39,9 +45,6 @@ export async function installCommand(slug, opts = {}) {
39
45
  process.exitCode = 1;
40
46
  return;
41
47
  }
42
- const session = await loadCredentials();
43
- if (!session)
44
- return;
45
48
  // Best-effort analyze pass on the freshly written files.
46
49
  for (const s of installedSlugs) {
47
50
  const path = join(CONFIG.skillsDir, s, 'SKILL.md');
@@ -55,7 +58,11 @@ export async function installCommand(slug, opts = {}) {
55
58
  /* file unreadable — skip */
56
59
  }
57
60
  }
58
- // Offer to deploy to all configured agents.
61
+ // Offer to deploy to all configured agents — unless the caller already
62
+ // answered the question (e.g. `prave sync` collects the answer once for
63
+ // the whole batch and threads it through `skipDeployPrompt`).
64
+ if (opts.skipDeployPrompt)
65
+ return;
59
66
  const rl = createInterface({ input: process.stdin, output: process.stdout });
60
67
  try {
61
68
  const ans = (await rl.question('\nDeploy to all configured agents? [y/N] ')).trim().toLowerCase();
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { api, ApiError } from '../lib/api.js';
4
+ import { requireAuth } from '../lib/credentials.js';
4
5
  import { log } from '../utils/logger.js';
5
6
  function formatTokens(n) {
6
7
  if (n < 1000)
@@ -11,6 +12,9 @@ function nameOf(s) {
11
12
  return s.name ?? s.file_path;
12
13
  }
13
14
  export async function optimizeCommand(opts = {}) {
15
+ const _session = await requireAuth("prave optimize");
16
+ if (!_session)
17
+ return;
14
18
  const spinner = ora('Analyzing your skill set…').start();
15
19
  try {
16
20
  const { data } = await api.get('/api/v1/intelligence/optimize', true);
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { api, ApiError } from '../lib/api.js';
4
+ import { requireAuth } from '../lib/credentials.js';
4
5
  import { log } from '../utils/logger.js';
5
6
  function formatThousands(n) {
6
7
  if (n < 1000)
@@ -13,6 +14,9 @@ function pad(s, width) {
13
14
  return s + ' '.repeat(width - s.length);
14
15
  }
15
16
  export async function overviewCommand(opts = {}) {
17
+ const _session = await requireAuth("prave overview");
18
+ if (!_session)
19
+ return;
16
20
  if (opts.json) {
17
21
  try {
18
22
  const { data } = await api.get('/api/v1/intelligence/overview', true);
@@ -3,6 +3,7 @@ import { createInterface } from 'node:readline/promises';
3
3
  import chalk from 'chalk';
4
4
  import { AGENT_REGISTRY, AGENT_TYPES } from '@prave/shared';
5
5
  import { api, ApiError } from '../lib/api.js';
6
+ import { requireAuth } from '../lib/credentials.js';
6
7
  import { CONFIG } from '../lib/config.js';
7
8
  import { log } from '../utils/logger.js';
8
9
  function detectOs() {
@@ -130,6 +131,9 @@ async function showAccount() {
130
131
  log.dim('Run `prave whoami` for full identity, or `prave logout` to sign out.');
131
132
  }
132
133
  export async function settingsCommand() {
134
+ const _session = await requireAuth("prave settings");
135
+ if (!_session)
136
+ return;
133
137
  const rl = createInterface({ input: process.stdin, output: process.stdout });
134
138
  try {
135
139
  let current;
@@ -1,28 +1,35 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
3
4
  import chalk from 'chalk';
4
5
  import ora from 'ora';
5
6
  import { CONFIG } from '../lib/config.js';
6
- import { loadCredentials } from '../lib/credentials.js';
7
+ import { requireAuth } from '../lib/credentials.js';
7
8
  import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
8
9
  import { log } from '../utils/logger.js';
9
10
  import { installCommand } from './install.js';
10
11
  /**
11
12
  * `prave sync` — re-pulls every locally installed Skill from the
12
13
  * registry. Picks up SKILL.md edits without the user having to remember
13
- * each slug. Skips deps (each top-level Skill already has its tree
14
- * resolved on the original install).
14
+ * each slug.
15
+ *
16
+ * The deploy-to-all-agents question is asked ONCE up-front for the entire
17
+ * queue (regression fix: it used to fire per Skill, forcing the user to
18
+ * mash `y` 30 times). The answer is then threaded into every
19
+ * `installCommand` invocation via `skipDeployPrompt: true` and we run a
20
+ * single batched deploy at the end.
15
21
  */
16
22
  export async function syncCommand() {
23
+ // Auth gate — sync mutates the install ledger and intelligence cache.
24
+ const session = await requireAuth('prave sync');
25
+ if (!session)
26
+ return;
17
27
  // Plan gate: sync requires Explorer or higher.
18
- const session = await loadCredentials();
19
- if (session) {
20
- const me = await fetchMyPlan();
21
- if (!me.limits.can_cli_sync) {
22
- log.warn('Sync requires the Explorer plan or higher.');
23
- log.dim(formatUpgradeHint('explorer'));
24
- return;
25
- }
28
+ const me = await fetchMyPlan();
29
+ if (!me.limits.can_cli_sync) {
30
+ log.warn('Sync requires the Explorer plan or higher.');
31
+ log.dim(formatUpgradeHint('explorer'));
32
+ return;
26
33
  }
27
34
  const spinner = ora('Scanning local Skills…').start();
28
35
  let entries = [];
@@ -44,11 +51,23 @@ export async function syncCommand() {
44
51
  return;
45
52
  }
46
53
  spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'}.`);
54
+ // Ask the deploy question ONCE for the whole batch.
55
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
56
+ let deployAfter = false;
57
+ try {
58
+ const ans = (await rl.question(`\nAfter syncing, deploy all ${slugs.length} Skills to your configured agents? [y/N] `))
59
+ .trim()
60
+ .toLowerCase();
61
+ deployAfter = ans === 'y' || ans === 'yes';
62
+ }
63
+ finally {
64
+ rl.close();
65
+ }
47
66
  let updated = 0;
48
67
  let failed = 0;
49
68
  for (const slug of slugs) {
50
69
  try {
51
- await installCommand(slug, { noDeps: true });
70
+ await installCommand(slug, { noDeps: true, skipDeployPrompt: true });
52
71
  updated++;
53
72
  }
54
73
  catch {
@@ -57,4 +76,16 @@ export async function syncCommand() {
57
76
  }
58
77
  }
59
78
  log.dim(`\nSynced ${updated} · failed ${failed}`);
79
+ if (deployAfter && updated > 0) {
80
+ const { deployCommand } = await import('./deploy.js');
81
+ log.info(`\nDeploying ${updated} Skills to configured agents…`);
82
+ for (const slug of slugs) {
83
+ try {
84
+ await deployCommand(slug, {});
85
+ }
86
+ catch (err) {
87
+ log.warn(` ✗ deploy ${slug} — ${err.message}`);
88
+ }
89
+ }
90
+ }
60
91
  }
@@ -0,0 +1,165 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { api } from '../lib/api.js';
7
+ import { CONFIG } from '../lib/config.js';
8
+ import { requireAuth } from '../lib/credentials.js';
9
+ import { log } from '../utils/logger.js';
10
+ /**
11
+ * `prave update [<slug>]` — diff every CLI-installed Skill against its
12
+ * current Prave version and pull the ones that are outdated.
13
+ *
14
+ * • No slug → walks the install ledger, compares each install's
15
+ * `installed_at` vs the Skill's `updated_at`, and updates the stale
16
+ * ones in-place under ~/.claude/skills/<slug>/SKILL.md.
17
+ * • Slug given → updates exactly that Skill (force-pulls latest, even if
18
+ * not stale — useful when the local file was edited and needs reset).
19
+ *
20
+ * Prints a short "audit-trail" line per Skill so the operator can see what
21
+ * changed: `markdown-pro v3 → v5 (last pulled 12 Apr · published 21 Apr)`.
22
+ */
23
+ export async function updateCommand(slug, opts = {}) {
24
+ const session = await requireAuth('prave update');
25
+ if (!session)
26
+ return;
27
+ const spinner = ora(slug ? `Resolving ${slug}…` : 'Checking your installed skills…').start();
28
+ let installs;
29
+ try {
30
+ const { data } = await api.get('/api/v1/me/installs', true);
31
+ installs = data;
32
+ }
33
+ catch (err) {
34
+ spinner.fail(`Couldn't fetch installs — ${err.message}`);
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ const targets = slug ? installs.filter((r) => r.skill?.slug === slug) : installs;
39
+ if (targets.length === 0) {
40
+ spinner.info(slug ? `${slug} is not in your install ledger.` : 'No installs on this account yet.');
41
+ return;
42
+ }
43
+ // Fetch the current Skill for each target in parallel.
44
+ spinner.text = `Comparing ${targets.length} skill${targets.length === 1 ? '' : 's'}…`;
45
+ const candidates = [];
46
+ const upToDate = [];
47
+ const errors = [];
48
+ await Promise.all(targets.map(async (row) => {
49
+ const targetSlug = row.skill?.slug;
50
+ if (!targetSlug)
51
+ return;
52
+ try {
53
+ const { data: skill } = await api.get(`/api/v1/skills/${targetSlug}`, true);
54
+ const isStale = isOutdated(row, skill, Boolean(slug));
55
+ if (!isStale) {
56
+ upToDate.push(targetSlug);
57
+ return;
58
+ }
59
+ candidates.push({
60
+ slug: targetSlug,
61
+ installedAt: row.installed_at,
62
+ serverUpdatedAt: skill.updated_at,
63
+ installedVersion: row.version ?? null,
64
+ latestVersion: typeof skill.current_version === 'number'
65
+ ? skill.current_version ?? null
66
+ : null,
67
+ remoteContent: skill.content ?? '',
68
+ localPath: join(CONFIG.skillsDir, targetSlug, 'SKILL.md'),
69
+ });
70
+ }
71
+ catch (err) {
72
+ errors.push(`${targetSlug}: ${err.message}`);
73
+ }
74
+ }));
75
+ if (candidates.length === 0) {
76
+ if (errors.length > 0) {
77
+ spinner.warn(`No updates available. ${errors.length} skill(s) couldn't be checked:`);
78
+ for (const e of errors)
79
+ log.dim(` ✗ ${e}`);
80
+ }
81
+ else {
82
+ spinner.succeed(upToDate.length === 1
83
+ ? `${upToDate[0]} is up to date.`
84
+ : `All ${upToDate.length} installed skills are up to date.`);
85
+ }
86
+ return;
87
+ }
88
+ spinner.succeed(`${candidates.length} skill${candidates.length === 1 ? '' : 's'} can be updated.`);
89
+ // Audit-trail print — same format as a tiny commit log.
90
+ for (const c of candidates) {
91
+ const versionLine = c.installedVersion !== null && c.latestVersion !== null
92
+ ? `${chalk.dim('v')}${c.installedVersion} → ${chalk.bold('v' + c.latestVersion)}`
93
+ : chalk.dim('content-hash diff');
94
+ console.log(` ${chalk.cyan('•')} ${chalk.bold(c.slug)} ${versionLine} ${chalk.dim(`(local ${formatDate(c.installedAt)} → registry ${formatDate(c.serverUpdatedAt)})`)}`);
95
+ }
96
+ if (opts.dryRun) {
97
+ log.dim(`\nDry run — nothing written. Re-run without --dry-run to apply.`);
98
+ return;
99
+ }
100
+ if (!opts.yes) {
101
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
102
+ try {
103
+ const ans = (await rl.question(`\nPull ${candidates.length} update${candidates.length === 1 ? '' : 's'}? [Y/n] `))
104
+ .trim()
105
+ .toLowerCase();
106
+ if (ans && ans !== 'y' && ans !== 'yes') {
107
+ log.dim('Aborted.');
108
+ return;
109
+ }
110
+ }
111
+ finally {
112
+ rl.close();
113
+ }
114
+ }
115
+ const writeSpinner = ora(`Writing ${candidates.length} update${candidates.length === 1 ? '' : 's'}…`).start();
116
+ let written = 0;
117
+ for (const c of candidates) {
118
+ try {
119
+ await mkdir(join(CONFIG.skillsDir, c.slug), { recursive: true });
120
+ await writeFile(c.localPath, c.remoteContent, 'utf8');
121
+ // Re-record the install row so installed_at/version reflect the pull.
122
+ await api
123
+ .post(`/api/v1/skills/${encodeURIComponent(c.slug)}/install`, { version: c.latestVersion ?? null }, true)
124
+ .catch(() => { });
125
+ written += 1;
126
+ }
127
+ catch (err) {
128
+ writeSpinner.warn(`${c.slug}: ${err.message}`);
129
+ }
130
+ }
131
+ writeSpinner.succeed(`Updated ${written}/${candidates.length}${written === candidates.length ? '' : ` (${candidates.length - written} failed)`}.`);
132
+ if (errors.length > 0) {
133
+ log.dim(`\n${errors.length} skill(s) couldn't be checked earlier:`);
134
+ for (const e of errors)
135
+ log.dim(` ✗ ${e}`);
136
+ }
137
+ }
138
+ /**
139
+ * "Stale" = the registry's `updated_at` is strictly newer than the
140
+ * install ledger's `installed_at`. For an explicit `prave update <slug>`
141
+ * we always treat the row as stale — the user is asking for a forced pull,
142
+ * which is also the right behaviour after a local edit gone wrong.
143
+ */
144
+ function isOutdated(row, skill, force) {
145
+ if (force)
146
+ return true;
147
+ const installedTs = new Date(row.installed_at).getTime();
148
+ const updatedTs = new Date(skill.updated_at).getTime();
149
+ return Number.isFinite(installedTs) && Number.isFinite(updatedTs) && updatedTs > installedTs;
150
+ }
151
+ function formatDate(iso) {
152
+ const d = new Date(iso);
153
+ if (Number.isNaN(d.getTime()))
154
+ return iso;
155
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
156
+ }
157
+ /** Read the local SKILL.md content if it exists — for the `--from-disk` flag (Phase 2). */
158
+ export async function readLocalSkillContent(slug) {
159
+ try {
160
+ return await readFile(join(CONFIG.skillsDir, slug, 'SKILL.md'), 'utf8');
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
@@ -1,5 +1,15 @@
1
+ import { ApiError, api } from '../lib/api.js';
1
2
  import { loadCredentials } from '../lib/credentials.js';
2
3
  import { log } from '../utils/logger.js';
4
+ /**
5
+ * `prave whoami` — quick check that the local credentials still authenticate
6
+ * against the API. Hits `GET /api/v1/me` so the displayed name is always the
7
+ * server-side truth (and any stale local data gets refreshed implicitly via
8
+ * the auto-refresh path on api.get).
9
+ *
10
+ * If we can't reach the API (offline, server down) we fall back to the
11
+ * locally cached email so the command stays useful in airplane mode.
12
+ */
3
13
  export async function whoamiCommand() {
4
14
  const creds = await loadCredentials();
5
15
  if (!creds) {
@@ -7,7 +17,25 @@ export async function whoamiCommand() {
7
17
  process.exitCode = 1;
8
18
  return;
9
19
  }
10
- log.kv('user_id', creds.user_id);
11
- if (creds.email)
12
- log.kv('email', creds.email);
20
+ try {
21
+ const { data } = await api.get('/api/v1/me', true);
22
+ const handle = data.username || data.display_name || data.email || creds.email || 'unknown';
23
+ log.kv('user', handle);
24
+ if (data.email)
25
+ log.kv('email', data.email);
26
+ log.kv('plan', data.plan);
27
+ }
28
+ catch (err) {
29
+ if (err instanceof ApiError && err.status === 401) {
30
+ log.warn('Session expired. Run `prave login` again.');
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ // Offline or transient API error — fall back to local creds so the
35
+ // command still does *something* useful instead of crashing.
36
+ if (creds.email)
37
+ log.kv('user', creds.email);
38
+ else
39
+ log.kv('user', creds.user_id);
40
+ }
13
41
  }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { searchCommand } from './commands/search.js';
19
19
  import { settingsCommand } from './commands/settings.js';
20
20
  import { syncCommand } from './commands/sync.js';
21
21
  import { uninstallCommand } from './commands/uninstall.js';
22
+ import { updateCommand } from './commands/update.js';
22
23
  import { whatdoesCommand } from './commands/whatdoes.js';
23
24
  import { whoamiCommand } from './commands/whoami.js';
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -33,8 +34,9 @@ program.command('whoami').description('Show the signed-in user').action(whoamiCo
33
34
  program
34
35
  .command('import')
35
36
  .description('Scan ~/.claude/skills/ — optionally upload to Prave')
36
- .option('--upload', 'upload scanned skills')
37
- .option('--private', 'upload as private (requires --upload)')
37
+ .option('--upload', 'upload scanned skills (requires --public or --private)')
38
+ .option('--public', 'upload as public (listed in the registry)')
39
+ .option('--private', 'upload as private (only you can see them)')
38
40
  .action(importCommand);
39
41
  program
40
42
  .command('install <slug>')
@@ -50,6 +52,12 @@ program
50
52
  .command('sync')
51
53
  .description('Pull updates for every locally installed Skill')
52
54
  .action(syncCommand);
55
+ program
56
+ .command('update [slug]')
57
+ .description('Diff installed Skills against the registry and pull anything outdated')
58
+ .option('--dry-run', "preview what would change, don't write")
59
+ .option('--yes', 'skip confirmation prompt')
60
+ .action((slug, opts) => updateCommand(slug, opts));
53
61
  program
54
62
  .command('list')
55
63
  .description('List installed Skills (default) or remote ones')
package/dist/lib/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { request } from 'undici';
2
2
  import { CONFIG } from './config.js';
3
- import { loadCredentials } from './credentials.js';
3
+ import { loadCredentials, saveCredentials } from './credentials.js';
4
4
  export class ApiError extends Error {
5
5
  status;
6
6
  constructor(message, status) {
@@ -9,7 +9,52 @@ export class ApiError extends Error {
9
9
  this.name = 'ApiError';
10
10
  }
11
11
  }
12
- async function call(method, path, body, withAuth = false) {
12
+ /**
13
+ * Single-flight refresh — multiple parallel requests all hitting 401 share
14
+ * one refresh round-trip instead of stampeding the API and burning the
15
+ * Supabase rate limiter.
16
+ */
17
+ let refreshing = null;
18
+ async function refreshTokens() {
19
+ if (refreshing)
20
+ return refreshing;
21
+ refreshing = (async () => {
22
+ const creds = await loadCredentials();
23
+ if (!creds?.refresh_token)
24
+ return null;
25
+ try {
26
+ const { statusCode, body } = await request(`${CONFIG.apiUrl}/api/v1/cli/refresh`, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ refresh_token: creds.refresh_token }),
30
+ });
31
+ const text = await body.text();
32
+ if (statusCode !== 200)
33
+ return null;
34
+ const payload = JSON.parse(text);
35
+ if (!payload.success || !payload.data)
36
+ return null;
37
+ const next = {
38
+ ...creds,
39
+ access_token: payload.data.access_token,
40
+ refresh_token: payload.data.refresh_token ?? creds.refresh_token,
41
+ user_id: payload.data.user_id,
42
+ };
43
+ await saveCredentials(next);
44
+ return next;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ })();
50
+ try {
51
+ return await refreshing;
52
+ }
53
+ finally {
54
+ refreshing = null;
55
+ }
56
+ }
57
+ async function call(method, path, body, withAuth = false, attempt = 0) {
13
58
  const headers = { 'Content-Type': 'application/json' };
14
59
  if (withAuth) {
15
60
  const creds = await loadCredentials();
@@ -26,6 +71,13 @@ async function call(method, path, body, withAuth = false) {
26
71
  if (statusCode === 204)
27
72
  return { data: undefined, status: statusCode };
28
73
  const payload = text ? JSON.parse(text) : { success: true, data: null, error: null };
74
+ // 401 with a refresh token on file → swap the access token and retry once.
75
+ // Any other status, or a second 401, falls through to the normal error path.
76
+ if (statusCode === 401 && withAuth && attempt === 0) {
77
+ const refreshed = await refreshTokens();
78
+ if (refreshed)
79
+ return call(method, path, body, withAuth, attempt + 1);
80
+ }
29
81
  if (statusCode >= 400 || payload.success === false) {
30
82
  throw new ApiError(payload.error ?? `HTTP ${statusCode}`, statusCode);
31
83
  }
@@ -23,3 +23,24 @@ export async function clearCredentials() {
23
23
  /* already gone */
24
24
  }
25
25
  }
26
+ /**
27
+ * Gate for CLI commands that mutate Prave-side state or whose effect we
28
+ * need to track (installs, deploys, settings, intelligence). Search and
29
+ * read-only Discover paths are intentionally NOT gated — anyone can
30
+ * browse the public registry from the terminal.
31
+ *
32
+ * Prints a friendly hint and sets a non-zero exit code when the caller
33
+ * isn't logged in.
34
+ */
35
+ export async function requireAuth(commandName) {
36
+ const creds = await loadCredentials();
37
+ if (creds)
38
+ return creds;
39
+ // Lazy-import the chalk/log utilities here to avoid a circular dep on
40
+ // the credentials module.
41
+ const { log } = await import('../utils/logger.js');
42
+ log.warn(`\`${commandName}\` requires sign-in.`);
43
+ log.dim('Run `prave login` first — installs are tracked against your account so we can keep counts honest.');
44
+ process.exitCode = 1;
45
+ return null;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
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": "0.3.1"
19
+ "@prave/shared": "0.4.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.12.7",