@hover-dev/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +59 -0
  3. package/dist/agents/argv.d.ts +11 -0
  4. package/dist/agents/argv.d.ts.map +1 -0
  5. package/dist/agents/argv.js +23 -0
  6. package/dist/agents/claude.d.ts +3 -0
  7. package/dist/agents/claude.d.ts.map +1 -0
  8. package/dist/agents/claude.js +145 -0
  9. package/dist/agents/detect.d.ts +16 -0
  10. package/dist/agents/detect.d.ts.map +1 -0
  11. package/dist/agents/detect.js +34 -0
  12. package/dist/agents/index.d.ts +6 -0
  13. package/dist/agents/index.d.ts.map +1 -0
  14. package/dist/agents/index.js +5 -0
  15. package/dist/agents/invoke.d.ts +10 -0
  16. package/dist/agents/invoke.d.ts.map +1 -0
  17. package/dist/agents/invoke.js +70 -0
  18. package/dist/agents/registry.d.ts +12 -0
  19. package/dist/agents/registry.d.ts.map +1 -0
  20. package/dist/agents/registry.js +15 -0
  21. package/dist/agents/types.d.ts +88 -0
  22. package/dist/agents/types.d.ts.map +1 -0
  23. package/dist/agents/types.js +23 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +2 -0
  27. package/dist/playwright/cdpStatus.d.ts +29 -0
  28. package/dist/playwright/cdpStatus.d.ts.map +1 -0
  29. package/dist/playwright/cdpStatus.js +96 -0
  30. package/dist/playwright/launchChrome.d.ts +29 -0
  31. package/dist/playwright/launchChrome.d.ts.map +1 -0
  32. package/dist/playwright/launchChrome.js +137 -0
  33. package/dist/playwright/preflight.d.ts +31 -0
  34. package/dist/playwright/preflight.d.ts.map +1 -0
  35. package/dist/playwright/preflight.js +71 -0
  36. package/dist/scripts/start-chrome.d.ts +3 -0
  37. package/dist/scripts/start-chrome.d.ts.map +1 -0
  38. package/dist/scripts/start-chrome.js +23 -0
  39. package/dist/service.d.ts +22 -0
  40. package/dist/service.d.ts.map +1 -0
  41. package/dist/service.js +485 -0
  42. package/dist/skills/writeSkill.d.ts +50 -0
  43. package/dist/skills/writeSkill.d.ts.map +1 -0
  44. package/dist/skills/writeSkill.js +169 -0
  45. package/dist/specs/humanSteps.d.ts +25 -0
  46. package/dist/specs/humanSteps.d.ts.map +1 -0
  47. package/dist/specs/humanSteps.js +97 -0
  48. package/dist/specs/writeCaseCsv.d.ts +28 -0
  49. package/dist/specs/writeCaseCsv.d.ts.map +1 -0
  50. package/dist/specs/writeCaseCsv.js +140 -0
  51. package/dist/specs/writeSpec.d.ts +27 -0
  52. package/dist/specs/writeSpec.d.ts.map +1 -0
  53. package/dist/specs/writeSpec.js +265 -0
  54. package/mcp.config.json +12 -0
  55. package/package.json +78 -0
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Local Hover WebSocket service.
3
+ *
4
+ * One process per Vite dev server. Started by vite-plugin-hover's
5
+ * configureServer hook, torn down on closeBundle. Binds to 127.0.0.1 only.
6
+ *
7
+ * Wire protocol (newline-free JSON over WebSocket):
8
+ *
9
+ * server → client
10
+ * { type: 'hello', payload: { agentId, model, version } }
11
+ * { type: 'event', payload: InvokeEvent } // see agents/types.ts
12
+ * { type: 'cdp-status', payload: { state, reason?, matchingTabUrl?, browser?, launching? } }
13
+ * { type: 'skill-saved', payload: { name, path } }
14
+ * { type: 'skill-exists', payload: { slug, existingPath } }
15
+ * { type: 'skills-list', payload: { skills: SkillSummary[] } }
16
+ * { type: 'spec-saved', payload: { name, path } }
17
+ * { type: 'spec-exists', payload: { slug, existingPath } }
18
+ * { type: 'case-csv-saved', payload: { name, path } }
19
+ * { type: 'case-csv-exists', payload: { slug, existingPath } }
20
+ * { type: 'error', payload: { message } }
21
+ *
22
+ * client → server
23
+ * { type: 'command', payload: { text, sessionId? } }
24
+ * { type: 'cancel' }
25
+ * { type: 'check-cdp', payload: { pageUrl } } // "is this widget in the debug Chrome?"
26
+ * { type: 'launch-chrome', payload: { pageUrl } } // start debug Chrome, navigate to pageUrl
27
+ * { type: 'focus-debug', payload: { pageUrl } } // bringToFront the matching tab in debug Chrome
28
+ * { type: 'save-skill', payload: { name, description, steps, overwrite? } }
29
+ * { type: 'save-spec', payload: { name, description, steps, assertions?, overwrite? } }
30
+ * { type: 'save-case-csv', payload: { name, description, steps, assertions?, jiraProjectKey?, labels?, overwrite? } }
31
+ * { type: 'list-skills' }
32
+ */
33
+ import { dirname, resolve } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { WebSocketServer, WebSocket } from 'ws';
36
+ import { invokeAgent } from './agents/invoke.js';
37
+ import { checkCdpStatus, focusDebugTab } from './playwright/cdpStatus.js';
38
+ import { launchDebugChrome } from './playwright/launchChrome.js';
39
+ import { preflightCDP } from './playwright/preflight.js';
40
+ import { writeSkill, listSkills, SkillExistsError, } from './skills/writeSkill.js';
41
+ import { writeSpec, SpecExistsError } from './specs/writeSpec.js';
42
+ import { writeCaseCsv, CaseCsvExistsError } from './specs/writeCaseCsv.js';
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ const DEFAULT_MCP_CONFIG = resolve(HERE, '..', 'mcp.config.json');
45
+ const PROTOCOL_VERSION = 1;
46
+ const PORT_RETRIES = 10;
47
+ /**
48
+ * Try to bind a WebSocketServer to <host>:<port>. Resolves with the wss on
49
+ * success; rejects with the bind error (typically EADDRINUSE) on failure.
50
+ */
51
+ function bind(host, port) {
52
+ return new Promise((resolve, reject) => {
53
+ const wss = new WebSocketServer({ host, port });
54
+ const onError = (err) => {
55
+ wss.off('listening', onListening);
56
+ reject(err);
57
+ };
58
+ const onListening = () => {
59
+ wss.off('error', onError);
60
+ resolve(wss);
61
+ };
62
+ wss.once('error', onError);
63
+ wss.once('listening', onListening);
64
+ });
65
+ }
66
+ /**
67
+ * Find a free port in [start, start+attempts) and bind a WebSocketServer to
68
+ * it. Each example app that loads vite-plugin-hover runs its own service —
69
+ * with auto-bump, multiple Vite dev servers can coexist (basic-app on 51789,
70
+ * stock-registration on 51790, etc.) and each widget connects only to its
71
+ * own service. The widget reads the actual port from window.__HOVER_PORT__.
72
+ */
73
+ async function pickAndBind(host, start, attempts) {
74
+ let lastErr = null;
75
+ for (let i = 0; i < attempts; i++) {
76
+ try {
77
+ return await bind(host, start + i);
78
+ }
79
+ catch (err) {
80
+ lastErr = err;
81
+ if (err.code !== 'EADDRINUSE')
82
+ throw err;
83
+ }
84
+ }
85
+ throw new Error(`[hover] no free port in [${start}, ${start + attempts}): ${lastErr?.message ?? ''}`);
86
+ }
87
+ export async function startService(opts) {
88
+ const requestedPort = opts.port;
89
+ const agentId = opts.agentId ?? 'claude';
90
+ const model = opts.model ?? 'sonnet';
91
+ // No default budget cap — long real-world flows (form filling, multi-step
92
+ // checkouts) routinely run past the old $0.50 ceiling and got cut off
93
+ // mid-run. The widget shows the running $ counter in the header instead,
94
+ // so the user can hit Stop when they've seen enough. Pass maxBudgetUsd
95
+ // explicitly (or via the Vite plugin option) if a hard ceiling is needed.
96
+ const maxBudgetUsd = opts.maxBudgetUsd;
97
+ const mcpConfig = opts.mcpConfig ?? DEFAULT_MCP_CONFIG;
98
+ const cdpUrl = opts.cdpUrl ?? 'http://localhost:9222';
99
+ const devRoot = opts.devRoot ?? process.cwd();
100
+ const wss = await pickAndBind('127.0.0.1', requestedPort, PORT_RETRIES);
101
+ const port = wss.address().port;
102
+ // Surface post-listen errors instead of crashing the host process.
103
+ wss.on('error', err => {
104
+ process.stderr.write(`[hover] WebSocketServer error: ${err.message}\n`);
105
+ });
106
+ wss.on('connection', ws => {
107
+ send(ws, { type: 'hello', payload: { agentId, model, version: PROTOCOL_VERSION } });
108
+ let busy = false;
109
+ let inflight = null;
110
+ let cancelled = false;
111
+ // If the page reloads (e.g. AI navigated to a same-origin URL), the WS
112
+ // connection drops. Abort the in-flight agent so we don't leave an
113
+ // orphan claude process driving the now-vanished browser tab.
114
+ ws.on('close', () => {
115
+ inflight?.abort();
116
+ });
117
+ const cancel = () => {
118
+ if (!busy)
119
+ return;
120
+ cancelled = true;
121
+ inflight?.abort();
122
+ // Send a synthetic session_end so the widget resets to idle immediately.
123
+ // The for-await loop below short-circuits on `cancelled`, so no events
124
+ // from the dying child will arrive after this.
125
+ send(ws, {
126
+ type: 'event',
127
+ payload: {
128
+ kind: 'session_end',
129
+ isError: true,
130
+ summary: 'cancelled by user',
131
+ },
132
+ });
133
+ };
134
+ ws.on('message', async (data) => {
135
+ let msg;
136
+ try {
137
+ msg = JSON.parse(data.toString());
138
+ }
139
+ catch {
140
+ return;
141
+ }
142
+ if (msg.type === 'cancel') {
143
+ cancel();
144
+ return;
145
+ }
146
+ if (msg.type === 'save-skill') {
147
+ await handleSaveSkill(ws, msg, devRoot);
148
+ return;
149
+ }
150
+ if (msg.type === 'list-skills') {
151
+ const skills = await listSkills(devRoot);
152
+ send(ws, { type: 'skills-list', payload: { skills } });
153
+ return;
154
+ }
155
+ if (msg.type === 'save-spec') {
156
+ await handleSaveSpec(ws, msg, devRoot);
157
+ return;
158
+ }
159
+ if (msg.type === 'save-case-csv') {
160
+ await handleSaveCaseCsv(ws, msg, devRoot);
161
+ return;
162
+ }
163
+ if (msg.type === 'check-cdp') {
164
+ await handleCheckCdp(ws, msg, cdpUrl);
165
+ return;
166
+ }
167
+ if (msg.type === 'launch-chrome') {
168
+ await handleLaunchChrome(ws, msg, cdpUrl);
169
+ return;
170
+ }
171
+ if (msg.type === 'focus-debug') {
172
+ await handleFocusDebug(ws, msg, cdpUrl);
173
+ return;
174
+ }
175
+ if (msg.type !== 'command')
176
+ return;
177
+ const text = msg.payload?.text;
178
+ const resumeSessionId = typeof msg.payload?.sessionId === 'string' && msg.payload.sessionId.length > 0
179
+ ? msg.payload.sessionId
180
+ : undefined;
181
+ if (typeof text !== 'string' || !text.trim())
182
+ return;
183
+ if (busy) {
184
+ send(ws, {
185
+ type: 'error',
186
+ payload: { message: 'A command is already running on this connection.' },
187
+ });
188
+ return;
189
+ }
190
+ busy = true;
191
+ cancelled = false;
192
+ inflight = new AbortController();
193
+ try {
194
+ // Preflight: refuse to invoke if CDP isn't reachable. Otherwise the
195
+ // Playwright MCP server would silently launch its own Chromium —
196
+ // and Hover's premise is to drive the user's existing Chrome (with
197
+ // their dev state, cookies, devtools open), never spawn a fresh one.
198
+ const cdp = await preflightCDP(cdpUrl);
199
+ if (!cdp.ok) {
200
+ send(ws, {
201
+ type: 'event',
202
+ payload: {
203
+ kind: 'session_end',
204
+ isError: true,
205
+ summary: cdp.reason,
206
+ },
207
+ });
208
+ return;
209
+ }
210
+ // Build a system-prompt addendum telling the agent about the user's
211
+ // current tab. The most common waste we observed: agent calls
212
+ // browser_navigate to the same URL the user is already on, triggering
213
+ // a wasteful full-page reload that also destroys the Hover widget
214
+ // momentarily (the widget re-injects + recovers, but the agent's
215
+ // own session sometimes gets confused).
216
+ const appendSystemPrompt = buildCdpHint(cdp.tabs);
217
+ for await (const ev of invokeAgent({
218
+ agentId,
219
+ prompt: text,
220
+ sessionId: resumeSessionId,
221
+ mcpConfig,
222
+ // cwd = devRoot so Claude Code auto-discovers `.claude/skills/`
223
+ // saved from this project (and CLAUDE.md, if any).
224
+ cwd: devRoot,
225
+ appendSystemPrompt,
226
+ // Skill stays in the allow list so saved skills under
227
+ // <devRoot>/.claude/skills/ can be invoked. mcp__playwright covers
228
+ // every browser tool.
229
+ allowedTools: ['mcp__playwright', 'Skill'],
230
+ disallowedTools: [
231
+ // file / shell / data access — never appropriate for browser driving
232
+ 'Bash', 'BashOutput', 'KillBash',
233
+ 'Edit', 'MultiEdit', 'Write', 'Read', 'NotebookEdit',
234
+ 'Grep', 'Glob', 'Task', 'TodoWrite',
235
+ 'WebFetch', 'WebSearch',
236
+ // plan / worktree / cron / notification — irrelevant in -p mode
237
+ 'EnterPlanMode', 'ExitPlanMode',
238
+ 'EnterWorktree', 'ExitWorktree',
239
+ 'CronCreate', 'CronDelete', 'CronList',
240
+ 'PushNotification', 'RemoteTrigger',
241
+ // task & tool introspection added in claude 2.1.x — let through and
242
+ // the agent will burn turns exploring instead of executing
243
+ 'ToolSearch',
244
+ 'Monitor', 'TaskOutput', 'TaskStop',
245
+ 'AskUserQuestion',
246
+ 'ShareOnboardingGuide',
247
+ ],
248
+ maxBudgetUsd,
249
+ model,
250
+ signal: inflight.signal,
251
+ })) {
252
+ if (cancelled || ws.readyState !== WebSocket.OPEN)
253
+ return;
254
+ send(ws, { type: 'event', payload: ev });
255
+ }
256
+ }
257
+ catch (err) {
258
+ const message = err instanceof Error ? err.message : String(err);
259
+ const errorEvent = {
260
+ kind: 'session_end',
261
+ isError: true,
262
+ summary: message,
263
+ };
264
+ if (ws.readyState === WebSocket.OPEN) {
265
+ send(ws, { type: 'event', payload: errorEvent });
266
+ }
267
+ }
268
+ finally {
269
+ busy = false;
270
+ inflight = null;
271
+ }
272
+ });
273
+ });
274
+ return {
275
+ port,
276
+ close: () => new Promise((res, rej) => {
277
+ wss.close(err => (err ? rej(err) : res()));
278
+ }),
279
+ };
280
+ }
281
+ function send(ws, message) {
282
+ ws.send(JSON.stringify(message));
283
+ }
284
+ function buildCdpHint(tabs) {
285
+ if (tabs.length === 0)
286
+ return '';
287
+ // Prefer the localhost tab if we have multiple — that's almost always the
288
+ // dev server the user is testing against.
289
+ const localhost = tabs.find(t => /localhost|127\.0\.0\.1/.test(t.url));
290
+ const active = localhost ?? tabs[0];
291
+ return [
292
+ `The user's Chrome currently has these tabs open:`,
293
+ ...tabs.map(t => ` - ${t.url}${t.title ? ` (${t.title})` : ''}`),
294
+ ``,
295
+ `The likely active dev tab is: ${active.url}`,
296
+ ``,
297
+ `Important: do NOT call browser_navigate to a URL that is already the active tab.`,
298
+ `That triggers an unnecessary full page reload. Instead, call browser_snapshot`,
299
+ `first to see the current page state, and only navigate if you actually need a`,
300
+ `different URL.`,
301
+ ].join('\n');
302
+ }
303
+ /**
304
+ * "Is this widget running inside the debug Chrome?" The widget asks this on
305
+ * connect (and after every status-changing event) so it can render itself as
306
+ * either:
307
+ * - same-window → normal, drives the page
308
+ * - wrong-window → disabled, with a "use the other window" notice
309
+ * - no-cdp → enabled but click triggers launch-chrome instead
310
+ */
311
+ async function handleCheckCdp(ws, msg, cdpUrl) {
312
+ const pageUrl = msg.payload?.pageUrl;
313
+ if (typeof pageUrl !== 'string' || !pageUrl) {
314
+ send(ws, { type: 'error', payload: { message: 'check-cdp: pageUrl is required' } });
315
+ return;
316
+ }
317
+ const status = await checkCdpStatus(cdpUrl, pageUrl);
318
+ send(ws, { type: 'cdp-status', payload: status });
319
+ }
320
+ /**
321
+ * Launch a debug Chrome navigated to `pageUrl`, then re-check status. The
322
+ * re-check usually returns 'wrong-window' (because the widget asking is in
323
+ * the user's regular Chrome, not the freshly-launched one) — the widget then
324
+ * displays the "use the other window" state.
325
+ */
326
+ async function handleLaunchChrome(ws, msg, cdpUrl) {
327
+ const pageUrl = msg.payload?.pageUrl;
328
+ if (typeof pageUrl !== 'string' || !pageUrl) {
329
+ send(ws, { type: 'error', payload: { message: 'launch-chrome: pageUrl is required' } });
330
+ return;
331
+ }
332
+ // Tell the widget we're launching so it can render a spinner immediately —
333
+ // findChromeBinary + spawn + ready-poll can take a few seconds.
334
+ send(ws, { type: 'cdp-status', payload: { state: 'no-cdp', launching: true } });
335
+ const port = (() => {
336
+ try {
337
+ return Number(new URL(cdpUrl).port) || 9222;
338
+ }
339
+ catch {
340
+ return 9222;
341
+ }
342
+ })();
343
+ const result = await launchDebugChrome({ url: pageUrl, port });
344
+ if (!result.ok) {
345
+ send(ws, { type: 'cdp-status', payload: { state: 'no-cdp', reason: result.reason } });
346
+ return;
347
+ }
348
+ // Re-check after launch so the widget gets the real status.
349
+ const status = await checkCdpStatus(cdpUrl, pageUrl);
350
+ send(ws, { type: 'cdp-status', payload: status });
351
+ }
352
+ /**
353
+ * bringToFront the debug-Chrome tab matching `pageUrl`'s origin (or open one
354
+ * if none exists). Used by the wrong-window UI's "switch to debug Chrome"
355
+ * button. Doesn't return cdp-status — bringToFront doesn't change anything
356
+ * the widget cares about, and the widget the user is about to focus is a
357
+ * different page (and will run its own check-cdp on its own ws connection).
358
+ */
359
+ async function handleFocusDebug(ws, msg, cdpUrl) {
360
+ const pageUrl = msg.payload?.pageUrl;
361
+ if (typeof pageUrl !== 'string' || !pageUrl) {
362
+ send(ws, { type: 'error', payload: { message: 'focus-debug: pageUrl is required' } });
363
+ return;
364
+ }
365
+ const result = await focusDebugTab(cdpUrl, pageUrl);
366
+ if (!result.ok) {
367
+ send(ws, { type: 'error', payload: { message: `focus-debug: ${result.reason}` } });
368
+ }
369
+ }
370
+ async function handleSaveSpec(ws, msg, devRoot) {
371
+ const name = msg.payload?.name;
372
+ const description = msg.payload?.description ?? '';
373
+ const steps = msg.payload?.steps;
374
+ const assertions = msg.payload?.assertions ?? [];
375
+ if (typeof name !== 'string' || !name.trim()) {
376
+ send(ws, { type: 'error', payload: { message: 'save-spec: name is required' } });
377
+ return;
378
+ }
379
+ if (!Array.isArray(steps) || steps.length === 0) {
380
+ send(ws, { type: 'error', payload: { message: 'save-spec: no steps to save' } });
381
+ return;
382
+ }
383
+ const overwrite = msg.payload?.overwrite === true;
384
+ try {
385
+ const result = await writeSpec({ devRoot, name, description, steps, assertions, overwrite });
386
+ send(ws, {
387
+ type: 'spec-saved',
388
+ payload: { name: result.slug, path: result.path },
389
+ });
390
+ }
391
+ catch (err) {
392
+ if (err instanceof SpecExistsError) {
393
+ send(ws, {
394
+ type: 'spec-exists',
395
+ payload: { slug: err.slug, existingPath: err.path },
396
+ });
397
+ return;
398
+ }
399
+ const message = err instanceof Error ? err.message : String(err);
400
+ send(ws, {
401
+ type: 'error',
402
+ payload: { message: `save-spec failed: ${message}` },
403
+ });
404
+ }
405
+ }
406
+ async function handleSaveCaseCsv(ws, msg, devRoot) {
407
+ const name = msg.payload?.name;
408
+ const description = msg.payload?.description ?? '';
409
+ const steps = msg.payload?.steps;
410
+ const assertions = msg.payload?.assertions ?? [];
411
+ const jiraProjectKey = msg.payload?.jiraProjectKey;
412
+ const labels = msg.payload?.labels;
413
+ if (typeof name !== 'string' || !name.trim()) {
414
+ send(ws, { type: 'error', payload: { message: 'save-case-csv: name is required' } });
415
+ return;
416
+ }
417
+ if (!Array.isArray(steps) || steps.length === 0) {
418
+ send(ws, { type: 'error', payload: { message: 'save-case-csv: no steps to save' } });
419
+ return;
420
+ }
421
+ const overwrite = msg.payload?.overwrite === true;
422
+ try {
423
+ const result = await writeCaseCsv({
424
+ devRoot, name, description, steps, assertions,
425
+ jiraProjectKey, labels, overwrite,
426
+ });
427
+ send(ws, {
428
+ type: 'case-csv-saved',
429
+ payload: { name: result.slug, path: result.path },
430
+ });
431
+ }
432
+ catch (err) {
433
+ if (err instanceof CaseCsvExistsError) {
434
+ send(ws, {
435
+ type: 'case-csv-exists',
436
+ payload: { slug: err.slug, existingPath: err.path },
437
+ });
438
+ return;
439
+ }
440
+ const message = err instanceof Error ? err.message : String(err);
441
+ send(ws, {
442
+ type: 'error',
443
+ payload: { message: `save-case-csv failed: ${message}` },
444
+ });
445
+ }
446
+ }
447
+ async function handleSaveSkill(ws, msg, devRoot) {
448
+ const name = msg.payload?.name;
449
+ const description = msg.payload?.description ?? '';
450
+ const steps = msg.payload?.steps;
451
+ if (typeof name !== 'string' || !name.trim()) {
452
+ send(ws, { type: 'error', payload: { message: 'save-skill: name is required' } });
453
+ return;
454
+ }
455
+ if (!Array.isArray(steps) || steps.length === 0) {
456
+ send(ws, { type: 'error', payload: { message: 'save-skill: no steps to save' } });
457
+ return;
458
+ }
459
+ const overwrite = msg.payload?.overwrite === true;
460
+ try {
461
+ const result = await writeSkill({ devRoot, name, description, steps, overwrite });
462
+ send(ws, {
463
+ type: 'skill-saved',
464
+ payload: { name: result.slug, path: result.path },
465
+ });
466
+ // Push a fresh list so the widget's skills overlay updates without a
467
+ // round-trip — most relevant right after the save.
468
+ const skills = await listSkills(devRoot);
469
+ send(ws, { type: 'skills-list', payload: { skills } });
470
+ }
471
+ catch (err) {
472
+ if (err instanceof SkillExistsError) {
473
+ send(ws, {
474
+ type: 'skill-exists',
475
+ payload: { slug: err.slug, existingPath: err.path },
476
+ });
477
+ return;
478
+ }
479
+ const message = err instanceof Error ? err.message : String(err);
480
+ send(ws, {
481
+ type: 'error',
482
+ payload: { message: `save-skill failed: ${message}` },
483
+ });
484
+ }
485
+ }
@@ -0,0 +1,50 @@
1
+ export declare class SkillExistsError extends Error {
2
+ readonly slug: string;
3
+ readonly path: string;
4
+ constructor(slug: string, path: string);
5
+ }
6
+ /**
7
+ * Serialized message shape from the widget's localStorage. Matches the
8
+ * `state.messages` schema in packages/vite-plugin/src/widget.js.
9
+ */
10
+ export interface SkillStep {
11
+ kind: 'user' | 'system' | 'step' | 'ai' | 'done';
12
+ text?: string;
13
+ tool?: string;
14
+ input?: unknown;
15
+ isError?: boolean;
16
+ turns?: number;
17
+ costUsd?: number;
18
+ summary?: string;
19
+ }
20
+ export interface WriteSkillOptions {
21
+ /** Directory under which `.claude/skills/<slug>/` is created. Usually the
22
+ * Vite project root (`server.config.root`). */
23
+ devRoot: string;
24
+ name: string;
25
+ description?: string;
26
+ steps: SkillStep[];
27
+ /** If false (default), throws SkillExistsError when a skill with the same
28
+ * slug already exists. If true, overwrites unconditionally. The widget
29
+ * uses the two paths to give the user a confirm dialog. */
30
+ overwrite?: boolean;
31
+ }
32
+ export interface WriteSkillResult {
33
+ path: string;
34
+ slug: string;
35
+ }
36
+ export declare function writeSkill(opts: WriteSkillOptions): Promise<WriteSkillResult>;
37
+ export interface SkillSummary {
38
+ slug: string;
39
+ name: string;
40
+ description: string;
41
+ path: string;
42
+ }
43
+ /**
44
+ * List skills under <devRoot>/.claude/skills/, reading the YAML frontmatter
45
+ * of each SKILL.md for `name` and `description`. Malformed entries are
46
+ * silently skipped — better to show 9 valid skills than refuse to render
47
+ * because one is broken. Hand-edited skills are first-class.
48
+ */
49
+ export declare function listSkills(devRoot: string): Promise<SkillSummary[]>;
50
+ //# sourceMappingURL=writeSkill.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"writeSkill.d.ts","sourceRoot":"","sources":["../../src/skills/writeSkill.ts"],"names":[],"mappings":"AAmBA,qBAAa,gBAAiB,SAAQ,KAAK;aACb,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC;oDACgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB;;gEAE4D;IAC5D,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoBnF;AA8ED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA8BzE"}