@prave/cli 1.5.1 → 1.6.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,234 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { track } from '../lib/analytics.js';
4
+ import { api, ApiError } from '../lib/api.js';
5
+ import { requireAuth } from '../lib/credentials.js';
6
+ import { detectCollisions, installRegistryHook, listForeignHooks, listRegistryHooks, removeRegistryHook, } from '../lib/hook.js';
7
+ import { assertSlug, InvalidSlugError } from '../lib/slug.js';
8
+ import { log } from '../utils/logger.js';
9
+ /* ─── install ─────────────────────────────────────────────────── */
10
+ export async function hooksInstallCommand(slug, opts = {}) {
11
+ track('cli_hooks_install', { slug, dry_run: !!opts.dryRun });
12
+ try {
13
+ assertSlug(slug);
14
+ }
15
+ catch (err) {
16
+ if (err instanceof InvalidSlugError) {
17
+ log.error(err.message);
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ throw err;
22
+ }
23
+ await requireAuth('hooks');
24
+ const spinner = ora(`Fetching hook ${chalk.cyan(slug)}…`).start();
25
+ let hook;
26
+ try {
27
+ const { data } = await api.get(`/api/v1/hooks/${encodeURIComponent(slug)}`);
28
+ hook = data;
29
+ }
30
+ catch (err) {
31
+ spinner.fail(`Hook ${chalk.cyan(slug)} not found`);
32
+ if (err instanceof ApiError && err.status !== 404)
33
+ log.error(err.message);
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ spinner.succeed(`Fetched ${chalk.cyan(hook.name)}`);
38
+ const def = {
39
+ slug: hook.slug,
40
+ event: hook.event,
41
+ matcher: hook.matcher,
42
+ command: hook.command,
43
+ timeout_seconds: hook.timeout_seconds,
44
+ };
45
+ const result = await installRegistryHook(def, { dryRun: opts.dryRun });
46
+ if (opts.dryRun) {
47
+ printDryRunDiff(result.before, result.after);
48
+ log.info(`(dry-run) ${result.changed ? 'Would install' : 'No change needed for'} ${chalk.cyan(slug)}.`);
49
+ return;
50
+ }
51
+ if (!result.changed) {
52
+ log.info(`Already installed: ${chalk.cyan(slug)} — no change.`);
53
+ return;
54
+ }
55
+ // Record the install receipt so the dashboard count + trending sort
56
+ // stay accurate. Best-effort: a network blip here must not undo the
57
+ // local install.
58
+ try {
59
+ await api.post(`/api/v1/hooks/${encodeURIComponent(slug)}/install`, {}, true);
60
+ }
61
+ catch (err) {
62
+ log.warn(`Local install succeeded but server receipt failed: ${err.message}`);
63
+ }
64
+ log.success(`Installed ${chalk.cyan(slug)} → ${result.settingsPath}`);
65
+ }
66
+ /* ─── remove ──────────────────────────────────────────────────── */
67
+ export async function hooksRemoveCommand(slug, opts = {}) {
68
+ track('cli_hooks_remove', { slug, dry_run: !!opts.dryRun });
69
+ try {
70
+ assertSlug(slug);
71
+ }
72
+ catch (err) {
73
+ if (err instanceof InvalidSlugError) {
74
+ log.error(err.message);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ throw err;
79
+ }
80
+ const result = await removeRegistryHook(slug, { dryRun: opts.dryRun });
81
+ if (opts.dryRun) {
82
+ printDryRunDiff(result.before, result.after);
83
+ log.info(`(dry-run) ${result.changed ? 'Would remove' : 'No matching hook for'} ${chalk.cyan(slug)}.`);
84
+ return;
85
+ }
86
+ if (!result.changed) {
87
+ log.info(`No installed hook with slug ${chalk.cyan(slug)} — nothing to remove.`);
88
+ return;
89
+ }
90
+ log.success(`Removed ${chalk.cyan(slug)} from ${result.settingsPath}`);
91
+ // Soft uninstall on the server so the user's dashboard count drops.
92
+ try {
93
+ await requireAuth('hooks');
94
+ await api.del(`/api/v1/hooks/${encodeURIComponent(slug)}/install`, true);
95
+ }
96
+ catch {
97
+ /* offline / not logged in — local state is what matters */
98
+ }
99
+ }
100
+ /* ─── list ────────────────────────────────────────────────────── */
101
+ export async function hooksListCommand() {
102
+ track('cli_hooks_list', {});
103
+ const installed = await listRegistryHooks();
104
+ if (installed.length === 0) {
105
+ log.info('No prave-managed hooks installed locally.');
106
+ return;
107
+ }
108
+ log.info(`${chalk.bold(installed.length)} prave-managed hook${installed.length === 1 ? '' : 's'} installed:\n`);
109
+ for (const h of installed) {
110
+ console.log(` ${chalk.cyan(h.slug)} ${chalk.dim(h.channel + (h.matcher ? `:${h.matcher}` : ''))}` +
111
+ (h.timeout ? chalk.dim(` (${h.timeout}s)`) : ''));
112
+ console.log(` ${chalk.gray(h.command)}`);
113
+ }
114
+ }
115
+ /* ─── sync ────────────────────────────────────────────────────── */
116
+ export async function hooksSyncCommand(opts = {}) {
117
+ track('cli_hooks_sync', { dry_run: !!opts.dryRun });
118
+ await requireAuth('hooks');
119
+ const installed = await listRegistryHooks();
120
+ if (installed.length === 0) {
121
+ log.info('No local prave-managed hooks to sync.');
122
+ return;
123
+ }
124
+ const slugs = installed.map((h) => h.slug);
125
+ const spinner = ora(`Syncing ${slugs.length} hook${slugs.length === 1 ? '' : 's'}…`).start();
126
+ let bulk;
127
+ try {
128
+ const { data } = await api.post('/api/v1/hooks/bulk/sync', { slugs }, true);
129
+ bulk = data;
130
+ }
131
+ catch (err) {
132
+ spinner.fail('Sync failed');
133
+ log.error(err.message);
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ spinner.succeed('Sync received');
138
+ let updated = 0;
139
+ for (const item of bulk.items) {
140
+ if (!item.hook)
141
+ continue;
142
+ const def = {
143
+ slug: item.hook.slug,
144
+ event: item.hook.event,
145
+ matcher: item.hook.matcher,
146
+ command: item.hook.command,
147
+ timeout_seconds: item.hook.timeout_seconds,
148
+ };
149
+ const result = await installRegistryHook(def, { dryRun: opts.dryRun });
150
+ if (result.changed)
151
+ updated++;
152
+ }
153
+ if (bulk.missing.length) {
154
+ log.warn(`Skipped ${bulk.missing.length} unknown slug${bulk.missing.length === 1 ? '' : 's'}: ${bulk.missing.join(', ')}`);
155
+ }
156
+ log.success(`${opts.dryRun ? '(dry-run) ' : ''}Updated ${updated} of ${slugs.length} hook${slugs.length === 1 ? '' : 's'}.`);
157
+ }
158
+ /* ─── audit ───────────────────────────────────────────────────── */
159
+ export async function hooksAuditCommand() {
160
+ track('cli_hooks_audit', {});
161
+ const installed = await listRegistryHooks();
162
+ const foreign = await listForeignHooks();
163
+ const collisions = detectCollisions(installed);
164
+ log.info(chalk.bold(`Prave-managed registry hooks: ${installed.length}`));
165
+ for (const h of installed) {
166
+ console.log(` ${chalk.cyan(h.slug)} ${chalk.dim(h.channel + (h.matcher ? `:${h.matcher}` : ''))}`);
167
+ }
168
+ if (foreign.length) {
169
+ console.log();
170
+ log.info(chalk.bold(`User-owned hooks (untouched): ${foreign.length}`));
171
+ for (const h of foreign) {
172
+ console.log(` ${chalk.dim(h.channel + (h.matcher ? `:${h.matcher}` : ''))} ${chalk.gray(h.command)}`);
173
+ }
174
+ }
175
+ if (collisions.length) {
176
+ console.log();
177
+ log.warn(`${collisions.length} collision${collisions.length === 1 ? '' : 's'} detected — multiple registry hooks share an event+matcher pair:`);
178
+ for (const c of collisions) {
179
+ console.log(` ${chalk.yellow(`${c.channel}:${c.matcher ?? '*'}`)} → ${c.slugs.map((s) => chalk.cyan(s)).join(', ')}`);
180
+ }
181
+ }
182
+ else {
183
+ console.log();
184
+ log.success('No collisions detected.');
185
+ }
186
+ }
187
+ /* ─── update ──────────────────────────────────────────────────── */
188
+ export async function hooksUpdateCommand(slug, opts = {}) {
189
+ track('cli_hooks_update', { slug: slug ?? null, dry_run: !!opts.dryRun });
190
+ await requireAuth('hooks');
191
+ const installed = await listRegistryHooks();
192
+ const targets = slug ? installed.filter((h) => h.slug === slug) : installed;
193
+ if (targets.length === 0) {
194
+ log.info(slug ? `${chalk.cyan(slug)} is not installed.` : 'No installed registry hooks to update.');
195
+ return;
196
+ }
197
+ let updated = 0;
198
+ for (const local of targets) {
199
+ let remote;
200
+ try {
201
+ const { data } = await api.get(`/api/v1/hooks/${encodeURIComponent(local.slug)}`);
202
+ remote = data;
203
+ }
204
+ catch (err) {
205
+ log.warn(`Skipping ${chalk.cyan(local.slug)} — ${err.message}`);
206
+ continue;
207
+ }
208
+ const def = {
209
+ slug: remote.slug,
210
+ event: remote.event,
211
+ matcher: remote.matcher,
212
+ command: remote.command,
213
+ timeout_seconds: remote.timeout_seconds,
214
+ };
215
+ const result = await installRegistryHook(def, { dryRun: opts.dryRun });
216
+ if (result.changed) {
217
+ updated++;
218
+ log.info(`${opts.dryRun ? '(dry-run) ' : ''}Updated ${chalk.cyan(local.slug)} → v${remote.version}`);
219
+ }
220
+ else {
221
+ log.info(`${chalk.cyan(local.slug)} already current.`);
222
+ }
223
+ }
224
+ log.success(`${opts.dryRun ? '(dry-run) ' : ''}${updated} hook${updated === 1 ? '' : 's'} updated.`);
225
+ }
226
+ /* ─── helpers ─────────────────────────────────────────────────── */
227
+ function printDryRunDiff(before, after) {
228
+ const b = JSON.stringify(before, null, 2);
229
+ const a = JSON.stringify(after, null, 2);
230
+ console.log(chalk.gray('--- before ---'));
231
+ console.log(chalk.red(b));
232
+ console.log(chalk.gray('--- after ---'));
233
+ console.log(chalk.green(a));
234
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { Command } from 'commander';
6
6
  import { conflictsCommand } from './commands/conflicts.js';
7
7
  import { diffCommand } from './commands/diff.js';
8
8
  import { docsCommand } from './commands/docs.js';
9
+ import { hooksAuditCommand, hooksInstallCommand, hooksListCommand, hooksRemoveCommand, hooksSyncCommand, hooksUpdateCommand, } from './commands/hooks.js';
9
10
  import { importCommand } from './commands/import.js';
10
11
  import { installCommand } from './commands/install.js';
11
12
  import { listCommand } from './commands/list.js';
@@ -144,6 +145,38 @@ hook
144
145
  .command('uninstall')
145
146
  .description('Disable real-time invocation tracking')
146
147
  .action(usageHookUninstallCommand);
148
+ /* ─── Hooks Manager — discover, install, sync registry hooks ─── */
149
+ const hooks = program
150
+ .command('hooks')
151
+ .description('Manage Claude Code hooks from the Prave registry (separate from `prave usage hook` which tracks Skill invocations)');
152
+ hooks
153
+ .command('install <slug>')
154
+ .description('Install a registry hook into ~/.claude/settings.json')
155
+ .option('--dry-run', "Print the diff that would be applied, but don't write")
156
+ .action((slug, opts) => hooksInstallCommand(slug, opts));
157
+ hooks
158
+ .command('remove <slug>')
159
+ .description('Remove a registry hook (only the entry with this slug — never touches the tracking hook)')
160
+ .option('--dry-run', "Print the diff that would be applied, but don't write")
161
+ .action((slug, opts) => hooksRemoveCommand(slug, opts));
162
+ hooks
163
+ .command('list')
164
+ .description('List prave-managed registry hooks installed locally')
165
+ .action(hooksListCommand);
166
+ hooks
167
+ .command('sync')
168
+ .description('Pull the latest content for every installed registry hook')
169
+ .option('--dry-run', "Print the diff that would be applied, but don't write")
170
+ .action((opts) => hooksSyncCommand(opts));
171
+ hooks
172
+ .command('audit')
173
+ .description('List installed registry + foreign hooks and flag collisions')
174
+ .action(hooksAuditCommand);
175
+ hooks
176
+ .command('update [slug]')
177
+ .description('Refresh installed registry hooks against the server (one slug or all)')
178
+ .option('--dry-run', "Print the diff that would be applied, but don't write")
179
+ .action((slug, opts) => hooksUpdateCommand(slug, opts));
147
180
  program
148
181
  .command('mcp-server')
149
182
  .description('Run the Prave MCP server over stdio. Wire into Claude Desktop / Cursor MCP / Continue.dev via { "command": "npx", "args": ["-y", "@prave/cli", "mcp-server"] }. Exposes search, install, audit, my-skills, whatdoes as MCP tools.')
package/dist/lib/hook.js CHANGED
@@ -14,6 +14,18 @@ import { dirname, join } from 'node:path';
14
14
  */
15
15
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
16
16
  const HOOK_MARKER = '__prave_managed';
17
+ /**
18
+ * Per-entry slug marker added in the Hooks Manager (V1, migration
19
+ * 060). The tracking hook installed by `prave usage hook install`
20
+ * deliberately leaves this field undefined — every "registry" hook
21
+ * installed via `prave hooks install <slug>` stamps its slug here.
22
+ *
23
+ * That discrimination is what makes `prave hooks remove <slug>` safe:
24
+ * it only targets entries with a matching `__prave_slug`, so the
25
+ * tracking hook can never be removed by mistake. Tests in
26
+ * `hook.test.ts` pin this invariant.
27
+ */
28
+ const HOOK_SLUG_MARKER = '__prave_slug';
17
29
  /**
18
30
  * Agents that currently support a real-time invocation hook. Adding a new
19
31
  * agent to this list means implementing its `installFor<Agent>` branch
@@ -21,6 +33,16 @@ const HOOK_MARKER = '__prave_managed';
21
33
  * misled about what's actually being instrumented.
22
34
  */
23
35
  export const HOOK_SUPPORTED = ['claude'];
36
+ const REGISTRY_CHANNELS = [
37
+ 'PreToolUse',
38
+ 'PostToolUse',
39
+ 'UserPromptSubmit',
40
+ 'Stop',
41
+ 'SubagentStop',
42
+ 'Notification',
43
+ 'PreCompact',
44
+ 'SessionStart',
45
+ ];
24
46
  const HOOK_COMMAND = 'prave usage report';
25
47
  // Companion command for the UserPromptSubmit channel so a typed-slash
26
48
  // `/graphify` is captured even when the Skill tool path doesn't fire a
@@ -127,7 +149,15 @@ export async function uninstallSkillHook() {
127
149
  return;
128
150
  const beforeCounts = blocks.map((b) => b.hooks?.length ?? 0);
129
151
  const filtered = blocks
130
- .map((b) => ({ ...b, hooks: b.hooks?.filter((h) => !h[HOOK_MARKER]) }))
152
+ .map((b) => ({
153
+ ...b,
154
+ // ONLY strip tracking-hook entries — those with
155
+ // `__prave_managed: true` AND no `__prave_slug`. Registry hooks
156
+ // installed via `prave hooks install <slug>` carry a
157
+ // `__prave_slug` and are guarded here so this code path can
158
+ // never remove them by accident.
159
+ hooks: b.hooks?.filter((h) => !(h[HOOK_MARKER] && !h[HOOK_SLUG_MARKER])),
160
+ }))
131
161
  .filter((b) => (b.hooks?.length ?? 0) > 0);
132
162
  const lengthChanged = filtered.length !== blocks.length;
133
163
  const innerChanged = !lengthChanged &&
@@ -164,3 +194,179 @@ async function writeSettings(settings) {
164
194
  await mkdir(dirname(SETTINGS_PATH), { recursive: true });
165
195
  await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
166
196
  }
197
+ /**
198
+ * Install / upsert a registry hook into `~/.claude/settings.json`.
199
+ * Read-modify-write is atomic-ish (single fs write) and guarded by
200
+ * the `__prave_slug` marker so we can:
201
+ * • Update an existing entry for the same slug in-place.
202
+ * • Never collide with the tracking hook (which has no slug
203
+ * marker) or with a hand-written user hook (which has neither
204
+ * marker).
205
+ *
206
+ * Returns the unified diff representation when `dryRun` is true so
207
+ * `prave hooks install --dry-run` can show what would change without
208
+ * actually writing.
209
+ */
210
+ export async function installRegistryHook(def, opts = {}) {
211
+ const before = await readSettings();
212
+ // Defensive deep clone — operating on a copy keeps `before` clean
213
+ // for the diff output.
214
+ const after = JSON.parse(JSON.stringify(before));
215
+ after.hooks ??= {};
216
+ const channelBlocks = (after.hooks[def.event] ??= []);
217
+ const entry = {
218
+ type: 'command',
219
+ command: def.command,
220
+ [HOOK_MARKER]: true,
221
+ [HOOK_SLUG_MARKER]: def.slug,
222
+ };
223
+ if (def.timeout_seconds != null)
224
+ entry.timeout = def.timeout_seconds;
225
+ // Look for an existing block matching (channel, matcher) AND
226
+ // already carrying our slug. Upsert vs append accordingly.
227
+ const matcherKey = def.matcher ?? undefined;
228
+ const blockIdx = channelBlocks.findIndex((b) => (b.matcher ?? undefined) === matcherKey);
229
+ let changed = false;
230
+ if (blockIdx >= 0) {
231
+ const block = channelBlocks[blockIdx];
232
+ block.hooks ??= [];
233
+ const entryIdx = block.hooks.findIndex((h) => h[HOOK_SLUG_MARKER] === def.slug);
234
+ if (entryIdx >= 0) {
235
+ const prev = block.hooks[entryIdx];
236
+ if (prev.command !== entry.command || prev.timeout !== entry.timeout) {
237
+ block.hooks[entryIdx] = entry;
238
+ changed = true;
239
+ }
240
+ }
241
+ else {
242
+ block.hooks.push(entry);
243
+ changed = true;
244
+ }
245
+ }
246
+ else {
247
+ channelBlocks.push({
248
+ matcher: matcherKey,
249
+ hooks: [entry],
250
+ });
251
+ changed = true;
252
+ }
253
+ if (changed && !opts.dryRun)
254
+ await writeSettings(after);
255
+ return { changed, settingsPath: SETTINGS_PATH, before, after };
256
+ }
257
+ /**
258
+ * Remove the registry hook with the given slug. Filters by
259
+ * `__prave_slug === slug` so the tracking hook (no slug) and any
260
+ * foreign user hook (no marker) are NEVER touched.
261
+ */
262
+ export async function removeRegistryHook(slug, opts = {}) {
263
+ const before = await readSettings();
264
+ const after = JSON.parse(JSON.stringify(before));
265
+ if (!after.hooks)
266
+ return { changed: false, settingsPath: SETTINGS_PATH, before, after };
267
+ let touched = false;
268
+ for (const channel of REGISTRY_CHANNELS) {
269
+ const blocks = after.hooks[channel];
270
+ if (!blocks?.length)
271
+ continue;
272
+ const filtered = blocks
273
+ .map((b) => ({
274
+ ...b,
275
+ hooks: b.hooks?.filter((h) => h[HOOK_SLUG_MARKER] !== slug),
276
+ }))
277
+ .filter((b) => (b.hooks?.length ?? 0) > 0);
278
+ if (filtered.length !== blocks.length ||
279
+ filtered.some((b, i) => (b.hooks?.length ?? 0) !== (blocks[i]?.hooks?.length ?? 0))) {
280
+ touched = true;
281
+ if (after.hooks) {
282
+ if (filtered.length)
283
+ after.hooks[channel] = filtered;
284
+ else
285
+ delete after.hooks[channel];
286
+ }
287
+ }
288
+ }
289
+ if (after.hooks && Object.keys(after.hooks).length === 0)
290
+ delete after.hooks;
291
+ if (touched && !opts.dryRun)
292
+ await writeSettings(after);
293
+ return { changed: touched, settingsPath: SETTINGS_PATH, before, after };
294
+ }
295
+ /**
296
+ * Enumerate the registry hooks currently installed locally — used by
297
+ * `prave hooks list` and `prave hooks audit`.
298
+ */
299
+ export async function listRegistryHooks() {
300
+ const settings = await readSettings();
301
+ const out = [];
302
+ if (!settings.hooks)
303
+ return out;
304
+ for (const channel of REGISTRY_CHANNELS) {
305
+ const blocks = settings.hooks[channel];
306
+ if (!blocks?.length)
307
+ continue;
308
+ for (const b of blocks) {
309
+ for (const h of b.hooks ?? []) {
310
+ if (!h[HOOK_SLUG_MARKER])
311
+ continue;
312
+ out.push({
313
+ channel,
314
+ matcher: b.matcher ?? null,
315
+ slug: h[HOOK_SLUG_MARKER],
316
+ command: h.command,
317
+ timeout: h.timeout ?? null,
318
+ });
319
+ }
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+ /**
325
+ * Foreign (user-authored) hooks living alongside our managed entries.
326
+ * Surfaced by `prave hooks audit` as informational context — never
327
+ * touched by any of the write helpers above.
328
+ */
329
+ export async function listForeignHooks() {
330
+ const settings = await readSettings();
331
+ const out = [];
332
+ if (!settings.hooks)
333
+ return out;
334
+ for (const channel of REGISTRY_CHANNELS) {
335
+ const blocks = settings.hooks[channel];
336
+ if (!blocks?.length)
337
+ continue;
338
+ for (const b of blocks) {
339
+ for (const h of b.hooks ?? []) {
340
+ if (h[HOOK_MARKER])
341
+ continue;
342
+ out.push({ channel, matcher: b.matcher ?? null, command: h.command });
343
+ }
344
+ }
345
+ }
346
+ return out;
347
+ }
348
+ /**
349
+ * Detect duplicate-coverage collisions inside the registry: two
350
+ * different prave-managed slugs writing to the same
351
+ * (channel, matcher) pair. The audit command surfaces these so the
352
+ * user can manually decide which one to keep.
353
+ */
354
+ export function detectCollisions(installed) {
355
+ const buckets = new Map();
356
+ for (const h of installed) {
357
+ const key = `${h.channel}|${h.matcher ?? '*'}`;
358
+ const list = buckets.get(key) ?? [];
359
+ list.push(h.slug);
360
+ buckets.set(key, list);
361
+ }
362
+ const out = [];
363
+ for (const [key, slugs] of buckets) {
364
+ if (slugs.length < 2)
365
+ continue;
366
+ const parts = key.split('|');
367
+ const channel = parts[0];
368
+ const matcher = parts[1] === '*' ? null : (parts[1] ?? null);
369
+ out.push({ channel, matcher, slugs });
370
+ }
371
+ return out;
372
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Prave CLI — discover, install, version, test, and ship Claude Skills. The developer platform for the complete Skill lifecycle.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -54,7 +54,7 @@
54
54
  "ora": "^8.0.1",
55
55
  "tar": "^7.4.3",
56
56
  "undici": "^6.18.0",
57
- "@prave/shared": "1.4.16"
57
+ "@prave/shared": "1.5.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",