@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/LICENSE +21 -0
- package/README.md +143 -0
- package/cordis.patch.yml +15 -0
- package/lib/index.d.mts +284 -0
- package/lib/index.mjs +957 -0
- package/package.json +41 -0
- package/skills/kicad-ipc/SKILL.md +81 -0
- package/skills/kicad-ipc/agents/openai.yaml +3 -0
- package/skills/kicad-ipc/references/ipc-pcb-workflows.md +145 -0
- package/skills/kicad-ipc/scripts/add_footprint_from_board_template.py +72 -0
- package/skills/kicad-ipc/scripts/create_copper_zone.py +85 -0
- package/skills/kicad-ipc/scripts/create_track.py +73 -0
- package/skills/kicad-ipc/scripts/create_via.py +58 -0
- package/skills/kicad-ipc/scripts/diagnose_ipc_connection.py +49 -0
- package/skills/kicad-ipc/scripts/kipy_common.py +80 -0
- package/skills/kicad-ipc/scripts/move_rotate_footprint.py +64 -0
- package/skills/kicad-ipc/scripts/refill_zones.py +33 -0
- package/skills/kicad-ipc/scripts/remove_selected_items.py +40 -0
- package/skills/kicad-ipc/scripts/update_selected_track_width.py +54 -0
- package/skills/kicad-ipc/scripts/verify_live_ipc.py +91 -0
- package/src/config.ts +79 -0
- package/src/index.ts +230 -0
- package/src/ipc.ts +300 -0
- package/src/paths.ts +66 -0
- package/src/scripts.ts +131 -0
- package/src/tools.ts +634 -0
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 };
|