@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.
- package/LICENSE +201 -0
- package/README.md +59 -0
- package/dist/agents/argv.d.ts +11 -0
- package/dist/agents/argv.d.ts.map +1 -0
- package/dist/agents/argv.js +23 -0
- package/dist/agents/claude.d.ts +3 -0
- package/dist/agents/claude.d.ts.map +1 -0
- package/dist/agents/claude.js +145 -0
- package/dist/agents/detect.d.ts +16 -0
- package/dist/agents/detect.d.ts.map +1 -0
- package/dist/agents/detect.js +34 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +5 -0
- package/dist/agents/invoke.d.ts +10 -0
- package/dist/agents/invoke.d.ts.map +1 -0
- package/dist/agents/invoke.js +70 -0
- package/dist/agents/registry.d.ts +12 -0
- package/dist/agents/registry.d.ts.map +1 -0
- package/dist/agents/registry.js +15 -0
- package/dist/agents/types.d.ts +88 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/agents/types.js +23 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/playwright/cdpStatus.d.ts +29 -0
- package/dist/playwright/cdpStatus.d.ts.map +1 -0
- package/dist/playwright/cdpStatus.js +96 -0
- package/dist/playwright/launchChrome.d.ts +29 -0
- package/dist/playwright/launchChrome.d.ts.map +1 -0
- package/dist/playwright/launchChrome.js +137 -0
- package/dist/playwright/preflight.d.ts +31 -0
- package/dist/playwright/preflight.d.ts.map +1 -0
- package/dist/playwright/preflight.js +71 -0
- package/dist/scripts/start-chrome.d.ts +3 -0
- package/dist/scripts/start-chrome.d.ts.map +1 -0
- package/dist/scripts/start-chrome.js +23 -0
- package/dist/service.d.ts +22 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +485 -0
- package/dist/skills/writeSkill.d.ts +50 -0
- package/dist/skills/writeSkill.d.ts.map +1 -0
- package/dist/skills/writeSkill.js +169 -0
- package/dist/specs/humanSteps.d.ts +25 -0
- package/dist/specs/humanSteps.d.ts.map +1 -0
- package/dist/specs/humanSteps.js +97 -0
- package/dist/specs/writeCaseCsv.d.ts +28 -0
- package/dist/specs/writeCaseCsv.d.ts.map +1 -0
- package/dist/specs/writeCaseCsv.js +140 -0
- package/dist/specs/writeSpec.d.ts +27 -0
- package/dist/specs/writeSpec.d.ts.map +1 -0
- package/dist/specs/writeSpec.js +265 -0
- package/mcp.config.json +12 -0
- package/package.json +78 -0
package/dist/service.js
ADDED
|
@@ -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"}
|