@hover-dev/core 0.2.1 → 0.2.3
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/README.md +1 -1
- package/dist/agents/claude.d.ts.map +1 -1
- package/dist/agents/claude.js +25 -17
- package/dist/agents/codex.d.ts +19 -0
- package/dist/agents/codex.d.ts.map +1 -0
- package/dist/agents/codex.js +210 -0
- package/dist/agents/detect.d.ts +27 -1
- package/dist/agents/detect.d.ts.map +1 -1
- package/dist/agents/detect.js +46 -3
- package/dist/agents/invoke.d.ts.map +1 -1
- package/dist/agents/invoke.js +21 -7
- package/dist/agents/registry.d.ts +10 -5
- package/dist/agents/registry.d.ts.map +1 -1
- package/dist/agents/registry.js +14 -5
- package/dist/agents/types.d.ts +71 -1
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/service/cdpHandlers.d.ts +39 -0
- package/dist/service/cdpHandlers.d.ts.map +1 -0
- package/dist/service/cdpHandlers.js +82 -0
- package/dist/service/cdpHint.d.ts +20 -0
- package/dist/service/cdpHint.d.ts.map +1 -0
- package/dist/service/cdpHint.js +86 -0
- package/dist/service/saveHandlers.d.ts +55 -0
- package/dist/service/saveHandlers.d.ts.map +1 -0
- package/dist/service/saveHandlers.js +80 -0
- package/dist/service/types.d.ts +41 -0
- package/dist/service/types.d.ts.map +1 -0
- package/dist/service/types.js +14 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +141 -235
- package/package.json +1 -1
package/dist/service.js
CHANGED
|
@@ -29,19 +29,28 @@
|
|
|
29
29
|
* { type: 'save-spec', payload: { name, description, steps, assertions?, overwrite? } }
|
|
30
30
|
* { type: 'save-case-csv', payload: { name, description, steps, assertions?, jiraProjectKey?, labels?, overwrite? } }
|
|
31
31
|
* { type: 'list-skills' }
|
|
32
|
+
* { type: 'list-agents' } // ask for the full agent registry + install status
|
|
33
|
+
* { type: 'switch-agent', payload: { agentId } } // set the service's current agent; broadcasts to all connections
|
|
34
|
+
*
|
|
35
|
+
* server → client (in addition to those documented in the file body):
|
|
36
|
+
* { type: 'agents', payload: { current: string, available: AgentAvailability[] } }
|
|
32
37
|
*/
|
|
33
38
|
import { dirname, resolve } from 'node:path';
|
|
34
39
|
import { fileURLToPath } from 'node:url';
|
|
35
40
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
36
41
|
import { invokeAgent } from './agents/invoke.js';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
42
|
+
import { listAgentAvailability, pickPrimaryAgent, } from './agents/detect.js';
|
|
43
|
+
import { getAgent } from './agents/registry.js';
|
|
39
44
|
import { preflightCDP } from './playwright/preflight.js';
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
45
|
+
import { listSkills } from './skills/writeSkill.js';
|
|
46
|
+
import { send } from './service/types.js';
|
|
47
|
+
import { buildCdpHint } from './service/cdpHint.js';
|
|
48
|
+
import { handleCheckCdp, handleLaunchChrome, handleFocusDebug, } from './service/cdpHandlers.js';
|
|
49
|
+
import { handleSaveArtifact, SKILL_CONFIG, SPEC_CONFIG, CASE_CSV_CONFIG, } from './service/saveHandlers.js';
|
|
43
50
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
51
|
const DEFAULT_MCP_CONFIG = resolve(HERE, '..', 'mcp.config.json');
|
|
52
|
+
// ClientMessage + send moved to ./service/types.ts so the cdp + save
|
|
53
|
+
// handler modules can share them. See those files for the wire shape.
|
|
45
54
|
const PROTOCOL_VERSION = 1;
|
|
46
55
|
const PORT_RETRIES = 10;
|
|
47
56
|
/**
|
|
@@ -86,7 +95,25 @@ async function pickAndBind(host, start, attempts) {
|
|
|
86
95
|
}
|
|
87
96
|
export async function startService(opts) {
|
|
88
97
|
const requestedPort = opts.port;
|
|
89
|
-
|
|
98
|
+
// Resolve the primary agent. Honor an explicit opts.agentId (or HOVER_AGENT
|
|
99
|
+
// env var) when set AND installed; otherwise fall back to whichever
|
|
100
|
+
// registered agent the user actually has on PATH, in registry order. This
|
|
101
|
+
// is what lets a user with only codex installed open a Hover dev server
|
|
102
|
+
// without needing to set HOVER_AGENT=codex.
|
|
103
|
+
const preferred = opts.agentId ?? process.env.HOVER_AGENT;
|
|
104
|
+
const primary = await pickPrimaryAgent(preferred);
|
|
105
|
+
let currentAgentId = primary?.descriptor.id ?? preferred ?? 'claude';
|
|
106
|
+
if (!primary) {
|
|
107
|
+
// Nothing installed — still bind so the widget can show a helpful
|
|
108
|
+
// "install one of these" dialog. Commands will fail with
|
|
109
|
+
// AgentNotInstalledError at invoke time.
|
|
110
|
+
process.stderr.write(`[hover] no supported agent CLI found on PATH (looked for: ` +
|
|
111
|
+
`${(await listAgentAvailability()).map(a => a.id).join(', ')}). ` +
|
|
112
|
+
`The widget will open but commands will fail until you install one.\n`);
|
|
113
|
+
}
|
|
114
|
+
else if (preferred && preferred !== primary.descriptor.id) {
|
|
115
|
+
process.stderr.write(`[hover] requested agent "${preferred}" is not installed; falling back to "${primary.descriptor.id}".\n`);
|
|
116
|
+
}
|
|
90
117
|
const model = opts.model ?? 'sonnet';
|
|
91
118
|
// No default budget cap — long real-world flows (form filling, multi-step
|
|
92
119
|
// checkouts) routinely run past the old $0.50 ceiling and got cut off
|
|
@@ -103,8 +130,36 @@ export async function startService(opts) {
|
|
|
103
130
|
wss.on('error', err => {
|
|
104
131
|
process.stderr.write(`[hover] WebSocketServer error: ${err.message}\n`);
|
|
105
132
|
});
|
|
133
|
+
// Cache the agent-availability list. The PATH scan is cheap (one `which`
|
|
134
|
+
// per registered agent) but we still don't want to re-run it on every
|
|
135
|
+
// hello; a single Vite dev server typically sees the widget connect and
|
|
136
|
+
// reconnect dozens of times during HMR.
|
|
137
|
+
let agentAvailabilityCache = null;
|
|
138
|
+
const getAvailability = async (refresh) => {
|
|
139
|
+
if (refresh || agentAvailabilityCache === null) {
|
|
140
|
+
agentAvailabilityCache = await listAgentAvailability();
|
|
141
|
+
}
|
|
142
|
+
return agentAvailabilityCache;
|
|
143
|
+
};
|
|
144
|
+
const broadcastAgents = async () => {
|
|
145
|
+
const available = await getAvailability(false);
|
|
146
|
+
const payload = { current: currentAgentId, available };
|
|
147
|
+
for (const client of wss.clients) {
|
|
148
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
149
|
+
send(client, { type: 'agents', payload });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
106
153
|
wss.on('connection', ws => {
|
|
107
|
-
send(ws, {
|
|
154
|
+
send(ws, {
|
|
155
|
+
type: 'hello',
|
|
156
|
+
payload: { agentId: currentAgentId, model, version: PROTOCOL_VERSION },
|
|
157
|
+
});
|
|
158
|
+
// Send the agent list as a follow-up event so the widget can render the
|
|
159
|
+
// dropdown immediately on connect / reconnect (e.g. after HMR).
|
|
160
|
+
void getAvailability(false).then(available => {
|
|
161
|
+
send(ws, { type: 'agents', payload: { current: currentAgentId, available } });
|
|
162
|
+
});
|
|
108
163
|
let busy = false;
|
|
109
164
|
let inflight = null;
|
|
110
165
|
let cancelled = false;
|
|
@@ -143,8 +198,50 @@ export async function startService(opts) {
|
|
|
143
198
|
cancel();
|
|
144
199
|
return;
|
|
145
200
|
}
|
|
201
|
+
if (msg.type === 'list-agents') {
|
|
202
|
+
// Force a refresh — the user may have just installed a new CLI
|
|
203
|
+
// and clicked the dropdown to see the change.
|
|
204
|
+
const available = await getAvailability(true);
|
|
205
|
+
send(ws, { type: 'agents', payload: { current: currentAgentId, available } });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (msg.type === 'switch-agent') {
|
|
209
|
+
const wanted = msg.payload?.agentId;
|
|
210
|
+
if (typeof wanted !== 'string' || !wanted) {
|
|
211
|
+
send(ws, { type: 'error', payload: { message: 'switch-agent: agentId is required' } });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!getAgent(wanted)) {
|
|
215
|
+
send(ws, { type: 'error', payload: { message: `switch-agent: unknown agent "${wanted}"` } });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Refuse to switch mid-flight; the user's running command would
|
|
219
|
+
// otherwise outlive its own descriptor and the events it produces
|
|
220
|
+
// would be parsed against the wrong wire format.
|
|
221
|
+
if (busy) {
|
|
222
|
+
send(ws, {
|
|
223
|
+
type: 'error',
|
|
224
|
+
payload: { message: 'switch-agent: a command is already running; stop it first' },
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const available = await getAvailability(false);
|
|
229
|
+
const entry = available.find(a => a.id === wanted);
|
|
230
|
+
if (!entry?.installed) {
|
|
231
|
+
send(ws, {
|
|
232
|
+
type: 'error',
|
|
233
|
+
payload: {
|
|
234
|
+
message: `switch-agent: "${wanted}" is not installed. ${entry?.installHint ? `Install: ${entry.installHint}` : ''}`.trim(),
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
currentAgentId = wanted;
|
|
240
|
+
await broadcastAgents();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
146
243
|
if (msg.type === 'save-skill') {
|
|
147
|
-
await
|
|
244
|
+
await handleSaveArtifact(ws, msg, devRoot, SKILL_CONFIG);
|
|
148
245
|
return;
|
|
149
246
|
}
|
|
150
247
|
if (msg.type === 'list-skills') {
|
|
@@ -153,11 +250,11 @@ export async function startService(opts) {
|
|
|
153
250
|
return;
|
|
154
251
|
}
|
|
155
252
|
if (msg.type === 'save-spec') {
|
|
156
|
-
await
|
|
253
|
+
await handleSaveArtifact(ws, msg, devRoot, SPEC_CONFIG);
|
|
157
254
|
return;
|
|
158
255
|
}
|
|
159
256
|
if (msg.type === 'save-case-csv') {
|
|
160
|
-
await
|
|
257
|
+
await handleSaveArtifact(ws, msg, devRoot, CASE_CSV_CONFIG);
|
|
161
258
|
return;
|
|
162
259
|
}
|
|
163
260
|
if (msg.type === 'check-cdp') {
|
|
@@ -214,8 +311,20 @@ export async function startService(opts) {
|
|
|
214
311
|
// momentarily (the widget re-injects + recovers, but the agent's
|
|
215
312
|
// own session sometimes gets confused).
|
|
216
313
|
const appendSystemPrompt = buildCdpHint(cdp.tabs);
|
|
314
|
+
// Snapshot the agent id so a switch-agent message during the run
|
|
315
|
+
// can't smear two agents across one invocation. (We also gate
|
|
316
|
+
// switch-agent on `busy`, but defense in depth.)
|
|
317
|
+
const invokedAgentId = currentAgentId;
|
|
318
|
+
const invokedDescriptor = getAgent(invokedAgentId);
|
|
319
|
+
// Only Claude's `--allowedTools`/`--disallowedTools` flags are
|
|
320
|
+
// honoured — passing them to a soft-sandbox agent like codex is a
|
|
321
|
+
// no-op (its buildArgs ignores them). We still gate at the service
|
|
322
|
+
// layer for clarity: a hard-sandbox agent gets the tight allowlist,
|
|
323
|
+
// a soft one gets nothing and relies on its descriptor's built-in
|
|
324
|
+
// sandbox flags + developer_instructions.
|
|
325
|
+
const isHardSandbox = invokedDescriptor?.sandboxStrength === 'hard';
|
|
217
326
|
for await (const ev of invokeAgent({
|
|
218
|
-
agentId,
|
|
327
|
+
agentId: invokedAgentId,
|
|
219
328
|
prompt: text,
|
|
220
329
|
sessionId: resumeSessionId,
|
|
221
330
|
mcpConfig,
|
|
@@ -226,25 +335,27 @@ export async function startService(opts) {
|
|
|
226
335
|
// Skill stays in the allow list so saved skills under
|
|
227
336
|
// <devRoot>/.claude/skills/ can be invoked. mcp__playwright covers
|
|
228
337
|
// every browser tool.
|
|
229
|
-
allowedTools: ['mcp__playwright', 'Skill'],
|
|
230
|
-
disallowedTools:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
338
|
+
allowedTools: isHardSandbox ? ['mcp__playwright', 'Skill'] : undefined,
|
|
339
|
+
disallowedTools: isHardSandbox
|
|
340
|
+
? [
|
|
341
|
+
// file / shell / data access — never appropriate for browser driving
|
|
342
|
+
'Bash', 'BashOutput', 'KillBash',
|
|
343
|
+
'Edit', 'MultiEdit', 'Write', 'Read', 'NotebookEdit',
|
|
344
|
+
'Grep', 'Glob', 'Task', 'TodoWrite',
|
|
345
|
+
'WebFetch', 'WebSearch',
|
|
346
|
+
// plan / worktree / cron / notification — irrelevant in -p mode
|
|
347
|
+
'EnterPlanMode', 'ExitPlanMode',
|
|
348
|
+
'EnterWorktree', 'ExitWorktree',
|
|
349
|
+
'CronCreate', 'CronDelete', 'CronList',
|
|
350
|
+
'PushNotification', 'RemoteTrigger',
|
|
351
|
+
// task & tool introspection added in claude 2.1.x — let through and
|
|
352
|
+
// the agent will burn turns exploring instead of executing
|
|
353
|
+
'ToolSearch',
|
|
354
|
+
'Monitor', 'TaskOutput', 'TaskStop',
|
|
355
|
+
'AskUserQuestion',
|
|
356
|
+
'ShareOnboardingGuide',
|
|
357
|
+
]
|
|
358
|
+
: undefined,
|
|
248
359
|
maxBudgetUsd,
|
|
249
360
|
model,
|
|
250
361
|
signal: inflight.signal,
|
|
@@ -278,208 +389,3 @@ export async function startService(opts) {
|
|
|
278
389
|
}),
|
|
279
390
|
};
|
|
280
391
|
}
|
|
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
|
-
}
|