@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/lib/index.mjs ADDED
@@ -0,0 +1,957 @@
1
+ import { getLogger } from "@huaqiu/dsh-plugin-log";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import { spawn } from "node:child_process";
6
+ const DEFAULT_TIMEOUT_MS = 3e4;
7
+ const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 15e3;
8
+ /** `refill_zones.py` polls zone fills; the script itself documents ~120 s. */
9
+ const DEFAULT_REFILL_TIMEOUT_MS = 15e4;
10
+ function firstNonEmpty(...values) {
11
+ for (const value of values) if (typeof value === "string" && value.trim().length > 0) return value;
12
+ }
13
+ /**
14
+ * Resolve the effective configuration.
15
+ *
16
+ * Precedence matches the rest of the Huaqiu DSH package set: explicit plugin
17
+ * config wins over the environment, which wins over the default.
18
+ */
19
+ function resolveKicadConfig(input = {}) {
20
+ const pythonPath = firstNonEmpty(input.pythonPath, process.env["DSH_KICAD_PYTHON"]) ?? "python3";
21
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
22
+ const diagnosticTimeoutMs = input.diagnosticTimeoutMs ?? DEFAULT_DIAGNOSTIC_TIMEOUT_MS;
23
+ const refillTimeoutMs = input.refillTimeoutMs ?? DEFAULT_REFILL_TIMEOUT_MS;
24
+ return {
25
+ pythonPath,
26
+ ...input.skillsDir ? { skillsDir: input.skillsDir } : {},
27
+ timeoutMs,
28
+ diagnosticTimeoutMs,
29
+ refillTimeoutMs
30
+ };
31
+ }
32
+ /** Whether any host configuration was supplied (used for startup logging). */
33
+ function hasHostConfig(input = {}) {
34
+ return Boolean(input.pythonPath ?? process.env["DSH_KICAD_PYTHON"] ?? input.skillsDir ?? process.env["DSH_KICAD_SKILLS_DIR"]);
35
+ }
36
+ //#endregion
37
+ //#region src/scripts.ts
38
+ /**
39
+ * Every bundled script, keyed by id.
40
+ *
41
+ * Mirrors `skills/kicad-ipc/scripts/` 1:1. Adding a script to the skill means
42
+ * adding it here (and exposing it in `./tools.ts`) — the bundling test asserts
43
+ * that all three stay consistent.
44
+ */
45
+ const KICAD_SCRIPTS = {
46
+ diagnose_ipc_connection: {
47
+ id: "diagnose_ipc_connection",
48
+ file: "diagnose_ipc_connection.py",
49
+ summary: "Check the KiCad IPC connection, API version and open board.",
50
+ effect: "read",
51
+ supportsSave: false
52
+ },
53
+ verify_live_ipc: {
54
+ id: "verify_live_ipc",
55
+ file: "verify_live_ipc.py",
56
+ summary: "Live create/update/clone/zone/delete smoke test inside one dropped commit.",
57
+ effect: "probe",
58
+ supportsSave: false
59
+ },
60
+ create_track: {
61
+ id: "create_track",
62
+ file: "create_track.py",
63
+ summary: "Create one straight track on an existing net.",
64
+ effect: "mutate",
65
+ supportsSave: true
66
+ },
67
+ create_via: {
68
+ id: "create_via",
69
+ file: "create_via.py",
70
+ summary: "Create one through via on an existing net.",
71
+ effect: "mutate",
72
+ supportsSave: true
73
+ },
74
+ update_selected_track_width: {
75
+ id: "update_selected_track_width",
76
+ file: "update_selected_track_width.py",
77
+ summary: "Resize the currently selected tracks and arc tracks.",
78
+ effect: "mutate",
79
+ supportsSave: true
80
+ },
81
+ remove_selected_items: {
82
+ id: "remove_selected_items",
83
+ file: "remove_selected_items.py",
84
+ summary: "Delete the current KiCad selection.",
85
+ effect: "mutate",
86
+ supportsSave: true
87
+ },
88
+ move_rotate_footprint: {
89
+ id: "move_rotate_footprint",
90
+ file: "move_rotate_footprint.py",
91
+ summary: "Move and/or rotate one footprint selected by reference.",
92
+ effect: "mutate",
93
+ supportsSave: true
94
+ },
95
+ add_footprint_from_board_template: {
96
+ id: "add_footprint_from_board_template",
97
+ file: "add_footprint_from_board_template.py",
98
+ summary: "Clone an on-board footprint as a template for a new reference.",
99
+ effect: "mutate",
100
+ supportsSave: true
101
+ },
102
+ create_copper_zone: {
103
+ id: "create_copper_zone",
104
+ file: "create_copper_zone.py",
105
+ summary: "Create an unfilled copper zone from a closed polygon.",
106
+ effect: "mutate",
107
+ supportsSave: true
108
+ },
109
+ refill_zones: {
110
+ id: "refill_zones",
111
+ file: "refill_zones.py",
112
+ summary: "Wait for existing copper zone fills to complete.",
113
+ effect: "mutate",
114
+ supportsSave: true
115
+ }
116
+ };
117
+ /** The bundled script ids, in registration order. */
118
+ const KICAD_SCRIPT_IDS = Object.keys(KICAD_SCRIPTS);
119
+ /**
120
+ * Look up one script by id.
121
+ * @throws when the id is not part of the bundled set.
122
+ */
123
+ function kicadScript(id) {
124
+ const script = KICAD_SCRIPTS[id];
125
+ if (!script) throw new Error(`@huaqiu/dsh-kicad: unknown bundled KiCad script "${id}"`);
126
+ return script;
127
+ }
128
+ /** The canonical skill directory name shipped by this package. */
129
+ const KICAD_SKILL_NAME = "kicad-ipc";
130
+ //#endregion
131
+ //#region src/paths.ts
132
+ /**
133
+ * Resolution of the bundled KiCad skill directory at runtime.
134
+ *
135
+ * The skill ships inside the installed package (`<pkg>/skills/kicad-ipc/`), not
136
+ * beside the source tree, so it is located relative to the loaded module. That
137
+ * makes one resolver correct for every install shape:
138
+ *
139
+ * - built artifact: `<pkg>/lib/index.mjs` -> `<pkg>/skills/kicad-ipc`
140
+ * - source (vitest): `<pkg>/src/index.ts` -> `<pkg>/skills/kicad-ipc`
141
+ * - npm / git install: identical to the built artifact case
142
+ *
143
+ * `skills` must stay in `package.json` `files[]` or npm strips it and this
144
+ * resolver fails loudly — which is the intended signal, because a `dsh-kicad`
145
+ * without its skill is a broken delivery boundary.
146
+ *
147
+ * @module
148
+ */
149
+ /**
150
+ * Absolute path of the bundled `kicad-ipc` skill directory.
151
+ *
152
+ * Resolution order: an explicit override (config / `DSH_KICAD_SKILLS_DIR`),
153
+ * then the package-relative location.
154
+ *
155
+ * @param moduleUrl - `import.meta.url` of the calling module.
156
+ * @param override - explicit directory (plugin config or env var).
157
+ * @returns the resolved directory, whether or not it exists yet.
158
+ */
159
+ function resolveSkillDir(moduleUrl, override) {
160
+ if (override && override.trim().length > 0) return resolve(override);
161
+ const envOverride = process.env["DSH_KICAD_SKILLS_DIR"];
162
+ if (envOverride && envOverride.trim().length > 0) return resolve(envOverride);
163
+ const here = dirname(new URL(moduleUrl).pathname);
164
+ return resolve(here, "..", "skills", KICAD_SKILL_NAME);
165
+ }
166
+ /**
167
+ * Resolve the skill directory and assert the skill is actually present.
168
+ *
169
+ * @throws when `SKILL.md` is missing — this is a packaging failure, not a
170
+ * runtime condition, so it must be loud rather than silently degraded.
171
+ */
172
+ function requireSkillDir(moduleUrl, override) {
173
+ const dir = resolveSkillDir(moduleUrl, override);
174
+ const skillFile = join(dir, "SKILL.md");
175
+ if (!existsSync(skillFile)) throw new Error(`@huaqiu/dsh-kicad: bundled skill missing at ${skillFile}. The installed package is incomplete — reinstall @huaqiu/dsh-kicad so that its skills/ directory is present.`);
176
+ return dir;
177
+ }
178
+ /** Absolute path of the bundled Python script directory. */
179
+ function scriptsDir(skillDir) {
180
+ return join(skillDir, "scripts");
181
+ }
182
+ //#endregion
183
+ //#region src/ipc.ts
184
+ /**
185
+ * KiCad IPC adapter.
186
+ *
187
+ * ── Boundary (task: dsh-kicad-skill-plugin §13) ──────────────────────────────
188
+ *
189
+ * DSH tool -> KiCad IPC adapter (this module) -> bundled script -> KiCad
190
+ *
191
+ * This module is the *only* place that knows how a KiCad capability is reached.
192
+ * It does not implement KiCad IPC and must never grow a second, competing
193
+ * representation of board state: every call returns what KiCad said, verbatim
194
+ * (§12 — KiCad is the single source of truth).
195
+ *
196
+ * The adapter deliberately runs the migrated `kicad-agent` scripts rather than
197
+ * re-implementing the protocol in TypeScript. Those scripts are the preserved
198
+ * implementation: they own `kipy` usage, commit/rollback, unit conversion and
199
+ * post-mutation verification. Rewriting them would be a redesign (§27).
200
+ *
201
+ * @module
202
+ */
203
+ /**
204
+ * Run one bundled KiCad script.
205
+ *
206
+ * Never throws for script-level failures — the outcome is reported, so a
207
+ * missing `kipy` or a closed KiCad degrades into a typed error the agent can
208
+ * act on instead of crashing the plugin. It only throws when the script itself
209
+ * is not part of the package.
210
+ */
211
+ async function runKicadScript(options) {
212
+ const { scriptsDir, pythonPath, script, args = [], timeoutMs, signal } = options;
213
+ const argv = [join(scriptsDir, script.file), ...args];
214
+ return new Promise((resolvePromise) => {
215
+ let child;
216
+ try {
217
+ child = spawn(pythonPath, argv, {
218
+ cwd: scriptsDir,
219
+ stdio: [
220
+ "ignore",
221
+ "pipe",
222
+ "pipe"
223
+ ]
224
+ });
225
+ } catch (err) {
226
+ resolvePromise({
227
+ script: script.id,
228
+ exitCode: null,
229
+ stdout: "",
230
+ stderr: String(err?.message ?? err),
231
+ argv: [pythonPath, ...argv],
232
+ timedOut: false
233
+ });
234
+ return;
235
+ }
236
+ let stdout = "";
237
+ let stderr = "";
238
+ let timedOut = false;
239
+ let settled = false;
240
+ const finish = (result) => {
241
+ if (settled) return;
242
+ settled = true;
243
+ clearTimeout(timer);
244
+ signal?.removeEventListener("abort", onAbort);
245
+ resolvePromise(result);
246
+ };
247
+ const timer = setTimeout(() => {
248
+ timedOut = true;
249
+ child.kill("SIGTERM");
250
+ setTimeout(() => {
251
+ if (!settled) child.kill("SIGKILL");
252
+ }, 2e3).unref();
253
+ }, timeoutMs);
254
+ const onAbort = () => {
255
+ timedOut = true;
256
+ child.kill("SIGTERM");
257
+ };
258
+ signal?.addEventListener("abort", onAbort, { once: true });
259
+ child.stdout?.setEncoding("utf8");
260
+ child.stderr?.setEncoding("utf8");
261
+ child.stdout?.on("data", (chunk) => {
262
+ stdout += chunk;
263
+ });
264
+ child.stderr?.on("data", (chunk) => {
265
+ stderr += chunk;
266
+ });
267
+ child.on("error", (err) => {
268
+ finish({
269
+ script: script.id,
270
+ exitCode: null,
271
+ stdout,
272
+ stderr: stderr || String(err?.message ?? err),
273
+ argv: [pythonPath, ...argv],
274
+ timedOut
275
+ });
276
+ });
277
+ child.on("close", (code) => {
278
+ finish({
279
+ script: script.id,
280
+ exitCode: code,
281
+ stdout: stdout.trim(),
282
+ stderr: stderr.trim(),
283
+ argv: [pythonPath, ...argv],
284
+ timedOut
285
+ });
286
+ });
287
+ });
288
+ }
289
+ /**
290
+ * Translate a raw run into a semantic error, or `undefined` on success.
291
+ *
292
+ * Exit codes are part of the scripts' contract:
293
+ *
294
+ * - `diagnose_ipc_connection.py`: 0 ok, 1 unreachable, 2 `kipy` missing,
295
+ * 3 API version mismatch, 4 no board open.
296
+ * - every argparse script: 2 = rejected arguments (KiCad untouched).
297
+ * - anything else non-zero: a real KiCad/script failure.
298
+ */
299
+ function classifyRun(run) {
300
+ if (run.timedOut) return {
301
+ kind: "DEADLINE_EXCEEDED",
302
+ message: `KiCad script "${run.script}" did not finish within its timeout. KiCad may be busy (a GUI operation in progress); retry a read once, and re-read board state before retrying a write.`
303
+ };
304
+ if (run.exitCode === 0) return void 0;
305
+ if (run.exitCode === null) return {
306
+ kind: "FAILED_PRECONDITION",
307
+ message: `Could not start the Python interpreter for KiCad IPC. Set dsh-kicad \`pythonPath\` (or \$DSH_KICAD_PYTHON) to an interpreter that has the official kicad-python package installed. ${detail(run)}`
308
+ };
309
+ const stdout = run.stdout || run.stderr;
310
+ if (run.script === "diagnose_ipc_connection") switch (run.exitCode) {
311
+ case 1: return {
312
+ kind: "UNAVAILABLE",
313
+ message: "KiCad IPC is unreachable. Open PCB Editor (the project manager is not enough), enable the KiCad API service in Preferences → Plugins, restart PCB Editor, and confirm DSH runs with Full Access. A permission denial is not a retryable connection failure. " + detail(run)
314
+ };
315
+ case 2: return {
316
+ kind: "FAILED_PRECONDITION",
317
+ message: "The kicad-python package (kipy) is not installed for the configured Python interpreter. Install the version matching the running KiCad. " + detail(run)
318
+ };
319
+ case 3: return {
320
+ kind: "FAILED_PRECONDITION",
321
+ message: "kicad-python and the connected KiCad disagree on the API version. Do not work around it — install the matching official package. " + detail(run)
322
+ };
323
+ case 4: return {
324
+ kind: "FAILED_PRECONDITION",
325
+ message: "Connected to KiCad, but no .kicad_pcb is open in PCB Editor. Open a board and retry. " + detail(run)
326
+ };
327
+ default: return {
328
+ kind: "INTERNAL",
329
+ message: `KiCad IPC diagnostic failed. ${detail(run)}`
330
+ };
331
+ }
332
+ if (run.exitCode === 2) return {
333
+ kind: "INVALID_ARGUMENT",
334
+ message: `KiCad script "${run.script}" rejected its arguments before touching the board. Check units (mm), required flags and value ranges. ${detail(run)}`
335
+ };
336
+ if (/ModuleNotFoundError|No module named/i.test(run.stderr)) return {
337
+ kind: "FAILED_PRECONDITION",
338
+ message: "kicad-python (kipy) is missing for the configured Python interpreter. " + detail(run)
339
+ };
340
+ return {
341
+ kind: "INTERNAL",
342
+ message: `KiCad script "${run.script}" failed (exit ${run.exitCode}). The commit was dropped, so the board is unchanged. ${detail(run, stdout)}`
343
+ };
344
+ }
345
+ /** Compact, single-line diagnostic tail appended to error messages. */
346
+ function detail(run, preferred = run.stdout || run.stderr) {
347
+ const text = preferred.replace(/\s+/g, " ").trim();
348
+ return text.length > 0 ? `KiCad said: ${text}` : "";
349
+ }
350
+ /** Convenience: run a script and return its semantic outcome. */
351
+ async function invokeKicadScript(options) {
352
+ const script = kicadScript(options.script.id);
353
+ const run = await runKicadScript({
354
+ ...options,
355
+ script
356
+ });
357
+ const error = classifyRun(run);
358
+ return {
359
+ ok: error === void 0,
360
+ run,
361
+ ...error ? { error } : {}
362
+ };
363
+ }
364
+ //#endregion
365
+ //#region src/tools.ts
366
+ /**
367
+ * Agent tools for `@huaqiu/dsh-kicad`.
368
+ *
369
+ * Ten tools, one per migrated `kicad-agent` script — a 1:1 preserve mapping
370
+ * (task §10): no script is split, merged or invented. The tools are the
371
+ * *executable interface*; `skills/kicad-ipc/SKILL.md` is the *reasoning* around
372
+ * them (§11). Tool descriptions therefore describe board-level intent and
373
+ * outcomes, never RPC mechanics.
374
+ *
375
+ * Every tool returns the same envelope:
376
+ *
377
+ * { ok: true, script, effect, output } KiCad's own report, verbatim
378
+ * { ok: false, script, effect, error: { kind, message } }
379
+ *
380
+ * Nothing here fabricates board state. A successful call means the script
381
+ * exited 0 and *verified* its own result inside KiCad — but per §9.5 the agent
382
+ * must still re-read affected state after an important mutation rather than
383
+ * trusting the return value alone.
384
+ *
385
+ * @module
386
+ */
387
+ function asJson(value) {
388
+ return JSON.parse(JSON.stringify(value));
389
+ }
390
+ function renderJson(_args, value) {
391
+ return [{
392
+ type: "text",
393
+ text: JSON.stringify(value)
394
+ }];
395
+ }
396
+ /**
397
+ * Shared failure semantics appended to every description.
398
+ *
399
+ * Kept in one place so the prompt contract cannot drift between tools — the
400
+ * same idiom as `@huaqiu/dsh-eda-host`.
401
+ */
402
+ const ERROR_SEMANTICS = "IMPORTANT SEMANTICS: on ok:false, error.kind distinguishes the cause: \"FAILED_PRECONDITION\" (KiCad IPC cannot run at all — no kipy, API version mismatch, or no .kicad_pcb open; ask the user to fix the environment, do NOT retry), \"UNAVAILABLE\" (KiCad is installed but unreachable — retry once after checking PCB Editor is open, the KiCad API service is enabled, and DSH has Full Access), \"DEADLINE_EXCEEDED\" (KiCad did not answer in time — retry a read once; for a write, re-read board state first), \"INVALID_ARGUMENT\" (the board was not touched — fix units/ranges and retry), \"INTERNAL\" (KiCad or the script failed; the commit was dropped so the board is unchanged). Do NOT fabricate board state from a failed call.";
403
+ /** Reading-only tools are safe to run alongside each other. */
404
+ const READ_ONLY = { isConcurrencySafe: () => true };
405
+ /** Mutating tools must never be batched into a parallel group. */
406
+ const MUTATING = { isConcurrencySafe: () => false };
407
+ /** Build a semantic failure envelope from a raw script run. */
408
+ function failureEnvelope(scriptId, effect, error, run) {
409
+ return {
410
+ ok: false,
411
+ script: scriptId,
412
+ effect,
413
+ error,
414
+ ...run ? { diagnostics: {
415
+ exitCode: run.exitCode,
416
+ stderr: run.stderr
417
+ } } : {}
418
+ };
419
+ }
420
+ /** Reject arguments the script would reject, before spawning a process. */
421
+ function invalidArgument(scriptId, effect, message) {
422
+ return failureEnvelope(scriptId, effect, {
423
+ kind: "INVALID_ARGUMENT",
424
+ message
425
+ });
426
+ }
427
+ /** Format millimetres compactly without trailing float noise. */
428
+ function mm(value) {
429
+ return String(Number.isInteger(value) ? value : Number(value.toFixed(6)));
430
+ }
431
+ /** Build `--flag value` pairs, skipping undefined optionals. */
432
+ function flags(pairs) {
433
+ const argv = [];
434
+ for (const [flag, value] of pairs) if (value !== void 0) argv.push(flag, value);
435
+ return argv;
436
+ }
437
+ /** Build a bare `--flag` for boolean switches. */
438
+ function switchFlag(flag, on) {
439
+ return on === true ? [flag] : [];
440
+ }
441
+ /**
442
+ * All KiCad tools contributed by this plugin.
443
+ */
444
+ function createKicadTools(env) {
445
+ const { scriptsDir, pythonPath, config } = env;
446
+ /**
447
+ * Shared executor: run one script and translate it into the envelope.
448
+ */
449
+ async function run(scriptId, args, timeoutMs, signal) {
450
+ const script = kicadScript(scriptId);
451
+ const run_ = await runKicadScript({
452
+ scriptsDir,
453
+ pythonPath,
454
+ script,
455
+ args,
456
+ timeoutMs,
457
+ ...signal ? { signal } : {}
458
+ });
459
+ const error = classifyRun(run_);
460
+ if (error) return failureEnvelope(scriptId, script.effect, error, run_);
461
+ return {
462
+ ok: true,
463
+ script: scriptId,
464
+ effect: script.effect,
465
+ output: run_.stdout
466
+ };
467
+ }
468
+ const saveFlag = (save) => switchFlag("--save", save);
469
+ return [
470
+ defineTool({
471
+ name: "kicad_ipc_diagnose",
472
+ description: "Check whether KiCad IPC is usable right now: reports the KiCad version, whether kicad-python matches it, and the name of the currently open PCB. Returns { ok, script, effect, output } where output is KiCad's own report. Use this FIRST before any KiCad read or write, and whenever a KiCad tool fails — it separates \"environment is wrong\" from \"KiCad is busy\". Read-only: it never modifies or saves the board. " + ERROR_SEMANTICS,
473
+ parameters: {},
474
+ output: {
475
+ schema: { type: "json" },
476
+ render: renderJson
477
+ },
478
+ timeoutMs: config.diagnosticTimeoutMs,
479
+ ...READ_ONLY,
480
+ async execute(_args, exec) {
481
+ return asJson(await run("diagnose_ipc_connection", [], config.diagnosticTimeoutMs, exec?.signal));
482
+ }
483
+ }),
484
+ defineTool({
485
+ name: "kicad_ipc_verify_live",
486
+ description: "Run the bundled KiCad IPC smoke test: it creates, updates, clones, zones and deletes objects — then DROPS the commit, so nothing is persisted and the board is left exactly as it was. Use it to prove a KiCad IPC installation can really mutate before attempting a real edit. Requires an existing GND net and at least one footprint on the board, and must not be run while the GUI holds unsaved edits. " + ERROR_SEMANTICS,
487
+ parameters: {},
488
+ output: {
489
+ schema: { type: "json" },
490
+ render: renderJson
491
+ },
492
+ timeoutMs: config.timeoutMs,
493
+ ...MUTATING,
494
+ async execute(_args, exec) {
495
+ return asJson(await run("verify_live_ipc", [], config.timeoutMs, exec?.signal));
496
+ }
497
+ }),
498
+ defineTool({
499
+ name: "kicad_pcb_create_track",
500
+ description: "Create one straight copper track on an EXISTING net of the open KiCad PCB. All coordinates and the width are millimetres. The net must already exist on the board — this tool never invents one; sync nets from the schematic first if a name does not resolve. The change is committed as a single KiCad undo step and verified against KiCad's returned object; the board file is only written when save is true. Returns the created track id in output. " + ERROR_SEMANTICS,
501
+ parameters: {
502
+ net: {
503
+ type: "string",
504
+ required: true,
505
+ description: "Existing PCB net name, e.g. \"GND\"."
506
+ },
507
+ start_x_mm: {
508
+ type: "number",
509
+ required: true,
510
+ description: "Start X in millimetres."
511
+ },
512
+ start_y_mm: {
513
+ type: "number",
514
+ required: true,
515
+ description: "Start Y in millimetres."
516
+ },
517
+ end_x_mm: {
518
+ type: "number",
519
+ required: true,
520
+ description: "End X in millimetres."
521
+ },
522
+ end_y_mm: {
523
+ type: "number",
524
+ required: true,
525
+ description: "End Y in millimetres."
526
+ },
527
+ width_mm: {
528
+ type: "number",
529
+ required: true,
530
+ description: "Track width in millimetres, > 0."
531
+ },
532
+ layer: {
533
+ type: "string",
534
+ description: "Target copper layer, e.g. \"F.Cu\" or \"B.Cu\". Defaults to F.Cu. Must be enabled on the board."
535
+ },
536
+ save: {
537
+ type: "boolean",
538
+ description: "Persist the board to disk via KiCad after a verified success. Default false — the edit stays in KiCad only."
539
+ }
540
+ },
541
+ output: {
542
+ schema: { type: "json" },
543
+ render: renderJson
544
+ },
545
+ timeoutMs: config.timeoutMs,
546
+ ...MUTATING,
547
+ async execute(args, exec) {
548
+ const a = args;
549
+ if (!(a.width_mm > 0)) return asJson(invalidArgument("create_track", "mutate", "width_mm must be greater than 0."));
550
+ return asJson(await run("create_track", [...flags([
551
+ ["--net", a.net],
552
+ ["--start", `${mm(a.start_x_mm)},${mm(a.start_y_mm)}`],
553
+ ["--end", `${mm(a.end_x_mm)},${mm(a.end_y_mm)}`],
554
+ ["--width-mm", mm(a.width_mm)],
555
+ ["--layer", a.layer]
556
+ ]), ...saveFlag(a.save)], config.timeoutMs, exec?.signal));
557
+ }
558
+ }),
559
+ defineTool({
560
+ name: "kicad_pcb_create_via",
561
+ description: "Create one through-hole via on an EXISTING net of the open KiCad PCB. All dimensions are millimetres and must satisfy 0 < drill < diameter. The net must already exist on the board. The change is a single KiCad undo step and is verified against KiCad's returned object. Returns the created via id in output. " + ERROR_SEMANTICS,
562
+ parameters: {
563
+ net: {
564
+ type: "string",
565
+ required: true,
566
+ description: "Existing PCB net name, e.g. \"GND\"."
567
+ },
568
+ x_mm: {
569
+ type: "number",
570
+ required: true,
571
+ description: "Via X position in millimetres."
572
+ },
573
+ y_mm: {
574
+ type: "number",
575
+ required: true,
576
+ description: "Via Y position in millimetres."
577
+ },
578
+ diameter_mm: {
579
+ type: "number",
580
+ required: true,
581
+ description: "Outer diameter in millimetres."
582
+ },
583
+ drill_mm: {
584
+ type: "number",
585
+ required: true,
586
+ description: "Drill diameter in millimetres, must be < diameter_mm."
587
+ },
588
+ save: {
589
+ type: "boolean",
590
+ description: "Persist the board to disk via KiCad after a verified success. Default false."
591
+ }
592
+ },
593
+ output: {
594
+ schema: { type: "json" },
595
+ render: renderJson
596
+ },
597
+ timeoutMs: config.timeoutMs,
598
+ ...MUTATING,
599
+ async execute(args, exec) {
600
+ const a = args;
601
+ if (!(0 < a.drill_mm && a.drill_mm < a.diameter_mm)) return asJson(invalidArgument("create_via", "mutate", "Require 0 < drill_mm < diameter_mm."));
602
+ return asJson(await run("create_via", [...flags([
603
+ ["--net", a.net],
604
+ ["--x-mm", mm(a.x_mm)],
605
+ ["--y-mm", mm(a.y_mm)],
606
+ ["--diameter-mm", mm(a.diameter_mm)],
607
+ ["--drill-mm", mm(a.drill_mm)]
608
+ ]), ...saveFlag(a.save)], config.timeoutMs, exec?.signal));
609
+ }
610
+ }),
611
+ defineTool({
612
+ name: "kicad_pcb_create_copper_zone",
613
+ description: "Create one UNFILLED copper zone on an EXISTING net of the open KiCad PCB from a closed polygon. The polygon is given as ordered vertices in millimetres; a closing vertex is added automatically. The zone is created for review only — run kicad_pcb_refill_zones afterwards to fill it. The net must already exist on the board. " + ERROR_SEMANTICS,
614
+ parameters: {
615
+ net: {
616
+ type: "string",
617
+ required: true,
618
+ description: "Existing PCB net name, e.g. \"GND\"."
619
+ },
620
+ points: {
621
+ type: "array",
622
+ required: true,
623
+ description: "Outline vertices in millimetres, in order; at least three distinct points.",
624
+ items: {
625
+ type: "object",
626
+ additionalProperties: false,
627
+ properties: {
628
+ x_mm: {
629
+ type: "number",
630
+ required: true,
631
+ description: "Vertex X in millimetres."
632
+ },
633
+ y_mm: {
634
+ type: "number",
635
+ required: true,
636
+ description: "Vertex Y in millimetres."
637
+ }
638
+ }
639
+ }
640
+ },
641
+ layer: {
642
+ type: "string",
643
+ description: "Target copper layer, e.g. \"F.Cu\". Defaults to F.Cu. Must be enabled on the board."
644
+ },
645
+ save: {
646
+ type: "boolean",
647
+ description: "Persist the board to disk via KiCad after a verified success. Default false."
648
+ }
649
+ },
650
+ output: {
651
+ schema: { type: "json" },
652
+ render: renderJson
653
+ },
654
+ timeoutMs: config.timeoutMs,
655
+ ...MUTATING,
656
+ async execute(args, exec) {
657
+ const a = args;
658
+ if (!Array.isArray(a.points) || a.points.length < 3) return asJson(invalidArgument("create_copper_zone", "mutate", "points needs at least three vertices."));
659
+ const polygon = a.points.map((p) => `${mm(p.x_mm)},${mm(p.y_mm)}`).join(";");
660
+ return asJson(await run("create_copper_zone", [...flags([
661
+ ["--net", a.net],
662
+ ["--points", polygon],
663
+ ["--layer", a.layer]
664
+ ]), ...saveFlag(a.save)], config.timeoutMs, exec?.signal));
665
+ }
666
+ }),
667
+ defineTool({
668
+ name: "kicad_pcb_add_footprint_from_template",
669
+ description: "Add a new footprint to the open KiCad PCB by cloning an existing on-board footprint as the template, then offsetting the clone. Use this when the user wants another instance of a footprint already placed; there is no public API to place directly from a library — report that gap instead of editing board files. new_reference must be unique on the board and different from source_reference. " + ERROR_SEMANTICS,
670
+ parameters: {
671
+ source_reference: {
672
+ type: "string",
673
+ required: true,
674
+ description: "Reference of the existing footprint to clone, e.g. \"R1\"."
675
+ },
676
+ new_reference: {
677
+ type: "string",
678
+ required: true,
679
+ description: "Unique reference for the new footprint, e.g. \"R2\"."
680
+ },
681
+ dx_mm: {
682
+ type: "number",
683
+ required: true,
684
+ description: "X offset from the template, millimetres."
685
+ },
686
+ dy_mm: {
687
+ type: "number",
688
+ required: true,
689
+ description: "Y offset from the template, millimetres."
690
+ },
691
+ save: {
692
+ type: "boolean",
693
+ description: "Persist the board to disk via KiCad after a verified success. Default false."
694
+ }
695
+ },
696
+ output: {
697
+ schema: { type: "json" },
698
+ render: renderJson
699
+ },
700
+ timeoutMs: config.timeoutMs,
701
+ ...MUTATING,
702
+ async execute(args, exec) {
703
+ const a = args;
704
+ if (a.new_reference === a.source_reference) return asJson(invalidArgument("add_footprint_from_board_template", "mutate", "new_reference must differ from source_reference."));
705
+ return asJson(await run("add_footprint_from_board_template", [...flags([
706
+ ["--source-reference", a.source_reference],
707
+ ["--new-reference", a.new_reference],
708
+ ["--dx-mm", mm(a.dx_mm)],
709
+ ["--dy-mm", mm(a.dy_mm)]
710
+ ]), ...saveFlag(a.save)], config.timeoutMs, exec?.signal));
711
+ }
712
+ }),
713
+ defineTool({
714
+ name: "kicad_pcb_move_rotate_footprint",
715
+ description: "Move and/or rotate one footprint on the open KiCad PCB, selected by its reference designator. Offsets are relative, rotation is incremental, in degrees. At least one of dx_mm / dy_mm / rotation_deg must be non-zero. The footprint is re-read from the board before the change, so KiCad's UUID and the rest of its properties are preserved. " + ERROR_SEMANTICS,
716
+ parameters: {
717
+ reference: {
718
+ type: "string",
719
+ required: true,
720
+ description: "Footprint reference designator, e.g. \"R1\". Must match exactly one footprint."
721
+ },
722
+ dx_mm: {
723
+ type: "number",
724
+ description: "Relative X offset in millimetres. Default 0."
725
+ },
726
+ dy_mm: {
727
+ type: "number",
728
+ description: "Relative Y offset in millimetres. Default 0."
729
+ },
730
+ rotation_deg: {
731
+ type: "number",
732
+ description: "Incremental rotation in degrees. Default 0."
733
+ },
734
+ save: {
735
+ type: "boolean",
736
+ description: "Persist the board to disk via KiCad after a verified success. Default false."
737
+ }
738
+ },
739
+ output: {
740
+ schema: { type: "json" },
741
+ render: renderJson
742
+ },
743
+ timeoutMs: config.timeoutMs,
744
+ ...MUTATING,
745
+ async execute(args, exec) {
746
+ const a = args;
747
+ if ((a.dx_mm ?? 0) === 0 && (a.dy_mm ?? 0) === 0 && (a.rotation_deg ?? 0) === 0) return asJson(invalidArgument("move_rotate_footprint", "mutate", "Specify at least one non-zero dx_mm, dy_mm or rotation_deg."));
748
+ return asJson(await run("move_rotate_footprint", [...flags([
749
+ ["--reference", a.reference],
750
+ ["--dx-mm", mm(a.dx_mm ?? 0)],
751
+ ["--dy-mm", mm(a.dy_mm ?? 0)],
752
+ ["--rotation-deg", mm(a.rotation_deg ?? 0)]
753
+ ]), ...saveFlag(a.save)], config.timeoutMs, exec?.signal));
754
+ }
755
+ }),
756
+ defineTool({
757
+ name: "kicad_pcb_update_selected_track_width",
758
+ description: "Resize the tracks and arc tracks currently SELECTED in KiCad's PCB Editor to a new width in millimetres. This operates on KiCad's live selection, so inspect the selection first and confirm it contains exactly what the user meant — the tool cannot narrow a vague scope for you. Objects are re-read from the board before the update. " + ERROR_SEMANTICS,
759
+ parameters: {
760
+ width_mm: {
761
+ type: "number",
762
+ required: true,
763
+ description: "Target track width in millimetres, > 0."
764
+ },
765
+ save: {
766
+ type: "boolean",
767
+ description: "Persist the board to disk via KiCad after a verified success. Default false."
768
+ }
769
+ },
770
+ output: {
771
+ schema: { type: "json" },
772
+ render: renderJson
773
+ },
774
+ timeoutMs: config.timeoutMs,
775
+ ...MUTATING,
776
+ async execute(args, exec) {
777
+ const a = args;
778
+ if (!(a.width_mm > 0)) return asJson(invalidArgument("update_selected_track_width", "mutate", "width_mm must be greater than 0."));
779
+ return asJson(await run("update_selected_track_width", [...flags([["--width-mm", mm(a.width_mm)]]), ...saveFlag(a.save)], config.timeoutMs, exec?.signal));
780
+ }
781
+ }),
782
+ defineTool({
783
+ name: "kicad_pcb_refill_zones",
784
+ description: "Wait for the copper zones on the open KiCad PCB to be filled. Call this after reviewing a zone created with kicad_pcb_create_copper_zone, or after any edit that invalidated fills. Filling is a board mutation and can legitimately take up to about two minutes, so the timeout is longer than for the other tools. Re-read the zones afterwards to confirm. " + ERROR_SEMANTICS,
785
+ parameters: { save: {
786
+ type: "boolean",
787
+ description: "Persist the board to disk via KiCad once fills complete. Default false."
788
+ } },
789
+ output: {
790
+ schema: { type: "json" },
791
+ render: renderJson
792
+ },
793
+ timeoutMs: config.refillTimeoutMs,
794
+ ...MUTATING,
795
+ async execute(args, exec) {
796
+ return asJson(await run("refill_zones", saveFlag(args.save), config.refillTimeoutMs, exec?.signal));
797
+ }
798
+ }),
799
+ defineTool({
800
+ name: "kicad_pcb_remove_selected_items",
801
+ description: "Delete everything currently SELECTED in KiCad's PCB Editor. confirm must be explicitly true — this is the guard against accidental bulk deletion. Before calling it, report to the user exactly what is selected and how many objects will go; never default the scope to \"all tracks\" or \"all objects\". Prefer keeping user hand-routing unless it is explicitly in scope. " + ERROR_SEMANTICS,
802
+ parameters: {
803
+ confirm: {
804
+ type: "boolean",
805
+ required: true,
806
+ description: "Must be true to authorise deleting the current selection."
807
+ },
808
+ save: {
809
+ type: "boolean",
810
+ description: "Persist the board to disk via KiCad after the deletion. Default false."
811
+ }
812
+ },
813
+ output: {
814
+ schema: { type: "json" },
815
+ render: renderJson
816
+ },
817
+ timeoutMs: config.timeoutMs,
818
+ ...MUTATING,
819
+ async execute(args, exec) {
820
+ const a = args;
821
+ if (a.confirm !== true) return asJson(invalidArgument("remove_selected_items", "mutate", "Deletion requires confirm: true, once the selection has been reported to the user."));
822
+ return asJson(await run("remove_selected_items", [...switchFlag("--yes", true), ...saveFlag(a.save)], config.timeoutMs, exec?.signal));
823
+ }
824
+ })
825
+ ];
826
+ }
827
+ /**
828
+ * Names of every tool this plugin registers — asserted by the tests and used
829
+ * for startup logging.
830
+ */
831
+ function kicadToolNames() {
832
+ return [
833
+ "kicad_ipc_diagnose",
834
+ "kicad_ipc_verify_live",
835
+ "kicad_pcb_create_track",
836
+ "kicad_pcb_create_via",
837
+ "kicad_pcb_create_copper_zone",
838
+ "kicad_pcb_add_footprint_from_template",
839
+ "kicad_pcb_move_rotate_footprint",
840
+ "kicad_pcb_update_selected_track_width",
841
+ "kicad_pcb_refill_zones",
842
+ "kicad_pcb_remove_selected_items"
843
+ ];
844
+ }
845
+ //#endregion
846
+ //#region src/index.ts
847
+ /** Plugin id — matches package.json. */
848
+ const name = "@huaqiu/dsh-kicad";
849
+ /**
850
+ * Cordis services this half depends on.
851
+ *
852
+ * `skills` is REQUIRED: it is the DSH runtime's skill registry, and registering
853
+ * the bundled `kicad-ipc` skill is this plugin's core job. Without the inject,
854
+ * `apply()`'s `ctx.skills` access would throw
855
+ * `cannot get property "skills" without inject`.
856
+ *
857
+ * `tools` is the DSH node runtime tool registry used for the KiCad tools.
858
+ *
859
+ * Note what is NOT here: `hqEdge`. Unlike `@huaqiu/dsh-eda-host`, this plugin
860
+ * talks to KiCad directly, so it must never depend on the edge bridge.
861
+ */
862
+ const inject = ["skills", "tools"];
863
+ const log = getLogger("dsh-kicad");
864
+ log.info("dsh-kicad: module loaded (waiting for the skills + tools services)");
865
+ /**
866
+ * Used only when the bundled SKILL.md has no parseable `description`
867
+ * frontmatter. The real value always comes from the shipped file, so the skill
868
+ * catalog cannot drift away from the skill body.
869
+ */
870
+ const FALLBACK_SKILL_DESCRIPTION = "Operate a KiCad PCB through the official KiCad IPC API: inspect the live board and create, modify or delete objects. Use for PCB automation and autorouter-result import — not for editing .kicad_pcb files directly.";
871
+ /**
872
+ * Extract the `description` field from SKILL.md YAML frontmatter.
873
+ *
874
+ * Only the single-line form is supported (quoted or bare), which is what the
875
+ * migrated skill uses. Returns `undefined` when absent so the caller can fall
876
+ * back rather than registering a skill with an empty description — DSH ignores
877
+ * frontmatter-less skills entirely, so an empty description would silently
878
+ * break discovery.
879
+ */
880
+ function skillDescription(markdown) {
881
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
882
+ if (!frontmatter?.[1]) return void 0;
883
+ const line = frontmatter[1].split(/\r?\n/).find((candidate) => /^\s*description\s*:/.test(candidate));
884
+ if (!line) return void 0;
885
+ const unquoted = line.slice(line.indexOf(":") + 1).trim().replace(/^["']/, "").replace(/["']$/, "");
886
+ return unquoted.length > 0 ? unquoted : void 0;
887
+ }
888
+ /**
889
+ * Read the bundled `kicad-ipc` SKILL.md.
890
+ *
891
+ * Exposed for tests and for callers that want the skill body without loading
892
+ * the plugin (e.g. a packaging check).
893
+ */
894
+ function readBundledSkill(moduleUrl, override) {
895
+ const dir = requireSkillDir(moduleUrl, override);
896
+ const content = readFileSync(join(dir, "SKILL.md"), "utf8");
897
+ return {
898
+ dir,
899
+ name: KICAD_SKILL_NAME,
900
+ description: skillDescription(content) ?? FALLBACK_SKILL_DESCRIPTION,
901
+ content
902
+ };
903
+ }
904
+ /**
905
+ * Host plugin body — register the `kicad-ipc` skill and the KiCad tools.
906
+ *
907
+ * Both halves are registered here so that one installation delivers both. The
908
+ * skill's `resourceBase` points at the bundled skill directory, which is how
909
+ * the agent reaches `references/ipc-pcb-workflows.md` and `scripts/` as
910
+ * progressive-disclosure resources.
911
+ *
912
+ * @param ctx - real cordis context (node side).
913
+ * @param config - plugin overlay config (python interpreter, timeouts).
914
+ * @returns disposer — unregisters the skill and the tools on plugin dispose.
915
+ */
916
+ function apply(ctx, config = {}) {
917
+ if (!ctx.tools || typeof ctx.tools.register !== "function") throw new Error("@huaqiu/dsh-kicad requires the DSH `tools` service (ctx.tools.register).");
918
+ if (!ctx.skills || typeof ctx.skills.register !== "function") throw new Error("@huaqiu/dsh-kicad requires the DSH `skills` service (ctx.skills.register).");
919
+ const resolved = resolveKicadConfig(config);
920
+ const skill = readBundledSkill(import.meta.url, config.skillsDir);
921
+ log.info("applying dsh-kicad node half", {
922
+ hasConfigHost: hasHostConfig(config),
923
+ pythonPath: resolved.pythonPath,
924
+ skillDir: skill.dir,
925
+ timeoutMs: resolved.timeoutMs
926
+ });
927
+ const disposers = [];
928
+ disposers.push(ctx.skills.register({
929
+ name: skill.name,
930
+ description: skill.description,
931
+ content: skill.content,
932
+ resourceBase: {
933
+ kind: "directory",
934
+ path: skill.dir
935
+ }
936
+ }));
937
+ const tools = createKicadTools({
938
+ scriptsDir: scriptsDir(skill.dir),
939
+ pythonPath: resolved.pythonPath,
940
+ config: resolved
941
+ });
942
+ for (const tool of tools) disposers.push(ctx.tools.register(tool));
943
+ log.info("dsh-kicad node half ready", {
944
+ skill: skill.name,
945
+ tools: tools.length,
946
+ expectedTools: kicadToolNames().length
947
+ });
948
+ return () => {
949
+ for (const dispose of disposers.reverse()) try {
950
+ dispose();
951
+ } catch (err) {
952
+ log.warn("dsh-kicad disposer failed", { error: String(err?.message ?? err) });
953
+ }
954
+ };
955
+ }
956
+ //#endregion
957
+ export { KICAD_SCRIPTS, KICAD_SCRIPT_IDS, KICAD_SKILL_NAME, apply, classifyRun, createKicadTools, inject, invokeKicadScript, kicadScript, kicadToolNames, name, readBundledSkill, requireSkillDir, resolveSkillDir, runKicadScript, scriptsDir, skillDescription };