@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
|
@@ -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
|
+
}
|