@prave/cli 1.1.2 → 1.2.1
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 +26 -13
- package/dist/commands/conflicts.js +101 -1
- package/dist/commands/import.js +37 -3
- package/dist/commands/install.js +7 -1
- package/dist/commands/optimize.js +64 -4
- package/dist/commands/search.js +30 -1
- package/dist/commands/settings.js +3 -14
- package/dist/commands/sync.js +104 -8
- package/dist/commands/usage.js +190 -39
- package/dist/index.js +5 -13
- package/dist/lib/hook.js +62 -27
- package/dist/lib/state.js +34 -0
- package/dist/lib/time.js +55 -0
- package/package.json +2 -2
- package/dist/commands/find.js +0 -103
package/README.md
CHANGED
|
@@ -51,6 +51,20 @@ That's enough to be useful. The full vocabulary is below.
|
|
|
51
51
|
|
|
52
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
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
|
+
|
|
54
68
|
## Commands
|
|
55
69
|
|
|
56
70
|
### Auth & account
|
|
@@ -66,7 +80,6 @@ That's enough to be useful. The full vocabulary is below.
|
|
|
66
80
|
| Command | What it does |
|
|
67
81
|
| -------------------------------------------------------- | --------------------------------------------- |
|
|
68
82
|
| `prave search <query>` | Search the public Skill registry. |
|
|
69
|
-
| `prave find <query>` `[--smart\|--local\|--marketplace]` | Cross-source smart search. |
|
|
70
83
|
| `prave install <slug>` `[--no-deps]` | Install into your agent's skills folder. |
|
|
71
84
|
| `prave uninstall <slug>` | Remove a locally installed Skill. |
|
|
72
85
|
| `prave list` `[--remote] [--verbose]` | What's installed locally (or remote). |
|
|
@@ -74,13 +87,13 @@ That's enough to be useful. The full vocabulary is below.
|
|
|
74
87
|
|
|
75
88
|
### Authoring & sync
|
|
76
89
|
|
|
77
|
-
| Command | What it does
|
|
78
|
-
| ----------------------------------------------- |
|
|
79
|
-
| `prave import` `[--upload --public\|--private]` | Scan your agent's skills folder, optionally publish to Prave.
|
|
90
|
+
| Command | What it does |
|
|
91
|
+
| ----------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
92
|
+
| `prave import` `[--upload --public\|--private]` | Scan your agent's skills folder, optionally publish to Prave. |
|
|
80
93
|
| `prave deploy <slug>` `[--agent <name>]` | Mirror a Skill across enabled agents (Claude / Cursor / Codex / Gemini / Cline / Amp). |
|
|
81
|
-
| `prave sync` | Pull updates for every locally installed Skill.
|
|
82
|
-
| `prave update [<slug>]` `[--dry-run]` | Diff installed Skills against the registry, pull what's outdated.
|
|
83
|
-
| `prave diff <slug>` | Local vs registry side-by-side diff.
|
|
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. |
|
|
84
97
|
|
|
85
98
|
### Intelligence
|
|
86
99
|
|
|
@@ -93,12 +106,12 @@ That's enough to be useful. The full vocabulary is below.
|
|
|
93
106
|
|
|
94
107
|
### Usage tracking _(Pro+)_
|
|
95
108
|
|
|
96
|
-
| Command | What it does
|
|
97
|
-
| --------------------------------- |
|
|
98
|
-
| `prave usage hook install` | Real-time invocation tracking for agents that support it.
|
|
99
|
-
| `prave usage hook uninstall` | Remove the real-time hook.
|
|
100
|
-
| `prave usage scan` `[--since 7d]` | Transcript scanner — backfill recent invocations.
|
|
101
|
-
| `prave usage status` | Hook health, recent counts, and top Skills.
|
|
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. |
|
|
102
115
|
|
|
103
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.
|
|
104
117
|
|
|
@@ -1,9 +1,47 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import chalk from 'chalk';
|
|
2
4
|
import ora from 'ora';
|
|
3
5
|
import { track } from '../lib/analytics.js';
|
|
4
6
|
import { api, ApiError } from '../lib/api.js';
|
|
7
|
+
import { CONFIG } from '../lib/config.js';
|
|
5
8
|
import { requireAuth } from '../lib/credentials.js';
|
|
9
|
+
import { checkboxPrompt } from '../lib/prompt.js';
|
|
10
|
+
import { isValidSlug } from '../lib/slug.js';
|
|
6
11
|
import { log } from '../utils/logger.js';
|
|
12
|
+
import { uninstallCommand } from './uninstall.js';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a `skill_metadata` row back to its on-disk slug, mirroring
|
|
15
|
+
* the canonical `~/.claude/skills/<slug>/SKILL.md` layout used by
|
|
16
|
+
* `prave optimize --remove-unused`. Returns `null` when the slug
|
|
17
|
+
* fails the registry regex (defence against `..` injection via a
|
|
18
|
+
* malformed `file_path`).
|
|
19
|
+
*/
|
|
20
|
+
function slugOf(s) {
|
|
21
|
+
const segs = s.file_path?.split('/').filter(Boolean) ?? [];
|
|
22
|
+
let candidate = null;
|
|
23
|
+
if (segs.length >= 2) {
|
|
24
|
+
const last = segs[segs.length - 1];
|
|
25
|
+
const parent = segs[segs.length - 2];
|
|
26
|
+
if (last.toLowerCase().endsWith('skill.md'))
|
|
27
|
+
candidate = parent;
|
|
28
|
+
}
|
|
29
|
+
if (!candidate && s.name) {
|
|
30
|
+
candidate = s.name.trim().toLowerCase().replace(/\s+/g, '-');
|
|
31
|
+
}
|
|
32
|
+
if (!candidate)
|
|
33
|
+
return null;
|
|
34
|
+
return isValidSlug(candidate) ? candidate : null;
|
|
35
|
+
}
|
|
36
|
+
async function existsOnDisk(slug) {
|
|
37
|
+
try {
|
|
38
|
+
const st = await stat(join(CONFIG.skillsDir, slug));
|
|
39
|
+
return st.isDirectory();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
7
45
|
function describe(c) {
|
|
8
46
|
const a = c.skill_a_name ?? c.skill_a_id;
|
|
9
47
|
const b = c.skill_b_name ?? c.skill_b_id;
|
|
@@ -44,8 +82,70 @@ export async function conflictsCommand(opts = {}) {
|
|
|
44
82
|
console.log(`${chalk.yellow('⚠️ ')} ${describe(c)}`);
|
|
45
83
|
}
|
|
46
84
|
if (opts.fix) {
|
|
85
|
+
// Interactive resolution: the most common (and safest) fix for a
|
|
86
|
+
// conflict is uninstalling one of the two competing Skills. We
|
|
87
|
+
// build a checkbox list of the unique slugs that appear in the
|
|
88
|
+
// conflict set, restricted to ones we can resolve back to a real
|
|
89
|
+
// on-disk folder (so we never offer to "uninstall" something the
|
|
90
|
+
// user hasn't actually got installed).
|
|
91
|
+
let metadata = [];
|
|
92
|
+
try {
|
|
93
|
+
const { data } = await api.get('/api/v1/intelligence/skills', true);
|
|
94
|
+
metadata = data;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
log.warn(`Could not fetch skill metadata — ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
// Tally how many conflicts each Skill participates in. Heuristic:
|
|
100
|
+
// a skill that conflicts with two others is the strongest fix
|
|
101
|
+
// candidate (uninstalling it resolves multiple rows at once), so
|
|
102
|
+
// we surface it first.
|
|
103
|
+
const conflictCount = new Map();
|
|
104
|
+
for (const c of conflicts) {
|
|
105
|
+
conflictCount.set(c.skill_a_id, (conflictCount.get(c.skill_a_id) ?? 0) + 1);
|
|
106
|
+
conflictCount.set(c.skill_b_id, (conflictCount.get(c.skill_b_id) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
const candidates = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const m of metadata) {
|
|
111
|
+
if (!conflictCount.has(m.id))
|
|
112
|
+
continue;
|
|
113
|
+
const slug = slugOf(m);
|
|
114
|
+
if (!slug || seen.has(slug))
|
|
115
|
+
continue;
|
|
116
|
+
if (!(await existsOnDisk(slug)))
|
|
117
|
+
continue;
|
|
118
|
+
seen.add(slug);
|
|
119
|
+
candidates.push({
|
|
120
|
+
slug,
|
|
121
|
+
name: m.name ?? slug,
|
|
122
|
+
count: conflictCount.get(m.id) ?? 1,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
candidates.sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug));
|
|
126
|
+
if (candidates.length === 0) {
|
|
127
|
+
console.log();
|
|
128
|
+
log.dim('Nothing to auto-fix — no conflicting Skills are installed under ~/.claude/skills/. ' +
|
|
129
|
+
'Run `prave whatdoes <skill>` to inspect frontmatter and resolve manually.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
console.log();
|
|
133
|
+
const picks = await checkboxPrompt('Select Skills to uninstall:', candidates.map((c) => ({
|
|
134
|
+
value: c.slug,
|
|
135
|
+
label: c.name,
|
|
136
|
+
hint: c.count > 1
|
|
137
|
+
? `${c.count} conflicts · ${c.slug}`
|
|
138
|
+
: `1 conflict · ${c.slug}`,
|
|
139
|
+
})));
|
|
140
|
+
if (!picks || picks.length === 0) {
|
|
141
|
+
log.dim('Aborted — nothing removed.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const slug of picks) {
|
|
145
|
+
await uninstallCommand(slug);
|
|
146
|
+
}
|
|
47
147
|
console.log();
|
|
48
|
-
log.dim(
|
|
148
|
+
log.dim(`Removed ${picks.length} skill(s). Run ${chalk.cyan('prave sync')} to refresh server-side metadata.`);
|
|
49
149
|
}
|
|
50
150
|
}
|
|
51
151
|
catch (err) {
|
package/dist/commands/import.js
CHANGED
|
@@ -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
|
|
78
|
-
|
|
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();
|
package/dist/commands/install.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
1
4
|
import chalk from 'chalk';
|
|
2
5
|
import ora from 'ora';
|
|
3
|
-
import readline from 'node:readline/promises';
|
|
4
6
|
import { track } from '../lib/analytics.js';
|
|
5
7
|
import { api, ApiError } from '../lib/api.js';
|
|
8
|
+
import { CONFIG } from '../lib/config.js';
|
|
6
9
|
import { requireAuth } from '../lib/credentials.js';
|
|
10
|
+
import { checkboxPrompt } from '../lib/prompt.js';
|
|
7
11
|
import { isValidSlug } from '../lib/slug.js';
|
|
8
12
|
import { log } from '../utils/logger.js';
|
|
9
13
|
import { uninstallCommand } from './uninstall.js';
|
|
@@ -40,6 +44,15 @@ function slugOf(s) {
|
|
|
40
44
|
// injected via a malformed `file_path`.
|
|
41
45
|
return isValidSlug(candidate) ? candidate : null;
|
|
42
46
|
}
|
|
47
|
+
async function existsOnDisk(slug) {
|
|
48
|
+
try {
|
|
49
|
+
const st = await stat(join(CONFIG.skillsDir, slug));
|
|
50
|
+
return st.isDirectory();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
43
56
|
async function confirmYesNo(question) {
|
|
44
57
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
58
|
try {
|
|
@@ -121,10 +134,57 @@ export async function optimizeCommand(opts = {}) {
|
|
|
121
134
|
log.dim(`Removed ${candidates.length} skill(s). Run ${chalk.cyan('prave sync')} to update server-side metadata.`);
|
|
122
135
|
}
|
|
123
136
|
else if (opts.apply) {
|
|
137
|
+
// `--apply` is the unified interactive cleanup: pick from the
|
|
138
|
+
// *underused* + *heavy* lists at once and uninstall whatever the
|
|
139
|
+
// user agrees to. This is strictly safer than blanket-deleting
|
|
140
|
+
// because every selection is opt-in.
|
|
141
|
+
//
|
|
142
|
+
// Merge candidates are intentionally NOT auto-actionable — merging
|
|
143
|
+
// two Skills is a content-rewrite that the user has to do by hand.
|
|
144
|
+
// We surface them above and leave them for the suggestions panel.
|
|
145
|
+
const offer = [];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const push = (skill, bucket) => {
|
|
148
|
+
const slug = slugOf(skill);
|
|
149
|
+
if (!slug || seen.has(slug))
|
|
150
|
+
return;
|
|
151
|
+
seen.add(slug);
|
|
152
|
+
offer.push({ slug, skill, bucket });
|
|
153
|
+
};
|
|
154
|
+
for (const s of data.underused)
|
|
155
|
+
push(s, 'underused');
|
|
156
|
+
for (const s of data.heavy)
|
|
157
|
+
push(s, 'heavy');
|
|
158
|
+
// Filter to ones actually present on disk — the API returns
|
|
159
|
+
// metadata for slug-keyed self-heal stubs that may not have a
|
|
160
|
+
// matching folder.
|
|
161
|
+
const installed = [];
|
|
162
|
+
for (const item of offer) {
|
|
163
|
+
if (await existsOnDisk(item.slug))
|
|
164
|
+
installed.push(item);
|
|
165
|
+
}
|
|
166
|
+
if (installed.length === 0) {
|
|
167
|
+
console.log();
|
|
168
|
+
log.dim('Nothing on disk to clean up — every flagged skill is either uninstalled already or only relevant to merge candidates (which need a manual rewrite).');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
console.log();
|
|
172
|
+
const picks = await checkboxPrompt('Pick skills to uninstall:', installed.map((i) => ({
|
|
173
|
+
value: i.slug,
|
|
174
|
+
label: nameOf(i.skill),
|
|
175
|
+
hint: i.bucket === 'underused'
|
|
176
|
+
? `underused · ${formatTokens(i.skill.estimated_tokens)} · ${i.slug}`
|
|
177
|
+
: `heavy · ${formatTokens(i.skill.estimated_tokens)} · ${i.slug}`,
|
|
178
|
+
})));
|
|
179
|
+
if (!picks || picks.length === 0) {
|
|
180
|
+
log.dim('Aborted — nothing removed.');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
for (const slug of picks) {
|
|
184
|
+
await uninstallCommand(slug);
|
|
185
|
+
}
|
|
124
186
|
console.log();
|
|
125
|
-
log.dim(
|
|
126
|
-
chalk.cyan('prave optimize --remove-unused') +
|
|
127
|
-
' to delete underused skills, or review the suggestions and adjust manually.');
|
|
187
|
+
log.dim(`Removed ${picks.length} skill(s). Run ${chalk.cyan('prave sync')} to refresh server-side metadata.`);
|
|
128
188
|
}
|
|
129
189
|
}
|
|
130
190
|
catch (err) {
|
package/dist/commands/search.js
CHANGED
|
@@ -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
|
-
|
|
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)')}
|
|
133
|
-
|
|
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 {
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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/commands/usage.js
CHANGED
|
@@ -104,45 +104,59 @@ export async function usageScanCommand(opts) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
* `prave usage report` — invoked by the Claude Code `PostToolUse` hook
|
|
107
|
+
* `prave usage report` — invoked by the Claude Code `PostToolUse` hook
|
|
108
|
+
* (and, when `--source=prompt`, the companion `UserPromptSubmit` hook).
|
|
108
109
|
* Reads the hook payload from stdin, extracts the Skill name, and fires
|
|
109
110
|
* a single-event POST against the slug-keyed self-healing endpoint.
|
|
110
111
|
* Errors are silent (we never want a hook failure to disturb the user's
|
|
111
|
-
* workflow);
|
|
112
|
-
*
|
|
112
|
+
* workflow); a rotated `~/.prave/hook.log` always captures the last 200
|
|
113
|
+
* fires so users can verify telemetry without flipping `PRAVE_DEBUG=1`.
|
|
114
|
+
*
|
|
115
|
+
* Hook discipline:
|
|
116
|
+
* - reads stdin with a 250 ms cap so we never block Claude Code
|
|
117
|
+
* - one POST attempt with a hard 4 s deadline (background-friendly)
|
|
118
|
+
* - on auth/network failure we log + bail; never throw
|
|
113
119
|
*/
|
|
114
|
-
export async function usageReportCommand() {
|
|
120
|
+
export async function usageReportCommand(opts = {}) {
|
|
115
121
|
const debug = process.env.PRAVE_DEBUG === '1' || process.env.PRAVE_DEBUG === 'true';
|
|
122
|
+
const source = opts.source === 'prompt' ? 'prompt' : 'tool';
|
|
123
|
+
await rotateHookLog();
|
|
116
124
|
const stdinPayload = await readStdin();
|
|
117
125
|
if (!stdinPayload) {
|
|
126
|
+
await hookLog(`${source}:no-stdin`);
|
|
118
127
|
if (debug)
|
|
119
128
|
await debugLog('no stdin payload');
|
|
120
129
|
return;
|
|
121
130
|
}
|
|
122
131
|
if (debug)
|
|
123
|
-
await debugLog(`stdin len=${stdinPayload.length}`);
|
|
132
|
+
await debugLog(`stdin len=${stdinPayload.length} source=${source}`);
|
|
124
133
|
// Claude Code's PostToolUse payload is documented as `{tool_name,
|
|
125
|
-
// tool_input, tool_response, ...}
|
|
126
|
-
//
|
|
127
|
-
// doesn't silently break on a single
|
|
134
|
+
// tool_input, tool_response, ...}`. UserPromptSubmit ships
|
|
135
|
+
// `{ prompt, ... }`. We defend against alternate shapes (capitalisation,
|
|
136
|
+
// future schema drift) so the hook doesn't silently break on a single
|
|
137
|
+
// field rename.
|
|
128
138
|
let parsed = {};
|
|
129
139
|
try {
|
|
130
140
|
parsed = JSON.parse(stdinPayload);
|
|
131
141
|
}
|
|
132
142
|
catch {
|
|
143
|
+
await hookLog(`${source}:bad-json`);
|
|
133
144
|
if (debug)
|
|
134
145
|
await debugLog('payload is not valid JSON');
|
|
135
146
|
return;
|
|
136
147
|
}
|
|
137
|
-
const rawSlug = extractSkillSlug(parsed);
|
|
148
|
+
const rawSlug = source === 'prompt' ? extractSlashCommand(parsed) : extractSkillSlug(parsed);
|
|
138
149
|
if (!rawSlug) {
|
|
150
|
+
await hookLog(`${source}:no-slug`);
|
|
139
151
|
if (debug)
|
|
140
152
|
await debugLog(`no skill slug in payload: ${stdinPayload.slice(0, 200)}`);
|
|
141
153
|
return;
|
|
142
154
|
}
|
|
143
155
|
const slug = rawSlug.toLowerCase().split(':').pop()?.trim().replace(/[^a-z0-9_-]+/g, '-');
|
|
144
|
-
if (!slug)
|
|
156
|
+
if (!slug) {
|
|
157
|
+
await hookLog(`${source}:empty-slug`);
|
|
145
158
|
return;
|
|
159
|
+
}
|
|
146
160
|
if (debug)
|
|
147
161
|
await debugLog(`slug=${slug}`);
|
|
148
162
|
const session = await requireAuthSilent();
|
|
@@ -183,18 +197,46 @@ export async function usageReportCommand() {
|
|
|
183
197
|
slug,
|
|
184
198
|
agent_type: 'claude',
|
|
185
199
|
triggered_at: new Date().toISOString(),
|
|
186
|
-
meta,
|
|
200
|
+
meta: { ...meta, source },
|
|
187
201
|
}, true);
|
|
202
|
+
await hookLog(`${source}:ok slug=${slug} recorded=${data.recorded} stub=${data.created_stub}`);
|
|
188
203
|
if (debug) {
|
|
189
204
|
await debugLog(`ok recorded=${data.recorded} stub=${data.created_stub} meta=${JSON.stringify(meta)}`);
|
|
190
205
|
}
|
|
191
206
|
}
|
|
192
207
|
catch (err) {
|
|
208
|
+
const msg = err.message;
|
|
209
|
+
await hookLog(`${source}:err slug=${slug} ${msg.slice(0, 120)}`);
|
|
193
210
|
if (debug)
|
|
194
|
-
await debugLog(`error: ${
|
|
211
|
+
await debugLog(`error: ${msg}`);
|
|
195
212
|
/* silent — never break the host shell */
|
|
196
213
|
}
|
|
197
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Extract a slash-command name from a UserPromptSubmit payload. Claude
|
|
217
|
+
* Code ships `{ prompt: '/graphify ...' }` (and tolerates leading
|
|
218
|
+
* whitespace). We treat the first whitespace-separated token after `/`
|
|
219
|
+
* as the slug. Returns `null` for non-slash prompts so we don't waste
|
|
220
|
+
* an API hop on every keystroke.
|
|
221
|
+
*/
|
|
222
|
+
function extractSlashCommand(payload) {
|
|
223
|
+
const candidates = [
|
|
224
|
+
payload.prompt,
|
|
225
|
+
payload.user_prompt,
|
|
226
|
+
payload.input,
|
|
227
|
+
];
|
|
228
|
+
for (const c of candidates) {
|
|
229
|
+
if (typeof c !== 'string')
|
|
230
|
+
continue;
|
|
231
|
+
const trimmed = c.trimStart();
|
|
232
|
+
if (!trimmed.startsWith('/'))
|
|
233
|
+
continue;
|
|
234
|
+
const token = trimmed.slice(1).split(/[\s\n\r]/, 1)[0] ?? '';
|
|
235
|
+
if (token)
|
|
236
|
+
return token;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
198
240
|
/**
|
|
199
241
|
* Walk the hook payload looking for the invoked Skill's slug. Tries the
|
|
200
242
|
* documented Claude Code path first, then several plausible aliases so
|
|
@@ -226,6 +268,45 @@ async function debugLog(line) {
|
|
|
226
268
|
/* swallow — debug is best-effort */
|
|
227
269
|
}
|
|
228
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Always-on per-fire breadcrumb log so users can answer "did the hook
|
|
273
|
+
* run?" without flipping `PRAVE_DEBUG=1`. Capped at 200 lines (rotated
|
|
274
|
+
* lazily by `rotateHookLog`). Lives at `~/.prave/hook.log`.
|
|
275
|
+
*/
|
|
276
|
+
async function hookLog(line) {
|
|
277
|
+
try {
|
|
278
|
+
const { mkdir, appendFile } = await import('node:fs/promises');
|
|
279
|
+
const { join } = await import('node:path');
|
|
280
|
+
await mkdir(CONFIG.praveDir, { recursive: true });
|
|
281
|
+
const stamp = new Date().toISOString();
|
|
282
|
+
await appendFile(join(CONFIG.praveDir, 'hook.log'), `${stamp} ${line}\n`);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* swallow */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const HOOK_LOG_MAX_LINES = 200;
|
|
289
|
+
async function rotateHookLog() {
|
|
290
|
+
try {
|
|
291
|
+
const { stat, readFile, writeFile } = await import('node:fs/promises');
|
|
292
|
+
const { join } = await import('node:path');
|
|
293
|
+
const path = join(CONFIG.praveDir, 'hook.log');
|
|
294
|
+
const info = await stat(path).catch(() => null);
|
|
295
|
+
// Only do the read-rewrite dance occasionally — when the file gets
|
|
296
|
+
// big enough that we suspect overflow. 32 KB ≈ 200 short lines.
|
|
297
|
+
if (!info || info.size < 32 * 1024)
|
|
298
|
+
return;
|
|
299
|
+
const raw = await readFile(path, 'utf8');
|
|
300
|
+
const lines = raw.split('\n');
|
|
301
|
+
if (lines.length <= HOOK_LOG_MAX_LINES)
|
|
302
|
+
return;
|
|
303
|
+
const trimmed = lines.slice(-HOOK_LOG_MAX_LINES).join('\n');
|
|
304
|
+
await writeFile(path, trimmed, 'utf8');
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
/* swallow */
|
|
308
|
+
}
|
|
309
|
+
}
|
|
229
310
|
export async function usageHookInstallCommand() {
|
|
230
311
|
track('cli_usage_hook_install');
|
|
231
312
|
const session = await requireAuth('prave usage hook install');
|
|
@@ -269,16 +350,22 @@ export async function usageStatusCommand() {
|
|
|
269
350
|
const { readFile } = await import('node:fs/promises');
|
|
270
351
|
const { join } = await import('node:path');
|
|
271
352
|
const { homedir } = await import('node:os');
|
|
272
|
-
// 1. Hook installed?
|
|
353
|
+
// 1. Hook installed? Check both channels we manage.
|
|
273
354
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
274
|
-
let
|
|
355
|
+
let toolHookInstalled = false;
|
|
356
|
+
let promptHookInstalled = false;
|
|
275
357
|
try {
|
|
276
358
|
const raw = await readFile(settingsPath, 'utf8');
|
|
277
|
-
|
|
359
|
+
const parsed = JSON.parse(raw);
|
|
360
|
+
toolHookInstalled =
|
|
361
|
+
(parsed.hooks?.PostToolUse ?? []).some((b) => b.matcher === 'Skill' && (b.hooks ?? []).some((h) => h.__prave_managed === true));
|
|
362
|
+
promptHookInstalled =
|
|
363
|
+
(parsed.hooks?.UserPromptSubmit ?? []).some((b) => (b.hooks ?? []).some((h) => h.__prave_managed === true));
|
|
278
364
|
}
|
|
279
365
|
catch {
|
|
280
366
|
/* no settings.json yet */
|
|
281
367
|
}
|
|
368
|
+
const hookInstalled = toolHookInstalled && promptHookInstalled;
|
|
282
369
|
// 2. Last 7 days of recorded events from the API.
|
|
283
370
|
let recent7 = 0;
|
|
284
371
|
let topSlugs = [];
|
|
@@ -300,14 +387,33 @@ export async function usageStatusCommand() {
|
|
|
300
387
|
catch {
|
|
301
388
|
/* never scanned */
|
|
302
389
|
}
|
|
303
|
-
// 4. Debug log
|
|
304
|
-
const
|
|
305
|
-
const
|
|
390
|
+
// 4. Debug log + hook log tails.
|
|
391
|
+
const debugLogPath = join(CONFIG.praveDir, 'usage.log');
|
|
392
|
+
const hookLogPath = join(CONFIG.praveDir, 'hook.log');
|
|
393
|
+
const debugAvailable = existsSync(debugLogPath);
|
|
394
|
+
const hookLogAvailable = existsSync(hookLogPath);
|
|
395
|
+
// 5. API reachability — token-validating ping. We already passed
|
|
396
|
+
// `requireAuth` above so the credentials file exists; this round-trip
|
|
397
|
+
// just confirms the access_token is still accepted server-side. If the
|
|
398
|
+
// hook has been silently 401-ing for a week, this is the only way the
|
|
399
|
+
// user finds out without flipping `PRAVE_DEBUG=1`.
|
|
400
|
+
let apiReachable = false;
|
|
401
|
+
let apiMessage = '';
|
|
402
|
+
try {
|
|
403
|
+
await api.get('/api/v1/intelligence/usage/recent?days=1', true);
|
|
404
|
+
apiReachable = true;
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
apiMessage =
|
|
408
|
+
err instanceof ApiError ? `${err.status} ${err.message}` : err.message;
|
|
409
|
+
}
|
|
306
410
|
// Render.
|
|
307
|
-
const checkmark = (ok) => (ok ? chalk.green('✓') : chalk.
|
|
411
|
+
const checkmark = (ok) => (ok ? chalk.green('✓') : chalk.red('✗'));
|
|
308
412
|
log.info(chalk.bold('Usage tracking status'));
|
|
309
413
|
console.log();
|
|
310
|
-
console.log(` ${checkmark(
|
|
414
|
+
console.log(` ${checkmark(toolHookInstalled)} PostToolUse hook (Skill tool fires): ${toolHookInstalled ? chalk.green('installed') : chalk.yellow('missing — `prave usage hook install`')}`);
|
|
415
|
+
console.log(` ${checkmark(promptHookInstalled)} UserPromptSubmit hook (slash commands like /graphify): ${promptHookInstalled ? chalk.green('installed') : chalk.yellow('missing — `prave usage hook install`')}`);
|
|
416
|
+
console.log(` ${checkmark(apiReachable)} API reachable + auth valid: ${apiReachable ? chalk.green('yes') : chalk.red(apiMessage || 'no')}`);
|
|
311
417
|
console.log(` ${checkmark(Boolean(lastScanAt))} Transcript scanner watermark: ${lastScanAt ?? chalk.dim('never run — `prave sync` includes it')}`);
|
|
312
418
|
console.log(` ${checkmark(recent7 > 0)} Events in last 7 days: ${chalk.cyan(String(recent7))}`);
|
|
313
419
|
if (topSlugs.length) {
|
|
@@ -317,20 +423,41 @@ export async function usageStatusCommand() {
|
|
|
317
423
|
console.log(` ${chalk.cyan(s.count.toString().padStart(4))} ${s.name}`);
|
|
318
424
|
}
|
|
319
425
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
426
|
+
// Last 5 hook fires — the breadcrumb trail that answers "did the hook
|
|
427
|
+
// even run when I typed /graphify?". Read the bottom of hook.log.
|
|
428
|
+
if (hookLogAvailable) {
|
|
429
|
+
try {
|
|
430
|
+
const raw = await readFile(hookLogPath, 'utf8');
|
|
431
|
+
const tail = raw.trimEnd().split('\n').slice(-5);
|
|
432
|
+
if (tail.length) {
|
|
433
|
+
console.log();
|
|
434
|
+
console.log(chalk.dim(' Last hook fires:'));
|
|
435
|
+
for (const line of tail)
|
|
436
|
+
console.log(` ${chalk.dim(line)}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
/* swallow */
|
|
441
|
+
}
|
|
327
442
|
}
|
|
328
|
-
|
|
443
|
+
console.log();
|
|
444
|
+
log.dim(`Hook breadcrumb log: ${hookLogPath}`);
|
|
445
|
+
if (debugAvailable)
|
|
446
|
+
log.dim(`Verbose debug log: ${debugLogPath}`);
|
|
447
|
+
log.dim('Set PRAVE_DEBUG=1 in your shell to enable verbose hook logging.');
|
|
448
|
+
if (!hookInstalled || !apiReachable || recent7 === 0) {
|
|
329
449
|
console.log();
|
|
330
|
-
log.warn('
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
450
|
+
log.warn('Telemetry may be incomplete. Suggested fixes:');
|
|
451
|
+
if (!toolHookInstalled || !promptHookInstalled) {
|
|
452
|
+
log.dim(' • Run `prave usage hook install` to wire BOTH PostToolUse and UserPromptSubmit.');
|
|
453
|
+
}
|
|
454
|
+
if (!apiReachable) {
|
|
455
|
+
log.dim(' • Run `prave login` — your access token may have expired (silently 401-ing).');
|
|
456
|
+
}
|
|
457
|
+
if (recent7 === 0 && hookInstalled && apiReachable) {
|
|
458
|
+
log.dim(' • Run a Skill in Claude Code, then re-run `prave usage status`.');
|
|
459
|
+
log.dim(' • Or run `prave usage scan --since 7d` to backfill from transcripts.');
|
|
460
|
+
}
|
|
334
461
|
}
|
|
335
462
|
}
|
|
336
463
|
async function fetchEnabledAgents() {
|
|
@@ -414,13 +541,37 @@ function parseSinceFlag(raw) {
|
|
|
414
541
|
async function readStdin() {
|
|
415
542
|
if (process.stdin.isTTY)
|
|
416
543
|
return '';
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
544
|
+
// Hard 1.5 s cap so a stuck pipe never blocks Claude Code's tool flow.
|
|
545
|
+
// 1 MB payload cap so a runaway stream can't OOM the hook either.
|
|
546
|
+
return new Promise((resolve) => {
|
|
547
|
+
const chunks = [];
|
|
548
|
+
let total = 0;
|
|
549
|
+
let settled = false;
|
|
550
|
+
const finish = () => {
|
|
551
|
+
if (settled)
|
|
552
|
+
return;
|
|
553
|
+
settled = true;
|
|
554
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
555
|
+
};
|
|
556
|
+
const timer = setTimeout(finish, 1500);
|
|
557
|
+
process.stdin.on('data', (chunk) => {
|
|
558
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
559
|
+
chunks.push(buf);
|
|
560
|
+
total += buf.length;
|
|
561
|
+
if (total > 1_000_000) {
|
|
562
|
+
clearTimeout(timer);
|
|
563
|
+
finish();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
process.stdin.once('end', () => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
finish();
|
|
569
|
+
});
|
|
570
|
+
process.stdin.once('error', () => {
|
|
571
|
+
clearTimeout(timer);
|
|
572
|
+
finish();
|
|
573
|
+
});
|
|
574
|
+
});
|
|
424
575
|
}
|
|
425
576
|
/**
|
|
426
577
|
* Auth check with no UX side-effects. Used by the hook so a logged-out
|
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';
|
|
@@ -120,12 +119,12 @@ program
|
|
|
120
119
|
program
|
|
121
120
|
.command('conflicts')
|
|
122
121
|
.description('Detect overlap, collisions, and missing dependencies')
|
|
123
|
-
.option('--fix', '
|
|
122
|
+
.option('--fix', 'after listing conflicts, interactively pick installed Skills to uninstall to resolve them')
|
|
124
123
|
.action(conflictsCommand);
|
|
125
124
|
program
|
|
126
125
|
.command('optimize')
|
|
127
126
|
.description('Recommendations: underused, mergeable, and heavy skills')
|
|
128
|
-
.option('--apply', '
|
|
127
|
+
.option('--apply', 'after listing recommendations, interactively pick underused + heavy skills to uninstall')
|
|
129
128
|
.option('--remove-unused', 'interactively delete skills that have not fired in 30+ days from ~/.claude/skills/')
|
|
130
129
|
.option('--yes', 'with --remove-unused: skip the confirmation prompt')
|
|
131
130
|
.action(optimizeCommand);
|
|
@@ -144,8 +143,9 @@ usage
|
|
|
144
143
|
.action(usageStatusCommand);
|
|
145
144
|
usage
|
|
146
145
|
.command('report')
|
|
147
|
-
.description('Internal: invoked by the Claude Code PostToolUse hook (reads stdin)')
|
|
148
|
-
.
|
|
146
|
+
.description('Internal: invoked by the Claude Code PostToolUse / UserPromptSubmit hook (reads stdin)')
|
|
147
|
+
.option('--source <kind>', 'hook channel that fired this report ("tool" or "prompt")', 'tool')
|
|
148
|
+
.action((opts) => usageReportCommand(opts));
|
|
149
149
|
const hook = usage.command('hook').description('Install/uninstall the Claude Code real-time usage hook');
|
|
150
150
|
hook
|
|
151
151
|
.command('install')
|
|
@@ -155,13 +155,6 @@ hook
|
|
|
155
155
|
.command('uninstall')
|
|
156
156
|
.description('Remove the Prave-managed hook from ~/.claude/settings.json')
|
|
157
157
|
.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
158
|
program
|
|
166
159
|
.command('deploy <skillname>')
|
|
167
160
|
.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 +176,6 @@ program
|
|
|
183
176
|
'',
|
|
184
177
|
'Discover & install',
|
|
185
178
|
' prave search <q> # public skill search',
|
|
186
|
-
' prave find <q> [--smart|--local] # smart cross-source search',
|
|
187
179
|
' prave install <slug> [--no-deps] # install into ~/.claude/skills/',
|
|
188
180
|
' prave uninstall <slug> # remove a local skill',
|
|
189
181
|
' prave list [--remote] [--verbose] # what is installed (or remote)',
|
package/dist/lib/hook.js
CHANGED
|
@@ -22,25 +22,50 @@ const HOOK_MARKER = '__prave_managed';
|
|
|
22
22
|
*/
|
|
23
23
|
export const HOOK_SUPPORTED = ['claude'];
|
|
24
24
|
const HOOK_COMMAND = 'prave usage report';
|
|
25
|
+
// Companion command for the UserPromptSubmit channel so a typed-slash
|
|
26
|
+
// `/graphify` is captured even when the Skill tool path doesn't fire a
|
|
27
|
+
// matching PostToolUse with a populated `tool_input.skill` field.
|
|
28
|
+
const PROMPT_HOOK_COMMAND = 'prave usage report --source=prompt';
|
|
29
|
+
/**
|
|
30
|
+
* Install on BOTH `PostToolUse` (matcher `Skill`) and `UserPromptSubmit`
|
|
31
|
+
* (catches slash commands like `/graphify` before tool dispatch). The two
|
|
32
|
+
* channels are deduped server-side by per-minute bucket, so double-fires
|
|
33
|
+
* for the same Skill in the same minute land as a single row — but every
|
|
34
|
+
* additional minute of activity is preserved. Idempotent + atomic.
|
|
35
|
+
*/
|
|
25
36
|
export async function installSkillHook() {
|
|
26
37
|
const settings = await readSettings();
|
|
27
38
|
settings.hooks ??= {};
|
|
28
39
|
settings.hooks.PostToolUse ??= [];
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
40
|
+
settings.hooks.UserPromptSubmit ??= [];
|
|
41
|
+
const postBlocks = settings.hooks.PostToolUse;
|
|
42
|
+
const promptBlocks = settings.hooks.UserPromptSubmit;
|
|
43
|
+
const postFresh = {
|
|
32
44
|
matcher: 'Skill',
|
|
33
45
|
hooks: [{ type: 'command', command: HOOK_COMMAND, [HOOK_MARKER]: true }],
|
|
34
46
|
};
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
const promptFresh = {
|
|
48
|
+
// UserPromptSubmit has no matcher concept in Claude Code today — the
|
|
49
|
+
// hook script itself filters for `prompt.startsWith('/')` before
|
|
50
|
+
// doing any work.
|
|
51
|
+
hooks: [{ type: 'command', command: PROMPT_HOOK_COMMAND, [HOOK_MARKER]: true }],
|
|
52
|
+
};
|
|
53
|
+
const upsert = (blocks, fresh, expectedCmd) => {
|
|
54
|
+
const idx = blocks.findIndex((b) => b.matcher === fresh.matcher && b.hooks?.some((h) => h[HOOK_MARKER]));
|
|
55
|
+
if (idx >= 0) {
|
|
56
|
+
const existingCmd = blocks[idx]?.hooks?.[0]?.command;
|
|
57
|
+
if (existingCmd === expectedCmd)
|
|
58
|
+
return false;
|
|
59
|
+
blocks[idx] = fresh;
|
|
60
|
+
return true;
|
|
39
61
|
}
|
|
40
|
-
blocks[existingIdx] = fresh;
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
62
|
blocks.push(fresh);
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
const changedPost = upsert(postBlocks, postFresh, HOOK_COMMAND);
|
|
66
|
+
const changedPrompt = upsert(promptBlocks, promptFresh, PROMPT_HOOK_COMMAND);
|
|
67
|
+
if (!changedPost && !changedPrompt) {
|
|
68
|
+
return { installed: false, alreadyPresent: true, settingsPath: SETTINGS_PATH };
|
|
44
69
|
}
|
|
45
70
|
await writeSettings(settings);
|
|
46
71
|
return { installed: true, alreadyPresent: false, settingsPath: SETTINGS_PATH };
|
|
@@ -93,25 +118,35 @@ export async function uninstallHooksForAgents(agents) {
|
|
|
93
118
|
}
|
|
94
119
|
export async function uninstallSkillHook() {
|
|
95
120
|
const settings = await readSettings();
|
|
96
|
-
|
|
97
|
-
if (!blocks?.length)
|
|
121
|
+
if (!settings.hooks)
|
|
98
122
|
return { removed: false, settingsPath: SETTINGS_PATH };
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
let touched = false;
|
|
124
|
+
const stripChannel = (channel) => {
|
|
125
|
+
const blocks = settings.hooks?.[channel];
|
|
126
|
+
if (!blocks?.length)
|
|
127
|
+
return;
|
|
128
|
+
const beforeCounts = blocks.map((b) => b.hooks?.length ?? 0);
|
|
129
|
+
const filtered = blocks
|
|
130
|
+
.map((b) => ({ ...b, hooks: b.hooks?.filter((h) => !h[HOOK_MARKER]) }))
|
|
131
|
+
.filter((b) => (b.hooks?.length ?? 0) > 0);
|
|
132
|
+
const lengthChanged = filtered.length !== blocks.length;
|
|
133
|
+
const innerChanged = !lengthChanged &&
|
|
134
|
+
filtered.some((b, i) => (b.hooks?.length ?? 0) !== beforeCounts[i]);
|
|
135
|
+
if (!lengthChanged && !innerChanged)
|
|
136
|
+
return;
|
|
137
|
+
touched = true;
|
|
138
|
+
if (settings.hooks) {
|
|
139
|
+
settings.hooks[channel] = filtered.length ? filtered : undefined;
|
|
140
|
+
if (!filtered.length)
|
|
141
|
+
delete settings.hooks[channel];
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
stripChannel('PostToolUse');
|
|
145
|
+
stripChannel('UserPromptSubmit');
|
|
146
|
+
if (!touched)
|
|
107
147
|
return { removed: false, settingsPath: SETTINGS_PATH };
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
settings.hooks.PostToolUse = filtered;
|
|
111
|
-
if (!filtered.length)
|
|
112
|
-
delete settings.hooks.PostToolUse;
|
|
113
|
-
if (Object.keys(settings.hooks).length === 0)
|
|
114
|
-
delete settings.hooks;
|
|
148
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
149
|
+
delete settings.hooks;
|
|
115
150
|
}
|
|
116
151
|
await writeSettings(settings);
|
|
117
152
|
return { removed: true, settingsPath: SETTINGS_PATH };
|
|
@@ -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
|
+
}
|
package/dist/lib/time.js
ADDED
|
@@ -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
|
|
3
|
+
"version": "1.2.1",
|
|
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
|
|
54
|
+
"@prave/shared": "1.2.1"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^20.12.7",
|
package/dist/commands/find.js
DELETED
|
@@ -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
|
-
}
|