@huaqiu/dsh-kicad 0.4.2

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/src/tools.ts ADDED
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Agent tools for `@huaqiu/dsh-kicad`.
3
+ *
4
+ * Ten tools, one per migrated `kicad-agent` script — a 1:1 preserve mapping
5
+ * (task §10): no script is split, merged or invented. The tools are the
6
+ * *executable interface*; `skills/kicad-ipc/SKILL.md` is the *reasoning* around
7
+ * them (§11). Tool descriptions therefore describe board-level intent and
8
+ * outcomes, never RPC mechanics.
9
+ *
10
+ * Every tool returns the same envelope:
11
+ *
12
+ * { ok: true, script, effect, output } KiCad's own report, verbatim
13
+ * { ok: false, script, effect, error: { kind, message } }
14
+ *
15
+ * Nothing here fabricates board state. A successful call means the script
16
+ * exited 0 and *verified* its own result inside KiCad — but per §9.5 the agent
17
+ * must still re-read affected state after an important mutation rather than
18
+ * trusting the return value alone.
19
+ *
20
+ * @module
21
+ */
22
+ import { defineTool } from '@deepseek-ai/dsh-tools'
23
+
24
+ import { type KicadConfig } from './config.js'
25
+ import {
26
+ classifyRun,
27
+ invokeKicadScript,
28
+ runKicadScript,
29
+ type KicadError,
30
+ type KicadErrorKind,
31
+ } from './ipc.js'
32
+ import { kicadScript, type ScriptEffect } from './scripts.js'
33
+
34
+ /** Structural alias of the DSH `JsonValue`. */
35
+ type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
36
+
37
+ function asJson(value: unknown): Json {
38
+ return JSON.parse(JSON.stringify(value)) as Json
39
+ }
40
+
41
+ function renderJson(_args: unknown, value: unknown) {
42
+ return [{ type: 'text' as const, text: JSON.stringify(value) }]
43
+ }
44
+
45
+ /** Result envelope shared by every KiCad tool. */
46
+ export type KicadToolResult =
47
+ | { ok: true; script: string; effect: ScriptEffect; output: string }
48
+ | {
49
+ ok: false
50
+ script: string
51
+ effect: ScriptEffect
52
+ error: KicadError
53
+ diagnostics?: { exitCode: number | null; stderr: string }
54
+ }
55
+
56
+ /** Everything a tool needs to reach the bundled scripts. */
57
+ export interface KicadToolEnv {
58
+ /** Directory holding the bundled Python scripts. */
59
+ scriptsDir: string
60
+ /** Python interpreter with `kipy`. */
61
+ pythonPath: string
62
+ config: KicadConfig
63
+ }
64
+
65
+ /** Structural view of DSH's `ToolRunContext`. */
66
+ export interface ToolExecLike {
67
+ signal?: AbortSignal
68
+ callId?: string
69
+ }
70
+
71
+ /**
72
+ * Shared failure semantics appended to every description.
73
+ *
74
+ * Kept in one place so the prompt contract cannot drift between tools — the
75
+ * same idiom as `@huaqiu/dsh-eda-host`.
76
+ */
77
+ const ERROR_SEMANTICS =
78
+ `IMPORTANT SEMANTICS: on ok:false, error.kind distinguishes the cause: ` +
79
+ `"FAILED_PRECONDITION" (KiCad IPC cannot run at all — no kipy, API version ` +
80
+ `mismatch, or no .kicad_pcb open; ask the user to fix the environment, do NOT ` +
81
+ `retry), "UNAVAILABLE" (KiCad is installed but unreachable — retry once after ` +
82
+ `checking PCB Editor is open, the KiCad API service is enabled, and DSH has ` +
83
+ `Full Access), "DEADLINE_EXCEEDED" (KiCad did not answer in time — retry a ` +
84
+ `read once; for a write, re-read board state first), "INVALID_ARGUMENT" (the ` +
85
+ `board was not touched — fix units/ranges and retry), "INTERNAL" (KiCad or ` +
86
+ `the script failed; the commit was dropped so the board is unchanged). ` +
87
+ `Do NOT fabricate board state from a failed call.`
88
+
89
+ /** Reading-only tools are safe to run alongside each other. */
90
+ const READ_ONLY = { isConcurrencySafe: () => true }
91
+ /** Mutating tools must never be batched into a parallel group. */
92
+ const MUTATING = { isConcurrencySafe: () => false }
93
+
94
+ /** Build a semantic failure envelope from a raw script run. */
95
+ function failureEnvelope(
96
+ scriptId: string,
97
+ effect: ScriptEffect,
98
+ error: KicadError,
99
+ run?: { exitCode: number | null; stderr: string },
100
+ ): KicadToolResult {
101
+ return {
102
+ ok: false,
103
+ script: scriptId,
104
+ effect,
105
+ error,
106
+ ...(run ? { diagnostics: { exitCode: run.exitCode, stderr: run.stderr } } : {}),
107
+ }
108
+ }
109
+
110
+ /** Reject arguments the script would reject, before spawning a process. */
111
+ function invalidArgument(scriptId: string, effect: ScriptEffect, message: string): KicadToolResult {
112
+ return failureEnvelope(scriptId, effect, { kind: 'INVALID_ARGUMENT' as KicadErrorKind, message })
113
+ }
114
+
115
+ /** Format millimetres compactly without trailing float noise. */
116
+ function mm(value: number): string {
117
+ return String(Number.isInteger(value) ? value : Number(value.toFixed(6)))
118
+ }
119
+
120
+ /** Build `--flag value` pairs, skipping undefined optionals. */
121
+ function flags(pairs: Array<[string, string | undefined]>): string[] {
122
+ const argv: string[] = []
123
+ for (const [flag, value] of pairs) {
124
+ if (value !== undefined) argv.push(flag, value)
125
+ }
126
+ return argv
127
+ }
128
+
129
+ /** Build a bare `--flag` for boolean switches. */
130
+ function switchFlag(flag: string, on: boolean | undefined): string[] {
131
+ return on === true ? [flag] : []
132
+ }
133
+
134
+ /**
135
+ * All KiCad tools contributed by this plugin.
136
+ */
137
+ export function createKicadTools(env: KicadToolEnv): ReturnType<typeof defineTool>[] {
138
+ const { scriptsDir, pythonPath, config } = env
139
+
140
+ /**
141
+ * Shared executor: run one script and translate it into the envelope.
142
+ */
143
+ async function run(
144
+ scriptId: string,
145
+ args: readonly string[],
146
+ timeoutMs: number,
147
+ signal?: AbortSignal,
148
+ ): Promise<KicadToolResult> {
149
+ const script = kicadScript(scriptId)
150
+ const run_ = await runKicadScript({
151
+ scriptsDir,
152
+ pythonPath,
153
+ script,
154
+ args,
155
+ timeoutMs,
156
+ ...(signal ? { signal } : {}),
157
+ })
158
+ const error = classifyRun(run_)
159
+ if (error) return failureEnvelope(scriptId, script.effect, error, run_)
160
+ return {
161
+ ok: true,
162
+ script: scriptId,
163
+ effect: script.effect,
164
+ output: run_.stdout,
165
+ }
166
+ }
167
+
168
+ const saveFlag = (save?: boolean) => switchFlag('--save', save)
169
+
170
+ return [
171
+ // ── Diagnostics ────────────────────────────────────────────────────────
172
+ defineTool({
173
+ name: 'kicad_ipc_diagnose',
174
+ description:
175
+ `Check whether KiCad IPC is usable right now: reports the KiCad version, ` +
176
+ `whether kicad-python matches it, and the name of the currently open PCB. ` +
177
+ `Returns { ok, script, effect, output } where output is KiCad's own report. ` +
178
+ `Use this FIRST before any KiCad read or write, and whenever a KiCad tool ` +
179
+ `fails — it separates "environment is wrong" from "KiCad is busy". ` +
180
+ `Read-only: it never modifies or saves the board. ` +
181
+ ERROR_SEMANTICS,
182
+ parameters: {},
183
+ output: { schema: { type: 'json' }, render: renderJson },
184
+ timeoutMs: config.diagnosticTimeoutMs,
185
+ ...READ_ONLY,
186
+ async execute(_args: unknown, exec: ToolExecLike) {
187
+ return asJson(
188
+ await run('diagnose_ipc_connection', [], config.diagnosticTimeoutMs, exec?.signal),
189
+ )
190
+ },
191
+ }),
192
+
193
+ defineTool({
194
+ name: 'kicad_ipc_verify_live',
195
+ description:
196
+ `Run the bundled KiCad IPC smoke test: it creates, updates, clones, zones ` +
197
+ `and deletes objects — then DROPS the commit, so nothing is persisted and ` +
198
+ `the board is left exactly as it was. Use it to prove a KiCad IPC ` +
199
+ `installation can really mutate before attempting a real edit. ` +
200
+ `Requires an existing GND net and at least one footprint on the board, and ` +
201
+ `must not be run while the GUI holds unsaved edits. ` +
202
+ ERROR_SEMANTICS,
203
+ parameters: {},
204
+ output: { schema: { type: 'json' }, render: renderJson },
205
+ timeoutMs: config.timeoutMs,
206
+ ...MUTATING,
207
+ async execute(_args: unknown, exec: ToolExecLike) {
208
+ return asJson(await run('verify_live_ipc', [], config.timeoutMs, exec?.signal))
209
+ },
210
+ }),
211
+
212
+ // ── Creation ───────────────────────────────────────────────────────────
213
+ defineTool({
214
+ name: 'kicad_pcb_create_track',
215
+ description:
216
+ `Create one straight copper track on an EXISTING net of the open KiCad ` +
217
+ `PCB. All coordinates and the width are millimetres. The net must already ` +
218
+ `exist on the board — this tool never invents one; sync nets from the ` +
219
+ `schematic first if a name does not resolve. The change is committed as a ` +
220
+ `single KiCad undo step and verified against KiCad's returned object; ` +
221
+ `the board file is only written when save is true. ` +
222
+ `Returns the created track id in output. ` +
223
+ ERROR_SEMANTICS,
224
+ parameters: {
225
+ net: { type: 'string', required: true, description: 'Existing PCB net name, e.g. "GND".' },
226
+ start_x_mm: { type: 'number', required: true, description: 'Start X in millimetres.' },
227
+ start_y_mm: { type: 'number', required: true, description: 'Start Y in millimetres.' },
228
+ end_x_mm: { type: 'number', required: true, description: 'End X in millimetres.' },
229
+ end_y_mm: { type: 'number', required: true, description: 'End Y in millimetres.' },
230
+ width_mm: { type: 'number', required: true, description: 'Track width in millimetres, > 0.' },
231
+ layer: {
232
+ type: 'string',
233
+ description: 'Target copper layer, e.g. "F.Cu" or "B.Cu". Defaults to F.Cu. Must be enabled on the board.',
234
+ },
235
+ save: {
236
+ type: 'boolean',
237
+ description: 'Persist the board to disk via KiCad after a verified success. Default false — the edit stays in KiCad only.',
238
+ },
239
+ },
240
+ output: { schema: { type: 'json' }, render: renderJson },
241
+ timeoutMs: config.timeoutMs,
242
+ ...MUTATING,
243
+ async execute(args: unknown, exec: ToolExecLike) {
244
+ const a = args as {
245
+ net: string
246
+ start_x_mm: number
247
+ start_y_mm: number
248
+ end_x_mm: number
249
+ end_y_mm: number
250
+ width_mm: number
251
+ layer?: string
252
+ save?: boolean
253
+ }
254
+ if (!(a.width_mm > 0)) {
255
+ return asJson(
256
+ invalidArgument('create_track', 'mutate', 'width_mm must be greater than 0.'),
257
+ )
258
+ }
259
+ const argv = [
260
+ ...flags([
261
+ ['--net', a.net],
262
+ ['--start', `${mm(a.start_x_mm)},${mm(a.start_y_mm)}`],
263
+ ['--end', `${mm(a.end_x_mm)},${mm(a.end_y_mm)}`],
264
+ ['--width-mm', mm(a.width_mm)],
265
+ ['--layer', a.layer],
266
+ ]),
267
+ ...saveFlag(a.save),
268
+ ]
269
+ return asJson(await run('create_track', argv, config.timeoutMs, exec?.signal))
270
+ },
271
+ }),
272
+
273
+ defineTool({
274
+ name: 'kicad_pcb_create_via',
275
+ description:
276
+ `Create one through-hole via on an EXISTING net of the open KiCad PCB. ` +
277
+ `All dimensions are millimetres and must satisfy 0 < drill < diameter. ` +
278
+ `The net must already exist on the board. The change is a single KiCad ` +
279
+ `undo step and is verified against KiCad's returned object. ` +
280
+ `Returns the created via id in output. ` +
281
+ ERROR_SEMANTICS,
282
+ parameters: {
283
+ net: { type: 'string', required: true, description: 'Existing PCB net name, e.g. "GND".' },
284
+ x_mm: { type: 'number', required: true, description: 'Via X position in millimetres.' },
285
+ y_mm: { type: 'number', required: true, description: 'Via Y position in millimetres.' },
286
+ diameter_mm: { type: 'number', required: true, description: 'Outer diameter in millimetres.' },
287
+ drill_mm: { type: 'number', required: true, description: 'Drill diameter in millimetres, must be < diameter_mm.' },
288
+ save: {
289
+ type: 'boolean',
290
+ description: 'Persist the board to disk via KiCad after a verified success. Default false.',
291
+ },
292
+ },
293
+ output: { schema: { type: 'json' }, render: renderJson },
294
+ timeoutMs: config.timeoutMs,
295
+ ...MUTATING,
296
+ async execute(args: unknown, exec: ToolExecLike) {
297
+ const a = args as {
298
+ net: string
299
+ x_mm: number
300
+ y_mm: number
301
+ diameter_mm: number
302
+ drill_mm: number
303
+ save?: boolean
304
+ }
305
+ if (!(0 < a.drill_mm && a.drill_mm < a.diameter_mm)) {
306
+ return asJson(
307
+ invalidArgument(
308
+ 'create_via',
309
+ 'mutate',
310
+ 'Require 0 < drill_mm < diameter_mm.',
311
+ ),
312
+ )
313
+ }
314
+ const argv = [
315
+ ...flags([
316
+ ['--net', a.net],
317
+ ['--x-mm', mm(a.x_mm)],
318
+ ['--y-mm', mm(a.y_mm)],
319
+ ['--diameter-mm', mm(a.diameter_mm)],
320
+ ['--drill-mm', mm(a.drill_mm)],
321
+ ]),
322
+ ...saveFlag(a.save),
323
+ ]
324
+ return asJson(await run('create_via', argv, config.timeoutMs, exec?.signal))
325
+ },
326
+ }),
327
+
328
+ defineTool({
329
+ name: 'kicad_pcb_create_copper_zone',
330
+ description:
331
+ `Create one UNFILLED copper zone on an EXISTING net of the open KiCad ` +
332
+ `PCB from a closed polygon. The polygon is given as ordered vertices in ` +
333
+ `millimetres; a closing vertex is added automatically. The zone is created ` +
334
+ `for review only — run kicad_pcb_refill_zones afterwards to fill it. ` +
335
+ `The net must already exist on the board. ` +
336
+ ERROR_SEMANTICS,
337
+ parameters: {
338
+ net: { type: 'string', required: true, description: 'Existing PCB net name, e.g. "GND".' },
339
+ points: {
340
+ type: 'array',
341
+ required: true,
342
+ description: 'Outline vertices in millimetres, in order; at least three distinct points.',
343
+ items: {
344
+ type: 'object',
345
+ additionalProperties: false,
346
+ properties: {
347
+ x_mm: { type: 'number', required: true, description: 'Vertex X in millimetres.' },
348
+ y_mm: { type: 'number', required: true, description: 'Vertex Y in millimetres.' },
349
+ },
350
+ },
351
+ },
352
+ layer: {
353
+ type: 'string',
354
+ description: 'Target copper layer, e.g. "F.Cu". Defaults to F.Cu. Must be enabled on the board.',
355
+ },
356
+ save: {
357
+ type: 'boolean',
358
+ description: 'Persist the board to disk via KiCad after a verified success. Default false.',
359
+ },
360
+ },
361
+ output: { schema: { type: 'json' }, render: renderJson },
362
+ timeoutMs: config.timeoutMs,
363
+ ...MUTATING,
364
+ async execute(args: unknown, exec: ToolExecLike) {
365
+ const a = args as {
366
+ net: string
367
+ points: Array<{ x_mm: number; y_mm: number }>
368
+ layer?: string
369
+ save?: boolean
370
+ }
371
+ if (!Array.isArray(a.points) || a.points.length < 3) {
372
+ return asJson(
373
+ invalidArgument(
374
+ 'create_copper_zone',
375
+ 'mutate',
376
+ 'points needs at least three vertices.',
377
+ ),
378
+ )
379
+ }
380
+ const polygon = a.points.map((p) => `${mm(p.x_mm)},${mm(p.y_mm)}`).join(';')
381
+ const argv = [
382
+ ...flags([['--net', a.net], ['--points', polygon], ['--layer', a.layer]]),
383
+ ...saveFlag(a.save),
384
+ ]
385
+ return asJson(await run('create_copper_zone', argv, config.timeoutMs, exec?.signal))
386
+ },
387
+ }),
388
+
389
+ defineTool({
390
+ name: 'kicad_pcb_add_footprint_from_template',
391
+ description:
392
+ `Add a new footprint to the open KiCad PCB by cloning an existing ` +
393
+ `on-board footprint as the template, then offsetting the clone. Use this ` +
394
+ `when the user wants another instance of a footprint already placed; ` +
395
+ `there is no public API to place directly from a library — report that ` +
396
+ `gap instead of editing board files. new_reference must be unique on the ` +
397
+ `board and different from source_reference. ` +
398
+ ERROR_SEMANTICS,
399
+ parameters: {
400
+ source_reference: {
401
+ type: 'string',
402
+ required: true,
403
+ description: 'Reference of the existing footprint to clone, e.g. "R1".',
404
+ },
405
+ new_reference: {
406
+ type: 'string',
407
+ required: true,
408
+ description: 'Unique reference for the new footprint, e.g. "R2".',
409
+ },
410
+ dx_mm: { type: 'number', required: true, description: 'X offset from the template, millimetres.' },
411
+ dy_mm: { type: 'number', required: true, description: 'Y offset from the template, millimetres.' },
412
+ save: {
413
+ type: 'boolean',
414
+ description: 'Persist the board to disk via KiCad after a verified success. Default false.',
415
+ },
416
+ },
417
+ output: { schema: { type: 'json' }, render: renderJson },
418
+ timeoutMs: config.timeoutMs,
419
+ ...MUTATING,
420
+ async execute(args: unknown, exec: ToolExecLike) {
421
+ const a = args as {
422
+ source_reference: string
423
+ new_reference: string
424
+ dx_mm: number
425
+ dy_mm: number
426
+ save?: boolean
427
+ }
428
+ if (a.new_reference === a.source_reference) {
429
+ return asJson(
430
+ invalidArgument(
431
+ 'add_footprint_from_board_template',
432
+ 'mutate',
433
+ 'new_reference must differ from source_reference.',
434
+ ),
435
+ )
436
+ }
437
+ const argv = [
438
+ ...flags([
439
+ ['--source-reference', a.source_reference],
440
+ ['--new-reference', a.new_reference],
441
+ ['--dx-mm', mm(a.dx_mm)],
442
+ ['--dy-mm', mm(a.dy_mm)],
443
+ ]),
444
+ ...saveFlag(a.save),
445
+ ]
446
+ return asJson(
447
+ await run('add_footprint_from_board_template', argv, config.timeoutMs, exec?.signal),
448
+ )
449
+ },
450
+ }),
451
+
452
+ // ── Modification ───────────────────────────────────────────────────────
453
+ defineTool({
454
+ name: 'kicad_pcb_move_rotate_footprint',
455
+ description:
456
+ `Move and/or rotate one footprint on the open KiCad PCB, selected by its ` +
457
+ `reference designator. Offsets are relative, rotation is incremental, in ` +
458
+ `degrees. At least one of dx_mm / dy_mm / rotation_deg must be non-zero. ` +
459
+ `The footprint is re-read from the board before the change, so KiCad's ` +
460
+ `UUID and the rest of its properties are preserved. ` +
461
+ ERROR_SEMANTICS,
462
+ parameters: {
463
+ reference: {
464
+ type: 'string',
465
+ required: true,
466
+ description: 'Footprint reference designator, e.g. "R1". Must match exactly one footprint.',
467
+ },
468
+ dx_mm: { type: 'number', description: 'Relative X offset in millimetres. Default 0.' },
469
+ dy_mm: { type: 'number', description: 'Relative Y offset in millimetres. Default 0.' },
470
+ rotation_deg: { type: 'number', description: 'Incremental rotation in degrees. Default 0.' },
471
+ save: {
472
+ type: 'boolean',
473
+ description: 'Persist the board to disk via KiCad after a verified success. Default false.',
474
+ },
475
+ },
476
+ output: { schema: { type: 'json' }, render: renderJson },
477
+ timeoutMs: config.timeoutMs,
478
+ ...MUTATING,
479
+ async execute(args: unknown, exec: ToolExecLike) {
480
+ const a = args as {
481
+ reference: string
482
+ dx_mm?: number
483
+ dy_mm?: number
484
+ rotation_deg?: number
485
+ save?: boolean
486
+ }
487
+ if ((a.dx_mm ?? 0) === 0 && (a.dy_mm ?? 0) === 0 && (a.rotation_deg ?? 0) === 0) {
488
+ return asJson(
489
+ invalidArgument(
490
+ 'move_rotate_footprint',
491
+ 'mutate',
492
+ 'Specify at least one non-zero dx_mm, dy_mm or rotation_deg.',
493
+ ),
494
+ )
495
+ }
496
+ const argv = [
497
+ ...flags([
498
+ ['--reference', a.reference],
499
+ ['--dx-mm', mm(a.dx_mm ?? 0)],
500
+ ['--dy-mm', mm(a.dy_mm ?? 0)],
501
+ ['--rotation-deg', mm(a.rotation_deg ?? 0)],
502
+ ]),
503
+ ...saveFlag(a.save),
504
+ ]
505
+ return asJson(await run('move_rotate_footprint', argv, config.timeoutMs, exec?.signal))
506
+ },
507
+ }),
508
+
509
+ defineTool({
510
+ name: 'kicad_pcb_update_selected_track_width',
511
+ description:
512
+ `Resize the tracks and arc tracks currently SELECTED in KiCad's PCB ` +
513
+ `Editor to a new width in millimetres. This operates on KiCad's live ` +
514
+ `selection, so inspect the selection first and confirm it contains ` +
515
+ `exactly what the user meant — the tool cannot narrow a vague scope for ` +
516
+ `you. Objects are re-read from the board before the update. ` +
517
+ ERROR_SEMANTICS,
518
+ parameters: {
519
+ width_mm: { type: 'number', required: true, description: 'Target track width in millimetres, > 0.' },
520
+ save: {
521
+ type: 'boolean',
522
+ description: 'Persist the board to disk via KiCad after a verified success. Default false.',
523
+ },
524
+ },
525
+ output: { schema: { type: 'json' }, render: renderJson },
526
+ timeoutMs: config.timeoutMs,
527
+ ...MUTATING,
528
+ async execute(args: unknown, exec: ToolExecLike) {
529
+ const a = args as { width_mm: number; save?: boolean }
530
+ if (!(a.width_mm > 0)) {
531
+ return asJson(
532
+ invalidArgument(
533
+ 'update_selected_track_width',
534
+ 'mutate',
535
+ 'width_mm must be greater than 0.',
536
+ ),
537
+ )
538
+ }
539
+ const argv = [...flags([['--width-mm', mm(a.width_mm)]]), ...saveFlag(a.save)]
540
+ return asJson(
541
+ await run('update_selected_track_width', argv, config.timeoutMs, exec?.signal),
542
+ )
543
+ },
544
+ }),
545
+
546
+ defineTool({
547
+ name: 'kicad_pcb_refill_zones',
548
+ description:
549
+ `Wait for the copper zones on the open KiCad PCB to be filled. Call this ` +
550
+ `after reviewing a zone created with kicad_pcb_create_copper_zone, or ` +
551
+ `after any edit that invalidated fills. Filling is a board mutation and ` +
552
+ `can legitimately take up to about two minutes, so the timeout is longer ` +
553
+ `than for the other tools. Re-read the zones afterwards to confirm. ` +
554
+ ERROR_SEMANTICS,
555
+ parameters: {
556
+ save: {
557
+ type: 'boolean',
558
+ description: 'Persist the board to disk via KiCad once fills complete. Default false.',
559
+ },
560
+ },
561
+ output: { schema: { type: 'json' }, render: renderJson },
562
+ timeoutMs: config.refillTimeoutMs,
563
+ ...MUTATING,
564
+ async execute(args: unknown, exec: ToolExecLike) {
565
+ const a = args as { save?: boolean }
566
+ return asJson(
567
+ await run('refill_zones', saveFlag(a.save), config.refillTimeoutMs, exec?.signal),
568
+ )
569
+ },
570
+ }),
571
+
572
+ // ── Deletion ───────────────────────────────────────────────────────────
573
+ defineTool({
574
+ name: 'kicad_pcb_remove_selected_items',
575
+ description:
576
+ `Delete everything currently SELECTED in KiCad's PCB Editor. ` +
577
+ `confirm must be explicitly true — this is the guard against accidental ` +
578
+ `bulk deletion. Before calling it, report to the user exactly what is ` +
579
+ `selected and how many objects will go; never default the scope to "all ` +
580
+ `tracks" or "all objects". Prefer keeping user hand-routing unless it is ` +
581
+ `explicitly in scope. ` +
582
+ ERROR_SEMANTICS,
583
+ parameters: {
584
+ confirm: {
585
+ type: 'boolean',
586
+ required: true,
587
+ description: 'Must be true to authorise deleting the current selection.',
588
+ },
589
+ save: {
590
+ type: 'boolean',
591
+ description: 'Persist the board to disk via KiCad after the deletion. Default false.',
592
+ },
593
+ },
594
+ output: { schema: { type: 'json' }, render: renderJson },
595
+ timeoutMs: config.timeoutMs,
596
+ ...MUTATING,
597
+ async execute(args: unknown, exec: ToolExecLike) {
598
+ const a = args as { confirm: boolean; save?: boolean }
599
+ if (a.confirm !== true) {
600
+ return asJson(
601
+ invalidArgument(
602
+ 'remove_selected_items',
603
+ 'mutate',
604
+ 'Deletion requires confirm: true, once the selection has been reported to the user.',
605
+ ),
606
+ )
607
+ }
608
+ const argv = [...switchFlag('--yes', true), ...saveFlag(a.save)]
609
+ return asJson(await run('remove_selected_items', argv, config.timeoutMs, exec?.signal))
610
+ },
611
+ }),
612
+ ]
613
+ }
614
+
615
+ /**
616
+ * Names of every tool this plugin registers — asserted by the tests and used
617
+ * for startup logging.
618
+ */
619
+ export function kicadToolNames(): string[] {
620
+ return [
621
+ 'kicad_ipc_diagnose',
622
+ 'kicad_ipc_verify_live',
623
+ 'kicad_pcb_create_track',
624
+ 'kicad_pcb_create_via',
625
+ 'kicad_pcb_create_copper_zone',
626
+ 'kicad_pcb_add_footprint_from_template',
627
+ 'kicad_pcb_move_rotate_footprint',
628
+ 'kicad_pcb_update_selected_track_width',
629
+ 'kicad_pcb_refill_zones',
630
+ 'kicad_pcb_remove_selected_items',
631
+ ]
632
+ }
633
+
634
+ export { classifyRun, invokeKicadScript }