@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/ipc.ts ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * KiCad IPC adapter.
3
+ *
4
+ * ── Boundary (task: dsh-kicad-skill-plugin §13) ──────────────────────────────
5
+ *
6
+ * DSH tool -> KiCad IPC adapter (this module) -> bundled script -> KiCad
7
+ *
8
+ * This module is the *only* place that knows how a KiCad capability is reached.
9
+ * It does not implement KiCad IPC and must never grow a second, competing
10
+ * representation of board state: every call returns what KiCad said, verbatim
11
+ * (§12 — KiCad is the single source of truth).
12
+ *
13
+ * The adapter deliberately runs the migrated `kicad-agent` scripts rather than
14
+ * re-implementing the protocol in TypeScript. Those scripts are the preserved
15
+ * implementation: they own `kipy` usage, commit/rollback, unit conversion and
16
+ * post-mutation verification. Rewriting them would be a redesign (§27).
17
+ *
18
+ * @module
19
+ */
20
+ import { spawn } from 'node:child_process'
21
+ import { join } from 'node:path'
22
+
23
+ import { kicadScript, type KicadScript } from './scripts.js'
24
+
25
+ /**
26
+ * Semantic failure kinds, shared by every KiCad tool.
27
+ *
28
+ * Same vocabulary as `@huaqiu/dsh-eda-host` so the agent's error handling does
29
+ * not have to differ per EDA plugin.
30
+ */
31
+ export type KicadErrorKind =
32
+ /** The environment cannot run KiCad IPC at all (no python, no kipy, no board). */
33
+ | 'FAILED_PRECONDITION'
34
+ /** KiCad is installed but unreachable right now — retryable. */
35
+ | 'UNAVAILABLE'
36
+ /** The script did not finish inside its timeout. */
37
+ | 'DEADLINE_EXCEEDED'
38
+ /** The arguments were rejected before KiCad was touched. */
39
+ | 'INVALID_ARGUMENT'
40
+ /** KiCad or the script failed after the connection was established. */
41
+ | 'INTERNAL'
42
+
43
+ export interface KicadError {
44
+ kind: KicadErrorKind
45
+ message: string
46
+ }
47
+
48
+ /** Raw result of one script invocation. */
49
+ export interface ScriptRun {
50
+ /** Script identifier (file stem). */
51
+ script: string
52
+ /** Process exit code; `null` when it never started or was killed. */
53
+ exitCode: number | null
54
+ stdout: string
55
+ stderr: string
56
+ /** Exact argv handed to the interpreter, for auditability. */
57
+ argv: string[]
58
+ /** `true` when the process was killed because the timeout elapsed. */
59
+ timedOut: boolean
60
+ }
61
+
62
+ export interface RunScriptOptions {
63
+ /** Directory holding the bundled scripts. */
64
+ scriptsDir: string
65
+ /** Python interpreter with `kipy` available. */
66
+ pythonPath: string
67
+ /** Script to run. */
68
+ script: KicadScript
69
+ /** CLI flags, already stringified (e.g. `['--net', 'GND']`). */
70
+ args?: readonly string[]
71
+ /** Timeout in milliseconds. */
72
+ timeoutMs: number
73
+ /** Optional cancellation from the tool call. */
74
+ signal?: AbortSignal
75
+ }
76
+
77
+ /** A script the caller asked for but that is not bundled. */
78
+ export class KicadScriptError extends Error {
79
+ constructor(message: string) {
80
+ super(message)
81
+ this.name = 'KicadScriptError'
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Run one bundled KiCad script.
87
+ *
88
+ * Never throws for script-level failures — the outcome is reported, so a
89
+ * missing `kipy` or a closed KiCad degrades into a typed error the agent can
90
+ * act on instead of crashing the plugin. It only throws when the script itself
91
+ * is not part of the package.
92
+ */
93
+ export async function runKicadScript(options: RunScriptOptions): Promise<ScriptRun> {
94
+ const { scriptsDir, pythonPath, script, args = [], timeoutMs, signal } = options
95
+
96
+ const scriptPath = join(scriptsDir, script.file)
97
+ // cwd is the scripts directory so `import kipy_common` resolves regardless of
98
+ // how the interpreter is invoked (documented requirement of the templates).
99
+ const argv = [scriptPath, ...args]
100
+
101
+ return new Promise<ScriptRun>((resolvePromise) => {
102
+ let child
103
+ try {
104
+ child = spawn(pythonPath, argv, {
105
+ cwd: scriptsDir,
106
+ stdio: ['ignore', 'pipe', 'pipe'],
107
+ })
108
+ } catch (err) {
109
+ resolvePromise({
110
+ script: script.id,
111
+ exitCode: null,
112
+ stdout: '',
113
+ stderr: String((err as Error)?.message ?? err),
114
+ argv: [pythonPath, ...argv],
115
+ timedOut: false,
116
+ })
117
+ return
118
+ }
119
+
120
+ let stdout = ''
121
+ let stderr = ''
122
+ let timedOut = false
123
+ let settled = false
124
+
125
+ const finish = (result: ScriptRun) => {
126
+ if (settled) return
127
+ settled = true
128
+ clearTimeout(timer)
129
+ signal?.removeEventListener('abort', onAbort)
130
+ resolvePromise(result)
131
+ }
132
+
133
+ const timer = setTimeout(() => {
134
+ timedOut = true
135
+ child.kill('SIGTERM')
136
+ // If SIGTERM is ignored, SIGKILL after a short grace period.
137
+ setTimeout(() => {
138
+ if (!settled) child.kill('SIGKILL')
139
+ }, 2_000).unref()
140
+ }, timeoutMs)
141
+
142
+ const onAbort = () => {
143
+ timedOut = true
144
+ child.kill('SIGTERM')
145
+ }
146
+ signal?.addEventListener('abort', onAbort, { once: true })
147
+
148
+ child.stdout?.setEncoding('utf8')
149
+ child.stderr?.setEncoding('utf8')
150
+ child.stdout?.on('data', (chunk: string) => {
151
+ stdout += chunk
152
+ })
153
+ child.stderr?.on('data', (chunk: string) => {
154
+ stderr += chunk
155
+ })
156
+
157
+ child.on('error', (err: NodeJS.ErrnoException) => {
158
+ finish({
159
+ script: script.id,
160
+ exitCode: null,
161
+ stdout,
162
+ stderr: stderr || String(err?.message ?? err),
163
+ argv: [pythonPath, ...argv],
164
+ timedOut,
165
+ })
166
+ })
167
+
168
+ child.on('close', (code: number | null) => {
169
+ finish({
170
+ script: script.id,
171
+ exitCode: code,
172
+ stdout: stdout.trim(),
173
+ stderr: stderr.trim(),
174
+ argv: [pythonPath, ...argv],
175
+ timedOut,
176
+ })
177
+ })
178
+ })
179
+ }
180
+
181
+ /**
182
+ * Translate a raw run into a semantic error, or `undefined` on success.
183
+ *
184
+ * Exit codes are part of the scripts' contract:
185
+ *
186
+ * - `diagnose_ipc_connection.py`: 0 ok, 1 unreachable, 2 `kipy` missing,
187
+ * 3 API version mismatch, 4 no board open.
188
+ * - every argparse script: 2 = rejected arguments (KiCad untouched).
189
+ * - anything else non-zero: a real KiCad/script failure.
190
+ */
191
+ export function classifyRun(run: ScriptRun): KicadError | undefined {
192
+ if (run.timedOut) {
193
+ return {
194
+ kind: 'DEADLINE_EXCEEDED',
195
+ message:
196
+ `KiCad script "${run.script}" did not finish within its timeout. ` +
197
+ 'KiCad may be busy (a GUI operation in progress); retry a read once, and ' +
198
+ 're-read board state before retrying a write.',
199
+ }
200
+ }
201
+
202
+ if (run.exitCode === 0) return undefined
203
+
204
+ // The interpreter itself was missing.
205
+ if (run.exitCode === null) {
206
+ return {
207
+ kind: 'FAILED_PRECONDITION',
208
+ message:
209
+ `Could not start the Python interpreter for KiCad IPC. ` +
210
+ 'Set dsh-kicad `pythonPath` (or $DSH_KICAD_PYTHON) to an interpreter that ' +
211
+ `has the official kicad-python package installed. ${detail(run)}`,
212
+ }
213
+ }
214
+
215
+ const stdout = run.stdout || run.stderr
216
+
217
+ if (run.script === 'diagnose_ipc_connection') {
218
+ switch (run.exitCode) {
219
+ case 1:
220
+ return {
221
+ kind: 'UNAVAILABLE',
222
+ message:
223
+ 'KiCad IPC is unreachable. Open PCB Editor (the project manager is ' +
224
+ 'not enough), enable the KiCad API service in Preferences → Plugins, ' +
225
+ 'restart PCB Editor, and confirm DSH runs with Full Access. ' +
226
+ 'A permission denial is not a retryable connection failure. ' +
227
+ detail(run),
228
+ }
229
+ case 2:
230
+ return {
231
+ kind: 'FAILED_PRECONDITION',
232
+ message:
233
+ 'The kicad-python package (kipy) is not installed for the configured ' +
234
+ 'Python interpreter. Install the version matching the running KiCad. ' +
235
+ detail(run),
236
+ }
237
+ case 3:
238
+ return {
239
+ kind: 'FAILED_PRECONDITION',
240
+ message:
241
+ 'kicad-python and the connected KiCad disagree on the API version. ' +
242
+ 'Do not work around it — install the matching official package. ' +
243
+ detail(run),
244
+ }
245
+ case 4:
246
+ return {
247
+ kind: 'FAILED_PRECONDITION',
248
+ message:
249
+ 'Connected to KiCad, but no .kicad_pcb is open in PCB Editor. ' +
250
+ 'Open a board and retry. ' +
251
+ detail(run),
252
+ }
253
+ default:
254
+ return { kind: 'INTERNAL', message: `KiCad IPC diagnostic failed. ${detail(run)}` }
255
+ }
256
+ }
257
+
258
+ if (run.exitCode === 2) {
259
+ return {
260
+ kind: 'INVALID_ARGUMENT',
261
+ message:
262
+ `KiCad script "${run.script}" rejected its arguments before touching the ` +
263
+ `board. Check units (mm), required flags and value ranges. ${detail(run)}`,
264
+ }
265
+ }
266
+
267
+ // kipy raises ModuleNotFoundError paths through the shared helper; make the
268
+ // most common cause explicit instead of a bare INTERNAL.
269
+ if (/ModuleNotFoundError|No module named/i.test(run.stderr)) {
270
+ return {
271
+ kind: 'FAILED_PRECONDITION',
272
+ message:
273
+ 'kicad-python (kipy) is missing for the configured Python interpreter. ' +
274
+ detail(run),
275
+ }
276
+ }
277
+
278
+ return {
279
+ kind: 'INTERNAL',
280
+ message:
281
+ `KiCad script "${run.script}" failed (exit ${run.exitCode}). ` +
282
+ `The commit was dropped, so the board is unchanged. ${detail(run, stdout)}`,
283
+ }
284
+ }
285
+
286
+ /** Compact, single-line diagnostic tail appended to error messages. */
287
+ function detail(run: ScriptRun, preferred = run.stdout || run.stderr): string {
288
+ const text = preferred.replace(/\s+/g, ' ').trim()
289
+ return text.length > 0 ? `KiCad said: ${text}` : ''
290
+ }
291
+
292
+ /** Convenience: run a script and return its semantic outcome. */
293
+ export async function invokeKicadScript(
294
+ options: RunScriptOptions,
295
+ ): Promise<{ ok: boolean; run: ScriptRun; error?: KicadError }> {
296
+ const script = kicadScript(options.script.id)
297
+ const run = await runKicadScript({ ...options, script })
298
+ const error = classifyRun(run)
299
+ return { ok: error === undefined, run, ...(error ? { error } : {}) }
300
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Resolution of the bundled KiCad skill directory at runtime.
3
+ *
4
+ * The skill ships inside the installed package (`<pkg>/skills/kicad-ipc/`), not
5
+ * beside the source tree, so it is located relative to the loaded module. That
6
+ * makes one resolver correct for every install shape:
7
+ *
8
+ * - built artifact: `<pkg>/lib/index.mjs` -> `<pkg>/skills/kicad-ipc`
9
+ * - source (vitest): `<pkg>/src/index.ts` -> `<pkg>/skills/kicad-ipc`
10
+ * - npm / git install: identical to the built artifact case
11
+ *
12
+ * `skills` must stay in `package.json` `files[]` or npm strips it and this
13
+ * resolver fails loudly — which is the intended signal, because a `dsh-kicad`
14
+ * without its skill is a broken delivery boundary.
15
+ *
16
+ * @module
17
+ */
18
+ import { existsSync } from 'node:fs'
19
+ import { dirname, join, resolve } from 'node:path'
20
+
21
+ import { KICAD_SKILL_NAME } from './scripts.js'
22
+
23
+ /**
24
+ * Absolute path of the bundled `kicad-ipc` skill directory.
25
+ *
26
+ * Resolution order: an explicit override (config / `DSH_KICAD_SKILLS_DIR`),
27
+ * then the package-relative location.
28
+ *
29
+ * @param moduleUrl - `import.meta.url` of the calling module.
30
+ * @param override - explicit directory (plugin config or env var).
31
+ * @returns the resolved directory, whether or not it exists yet.
32
+ */
33
+ export function resolveSkillDir(moduleUrl: string, override?: string): string {
34
+ if (override && override.trim().length > 0) return resolve(override)
35
+ const envOverride = process.env['DSH_KICAD_SKILLS_DIR']
36
+ if (envOverride && envOverride.trim().length > 0) return resolve(envOverride)
37
+
38
+ const here = dirname(new URL(moduleUrl).pathname)
39
+ // One level up is right for both `lib/` (built) and `src/` (vitest) because
40
+ // both sit directly under the package root next to `skills/`.
41
+ return resolve(here, '..', 'skills', KICAD_SKILL_NAME)
42
+ }
43
+
44
+ /**
45
+ * Resolve the skill directory and assert the skill is actually present.
46
+ *
47
+ * @throws when `SKILL.md` is missing — this is a packaging failure, not a
48
+ * runtime condition, so it must be loud rather than silently degraded.
49
+ */
50
+ export function requireSkillDir(moduleUrl: string, override?: string): string {
51
+ const dir = resolveSkillDir(moduleUrl, override)
52
+ const skillFile = join(dir, 'SKILL.md')
53
+ if (!existsSync(skillFile)) {
54
+ throw new Error(
55
+ `@huaqiu/dsh-kicad: bundled skill missing at ${skillFile}. ` +
56
+ 'The installed package is incomplete — reinstall @huaqiu/dsh-kicad so ' +
57
+ 'that its skills/ directory is present.',
58
+ )
59
+ }
60
+ return dir
61
+ }
62
+
63
+ /** Absolute path of the bundled Python script directory. */
64
+ export function scriptsDir(skillDir: string): string {
65
+ return join(skillDir, 'scripts')
66
+ }
package/src/scripts.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Registry of the KiCad IPC script templates bundled with this package.
3
+ *
4
+ * The scripts are the migrated `kicad-agent` executable surface. They live
5
+ * under `skills/kicad-ipc/scripts/` and are shipped verbatim — this module only
6
+ * describes them so the DSH tools and the skill stay in sync. Nothing here
7
+ * reimplements KiCad IPC: the scripts own it (see `./ipc.ts`).
8
+ *
9
+ * @module
10
+ */
11
+
12
+ /** How one bundled script changes (or does not change) the KiCad board. */
13
+ export type ScriptEffect =
14
+ /** Reads only. Safe to run at any time. */
15
+ | 'read'
16
+ /** Mutates the board inside a dropped commit — nothing is persisted. */
17
+ | 'probe'
18
+ /** Mutates the board and pushes the commit. Persists only with `save`. */
19
+ | 'mutate'
20
+
21
+ export interface KicadScript {
22
+ /** Stable id — the script's file stem. */
23
+ readonly id: string
24
+ /** File name inside `skills/kicad-ipc/scripts/`. */
25
+ readonly file: string
26
+ /** One-line, agent-facing summary of what the script does. */
27
+ readonly summary: string
28
+ /** What the script does to the board. */
29
+ readonly effect: ScriptEffect
30
+ /** Whether the script accepts the shared `--save` flag. */
31
+ readonly supportsSave: boolean
32
+ }
33
+
34
+ /**
35
+ * Every bundled script, keyed by id.
36
+ *
37
+ * Mirrors `skills/kicad-ipc/scripts/` 1:1. Adding a script to the skill means
38
+ * adding it here (and exposing it in `./tools.ts`) — the bundling test asserts
39
+ * that all three stay consistent.
40
+ */
41
+ export const KICAD_SCRIPTS: Readonly<Record<string, KicadScript>> = {
42
+ diagnose_ipc_connection: {
43
+ id: 'diagnose_ipc_connection',
44
+ file: 'diagnose_ipc_connection.py',
45
+ summary: 'Check the KiCad IPC connection, API version and open board.',
46
+ effect: 'read',
47
+ supportsSave: false,
48
+ },
49
+ verify_live_ipc: {
50
+ id: 'verify_live_ipc',
51
+ file: 'verify_live_ipc.py',
52
+ summary:
53
+ 'Live create/update/clone/zone/delete smoke test inside one dropped commit.',
54
+ effect: 'probe',
55
+ supportsSave: false,
56
+ },
57
+ create_track: {
58
+ id: 'create_track',
59
+ file: 'create_track.py',
60
+ summary: 'Create one straight track on an existing net.',
61
+ effect: 'mutate',
62
+ supportsSave: true,
63
+ },
64
+ create_via: {
65
+ id: 'create_via',
66
+ file: 'create_via.py',
67
+ summary: 'Create one through via on an existing net.',
68
+ effect: 'mutate',
69
+ supportsSave: true,
70
+ },
71
+ update_selected_track_width: {
72
+ id: 'update_selected_track_width',
73
+ file: 'update_selected_track_width.py',
74
+ summary: 'Resize the currently selected tracks and arc tracks.',
75
+ effect: 'mutate',
76
+ supportsSave: true,
77
+ },
78
+ remove_selected_items: {
79
+ id: 'remove_selected_items',
80
+ file: 'remove_selected_items.py',
81
+ summary: 'Delete the current KiCad selection.',
82
+ effect: 'mutate',
83
+ supportsSave: true,
84
+ },
85
+ move_rotate_footprint: {
86
+ id: 'move_rotate_footprint',
87
+ file: 'move_rotate_footprint.py',
88
+ summary: 'Move and/or rotate one footprint selected by reference.',
89
+ effect: 'mutate',
90
+ supportsSave: true,
91
+ },
92
+ add_footprint_from_board_template: {
93
+ id: 'add_footprint_from_board_template',
94
+ file: 'add_footprint_from_board_template.py',
95
+ summary: 'Clone an on-board footprint as a template for a new reference.',
96
+ effect: 'mutate',
97
+ supportsSave: true,
98
+ },
99
+ create_copper_zone: {
100
+ id: 'create_copper_zone',
101
+ file: 'create_copper_zone.py',
102
+ summary: 'Create an unfilled copper zone from a closed polygon.',
103
+ effect: 'mutate',
104
+ supportsSave: true,
105
+ },
106
+ refill_zones: {
107
+ id: 'refill_zones',
108
+ file: 'refill_zones.py',
109
+ summary: 'Wait for existing copper zone fills to complete.',
110
+ effect: 'mutate',
111
+ supportsSave: true,
112
+ },
113
+ }
114
+
115
+ /** The bundled script ids, in registration order. */
116
+ export const KICAD_SCRIPT_IDS: readonly string[] = Object.keys(KICAD_SCRIPTS)
117
+
118
+ /**
119
+ * Look up one script by id.
120
+ * @throws when the id is not part of the bundled set.
121
+ */
122
+ export function kicadScript(id: string): KicadScript {
123
+ const script = KICAD_SCRIPTS[id]
124
+ if (!script) {
125
+ throw new Error(`@huaqiu/dsh-kicad: unknown bundled KiCad script "${id}"`)
126
+ }
127
+ return script
128
+ }
129
+
130
+ /** The canonical skill directory name shipped by this package. */
131
+ export const KICAD_SKILL_NAME = 'kicad-ipc'