@prave/cli 1.1.1 → 1.2.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/README.md ADDED
@@ -0,0 +1,179 @@
1
+ <div align="center">
2
+
3
+ # `@prave/cli`
4
+
5
+ **The Prave CLI — discover, install, author, version, test, and ship Skills to any AI agent from your terminal.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/@prave/cli?color=06B6D4&label=npm&style=flat-square)](https://www.npmjs.com/package/@prave/cli)
8
+ [![npm downloads](https://img.shields.io/npm/dm/@prave/cli?color=06B6D4&label=downloads&style=flat-square)](https://www.npmjs.com/package/@prave/cli)
9
+ [![node](https://img.shields.io/node/v/@prave/cli?color=06B6D4&label=node&style=flat-square)](https://nodejs.org)
10
+ [![license](https://img.shields.io/npm/l/@prave/cli?color=06B6D4&style=flat-square)](https://github.com/eppstudio/prave/blob/main/LICENSE)
11
+ [![docs](https://img.shields.io/badge/docs-prave.app-06B6D4?style=flat-square)](https://prave.app/docs)
12
+ [![status](https://img.shields.io/badge/status-status.prave.app-22c55e?style=flat-square)](https://status.prave.app)
13
+
14
+ [**Website**](https://prave.app) · [**Documentation**](https://prave.app/docs) · [**CLI Cheat Sheet**](https://prave.app/docs/cli/cheat-sheet) · [**Status**](https://status.prave.app)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## What is Prave?
21
+
22
+ [Prave](https://prave.app) is the developer platform for the **complete lifecycle of AI agent Skills** — a registry to discover them, an editor to write them, an intelligence layer to keep your library lean, a tester to verify them, and this CLI to wire it all into your agent's skills directory on every machine you work from.
23
+
24
+ The Skill format originated with [Claude Code](https://docs.claude.com/en/docs/claude-code/skills) (a folder with a `SKILL.md` and frontmatter). The same format works **out of the box** with any agent that auto-loads instruction files from a folder — Prave is agent-aware: each Skill is tagged with which agents understand it, and `prave deploy` mirrors a Skill across **Claude Code · OpenAI Codex · Cursor · Gemini CLI · Cline · Amp**, with more added as the ecosystem grows.
25
+
26
+ This package is the **command-line surface**. The web app, the public registry, billing, and the API live at [prave.app](https://prave.app).
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install -g @prave/cli
32
+ # or
33
+ pnpm add -g @prave/cli
34
+ # or
35
+ yarn global add @prave/cli
36
+ ```
37
+
38
+ Requires **Node.js 18+**.
39
+
40
+ ## Quick start
41
+
42
+ ```bash
43
+ prave login # device-code auth in your browser
44
+ prave search "review pull requests"
45
+ prave install pr-reviewer # writes into your agent's skills folder
46
+ prave deploy pr-reviewer # mirror it to every agent you use
47
+ prave list # what's now installed locally
48
+ ```
49
+
50
+ That's enough to be useful. The full vocabulary is below.
51
+
52
+ > **Multi-agent by default.** `prave install` writes to your primary agent's skills folder (Claude Code by default; configurable via `prave settings`). `prave deploy` then mirrors the Skill to every other agent you've enabled — Cursor, Codex, Gemini, Cline, Amp — converting between their respective rule-file formats automatically.
53
+
54
+ ### `prave sync` in practice
55
+
56
+ `prave sync` shows you when you last ran it and confirms before doing any work — under 30 minutes ago and the default flips to `n`. Skills then install in parallel (concurrency 4) with a per-Skill progress counter:
57
+
58
+ ```bash
59
+ $ prave sync
60
+ Last sync: 2 hours ago — sync again? [Y/n] y
61
+ Syncing 12 Skills — this takes about 15 seconds.
62
+ ✓ 12 / 12 — pr-reviewer
63
+ Synced 12 · failed 0
64
+ ```
65
+
66
+ The `last_sync_at` watermark persists at `~/.prave/state.json`. Pass `--yes` to skip the prompt in CI scripts.
67
+
68
+ ## Commands
69
+
70
+ ### Auth & account
71
+
72
+ | Command | What it does |
73
+ | -------------- | --------------------------------------------------------------------------------- |
74
+ | `prave login` | Browser device-code auth. Tokens stored chmod-600 at `~/.prave/credentials.json`. |
75
+ | `prave logout` | Forget local credentials. |
76
+ | `prave whoami` | Show signed-in user, plan, and credentials path. |
77
+
78
+ ### Discover & install
79
+
80
+ | Command | What it does |
81
+ | -------------------------------------------------------- | --------------------------------------------- |
82
+ | `prave search <query>` | Search the public Skill registry. |
83
+ | `prave install <slug>` `[--no-deps]` | Install into your agent's skills folder. |
84
+ | `prave uninstall <slug>` | Remove a locally installed Skill. |
85
+ | `prave list` `[--remote] [--verbose]` | What's installed locally (or remote). |
86
+ | `prave export <slug>` `-o file.md` | Dump a Skill's `SKILL.md` without installing. |
87
+
88
+ ### Authoring & sync
89
+
90
+ | Command | What it does |
91
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------- |
92
+ | `prave import` `[--upload --public\|--private]` | Scan your agent's skills folder, optionally publish to Prave. |
93
+ | `prave deploy <slug>` `[--agent <name>]` | Mirror a Skill across enabled agents (Claude / Cursor / Codex / Gemini / Cline / Amp). |
94
+ | `prave sync` | Pull updates for every locally installed Skill. |
95
+ | `prave update [<slug>]` `[--dry-run]` | Diff installed Skills against the registry, pull what's outdated. |
96
+ | `prave diff <slug>` | Local vs registry side-by-side diff. |
97
+
98
+ ### Intelligence
99
+
100
+ | Command | What it does |
101
+ | ------------------------------------ | ----------------------------------------------------- |
102
+ | `prave overview` `[--json]` | Summary of token cost, conflicts, and library health. |
103
+ | `prave whatdoes <slug>` | Triggers, tokens, conflicts for a Skill. |
104
+ | `prave conflicts` | Cross-Skill collision check. |
105
+ | `prave optimize` `[--remove-unused]` | Recommendations: heavy / underused / mergeable. |
106
+
107
+ ### Usage tracking _(Pro+)_
108
+
109
+ | Command | What it does |
110
+ | --------------------------------- | --------------------------------------------------------- |
111
+ | `prave usage hook install` | Real-time invocation tracking for agents that support it. |
112
+ | `prave usage hook uninstall` | Remove the real-time hook. |
113
+ | `prave usage scan` `[--since 7d]` | Transcript scanner — backfill recent invocations. |
114
+ | `prave usage status` | Hook health, recent counts, and top Skills. |
115
+
116
+ > **Coverage today:** Real-time tracking lives natively on Claude Code via its plugin contract. For every other agent, the **transcript scanner** auto-runs at the tail of `prave sync` and reads the agent's local conversation history to extract Skill invocations — so Intelligence and the optimiser stay accurate everywhere. Real-time hooks for Cursor / Codex / Gemini / Cline / Amp ship as soon as those agents expose an equivalent contract.
117
+
118
+ ### Settings
119
+
120
+ | Command | What it does |
121
+ | ---------------- | ----------------------------------------------------------- |
122
+ | `prave settings` | Configure agents, providers, and credits from the terminal. |
123
+ | `prave cheat` | Print the full one-page command reference. |
124
+
125
+ ## Auto-update
126
+
127
+ `prave login` retries on `401` once with a fresh access token swapped via your stored `refresh_token`. You won't see "expired token" mid-install — re-authenticating is only required when the **refresh token itself** is rejected (you signed out everywhere or were idle for 30+ days).
128
+
129
+ ## Privacy & telemetry
130
+
131
+ The CLI sends a **fire-and-forget event per command** (e.g. `cli_install`, `cli_sync`, `cli_login_started`) to PostHog EU Cloud so we can see which commands matter to users. Events carry your Supabase user-id once you've logged in (or a stable per-machine UUID at `~/.prave/anon-id` if you haven't), plus OS family, OS release, Node.js version, and the CLI version — no command arguments, no Skill content, no file paths.
132
+
133
+ **Opt out** by setting:
134
+
135
+ ```bash
136
+ PRAVE_TELEMETRY=0
137
+ ```
138
+
139
+ (also accepts `false`, `off`, `no`). When set, no event ever leaves your machine.
140
+
141
+ Full data-protection details: [Privacy Policy](https://prave.app/legal/privacy).
142
+
143
+ ## Where things live
144
+
145
+ The exact Skill location depends on the agent — `prave settings` lets you reconfigure per-agent paths, and `prave list` always reflects what is actually on disk. The defaults:
146
+
147
+ ```
148
+ Claude Code ~/.claude/skills/<slug>/SKILL.md
149
+ Cursor ~/.cursor/rules/<slug>.mdc
150
+ Codex ~/.codex/skills/<slug>/SKILL.md
151
+ Gemini CLI ~/.gemini/skills/<slug>/SKILL.md
152
+ Cline ~/.cline/rules/<slug>.md
153
+ Amp ~/.amp/skills/<slug>/SKILL.md
154
+ ```
155
+
156
+ Local Prave state:
157
+
158
+ ```
159
+ ~/.prave/credentials.json # access + refresh tokens (chmod 600)
160
+ ~/.prave/anon-id # anonymous telemetry id (pre-login)
161
+ ~/.prave/usage-cursor.json # transcript scanner watermark
162
+ ```
163
+
164
+ ## Status
165
+
166
+ API health and uptime: [status.prave.app](https://status.prave.app) — auto-refreshes every 30 seconds, shows the live build version + commit SHA, and surfaces incidents from the last 30 days.
167
+
168
+ ## Links
169
+
170
+ - 🌐 [**prave.app**](https://prave.app) — the platform
171
+ - 📚 [**prave.app/docs**](https://prave.app/docs) — full documentation
172
+ - 📖 [**CLI Cheat Sheet**](https://prave.app/docs/cli/cheat-sheet) — every command on one page
173
+ - 💚 [**status.prave.app**](https://status.prave.app) — real-time health
174
+ - 🐛 [**GitHub Issues**](https://github.com/eppstudio/prave/issues) — bug reports & feature requests
175
+ - ✉️ [info@epplab-studio.de](mailto:info@epplab-studio.de) — direct contact
176
+
177
+ ## License
178
+
179
+ [MIT](https://github.com/eppstudio/prave/blob/main/LICENSE) © [EppLab Studio](https://epplab-studio.de)
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, 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 { track } from '../lib/analytics.js';
@@ -74,8 +75,42 @@ export async function importCommand(opts) {
74
75
  }
75
76
  return;
76
77
  }
77
- // Visibility is required on every upload — no silent default.
78
- if (opts.public === opts.private) {
78
+ // Visibility resolution. Order:
79
+ // 1. Explicit flags win — covers CI / scripted usage.
80
+ // 2. Interactive TTY with no flags → prompt the user (default: private,
81
+ // because mistakenly making private notes public is far worse than
82
+ // mistakenly keeping public ones private).
83
+ // 3. Non-TTY with no flags → hard error so a script never silently
84
+ // uploads to the wrong visibility.
85
+ let visibility;
86
+ if (opts.public !== opts.private) {
87
+ visibility = opts.private ? 'private' : 'public';
88
+ }
89
+ else if (process.stdin.isTTY && skills.length > 0) {
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
91
+ try {
92
+ const ans = (await rl.question(`\nUpload ${skills.length} local Skill${skills.length === 1 ? '' : 's'} as PUBLIC (visible in marketplace) or PRIVATE (only your account)? [public/private/cancel] `))
93
+ .trim()
94
+ .toLowerCase();
95
+ if (ans === 'cancel' || ans === 'c' || ans === 'q' || ans === 'quit') {
96
+ log.dim('Upload cancelled.');
97
+ return;
98
+ }
99
+ if (ans === 'public' || ans === 'pub') {
100
+ visibility = 'public';
101
+ }
102
+ else {
103
+ // Default to private on blank input or anything else (including
104
+ // ambiguous abbreviations like "p"). Better to upload-private and
105
+ // re-publish than to leak.
106
+ visibility = 'private';
107
+ }
108
+ }
109
+ finally {
110
+ rl.close();
111
+ }
112
+ }
113
+ else {
79
114
  log.warn('Specify visibility: --public or --private (one is required on upload).');
80
115
  log.dim('Examples:');
81
116
  log.dim(' prave import --upload --private # only you can see them');
@@ -83,7 +118,6 @@ export async function importCommand(opts) {
83
118
  process.exitCode = 1;
84
119
  return;
85
120
  }
86
- const visibility = opts.private ? 'private' : 'public';
87
121
  // Plan gate: authoring (public + private) is Pro+. Free is read-only on
88
122
  // the registry side — discover, install, bookmark, but no upload.
89
123
  const me = await fetchMyPlan();
@@ -73,7 +73,13 @@ export async function installCommand(slug, opts = {}) {
73
73
  await pullOne(s, { hasSession: true, force: Boolean(opts.force) });
74
74
  }
75
75
  installedSlugs = slugs;
76
- spinner.succeed(`Installed ${slugs.length} skill${slugs.length === 1 ? '' : 's'} ${CONFIG.skillsDir}`);
76
+ // Root slug = the user-requested one (last item by depth-sort), deps are
77
+ // the remaining ones. If `--no-deps` was passed, slugs is just [slug].
78
+ const depCount = slugs.length - 1;
79
+ const summary = depCount > 0
80
+ ? `Installed ${slug} + ${depCount} dep${depCount === 1 ? '' : 's'} → ${CONFIG.skillsDir}`
81
+ : `Installed ${slug} → ${CONFIG.skillsDir}`;
82
+ spinner.succeed(summary);
77
83
  if (slugs.length > 1) {
78
84
  log.dim(` chain: ${slugs.join(' → ')}`);
79
85
  }
@@ -2,6 +2,18 @@ import chalk from 'chalk';
2
2
  import { track } from '../lib/analytics.js';
3
3
  import { api } from '../lib/api.js';
4
4
  import { log } from '../utils/logger.js';
5
+ const SLUG_COL = 32;
6
+ function formatInstalls(n) {
7
+ // Thousands separator. Locale-pinned to en-US so CI snapshots stay stable.
8
+ return n.toLocaleString('en-US');
9
+ }
10
+ function truncate(text, max) {
11
+ if (text.length <= max)
12
+ return text;
13
+ if (max <= 1)
14
+ return '…';
15
+ return text.slice(0, max - 1) + '…';
16
+ }
5
17
  export async function searchCommand(query) {
6
18
  track('cli_search', { length: query.length });
7
19
  const { data: skills } = await api.get(`/api/v1/skills?q=${encodeURIComponent(query)}&limit=25`);
@@ -9,7 +21,24 @@ export async function searchCommand(query) {
9
21
  log.dim(`No skills match "${query}".`);
10
22
  return;
11
23
  }
24
+ console.log(chalk.dim(` ${skills.length} results`));
25
+ const cols = process.stdout.columns ?? 80;
26
+ // Description is indented 4 spaces. Reserve 1 char for the trailing
27
+ // ellipsis, leave the rest for content.
28
+ const descBudget = Math.max(20, cols - 4);
12
29
  for (const s of skills) {
13
- console.log(` ${chalk.cyan('•')} ${s.slug} ${chalk.dim(s.description ?? '')} ${chalk.magenta(`↓ ${s.install_count}`)}`);
30
+ const installsText = `↓ ${formatInstalls(s.install_count)}`;
31
+ const slugCell = s.slug.padEnd(SLUG_COL, ' ');
32
+ console.log(` ${chalk.cyan('•')} ${slugCell}${chalk.magenta(installsText)}`);
33
+ const desc = s.description?.trim();
34
+ if (desc) {
35
+ console.log(` ${chalk.dim(truncate(desc, descBudget))}`);
36
+ }
37
+ else {
38
+ console.log(` ${chalk.dim('(no description)')}`);
39
+ }
40
+ }
41
+ if (skills.length > 0) {
42
+ console.log(chalk.dim(' → prave install <slug>'));
14
43
  }
15
44
  }
@@ -100,14 +100,6 @@ async function configureOs(rl, current) {
100
100
  log.success(`OS set to ${updated.detected_os}`);
101
101
  return updated;
102
102
  }
103
- async function showAccount() {
104
- console.log();
105
- log.dim('Account info:');
106
- log.dim(` API: ${CONFIG.apiUrl}`);
107
- log.dim(` Credentials: ${CONFIG.credentialsPath}`);
108
- log.dim(` Local config: ${CONFIG.configPath}`);
109
- log.dim('Run `prave whoami` for full identity, or `prave logout` to sign out.');
110
- }
111
103
  export async function settingsCommand() {
112
104
  const _session = await requireAuth("prave settings");
113
105
  if (!_session)
@@ -129,9 +121,8 @@ export async function settingsCommand() {
129
121
  console.log(` ${chalk.cyan('1)')} Agent Configuration`);
130
122
  console.log(` ${chalk.cyan('2)')} Skill Paths`);
131
123
  console.log(` ${chalk.cyan('3)')} OS Settings`);
132
- console.log(` ${chalk.cyan('4)')} Account`);
133
- console.log(` ${chalk.cyan('5)')} Exit`);
134
- const ans = (await rl.question('\nChoose [1-5]: ')).trim();
124
+ console.log(` ${chalk.cyan('4)')} Exit`);
125
+ const ans = (await rl.question('\nChoose [1-4]: ')).trim();
135
126
  try {
136
127
  if (ans === '1')
137
128
  current = await configureAgents(rl, current);
@@ -139,9 +130,7 @@ export async function settingsCommand() {
139
130
  current = await configurePaths(rl, current);
140
131
  else if (ans === '3')
141
132
  current = await configureOs(rl, current);
142
- else if (ans === '4')
143
- await showAccount();
144
- else if (ans === '5' || ans === '' || ans.toLowerCase() === 'exit') {
133
+ else if (ans === '4' || ans === '' || ans.toLowerCase() === 'exit') {
145
134
  break;
146
135
  }
147
136
  else {
@@ -7,18 +7,64 @@ import { track } from '../lib/analytics.js';
7
7
  import { CONFIG } from '../lib/config.js';
8
8
  import { requireAuth } from '../lib/credentials.js';
9
9
  import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
10
+ import { readState, writeState } from '../lib/state.js';
11
+ import { formatRelative, msSince } from '../lib/time.js';
10
12
  import { log } from '../utils/logger.js';
11
13
  import { installCommand } from './install.js';
14
+ /**
15
+ * Empirical per-skill timing — `installCommand` does ~1 registry GET +
16
+ * ~1 file write + ~1 best-effort POST analyze, which clocks in around
17
+ * 0.4s on a warm connection. With concurrency=4 we approximate effective
18
+ * ~0.4s per skill / 4 ≈ 0.1s, but include a safety multiplier so the
19
+ * estimate doesn't oversell. End result: estimate ~ 0.15s/skill, rounded
20
+ * to nearest 5s with a 5s floor.
21
+ */
22
+ const SECONDS_PER_SKILL = 0.15;
23
+ const SYNC_CONCURRENCY = 4;
24
+ function estimateSeconds(count) {
25
+ const raw = count * SECONDS_PER_SKILL;
26
+ const rounded = Math.round(raw / 5) * 5;
27
+ return Math.max(5, rounded);
28
+ }
29
+ /**
30
+ * Inline concurrency limiter — runs `fn(item)` for each item, never letting
31
+ * more than `limit` promises be in-flight at once. Returns when all settle.
32
+ *
33
+ * We deliberately avoid `Promise.all` over the full list to keep the agent
34
+ * gentle on the API and on the user's disk; ~3-4x throughput vs sequential
35
+ * is the goal, not "fire 200 requests at once".
36
+ */
37
+ async function runWithConcurrency(items, limit, fn) {
38
+ const results = new Array(items.length);
39
+ let cursor = 0;
40
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
41
+ while (true) {
42
+ const i = cursor++;
43
+ if (i >= items.length)
44
+ return;
45
+ results[i] = await fn(items[i], i);
46
+ }
47
+ });
48
+ await Promise.all(workers);
49
+ return results;
50
+ }
12
51
  /**
13
52
  * `prave sync` — re-pulls every locally installed Skill from the
14
53
  * registry. Picks up SKILL.md edits without the user having to remember
15
54
  * each slug.
16
55
  *
17
- * The deploy-to-all-agents question is asked ONCE up-front for the entire
18
- * queue (regression fix: it used to fire per Skill, forcing the user to
19
- * mash `y` 30 times). The answer is then threaded into every
20
- * `installCommand` invocation via `skipDeployPrompt: true` and we run a
21
- * single batched deploy at the end.
56
+ * Behaviours layered on top of the basic loop:
57
+ * Last-sync timestamp persisted to ~/.prave/state.json used to nudge
58
+ * against accidental rapid re-syncs (<30 min) and to skip the prompt
59
+ * entirely on the first ever run.
60
+ * Concurrency-limited parallel pulls (4 in flight) — ~3-4x faster than
61
+ * the previous sequential loop.
62
+ * • Per-skill progress: spinner text ticks "Installed N / M — slug" so
63
+ * the user can see actual liveness on slow connections.
64
+ * • The deploy-to-all-agents question is asked ONCE up-front for the
65
+ * entire queue and threaded into every `installCommand` invocation
66
+ * via `skipDeployPrompt: true`. A single batched deploy runs at the
67
+ * end.
22
68
  */
23
69
  export async function syncCommand() {
24
70
  track('cli_sync');
@@ -33,6 +79,38 @@ export async function syncCommand() {
33
79
  log.dim(formatUpgradeHint('explorer'));
34
80
  return;
35
81
  }
82
+ // Last-sync nudge. We read state up-front because if the user bails on
83
+ // the prompt we don't want to overwrite their last-sync timestamp.
84
+ const state = await readState();
85
+ if (state.last_sync_at) {
86
+ const since = msSince(state.last_sync_at);
87
+ const pretty = formatRelative(state.last_sync_at);
88
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
89
+ let proceed = true;
90
+ try {
91
+ // Stronger nudge when the user just synced (<30 min). Default flips
92
+ // to "no" — they almost certainly hit the wrong command.
93
+ if (since !== null && since < 30 * 60 * 1000) {
94
+ const ans = (await rl.question(`Last sync: ${pretty}. Sync again? [y/N] `))
95
+ .trim()
96
+ .toLowerCase();
97
+ proceed = ans === 'y' || ans === 'yes';
98
+ }
99
+ else {
100
+ const ans = (await rl.question(`Last sync: ${pretty}. Sync again? [Y/n] `))
101
+ .trim()
102
+ .toLowerCase();
103
+ proceed = ans === '' || ans === 'y' || ans === 'yes';
104
+ }
105
+ }
106
+ finally {
107
+ rl.close();
108
+ }
109
+ if (!proceed) {
110
+ log.dim('Sync cancelled.');
111
+ return;
112
+ }
113
+ }
36
114
  const spinner = ora('Scanning local Skills…').start();
37
115
  let entries = [];
38
116
  try {
@@ -53,6 +131,9 @@ export async function syncCommand() {
53
131
  return;
54
132
  }
55
133
  spinner.succeed(`Found ${slugs.length} installed Skill${slugs.length === 1 ? '' : 's'}.`);
134
+ // Pre-flight time estimate — sets expectations before the user hits Y.
135
+ const estSeconds = estimateSeconds(slugs.length);
136
+ console.log(chalk.dim(`Syncing ${slugs.length} Skill${slugs.length === 1 ? '' : 's'} — this takes about ${estSeconds} seconds.`));
56
137
  // Ask the deploy question ONCE for the whole batch.
57
138
  const rl = createInterface({ input: process.stdin, output: process.stdout });
58
139
  let deployAfter = false;
@@ -65,19 +146,34 @@ export async function syncCommand() {
65
146
  finally {
66
147
  rl.close();
67
148
  }
149
+ // Parallel install with live progress counter.
150
+ const progress = ora(`Installed 0 / ${slugs.length}`).start();
151
+ let done = 0;
68
152
  let updated = 0;
69
153
  let failed = 0;
70
- for (const slug of slugs) {
154
+ await runWithConcurrency(slugs, SYNC_CONCURRENCY, async (slug) => {
71
155
  try {
72
156
  await installCommand(slug, { noDeps: true, skipDeployPrompt: true });
73
157
  updated++;
74
158
  }
75
159
  catch {
76
160
  failed++;
77
- console.log(chalk.red(` ✗ ${slug}`));
78
161
  }
162
+ finally {
163
+ done++;
164
+ progress.text = `Installed ${done} / ${slugs.length} — ${slug}`;
165
+ }
166
+ });
167
+ if (failed === 0) {
168
+ progress.succeed(`Synced ${updated} / ${slugs.length}`);
169
+ }
170
+ else {
171
+ progress.warn(`Synced ${updated} · failed ${failed}`);
79
172
  }
80
- log.dim(`\nSynced ${updated} · failed ${failed}`);
173
+ // Persist last-sync timestamp regardless of partial failures — the user
174
+ // *did* attempt a sync, and we want the cooldown to apply to retries
175
+ // just as much as to the happy path.
176
+ await writeState({ last_sync_at: new Date().toISOString() }).catch(() => { });
81
177
  if (deployAfter && updated > 0) {
82
178
  const { deployCommand } = await import('./deploy.js');
83
179
  log.info(`\nDeploying ${updated} Skills to configured agents…`);
package/dist/index.js CHANGED
@@ -7,7 +7,6 @@ import { conflictsCommand } from './commands/conflicts.js';
7
7
  import { deployCommand } from './commands/deploy.js';
8
8
  import { diffCommand } from './commands/diff.js';
9
9
  import { exportCommand } from './commands/export.js';
10
- import { findCommand } from './commands/find.js';
11
10
  import { importCommand } from './commands/import.js';
12
11
  import { installCommand } from './commands/install.js';
13
12
  import { listCommand } from './commands/list.js';
@@ -155,13 +154,6 @@ hook
155
154
  .command('uninstall')
156
155
  .description('Remove the Prave-managed hook from ~/.claude/settings.json')
157
156
  .action(usageHookUninstallCommand);
158
- program
159
- .command('find <query>')
160
- .description('Smart skill search across local and marketplace')
161
- .option('--local', 'only search local skills')
162
- .option('--marketplace', 'only search the marketplace')
163
- .option('--smart', 'use the LLM-assisted search endpoint')
164
- .action(findCommand);
165
157
  program
166
158
  .command('deploy <skillname>')
167
159
  .description('Deploy a Skill to every configured agent (Claude Code, Codex, Cursor, Gemini, Cline, Amp). Free plan deploys to Claude Code only; Pro and Max deploy across all six. The skill must already exist locally — use `prave install` first or point to a SKILL.md folder.')
@@ -183,7 +175,6 @@ program
183
175
  '',
184
176
  'Discover & install',
185
177
  ' prave search <q> # public skill search',
186
- ' prave find <q> [--smart|--local] # smart cross-source search',
187
178
  ' prave install <slug> [--no-deps] # install into ~/.claude/skills/',
188
179
  ' prave uninstall <slug> # remove a local skill',
189
180
  ' prave list [--remote] [--verbose] # what is installed (or remote)',
@@ -0,0 +1,34 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CONFIG } from './config.js';
4
+ const STATE_PATH = join(CONFIG.praveDir, 'state.json');
5
+ export async function readState() {
6
+ try {
7
+ const raw = await readFile(STATE_PATH, 'utf8');
8
+ const parsed = JSON.parse(raw);
9
+ if (parsed && typeof parsed === 'object')
10
+ return parsed;
11
+ return {};
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ /**
18
+ * Atomic write — write to .tmp, then rename. Prevents partial JSON if the
19
+ * process dies mid-write (which would otherwise make the next readState()
20
+ * throw on JSON.parse and quietly reset state to {}).
21
+ */
22
+ export async function writeState(patch) {
23
+ await mkdir(CONFIG.praveDir, { recursive: true, mode: 0o700 });
24
+ const current = await readState();
25
+ const next = { ...current, ...patch };
26
+ const tmp = `${STATE_PATH}.tmp`;
27
+ await writeFile(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
28
+ await rename(tmp, STATE_PATH);
29
+ // Re-apply mode after rename — defensive: rename preserves the source's
30
+ // mode, but if a stale state.json exists with looser perms, this brings
31
+ // it back to user-only.
32
+ await chmod(STATE_PATH, 0o600);
33
+ return next;
34
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Human-friendly relative time formatting for CLI output.
3
+ *
4
+ * Returns short English phrases like "2 hours ago", "12 minutes ago",
5
+ * "yesterday". Uses absolute thresholds rather than Intl.RelativeTimeFormat
6
+ * because we want consistent CLI copy across locales (the rest of the CLI
7
+ * is English-only).
8
+ */
9
+ export function formatRelative(date) {
10
+ const d = date instanceof Date ? date : new Date(date);
11
+ const now = Date.now();
12
+ const ms = now - d.getTime();
13
+ const sec = Math.round(ms / 1000);
14
+ if (sec < 0)
15
+ return 'just now';
16
+ if (sec < 45)
17
+ return 'just now';
18
+ const min = Math.round(sec / 60);
19
+ if (min < 2)
20
+ return 'a minute ago';
21
+ if (min < 60)
22
+ return `${min} minutes ago`;
23
+ const hr = Math.round(min / 60);
24
+ if (hr < 2)
25
+ return 'an hour ago';
26
+ if (hr < 24)
27
+ return `${hr} hours ago`;
28
+ const day = Math.round(hr / 24);
29
+ if (day < 2)
30
+ return 'yesterday';
31
+ if (day < 30)
32
+ return `${day} days ago`;
33
+ const month = Math.round(day / 30);
34
+ if (month < 2)
35
+ return 'a month ago';
36
+ if (month < 12)
37
+ return `${month} months ago`;
38
+ const year = Math.round(day / 365);
39
+ if (year < 2)
40
+ return 'a year ago';
41
+ return `${year} years ago`;
42
+ }
43
+ /**
44
+ * Returns the gap (in ms) between `date` and now, or null if undefined.
45
+ * Convenience wrapper used by callers that need both raw delta and pretty
46
+ * output without re-parsing the date.
47
+ */
48
+ export function msSince(date) {
49
+ if (date == null)
50
+ return null;
51
+ const d = date instanceof Date ? date : new Date(date);
52
+ if (Number.isNaN(d.getTime()))
53
+ return null;
54
+ return Date.now() - d.getTime();
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Prave CLI — discover, install, version, test, and ship Claude Skills. The developer platform for the complete Skill lifecycle.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -51,7 +51,7 @@
51
51
  "open": "^10.1.0",
52
52
  "ora": "^8.0.1",
53
53
  "undici": "^6.18.0",
54
- "@prave/shared": "1.1.1"
54
+ "@prave/shared": "1.2.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^20.12.7",
@@ -1,103 +0,0 @@
1
- import { createInterface } from 'node:readline/promises';
2
- import chalk from 'chalk';
3
- import ora from 'ora';
4
- import { tokenTier } from '@prave/shared';
5
- import { track } from '../lib/analytics.js';
6
- import { api, ApiError } from '../lib/api.js';
7
- import { log } from '../utils/logger.js';
8
- const TIER_EMOJI = {
9
- lean: '🟢',
10
- medium: '🟡',
11
- heavy: '🔴',
12
- };
13
- function formatTokens(n) {
14
- if (n < 1000)
15
- return `~${n}`;
16
- return `~${(n / 1000).toFixed(1)}k`;
17
- }
18
- function renderResult(r, idx) {
19
- const badge = r.source === 'local'
20
- ? chalk.cyan('[local]')
21
- : chalk.magenta('[marketplace]');
22
- const tier = TIER_EMOJI[tokenTier(r.estimated_tokens)];
23
- const slash = r.slash_command ? chalk.dim(` ${r.slash_command}`) : '';
24
- const installs = r.source === 'marketplace' && typeof r.install_count === 'number'
25
- ? chalk.dim(` · ${r.install_count} installs`)
26
- : '';
27
- console.log(`${chalk.bold(`${idx + 1}.`)} ${badge} ${chalk.bold(r.name)}${slash} ${tier} ${chalk.dim(formatTokens(r.estimated_tokens))}${installs}`);
28
- if (r.description)
29
- log.dim(` ${r.description}`);
30
- }
31
- export async function findCommand(query, opts = {}) {
32
- track('cli_find', {
33
- length: query.length,
34
- mode: opts.smart ? 'smart' : opts.local ? 'local' : opts.marketplace ? 'marketplace' : 'both',
35
- });
36
- const scope = opts.local
37
- ? 'local'
38
- : opts.marketplace
39
- ? 'marketplace'
40
- : 'both';
41
- const spinner = ora(opts.smart ? 'Smart-searching…' : 'Searching…').start();
42
- try {
43
- let results;
44
- if (opts.smart) {
45
- const { data } = await api.post('/api/v1/intelligence/llm-search', { query }, true);
46
- spinner.stop();
47
- console.log(chalk.dim(`Task: ${data.task}`));
48
- if (data.suggested_triggers.length > 0) {
49
- log.dim(`Suggested triggers: ${data.suggested_triggers.join(', ')}`);
50
- }
51
- console.log();
52
- results = data.results;
53
- }
54
- else {
55
- const { data } = await api.post('/api/v1/intelligence/search', { query, scope }, true);
56
- spinner.stop();
57
- results = data;
58
- }
59
- if (results.length === 0) {
60
- log.dim('No matches.');
61
- return;
62
- }
63
- const top = results.slice(0, 5);
64
- top.forEach((r, i) => renderResult(r, i));
65
- if (opts.local)
66
- return; // skip prompt when local-only
67
- const installable = top.find((r) => r.source === 'marketplace' && !r.is_installed);
68
- if (!installable)
69
- return;
70
- const rl = createInterface({ input: process.stdin, output: process.stdout });
71
- try {
72
- const answer = (await rl.question(`\nInstall ${chalk.bold(installable.slug)}? [y/N] `)).trim().toLowerCase();
73
- if (answer === 'y' || answer === 'yes') {
74
- const { installCommand } = await import('./install.js');
75
- await installCommand(installable.slug, {});
76
- }
77
- }
78
- finally {
79
- rl.close();
80
- }
81
- }
82
- catch (err) {
83
- spinner.stop();
84
- if (err instanceof ApiError && err.status === 402) {
85
- // Server returned the semantic-search upsell. Render it as a
86
- // structured upgrade hint instead of a raw error so the message
87
- // reads like guidance, not a crash.
88
- log.error(err.message);
89
- console.log();
90
- console.log(chalk.dim(' Pro · $12/mo includes:'));
91
- console.log(chalk.dim(' · `prave search "<natural language>"` ranked by intent'));
92
- console.log(chalk.dim(' · Skill Intelligence audit + 30-day trigger telemetry'));
93
- console.log(chalk.dim(' · Cross-machine sync · Tester · Authoring'));
94
- console.log();
95
- console.log(` ${chalk.bold('→ Upgrade:')} ${chalk.cyan('https://prave.app/#pricing')}`);
96
- console.log(chalk.dim(' Or browse the registry without semantic search:'), chalk.cyan('https://prave.app/discover'));
97
- process.exitCode = 1;
98
- return;
99
- }
100
- log.error(err instanceof ApiError ? err.message : err.message);
101
- process.exitCode = 1;
102
- }
103
- }