@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.
@@ -0,0 +1,80 @@
1
+ """Shared helpers for the KiCad IPC CRUD script templates.
2
+
3
+ Run the sibling scripts with the Python environment that contains the official
4
+ ``kicad-python`` package. This module deliberately has no pcbnew fallback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from kipy import KiCad
10
+ from kipy.board_types import BoardLayer
11
+
12
+
13
+ def connect_board(timeout_ms: int = 5000):
14
+ """Return a checked KiCad connection and the currently open PCB board."""
15
+ kicad = KiCad(timeout_ms=timeout_ms)
16
+
17
+ if not kicad.check_version():
18
+ raise RuntimeError(
19
+ "kicad-python 与已连接 KiCad 的 API 版本不匹配;请使用匹配的官方包。"
20
+ )
21
+
22
+ board = kicad.get_board()
23
+ if board is None:
24
+ raise RuntimeError("没有打开的 PCB;请先在 PCB Editor 中打开 .kicad_pcb。")
25
+
26
+ return kicad, board
27
+
28
+
29
+ def close_kicad(kicad) -> None:
30
+ """Close newer clients without breaking KiCad 10 / kicad-python 0.8 clients."""
31
+ close = getattr(kicad, "close", None)
32
+ if callable(close):
33
+ close()
34
+
35
+
36
+ def get_required_net(board, name: str):
37
+ """Resolve an existing board net by exact name; never invent a replacement."""
38
+ for net in board.get_nets():
39
+ if net.name == name:
40
+ return net
41
+ raise ValueError(f"PCB 中不存在网络 {name!r};请先从原理图同步网络。")
42
+
43
+
44
+ def resolve_copper_layer(board, name: str):
45
+ """Resolve a current-board copper layer, with a safe F.Cu/B.Cu legacy fallback."""
46
+ if hasattr(board, "get_layer_by_name"):
47
+ layer = board.get_layer_by_name(name)
48
+ if layer != BoardLayer.BL_UNDEFINED:
49
+ if layer in board.get_enabled_layers():
50
+ return layer
51
+ raise ValueError(f"层 {name!r} 未在当前 PCB 中启用。")
52
+
53
+ standard_layers = {
54
+ "F.Cu": BoardLayer.BL_F_Cu,
55
+ "B.Cu": BoardLayer.BL_B_Cu,
56
+ }
57
+ try:
58
+ layer = standard_layers[name]
59
+ except KeyError as exc:
60
+ raise ValueError(
61
+ f"无法在此 KiCad 版本解析层 {name!r};请使用 F.Cu/B.Cu 或升级。"
62
+ ) from exc
63
+
64
+ if hasattr(board, "get_enabled_layers") and layer not in board.get_enabled_layers():
65
+ raise ValueError(f"层 {name!r} 未在当前 PCB 中启用。")
66
+ return layer
67
+
68
+
69
+ def commit_or_drop(board, message: str, operation, validate=None):
70
+ """Run an operation and optional result check in one KiCad undo transaction."""
71
+ commit = board.begin_commit()
72
+ try:
73
+ result = operation()
74
+ if validate is not None:
75
+ validate(result)
76
+ board.push_commit(commit, message)
77
+ return result
78
+ except Exception:
79
+ board.drop_commit(commit)
80
+ raise
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Move and/or rotate one existing footprint via KiCad IPC."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from kipy.geometry import Angle, Vector2
9
+
10
+ from kipy_common import close_kicad, commit_or_drop, connect_board
11
+
12
+
13
+ def get_footprint(board, reference: str):
14
+ matches = [
15
+ footprint
16
+ for footprint in board.get_footprints()
17
+ if footprint.reference_field.text.value == reference
18
+ ]
19
+ if len(matches) != 1:
20
+ raise RuntimeError(f"reference {reference!r} 匹配到 {len(matches)} 个封装;未做修改。")
21
+ return matches[0]
22
+
23
+
24
+ def main() -> None:
25
+ parser = argparse.ArgumentParser(description=__doc__)
26
+ parser.add_argument("--reference", required=True, help="目标封装 reference,例如 R1")
27
+ parser.add_argument("--dx-mm", type=float, default=0.0, help="X 位移,单位 mm")
28
+ parser.add_argument("--dy-mm", type=float, default=0.0, help="Y 位移,单位 mm")
29
+ parser.add_argument("--rotation-deg", type=float, default=0.0, help="增量旋转角度,单位度")
30
+ parser.add_argument("--save", action="store_true", help="验证成功后通过 IPC 保存 PCB")
31
+ args = parser.parse_args()
32
+ if args.dx_mm == 0 and args.dy_mm == 0 and args.rotation_deg == 0:
33
+ parser.error("至少指定一个位移或旋转参数")
34
+
35
+ kicad, board = connect_board()
36
+ try:
37
+ footprint = get_footprint(board, args.reference)
38
+ old_position = footprint.position
39
+ old_orientation = footprint.orientation
40
+ footprint.position += Vector2.from_xy_mm(args.dx_mm, args.dy_mm)
41
+ footprint.orientation += Angle.from_degrees(args.rotation_deg)
42
+
43
+ def validate(updated):
44
+ if len(updated) != 1:
45
+ raise RuntimeError("KiCad 未更新恰好一个封装。")
46
+ if updated[0].position == old_position and updated[0].orientation == old_orientation:
47
+ raise RuntimeError("KiCad 没有应用封装变换。")
48
+
49
+ updated, = commit_or_drop(
50
+ board,
51
+ f"Move/rotate footprint {args.reference}",
52
+ lambda: board.update_items(footprint),
53
+ validate,
54
+ )
55
+ print(f"已更新 {args.reference}: position={updated.position}, orientation={updated.orientation}")
56
+ if args.save:
57
+ board.save()
58
+ print("已通过 IPC 保存 PCB。")
59
+ finally:
60
+ close_kicad(kicad)
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env python3
2
+ """Fill existing copper zones on the open PCB via KiCad IPC."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from kipy_common import close_kicad, connect_board
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description=__doc__)
13
+ parser.add_argument("--save", action="store_true", help="填充完成后通过 IPC 保存 PCB")
14
+ args = parser.parse_args()
15
+
16
+ kicad, board = connect_board(timeout_ms=30000)
17
+ try:
18
+ zones = list(board.get_zones())
19
+ if not zones:
20
+ print("当前 PCB 没有区域,无需填充。")
21
+ return
22
+
23
+ board.refill_zones(block=True, max_poll_seconds=120.0)
24
+ print(f"已请求并等待 {len(zones)} 个区域填充完成。")
25
+ if args.save:
26
+ board.save()
27
+ print("已通过 IPC 保存 PCB。")
28
+ finally:
29
+ close_kicad(kicad)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+ """Delete exactly the currently selected KiCad items via IPC."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from kipy_common import close_kicad, commit_or_drop, connect_board
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description=__doc__)
13
+ parser.add_argument("--yes", action="store_true", help="确认删除当前选择中的所有对象")
14
+ parser.add_argument("--save", action="store_true", help="删除后通过 IPC 保存 PCB")
15
+ args = parser.parse_args()
16
+ if not args.yes:
17
+ parser.error("删除需要显式传入 --yes")
18
+
19
+ kicad, board = connect_board()
20
+ try:
21
+ targets = list(board.get_selection())
22
+ if not targets:
23
+ raise RuntimeError("KiCad 当前没有选择对象;未做任何修改。")
24
+
25
+ print(f"即将删除当前选择中的 {len(targets)} 个对象。")
26
+ commit_or_drop(
27
+ board,
28
+ f"Delete {len(targets)} selected items",
29
+ lambda: board.remove_items(targets),
30
+ )
31
+ print("已删除当前选择中的对象。")
32
+ if args.save:
33
+ board.save()
34
+ print("已通过 IPC 保存 PCB。")
35
+ finally:
36
+ close_kicad(kicad)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ """Update the width of currently selected straight or arc tracks via IPC."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from kipy.board_types import ArcTrack, Track
9
+ from kipy.util import from_mm
10
+
11
+ from kipy_common import close_kicad, commit_or_drop, connect_board
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(description=__doc__)
16
+ parser.add_argument("--width-mm", type=float, required=True, help="目标线宽,单位 mm")
17
+ parser.add_argument("--save", action="store_true", help="验证成功后通过 IPC 保存 PCB")
18
+ args = parser.parse_args()
19
+ if args.width_mm <= 0:
20
+ parser.error("--width-mm 必须大于 0")
21
+
22
+ kicad, board = connect_board()
23
+ try:
24
+ targets = [item for item in board.get_selection() if isinstance(item, (Track, ArcTrack))]
25
+ if not targets:
26
+ raise RuntimeError("当前 KiCad 选择中没有直线或圆弧走线;未做任何修改。")
27
+
28
+ requested_width = from_mm(args.width_mm)
29
+ for target in targets:
30
+ target.width = requested_width
31
+
32
+ def validate(updated):
33
+ if len(updated) != len(targets):
34
+ raise RuntimeError("KiCad 未更新全部目标走线。")
35
+ if any(item.width != requested_width for item in updated):
36
+ raise RuntimeError("KiCad 未接受全部目标线宽。")
37
+
38
+ updated = commit_or_drop(
39
+ board,
40
+ f"Set width of {len(targets)} selected tracks",
41
+ lambda: board.update_items(targets),
42
+ validate,
43
+ )
44
+
45
+ print(f"已更新 {len(updated)} 条选中走线。")
46
+ if args.save:
47
+ board.save()
48
+ print("已通过 IPC 保存 PCB。")
49
+ finally:
50
+ close_kicad(kicad)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ """Run a non-persistent live IPC smoke test against the open PCB.
3
+
4
+ The test performs real API creates, updates, a footprint clone, a copper-zone
5
+ create, and a delete inside one unpushed KiCad commit, then drops that commit.
6
+ It never calls save(). Use only on a board whose unsaved edits you have
7
+ reviewed: dropping a commit does not protect unrelated unsaved GUI changes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from kipy.board_types import BoardLayer, Track, Via, Zone
13
+ from kipy.common_types import PolygonWithHoles
14
+ from kipy.geometry import Angle, PolyLine, PolyLineNode, Vector2
15
+ from kipy.util import from_mm
16
+
17
+ from kipy_common import close_kicad, connect_board
18
+
19
+
20
+ def main() -> None:
21
+ kicad, board = connect_board(timeout_ms=10000)
22
+ commit = None
23
+ try:
24
+ nets = {net.name: net for net in board.get_nets()}
25
+ if "GND" not in nets:
26
+ raise RuntimeError("烟测要求当前板已有 GND 网络。")
27
+ source = board.get_footprints()[0]
28
+ before = (len(board.get_tracks()), len(board.get_vias()), len(board.get_zones()), len(board.get_footprints()))
29
+
30
+ commit = board.begin_commit()
31
+
32
+ track = Track()
33
+ track.start = Vector2.from_xy_mm(1.0, 1.0)
34
+ track.end = Vector2.from_xy_mm(2.0, 1.0)
35
+ track.width = from_mm(0.25)
36
+ track.layer = BoardLayer.BL_F_Cu
37
+ track.net = nets["GND"]
38
+ created_track, = board.create_items(track)
39
+ assert created_track.net.name == "GND"
40
+
41
+ created_track.width = from_mm(0.30)
42
+ updated_track, = board.update_items(created_track)
43
+ assert updated_track.width == from_mm(0.30)
44
+
45
+ via = Via()
46
+ via.position = Vector2.from_xy_mm(1.5, 1.0)
47
+ via.diameter = from_mm(0.8)
48
+ via.drill_diameter = from_mm(0.4)
49
+ via.net = nets["GND"]
50
+ created_via, = board.create_items(via)
51
+ assert created_via.net.name == "GND"
52
+
53
+ source.position += Vector2.from_xy_mm(0.1, 0.1)
54
+ source.orientation += Angle.from_degrees(5)
55
+ updated_footprint, = board.update_items(source)
56
+ assert updated_footprint.id == source.id
57
+
58
+ clone = updated_footprint.clone()
59
+ clone.position += Vector2.from_xy_mm(1.0, 1.0)
60
+ clone.reference_field.text.value = "__IPC_SMOKE_TEST__"
61
+ created_footprint, = board.create_items(clone)
62
+ assert created_footprint.reference_field.text.value == "__IPC_SMOKE_TEST__"
63
+
64
+ outline = PolyLine()
65
+ for x_mm, y_mm in ((3.0, 1.0), (4.0, 1.0), (4.0, 2.0), (3.0, 2.0), (3.0, 1.0)):
66
+ outline.append(PolyLineNode.from_xy(from_mm(x_mm), from_mm(y_mm)))
67
+ polygon = PolygonWithHoles()
68
+ polygon.outline = outline
69
+ zone = Zone()
70
+ zone.net = nets["GND"]
71
+ zone.layers = [BoardLayer.BL_F_Cu]
72
+ zone.outline = polygon
73
+ created_zone, = board.create_items(zone)
74
+ assert created_zone.net is not None and created_zone.net.name == "GND"
75
+
76
+ board.remove_items(updated_track)
77
+ board.drop_commit(commit)
78
+ commit = None
79
+
80
+ after = (len(board.get_tracks()), len(board.get_vias()), len(board.get_zones()), len(board.get_footprints()))
81
+ if after != before:
82
+ raise RuntimeError(f"drop_commit 后对象数量不匹配:before={before}, after={after}")
83
+ print("IPC live smoke test passed; all temporary changes were dropped and not saved.")
84
+ finally:
85
+ if commit is not None:
86
+ board.drop_commit(commit)
87
+ close_kicad(kicad)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
package/src/config.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Configuration for `@huaqiu/dsh-kicad`.
3
+ *
4
+ * Deliberately small. Everything KiCad-specific (socket, token, board state)
5
+ * belongs to KiCad and the bundled scripts — the scripts read
6
+ * `KICAD_API_SOCKET` / `KICAD_API_TOKEN` that KiCad injects, and this package
7
+ * never guesses them. The only host configuration here is *how to reach the
8
+ * Python environment that owns `kipy`*, which is a machine concern, not a
9
+ * design concern.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ /** Configuration accepted by the plugin's `apply()`. */
15
+ export interface KicadConfig {
16
+ /**
17
+ * Python interpreter used to run the bundled KiCad scripts. It must have the
18
+ * official `kicad-python` package (`kipy`) installed.
19
+ */
20
+ pythonPath: string
21
+ /** Scripts directory override (defaults to the bundled skill's `scripts/`). */
22
+ skillsDir?: string
23
+ /** Per-script timeout in milliseconds. */
24
+ timeoutMs: number
25
+ /** Timeout for the two diagnostic scripts, which are fast but must be prompt. */
26
+ diagnosticTimeoutMs: number
27
+ /** Timeout for `refill_zones`, which can legitimately block for ~2 minutes. */
28
+ refillTimeoutMs: number
29
+ }
30
+
31
+ export type KicadConfigInput = Partial<KicadConfig>
32
+
33
+ /** Default Python interpreter when nothing is configured. */
34
+ export const DEFAULT_PYTHON_PATH = 'python3'
35
+
36
+ const DEFAULT_TIMEOUT_MS = 30_000
37
+ const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 15_000
38
+ /** `refill_zones.py` polls zone fills; the script itself documents ~120 s. */
39
+ const DEFAULT_REFILL_TIMEOUT_MS = 150_000
40
+
41
+ function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
42
+ for (const value of values) {
43
+ if (typeof value === 'string' && value.trim().length > 0) return value
44
+ }
45
+ return undefined
46
+ }
47
+
48
+ /**
49
+ * Resolve the effective configuration.
50
+ *
51
+ * Precedence matches the rest of the Huaqiu DSH package set: explicit plugin
52
+ * config wins over the environment, which wins over the default.
53
+ */
54
+ export function resolveKicadConfig(input: KicadConfigInput = {}): KicadConfig {
55
+ const pythonPath =
56
+ firstNonEmpty(input.pythonPath, process.env['DSH_KICAD_PYTHON']) ?? DEFAULT_PYTHON_PATH
57
+
58
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS
59
+ const diagnosticTimeoutMs = input.diagnosticTimeoutMs ?? DEFAULT_DIAGNOSTIC_TIMEOUT_MS
60
+ const refillTimeoutMs = input.refillTimeoutMs ?? DEFAULT_REFILL_TIMEOUT_MS
61
+
62
+ return {
63
+ pythonPath,
64
+ ...(input.skillsDir ? { skillsDir: input.skillsDir } : {}),
65
+ timeoutMs,
66
+ diagnosticTimeoutMs,
67
+ refillTimeoutMs,
68
+ }
69
+ }
70
+
71
+ /** Whether any host configuration was supplied (used for startup logging). */
72
+ export function hasHostConfig(input: KicadConfigInput = {}): boolean {
73
+ return Boolean(
74
+ input.pythonPath ??
75
+ process.env['DSH_KICAD_PYTHON'] ??
76
+ input.skillsDir ??
77
+ process.env['DSH_KICAD_SKILLS_DIR'],
78
+ )
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `@huaqiu/dsh-kicad` — node plugin entry.
3
+ *
4
+ * ── Delivery boundary (task: dsh-kicad-skill-plugin §14, §28) ───────────────
5
+ * This plugin ships TWO things that used to need two installations:
6
+ *
7
+ * 1. ten KiCad tools, registered with `ctx.tools.register(defineTool(...))`
8
+ * 2. the `kicad-ipc` skill, registered with `ctx.skills.register(...)`
9
+ *
10
+ * Installing the Huaqiu DSH PCB/EDA bundle therefore makes KiCad agent
11
+ * capabilities AND the skill that teaches the agent to use them available out of
12
+ * the box. There is no separate skill installation step, and this plugin adds no
13
+ * second skill-registration mechanism — `ctx.skills.register()` is the DSH
14
+ * runtime's own plugin-bundled skill channel.
15
+ *
16
+ * ── Boundaries ──────────────────────────────────────────────────────────────
17
+ * KiCad IPC is reached through the bundled Python scripts under
18
+ * `skills/kicad-ipc/scripts/` (see `./ipc.ts`). This package holds no HQ Edge
19
+ * dependency of any kind — no runtime, executable, service, port, config or
20
+ * artifact dependency (§3, §21) — and no `@hqedge/*` import.
21
+ *
22
+ * @module @huaqiu/dsh-kicad
23
+ */
24
+ import type { Context } from '@deepseek-ai/cordis'
25
+ import { getLogger } from '@huaqiu/dsh-plugin-log'
26
+ import { readFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+
29
+ import {
30
+ hasHostConfig,
31
+ resolveKicadConfig,
32
+ type KicadConfig,
33
+ type KicadConfigInput,
34
+ } from './config.js'
35
+ import { requireSkillDir, resolveSkillDir, scriptsDir } from './paths.js'
36
+ import { KICAD_SKILL_NAME } from './scripts.js'
37
+ import { createKicadTools, kicadToolNames } from './tools.js'
38
+
39
+ /** Plugin id — matches package.json. */
40
+ export const name = '@huaqiu/dsh-kicad'
41
+
42
+ /**
43
+ * Cordis services this half depends on.
44
+ *
45
+ * `skills` is REQUIRED: it is the DSH runtime's skill registry, and registering
46
+ * the bundled `kicad-ipc` skill is this plugin's core job. Without the inject,
47
+ * `apply()`'s `ctx.skills` access would throw
48
+ * `cannot get property "skills" without inject`.
49
+ *
50
+ * `tools` is the DSH node runtime tool registry used for the KiCad tools.
51
+ *
52
+ * Note what is NOT here: `hqEdge`. Unlike `@huaqiu/dsh-eda-host`, this plugin
53
+ * talks to KiCad directly, so it must never depend on the edge bridge.
54
+ */
55
+ export const inject = ['skills', 'tools'] as const
56
+
57
+ export type { KicadConfig, KicadConfigInput } from './config.js'
58
+ export type { KicadError, KicadErrorKind, ScriptRun } from './ipc.js'
59
+ export type { KicadScript, ScriptEffect } from './scripts.js'
60
+ export { KICAD_SCRIPTS, KICAD_SCRIPT_IDS, KICAD_SKILL_NAME, kicadScript } from './scripts.js'
61
+ export { kicadToolNames } from './tools.js'
62
+ export { resolveSkillDir, requireSkillDir, scriptsDir } from './paths.js'
63
+ export { runKicadScript, classifyRun, invokeKicadScript } from './ipc.js'
64
+ export { createKicadTools } from './tools.js'
65
+
66
+ /**
67
+ * The `skills` service as this plugin uses it.
68
+ *
69
+ * `@deepseek-ai/dsh-tools` already augments `Context` with `tools`, but nothing
70
+ * in this workspace augments `skills` — `@deepseek-ai/dsh-skill` is a runtime
71
+ * dependency of the harness, not of this package (we never import it; the
72
+ * service arrives through Cordis injection). Declaring the shape we consume is
73
+ * the same technique `@huaqiu/dsh-eda-host` uses for `hqEdge`.
74
+ */
75
+ export interface SkillRegistration {
76
+ /** Skill id — must match `/^[a-z0-9]+(?:-[a-z0-9]+)*$/` to be discoverable. */
77
+ name: string
78
+ /** Catalog description shown to the model. */
79
+ description: string
80
+ /** Full SKILL.md body. */
81
+ content: string
82
+ /** Where `references/` and `scripts/` live for progressive disclosure. */
83
+ resourceBase: { kind: 'directory'; path: string }
84
+ }
85
+
86
+ declare module '@deepseek-ai/cordis' {
87
+ interface Context {
88
+ /** DSH skill registry, provided by the `@deepseek-ai/dsh-skill` service. */
89
+ skills: {
90
+ /** Register a plugin-bundled skill; returns its unregister disposer. */
91
+ register(skill: SkillRegistration): () => void
92
+ }
93
+ }
94
+ }
95
+
96
+ /** Shared component name for the unified DSH-plugin log. */
97
+ const COMPONENT = 'dsh-kicad'
98
+ const log = getLogger(COMPONENT)
99
+
100
+ // Emitted on import, before any Cordis dependency is resolved. Pairs with the
101
+ // "node half ready" marker in apply(): if this line is logged but that one is
102
+ // not, the `skills`/`tools` services were never provided and this plugin is
103
+ // still pending — which is otherwise completely silent. Same idiom as
104
+ // `@huaqiu/dsh-eda-host`.
105
+ log.info('dsh-kicad: module loaded (waiting for the skills + tools services)')
106
+
107
+ /**
108
+ * Used only when the bundled SKILL.md has no parseable `description`
109
+ * frontmatter. The real value always comes from the shipped file, so the skill
110
+ * catalog cannot drift away from the skill body.
111
+ */
112
+ const FALLBACK_SKILL_DESCRIPTION =
113
+ 'Operate a KiCad PCB through the official KiCad IPC API: inspect the live ' +
114
+ 'board and create, modify or delete objects. Use for PCB automation and ' +
115
+ 'autorouter-result import — not for editing .kicad_pcb files directly.'
116
+
117
+ /**
118
+ * Extract the `description` field from SKILL.md YAML frontmatter.
119
+ *
120
+ * Only the single-line form is supported (quoted or bare), which is what the
121
+ * migrated skill uses. Returns `undefined` when absent so the caller can fall
122
+ * back rather than registering a skill with an empty description — DSH ignores
123
+ * frontmatter-less skills entirely, so an empty description would silently
124
+ * break discovery.
125
+ */
126
+ export function skillDescription(markdown: string): string | undefined {
127
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
128
+ if (!frontmatter?.[1]) return undefined
129
+ const line = frontmatter[1]
130
+ .split(/\r?\n/)
131
+ .find((candidate) => /^\s*description\s*:/.test(candidate))
132
+ if (!line) return undefined
133
+ const raw = line.slice(line.indexOf(':') + 1).trim()
134
+ const unquoted = raw.replace(/^["']/, '').replace(/["']$/, '')
135
+ return unquoted.length > 0 ? unquoted : undefined
136
+ }
137
+
138
+ /**
139
+ * Read the bundled `kicad-ipc` SKILL.md.
140
+ *
141
+ * Exposed for tests and for callers that want the skill body without loading
142
+ * the plugin (e.g. a packaging check).
143
+ */
144
+ export function readBundledSkill(moduleUrl: string, override?: string): {
145
+ dir: string
146
+ name: string
147
+ description: string
148
+ content: string
149
+ } {
150
+ const dir = requireSkillDir(moduleUrl, override)
151
+ const content = readFileSync(join(dir, 'SKILL.md'), 'utf8')
152
+ return {
153
+ dir,
154
+ name: KICAD_SKILL_NAME,
155
+ description: skillDescription(content) ?? FALLBACK_SKILL_DESCRIPTION,
156
+ content,
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Host plugin body — register the `kicad-ipc` skill and the KiCad tools.
162
+ *
163
+ * Both halves are registered here so that one installation delivers both. The
164
+ * skill's `resourceBase` points at the bundled skill directory, which is how
165
+ * the agent reaches `references/ipc-pcb-workflows.md` and `scripts/` as
166
+ * progressive-disclosure resources.
167
+ *
168
+ * @param ctx - real cordis context (node side).
169
+ * @param config - plugin overlay config (python interpreter, timeouts).
170
+ * @returns disposer — unregisters the skill and the tools on plugin dispose.
171
+ */
172
+ export function apply(ctx: Context, config: KicadConfigInput = {}): () => void {
173
+ if (!ctx.tools || typeof ctx.tools.register !== 'function') {
174
+ throw new Error('@huaqiu/dsh-kicad requires the DSH `tools` service (ctx.tools.register).')
175
+ }
176
+ if (!ctx.skills || typeof ctx.skills.register !== 'function') {
177
+ throw new Error('@huaqiu/dsh-kicad requires the DSH `skills` service (ctx.skills.register).')
178
+ }
179
+
180
+ const resolved = resolveKicadConfig(config)
181
+
182
+ // Throws when the installed package is missing its skill — a packaging
183
+ // failure must be loud, not silently degraded (§15).
184
+ const skill = readBundledSkill(import.meta.url, config.skillsDir)
185
+
186
+ log.info('applying dsh-kicad node half', {
187
+ hasConfigHost: hasHostConfig(config),
188
+ pythonPath: resolved.pythonPath,
189
+ skillDir: skill.dir,
190
+ timeoutMs: resolved.timeoutMs,
191
+ })
192
+
193
+ const disposers: Array<() => void> = []
194
+
195
+ // ── Skill (bundled, no separate installation) ────────────────────────────
196
+ disposers.push(
197
+ ctx.skills.register({
198
+ name: skill.name,
199
+ description: skill.description,
200
+ content: skill.content,
201
+ resourceBase: { kind: 'directory', path: skill.dir },
202
+ }),
203
+ )
204
+
205
+ // ── Tools ────────────────────────────────────────────────────────────────
206
+ const tools = createKicadTools({
207
+ scriptsDir: scriptsDir(skill.dir),
208
+ pythonPath: resolved.pythonPath,
209
+ config: resolved,
210
+ })
211
+ for (const tool of tools) {
212
+ disposers.push(ctx.tools.register(tool))
213
+ }
214
+
215
+ log.info('dsh-kicad node half ready', {
216
+ skill: skill.name,
217
+ tools: tools.length,
218
+ expectedTools: kicadToolNames().length,
219
+ })
220
+
221
+ return () => {
222
+ for (const dispose of disposers.reverse()) {
223
+ try {
224
+ dispose()
225
+ } catch (err) {
226
+ log.warn('dsh-kicad disposer failed', { error: String((err as Error)?.message ?? err) })
227
+ }
228
+ }
229
+ }
230
+ }