@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.
- package/dist/commands/conflicts.js +4 -0
- package/dist/commands/deploy.js +4 -0
- package/dist/commands/import.js +24 -2
- package/dist/commands/install.js +14 -7
- package/dist/commands/login.js +10 -0
- package/dist/commands/optimize.js +4 -0
- package/dist/commands/overview.js +4 -0
- package/dist/commands/settings.js +13 -31
- package/dist/commands/sync.js +54 -12
- package/dist/commands/update.js +165 -0
- package/dist/commands/usage.js +267 -0
- package/dist/commands/whoami.js +31 -3
- package/dist/index.js +33 -2
- package/dist/lib/agent-onboarding.js +107 -0
- package/dist/lib/api.js +54 -2
- package/dist/lib/credentials.js +21 -0
- package/dist/lib/hook.js +131 -0
- package/dist/lib/prompt.js +124 -0
- package/dist/lib/usage-cursor.js +34 -0
- package/dist/lib/usage-scanner.js +154 -0
- package/package.json +2 -2
package/dist/lib/hook.js
ADDED
|
@@ -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.
|
|
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.
|
|
19
|
+
"@prave/shared": "1.0.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^20.12.7",
|