@prave/cli 0.4.10 → 1.0.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.
@@ -0,0 +1,131 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ /**
5
+ * Real-time usage hook installer. Currently only Claude Code exposes a
6
+ * suitable hook contract (`PostToolUse` with stdin payload) — the other
7
+ * supported agents (Codex, Cursor, Gemini, Cline, Amp) don't yet ship
8
+ * a comparable mechanism, so for those we degrade gracefully and let
9
+ * the transcript scanner cover them once they do.
10
+ *
11
+ * The Claude block is wrapped with sentinel keys (`__prave_managed: true`)
12
+ * so the uninstaller can find and remove only the Prave entry without
13
+ * disturbing user-authored hooks. Idempotent: re-installing just refreshes.
14
+ */
15
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
16
+ const HOOK_MARKER = '__prave_managed';
17
+ /**
18
+ * Agents that currently support a real-time invocation hook. Adding a new
19
+ * agent to this list means implementing its `installFor<Agent>` branch
20
+ * below; until then we surface "not yet supported" so users are never
21
+ * misled about what's actually being instrumented.
22
+ */
23
+ export const HOOK_SUPPORTED = ['claude'];
24
+ const HOOK_COMMAND = 'prave usage report';
25
+ export async function installSkillHook() {
26
+ const settings = await readSettings();
27
+ settings.hooks ??= {};
28
+ settings.hooks.PostToolUse ??= [];
29
+ const blocks = settings.hooks.PostToolUse;
30
+ const existingIdx = blocks.findIndex((b) => b.matcher === 'Skill' && b.hooks?.some((h) => h[HOOK_MARKER]));
31
+ const fresh = {
32
+ matcher: 'Skill',
33
+ hooks: [{ type: 'command', command: HOOK_COMMAND, [HOOK_MARKER]: true }],
34
+ };
35
+ if (existingIdx >= 0) {
36
+ const existingCmd = blocks[existingIdx]?.hooks?.[0]?.command;
37
+ if (existingCmd === HOOK_COMMAND) {
38
+ return { installed: false, alreadyPresent: true, settingsPath: SETTINGS_PATH };
39
+ }
40
+ blocks[existingIdx] = fresh;
41
+ }
42
+ else {
43
+ blocks.push(fresh);
44
+ }
45
+ await writeSettings(settings);
46
+ return { installed: true, alreadyPresent: false, settingsPath: SETTINGS_PATH };
47
+ }
48
+ export async function installHooksForAgents(agents) {
49
+ const out = [];
50
+ for (const agent of agents) {
51
+ if (!HOOK_SUPPORTED.includes(agent)) {
52
+ out.push({
53
+ agent,
54
+ status: 'unsupported',
55
+ message: `${agent} doesn't expose a real-time hook contract yet — transcript scanner via \`prave sync\` will track usage when supported.`,
56
+ });
57
+ continue;
58
+ }
59
+ try {
60
+ const result = await installSkillHook();
61
+ out.push({
62
+ agent,
63
+ status: result.alreadyPresent ? 'already' : 'installed',
64
+ message: result.settingsPath,
65
+ });
66
+ }
67
+ catch (err) {
68
+ out.push({ agent, status: 'error', message: err.message });
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ export async function uninstallHooksForAgents(agents) {
74
+ const out = [];
75
+ for (const agent of agents) {
76
+ if (!HOOK_SUPPORTED.includes(agent)) {
77
+ out.push({ agent, status: 'unsupported' });
78
+ continue;
79
+ }
80
+ try {
81
+ const result = await uninstallSkillHook();
82
+ out.push({
83
+ agent,
84
+ status: result.removed ? 'installed' : 'already',
85
+ message: result.settingsPath,
86
+ });
87
+ }
88
+ catch (err) {
89
+ out.push({ agent, status: 'error', message: err.message });
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ export async function uninstallSkillHook() {
95
+ const settings = await readSettings();
96
+ const blocks = settings.hooks?.PostToolUse;
97
+ if (!blocks?.length)
98
+ return { removed: false, settingsPath: SETTINGS_PATH };
99
+ const before = blocks.length;
100
+ const filtered = blocks
101
+ .map((b) => ({
102
+ ...b,
103
+ hooks: b.hooks?.filter((h) => !h[HOOK_MARKER]),
104
+ }))
105
+ .filter((b) => (b.hooks?.length ?? 0) > 0);
106
+ if (filtered.length === before && filtered.every((b, i) => b.hooks?.length === blocks[i]?.hooks?.length)) {
107
+ return { removed: false, settingsPath: SETTINGS_PATH };
108
+ }
109
+ if (settings.hooks) {
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;
115
+ }
116
+ await writeSettings(settings);
117
+ return { removed: true, settingsPath: SETTINGS_PATH };
118
+ }
119
+ async function readSettings() {
120
+ try {
121
+ const raw = await readFile(SETTINGS_PATH, 'utf8');
122
+ return JSON.parse(raw);
123
+ }
124
+ catch {
125
+ return {};
126
+ }
127
+ }
128
+ async function writeSettings(settings) {
129
+ await mkdir(dirname(SETTINGS_PATH), { recursive: true });
130
+ await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
131
+ }
@@ -0,0 +1,124 @@
1
+ import { stdin, stdout } from 'node:process';
2
+ import readline from 'node:readline';
3
+ import chalk from 'chalk';
4
+ export async function checkboxPrompt(question, items, opts = {}) {
5
+ if (!stdin.isTTY || !stdout.isTTY) {
6
+ return fallbackPrompt(question, items, opts);
7
+ }
8
+ const selected = new Set(opts.initial ?? []);
9
+ let cursor = 0;
10
+ const minSelected = opts.minSelected ?? 0;
11
+ const cleanup = () => {
12
+ if (typeof stdin.setRawMode === 'function')
13
+ stdin.setRawMode(false);
14
+ stdin.removeAllListeners('keypress');
15
+ stdin.pause();
16
+ stdout.write(showCursor);
17
+ };
18
+ return new Promise((resolve) => {
19
+ readline.emitKeypressEvents(stdin);
20
+ if (typeof stdin.setRawMode === 'function')
21
+ stdin.setRawMode(true);
22
+ stdin.resume();
23
+ stdout.write(hideCursor);
24
+ let firstRender = true;
25
+ const render = () => {
26
+ if (!firstRender) {
27
+ // Move cursor up to the start of our last render and erase down.
28
+ stdout.write(`\x1b[${items.length + 2}A`);
29
+ stdout.write('\x1b[J');
30
+ }
31
+ firstRender = false;
32
+ stdout.write(`${chalk.bold(question)} ${chalk.dim('(space=toggle, a=all, enter=confirm, esc=cancel)')}\n`);
33
+ for (let i = 0; i < items.length; i++) {
34
+ const item = items[i];
35
+ const isCursor = i === cursor;
36
+ const isSelected = selected.has(item.value);
37
+ const box = isSelected ? chalk.green('◉') : chalk.dim('◯');
38
+ const arrow = isCursor ? chalk.cyan('›') : ' ';
39
+ const label = isCursor ? chalk.cyan(item.label) : item.label;
40
+ const hint = item.hint ? chalk.dim(` — ${item.hint}`) : '';
41
+ stdout.write(`${arrow} ${box} ${label}${hint}\n`);
42
+ }
43
+ const ok = selected.size >= minSelected;
44
+ stdout.write(`${chalk.dim(ok
45
+ ? `${selected.size} selected · enter to confirm`
46
+ : `at least ${minSelected} required · ${selected.size} selected`)}\n`);
47
+ };
48
+ const onKey = (_str, key) => {
49
+ if (!key)
50
+ return;
51
+ if (key.ctrl && key.name === 'c') {
52
+ cleanup();
53
+ resolve(null);
54
+ return;
55
+ }
56
+ switch (key.name) {
57
+ case 'up':
58
+ case 'k':
59
+ cursor = (cursor - 1 + items.length) % items.length;
60
+ break;
61
+ case 'down':
62
+ case 'j':
63
+ cursor = (cursor + 1) % items.length;
64
+ break;
65
+ case 'space': {
66
+ const v = items[cursor].value;
67
+ if (selected.has(v))
68
+ selected.delete(v);
69
+ else
70
+ selected.add(v);
71
+ break;
72
+ }
73
+ case 'a':
74
+ if (selected.size === items.length)
75
+ selected.clear();
76
+ else
77
+ for (const i of items)
78
+ selected.add(i.value);
79
+ break;
80
+ case 'return':
81
+ if (selected.size < minSelected)
82
+ return;
83
+ cleanup();
84
+ resolve([...selected]);
85
+ return;
86
+ case 'escape':
87
+ case 'q':
88
+ cleanup();
89
+ resolve(null);
90
+ return;
91
+ }
92
+ render();
93
+ };
94
+ stdin.on('keypress', onKey);
95
+ render();
96
+ });
97
+ }
98
+ const hideCursor = '\x1b[?25l';
99
+ const showCursor = '\x1b[?25h';
100
+ async function fallbackPrompt(question, items, opts) {
101
+ const { createInterface } = await import('node:readline/promises');
102
+ const rl = createInterface({ input: stdin, output: stdout });
103
+ try {
104
+ stdout.write(`${question}\n`);
105
+ for (const i of items) {
106
+ stdout.write(` - ${i.value}${i.label !== i.value ? ` (${i.label})` : ''}${i.hint ? ` — ${i.hint}` : ''}\n`);
107
+ }
108
+ const def = (opts.initial ?? []).join(',');
109
+ const ans = (await rl.question(`Comma-separated values${def ? ` [${def}]` : ''}: `)).trim();
110
+ if (!ans && opts.initial)
111
+ return [...opts.initial];
112
+ if (!ans)
113
+ return [];
114
+ const valid = new Set(items.map((i) => i.value));
115
+ const tokens = ans
116
+ .split(',')
117
+ .map((s) => s.trim())
118
+ .filter((t) => valid.has(t));
119
+ return tokens;
120
+ }
121
+ finally {
122
+ rl.close();
123
+ }
124
+ }
@@ -0,0 +1,34 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { CONFIG } from './config.js';
4
+ /**
5
+ * Persistent watermark for the transcript scanner. Without it every
6
+ * `prave usage scan` would re-walk every JSONL we've ever recorded; with
7
+ * it we only re-process files modified since the last successful scan.
8
+ *
9
+ * Stored at `~/.prave/usage-cursor.json` (plain text, no secrets) so it
10
+ * survives across CLI versions and machine reboots.
11
+ */
12
+ const CURSOR_PATH = join(CONFIG.praveDir, 'usage-cursor.json');
13
+ export async function loadCursor() {
14
+ try {
15
+ const raw = await readFile(CURSOR_PATH, 'utf8');
16
+ const parsed = JSON.parse(raw);
17
+ if (parsed.lastScanAt) {
18
+ const ms = Date.parse(parsed.lastScanAt);
19
+ if (!Number.isNaN(ms))
20
+ return new Date(ms);
21
+ }
22
+ }
23
+ catch {
24
+ /* fall through to default */
25
+ }
26
+ // First run: look back 30 days. Matches the optimiser's "30+ days
27
+ // unused" window so the very first scan can fully populate it.
28
+ return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
29
+ }
30
+ export async function saveCursor(at = new Date()) {
31
+ await mkdir(dirname(CURSOR_PATH), { recursive: true });
32
+ const state = { lastScanAt: at.toISOString() };
33
+ await writeFile(CURSOR_PATH, JSON.stringify(state, null, 2), 'utf8');
34
+ }
@@ -0,0 +1,154 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
5
+ /**
6
+ * Scan every .jsonl transcript newer than `since` for Skill invocations.
7
+ *
8
+ * @param installedSlugs the slugs we recognise — anything outside this
9
+ * set is ignored to avoid noise from quoted text.
10
+ * @param since lower bound for transcript modification time. Defaults
11
+ * to 30 days ago on a fresh run; the CLI persists a watermark in
12
+ * `~/.prave/usage-cursor.json` so subsequent runs only re-scan recent
13
+ * transcripts.
14
+ */
15
+ export async function scanTranscriptsForUsage(installedSlugs, since) {
16
+ if (!installedSlugs.length)
17
+ return [];
18
+ const slugSet = new Set(installedSlugs);
19
+ const sinceMs = since.getTime();
20
+ const events = [];
21
+ const projectDirs = await safeReaddir(PROJECTS_DIR);
22
+ for (const projectName of projectDirs) {
23
+ const projectDir = join(PROJECTS_DIR, projectName);
24
+ const projectStat = await safeStat(projectDir);
25
+ if (!projectStat?.isDirectory())
26
+ continue;
27
+ const files = await safeReaddir(projectDir);
28
+ for (const file of files) {
29
+ if (!file.endsWith('.jsonl'))
30
+ continue;
31
+ const path = join(projectDir, file);
32
+ const fileStat = await safeStat(path);
33
+ if (!fileStat || fileStat.mtimeMs < sinceMs)
34
+ continue;
35
+ try {
36
+ const raw = await readFile(path, 'utf8');
37
+ const lines = raw.split('\n');
38
+ for (const line of lines) {
39
+ if (!line)
40
+ continue;
41
+ extractEventsFromLine(line, slugSet, sinceMs, events);
42
+ }
43
+ }
44
+ catch {
45
+ // Skip unreadable / corrupt JSONLs silently.
46
+ }
47
+ }
48
+ }
49
+ return events;
50
+ }
51
+ const COMMAND_NAME_RE = /<command-name>\s*\/?([a-z0-9][a-z0-9_-]{0,80})\s*<\/command-name>/gi;
52
+ /**
53
+ * Per-line extractor. The transcript is one JSON object per line, but
54
+ * we don't always need to parse the whole thing — slash commands land in
55
+ * the `<command-name>` string and a regex hit on the raw line is enough.
56
+ *
57
+ * For the canonical Skill-tool path we DO parse (only when the line
58
+ * actually mentions `"name":"Skill"`, so the parse cost stays bounded).
59
+ */
60
+ function extractEventsFromLine(line, slugSet, sinceMs, out) {
61
+ // Slash-command hits — cheap and don't require JSON.parse.
62
+ let m;
63
+ COMMAND_NAME_RE.lastIndex = 0;
64
+ while ((m = COMMAND_NAME_RE.exec(line))) {
65
+ const slug = m[1]?.toLowerCase();
66
+ if (!slug || !slugSet.has(slug))
67
+ continue;
68
+ const ts = sniffTimestamp(line, sinceMs);
69
+ if (!ts)
70
+ continue;
71
+ out.push({ slug, triggered_at: ts, trigger_phrase: `/${slug}` });
72
+ }
73
+ // Skill tool invocations — only parse when the line claims to have one.
74
+ if (!line.includes('"name":"Skill"'))
75
+ return;
76
+ try {
77
+ const obj = JSON.parse(line);
78
+ walkForSkillToolUse(obj, slugSet, sinceMs, out);
79
+ }
80
+ catch {
81
+ // Malformed line — ignore.
82
+ }
83
+ }
84
+ function walkForSkillToolUse(node, slugSet, sinceMs, out) {
85
+ if (!node)
86
+ return;
87
+ if (Array.isArray(node)) {
88
+ for (const item of node)
89
+ walkForSkillToolUse(item, slugSet, sinceMs, out);
90
+ return;
91
+ }
92
+ if (typeof node !== 'object')
93
+ return;
94
+ const obj = node;
95
+ // Detect a tool_use block at any level.
96
+ const block = obj;
97
+ if (block.type === 'tool_use' && block.name === 'Skill' && block.input?.skill) {
98
+ const slug = String(block.input.skill).toLowerCase().split(':').pop() ?? '';
99
+ if (slug && slugSet.has(slug)) {
100
+ const ts = sniffTimestampFromObject(obj, sinceMs);
101
+ if (ts)
102
+ out.push({ slug, triggered_at: ts, trigger_phrase: null });
103
+ }
104
+ }
105
+ // Recurse into known transcript fields. We don't blindly walk every
106
+ // value — that would re-parse user-prompt text trees we explicitly
107
+ // committed to NOT inspecting.
108
+ for (const key of ['message', 'content', 'children', 'tool_use', 'tool_results']) {
109
+ const child = obj[key];
110
+ if (child)
111
+ walkForSkillToolUse(child, slugSet, sinceMs, out);
112
+ }
113
+ }
114
+ /**
115
+ * Best-effort timestamp recovery from a JSONL line. Claude Code stamps
116
+ * each line with `"timestamp":"<iso>"` at the top level. If we can't
117
+ * find one, we drop the event (we'd rather lose a record than mis-date
118
+ * one).
119
+ */
120
+ function sniffTimestamp(line, sinceMs) {
121
+ const match = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
122
+ if (!match)
123
+ return null;
124
+ const ts = match[1];
125
+ const ms = Date.parse(ts);
126
+ if (Number.isNaN(ms) || ms < sinceMs)
127
+ return null;
128
+ return ts;
129
+ }
130
+ function sniffTimestampFromObject(obj, sinceMs) {
131
+ const ts = obj.timestamp;
132
+ if (typeof ts !== 'string')
133
+ return null;
134
+ const ms = Date.parse(ts);
135
+ if (Number.isNaN(ms) || ms < sinceMs)
136
+ return null;
137
+ return ts;
138
+ }
139
+ async function safeReaddir(path) {
140
+ try {
141
+ return await readdir(path);
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
147
+ async function safeStat(path) {
148
+ try {
149
+ return await stat(path);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "0.4.10",
3
+ "version": "1.0.0",
4
4
  "description": "Prave CLI — import, export, install, sync Claude Skills.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "open": "^10.1.0",
17
17
  "ora": "^8.0.1",
18
18
  "undici": "^6.18.0",
19
- "@prave/shared": "0.3.3"
19
+ "@prave/shared": "1.0.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.12.7",