Skip to content

Commit a74b00d

Browse files
committed
feat: Add tsci convert command for KiCad .kicad_mod files
- Implement convert command to transform KiCad footprint files to tscircuit TSX format - Add comprehensive error handling for file validation and conversion errors - Support custom output path with -o/--output option - Use kicad-component-converter and circuit-json-to-tscircuit packages - Include comprehensive test suite for all functionality - Add command registration and help text - Update dependencies: [email protected], [email protected] Resolves issue #323 for KiCad mod file conversion support.
1 parent 32abde4 commit a74b00d

File tree

5 files changed

+175
-9
lines changed

5 files changed

+175
-9
lines changed

bun.lock

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
"workspaces": {
44
"": {
55
"name": "@tscircuit/cli",
6+
"dependencies": {
7+
"circuit-json-to-tscircuit": "^0.0.9",
8+
"kicad-component-converter": "^0.1.14",
9+
"kicad-converter": "^0.0.17",
10+
},
611
"devDependencies": {
712
"@babel/standalone": "^7.26.9",
813
"@biomejs/biome": "^1.9.4",
@@ -707,7 +712,7 @@
707712

708713
"circuit-json-to-simple-3d": ["[email protected]", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-9qtm6h1zLgeB+pMtH2f91xD6ldua3+kKxg/i9+HpaP98bTNYumARll56l4dHRHbiUMBSinawg7G6410P7sLVpg=="],
709714

710-
"circuit-json-to-tscircuit": ["[email protected].4", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-LpHbOwdPE4+CooWPAPoKXWs4vxTrjJgu/avnxE3AqGwCD9r0ZnT73mEAB9oQi6T1i7T53zdkSR6y+zpsyCSE7g=="],
715+
"circuit-json-to-tscircuit": ["[email protected].9", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-2B4E3kOU9zFbJ6SyCKcp9ktlay/Xf2gbLuGcWE8rBL3uuypJU3uX4MFjHVfwx8cbvB/0LTF5v3gHTYbxpiZMOg=="],
711716

712717
"circuit-to-svg": ["[email protected]", "", { "dependencies": { "@types/node": "^22.5.5", "bun-types": "^1.1.40", "svgson": "^5.3.1", "transformation-matrix": "^2.16.1" }, "peerDependencies": { "tscircuit": "*" } }, "sha512-e8LtpC3M9TLcOpwH6g4jktiA28GYlVyLjc/wSdUfZ7kPfCQjX+Wsh/7MhL1vPJxWviVUHW4eAMFMuwHlJfWsAA=="],
713718

@@ -1091,7 +1096,9 @@
10911096

10921097
"jwt-decode": ["[email protected]", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
10931098

1094-
"kicad-converter": ["[email protected]", "", { "dependencies": { "@tscircuit/soup-util": "^0.0.30", "circuit-json-to-connectivity-map": "^0.0.16" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-3beH+cL75SLhuvpeYp7inQtpWq9yZp85v2SuvgN2XhDD492nEc/N5Jf1z5eAgUuL/8VpjCj/zZtk7FpSCb3+Wg=="],
1099+
"kicad-component-converter": ["[email protected]", "", { "bin": { "kicad-mod-converter": "dist/cli.js" } }, "sha512-E6e1r0N6GNgsEsqA3mgCl1xiMzfo1BcS/0T5VkE4tGhAhMTsxalmsD1tBsnITLj295xYsoEAMpgEmrG6NkkNuQ=="],
1100+
1101+
"kicad-converter": ["[email protected]", "", { "peerDependencies": { "tscircuit": "*", "typescript": "^5.0.0", "zod": "*" } }, "sha512-fb8B8frGrMkm52WRUo3XIhqkUSKERjNABtPAEP58Nyrcop0ux8++PlOptj6B6LzXWw3Rkt3EgjDillqGjoPfAg=="],
10951102

10961103
"kind-of": ["[email protected]", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
10971104

@@ -1761,8 +1768,12 @@
17611768

17621769
"@tscircuit/fake-snippets/circuit-json": ["[email protected]", "", { "dependencies": { "nanoid": "^5.0.7", "zod": "^3.23.6" } }, "sha512-HbJAQZ/h1Abm4jSOYcQ/eaJ5PgmgXXskP1corGD/gmZExgPHo44Zr+9OaGNOGGf1MC+zH1vo1vhWSzg5e8cLoQ=="],
17631770

1771+
"@tscircuit/fake-snippets/circuit-json-to-tscircuit": ["[email protected]", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-LpHbOwdPE4+CooWPAPoKXWs4vxTrjJgu/avnxE3AqGwCD9r0ZnT73mEAB9oQi6T1i7T53zdkSR6y+zpsyCSE7g=="],
1772+
17641773
"@tscircuit/fake-snippets/dsn-converter": ["[email protected]", "", { "dependencies": { "@tscircuit/soup-util": "^0.0.41", "debug": "^4.3.7", "uuid": "^10.0.0", "zod": "^3.23.8" }, "peerDependencies": { "circuit-json": "*", "typescript": "^5.0.0" } }, "sha512-7sbh7VeRxGjFCDe532lcpaj/Zk9kGn+RUTDu2yMaYnyal8mGFhVlKk7MDfo5C5Y44bxY3HVcLYtfJt8hoEDxPQ=="],
17651774

1775+
"@tscircuit/fake-snippets/kicad-converter": ["[email protected]", "", { "dependencies": { "@tscircuit/soup-util": "^0.0.30", "circuit-json-to-connectivity-map": "^0.0.16" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-3beH+cL75SLhuvpeYp7inQtpWq9yZp85v2SuvgN2XhDD492nEc/N5Jf1z5eAgUuL/8VpjCj/zZtk7FpSCb3+Wg=="],
1776+
17661777
"@tscircuit/schematic-autolayout/@tscircuit/soup-util": ["@tscircuit/[email protected]", "", { "dependencies": { "parsel-js": "^1.1.2" }, "peerDependencies": { "circuit-json": "*", "transformation-matrix": "*", "zod": "*" } }, "sha512-GdcuFxk+qnJZv+eI7ZoJ1MJEseFgRyaztMtQ/OXA2SUnxyPEH0UTy9vkhKTm+8GTePryEgdXcc65TgUyrr44ww=="],
17671778

17681779
"@types/debug/@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
@@ -1819,10 +1830,6 @@
18191830

18201831
"jscad-electronics/circuit-json": ["[email protected]", "", { "dependencies": { "nanoid": "^5.0.7", "zod": "^3.23.6" } }, "sha512-cEqFxLadAxS+tm7y5/llS4FtqN3QbzjBNubely7vFo8w05sZnGRCcLzZwKL7rC7He1CqqyFynD4MdeL+F/PjBQ=="],
18211832

1822-
"kicad-converter/@tscircuit/soup-util": ["@tscircuit/[email protected]", "", { "dependencies": { "parsel-js": "^1.1.2" }, "peerDependencies": { "@tscircuit/soup": "*", "transformation-matrix": "*", "zod": "*" } }, "sha512-ahb/slqXg06Cp8OkjqhhpADnzJNOVhBbXb/ea8Uow2vBvAgLNSFw/Js7Qd5e9Mxch/zjs8LeCk4oZom36RfWhw=="],
1823-
1824-
"kicad-converter/circuit-json-to-connectivity-map": ["[email protected]", "", { "dependencies": { "@tscircuit/math-utils": "^0.0.4" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-3eZuFjyqcCT46FxFXiiyUplH8wvGoZjmhmpD+VSZER0aI+rPKKawqvkBpaEZZOIIqg3rYEfO98kVH/syvyI8yQ=="],
1825-
18261833
"log-symbols/is-unicode-supported": ["[email protected]", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
18271834

18281835
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1905,6 +1912,10 @@
19051912

19061913
"@ts-morph/common/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
19071914

1915+
"@tscircuit/fake-snippets/kicad-converter/@tscircuit/soup-util": ["@tscircuit/[email protected]", "", { "dependencies": { "parsel-js": "^1.1.2" }, "peerDependencies": { "@tscircuit/soup": "*", "transformation-matrix": "*", "zod": "*" } }, "sha512-ahb/slqXg06Cp8OkjqhhpADnzJNOVhBbXb/ea8Uow2vBvAgLNSFw/Js7Qd5e9Mxch/zjs8LeCk4oZom36RfWhw=="],
1916+
1917+
"@tscircuit/fake-snippets/kicad-converter/circuit-json-to-connectivity-map": ["[email protected]", "", { "dependencies": { "@tscircuit/math-utils": "^0.0.4" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-3eZuFjyqcCT46FxFXiiyUplH8wvGoZjmhmpD+VSZER0aI+rPKKawqvkBpaEZZOIIqg3rYEfO98kVH/syvyI8yQ=="],
1918+
19081919
"@vercel/routing-utils/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
19091920

19101921
"bl/readable-stream/string_decoder": ["[email protected]", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
@@ -1935,8 +1946,6 @@
19351946

19361947
"js-beautify/nopt/abbrev": ["[email protected]", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
19371948

1938-
"kicad-converter/circuit-json-to-connectivity-map/@tscircuit/math-utils": ["@tscircuit/[email protected]", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-8Bu/C+go95Zk9AXb4Pe37OgObGhOd5F7UIzXV+u1PKuhpJpGjr+X/WHBzSI7xHrBSvwsf39/Luooe4b3djuzgQ=="],
1939-
19401949
"ora/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
19411950

19421951
"prebuild-install/tar-fs/tar-stream": ["[email protected]", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
@@ -2047,6 +2056,8 @@
20472056

20482057
"wrap-ansi/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
20492058

2059+
"@tscircuit/fake-snippets/kicad-converter/circuit-json-to-connectivity-map/@tscircuit/math-utils": ["@tscircuit/[email protected]", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-8Bu/C+go95Zk9AXb4Pe37OgObGhOd5F7UIzXV+u1PKuhpJpGjr+X/WHBzSI7xHrBSvwsf39/Luooe4b3djuzgQ=="],
2060+
20502061
"js-beautify/glob/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
20512062

20522063
"prebuild-install/tar-fs/tar-stream/readable-stream": ["[email protected]", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],

cli/convert/register.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Command } from "commander"
2+
import path from "node:path"
3+
import fs from "node:fs"
4+
import kleur from "kleur"
5+
import { prompts } from "lib/utils/prompts"
6+
import { parseKicadModToCircuitJson } from "kicad-component-converter"
7+
import { convertCircuitJsonToTscircuit } from "circuit-json-to-tscircuit"
8+
9+
export const registerConvert = (program: Command) => {
10+
program
11+
.command("convert")
12+
.description("Convert KiCad .kicad_mod files to tscircuit format")
13+
.argument("<file>", "Path to the .kicad_mod file")
14+
.option("-o, --output <path>", "Output file path")
15+
.action(async (filePath: string, options: { output?: string }) => {
16+
const absolutePath = path.resolve(filePath)
17+
18+
if (!fs.existsSync(absolutePath)) {
19+
console.error(kleur.red(`File not found: ${absolutePath}`))
20+
return process.exit(1)
21+
}
22+
23+
if (!absolutePath.endsWith(".kicad_mod")) {
24+
console.error(kleur.red("File must be a .kicad_mod file"))
25+
return process.exit(1)
26+
}
27+
28+
try {
29+
console.log(
30+
kleur.yellow(`Converting ${path.basename(absolutePath)}...`),
31+
)
32+
33+
const kicadModContent = fs.readFileSync(absolutePath, "utf-8")
34+
35+
// Parse KiCad mod file and convert to circuit JSON
36+
const circuitJson = await parseKicadModToCircuitJson(kicadModContent)
37+
38+
// Convert circuit JSON to tscircuit code
39+
const tscircuitCode = convertCircuitJsonToTscircuit(circuitJson)
40+
41+
// Determine output path
42+
const outputPath =
43+
options.output || absolutePath.replace(/\.kicad_mod$/, ".tsx")
44+
45+
// Write the output file
46+
fs.writeFileSync(outputPath, tscircuitCode)
47+
48+
console.log(kleur.green(`Successfully converted to: ${outputPath}`))
49+
} catch (error) {
50+
console.error(
51+
kleur.red("Failed to convert file:"),
52+
error instanceof Error ? error.message : error,
53+
)
54+
return process.exit(1)
55+
}
56+
})
57+
}

cli/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { registerRemove } from "./remove/register"
2323
import { registerBuild } from "./build/register"
2424
import { registerSnapshot } from "./snapshot/register"
2525
import { registerSetup } from "./setup/register"
26+
import { registerConvert } from "./convert/register"
2627

2728
export const program = new Command()
2829

@@ -55,6 +56,7 @@ registerUpgradeCommand(program)
5556

5657
registerSearch(program)
5758
registerImport(program)
59+
registerConvert(program)
5860

5961
// Manually handle --version, -v, and -V flags
6062
if (

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,10 @@
7777
"cli": "bun ./cli/main.ts",
7878
"pkg-pr-new-release": "bunx pkg-pr-new publish --comment=off --peerDeps"
7979
},
80-
"type": "module"
80+
"type": "module",
81+
"dependencies": {
82+
"circuit-json-to-tscircuit": "^0.0.9",
83+
"kicad-component-converter": "^0.1.14",
84+
"kicad-converter": "^0.0.17"
85+
}
8186
}

tests/cli/convert/convert.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { test, expect } from "bun:test"
2+
import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture"
3+
import fs from "node:fs"
4+
import path from "node:path"
5+
6+
test("convert command converts KiCad mod to tscircuit TSX", async () => {
7+
const { runCommand, tmpDir } = await getCliTestFixture({ loggedIn: true })
8+
9+
// Create a test KiCad mod file
10+
const kicadModContent = `(footprint "R_0805_2012Metric" (version 20211014) (generator pcbnew)
11+
(layer "F.Cu")
12+
(tedit 5F68FEEE)
13+
(descr "Resistor SMD 0805 (2012 Metric), square (rectangular) end terminal")
14+
(tags "resistor")
15+
(attr smd)
16+
(fp_text reference "REF**" (at 0 -1.65) (layer "F.SilkS")
17+
(effects (font (size 1 1) (thickness 0.15)))
18+
)
19+
(fp_text value "R_0805_2012Metric" (at 0 1.65) (layer "F.Fab")
20+
(effects (font (size 1 1) (thickness 0.15)))
21+
)
22+
(pad "1" smd roundrect (at -0.9125 0) (size 1.025 1.4) (layers "F.Cu" "F.Paste" "F.Mask"))
23+
(pad "2" smd roundrect (at 0.9125 0) (size 1.025 1.4) (layers "F.Cu" "F.Paste" "F.Mask"))
24+
)`
25+
26+
const kicadModPath = path.join(tmpDir, "test-resistor.kicad_mod")
27+
fs.writeFileSync(kicadModPath, kicadModContent)
28+
29+
const { stdout, stderr } = await runCommand(`tsci convert ${kicadModPath}`)
30+
31+
expect(stderr).toBe("")
32+
expect(stdout.toLowerCase()).toContain("successfully converted")
33+
34+
// Check that the output TSX file was created
35+
const outputPath = kicadModPath.replace(/\.kicad_mod$/, ".tsx")
36+
expect(fs.existsSync(outputPath)).toBe(true)
37+
38+
// Check that the output contains valid tscircuit code
39+
const outputContent = fs.readFileSync(outputPath, "utf-8")
40+
expect(outputContent).toContain("import")
41+
expect(outputContent).toContain("ChipProps")
42+
expect(outputContent).toContain("<chip")
43+
expect(outputContent).toContain("<footprint>")
44+
expect(outputContent).toContain("<smtpad")
45+
expect(outputContent).toContain('portHints={["1"]}')
46+
expect(outputContent).toContain('portHints={["2"]}')
47+
}, 20_000)
48+
49+
test("convert command with custom output path", async () => {
50+
const { runCommand, tmpDir } = await getCliTestFixture({ loggedIn: true })
51+
52+
const kicadModContent = `(footprint "TestFootprint" (version 20211014)
53+
(layer "F.Cu")
54+
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu"))
55+
)`
56+
57+
const kicadModPath = path.join(tmpDir, "test.kicad_mod")
58+
const customOutputPath = path.join(tmpDir, "custom-output.tsx")
59+
fs.writeFileSync(kicadModPath, kicadModContent)
60+
61+
const { stdout, stderr } = await runCommand(
62+
`tsci convert ${kicadModPath} -o ${customOutputPath}`,
63+
)
64+
65+
expect(stderr).toBe("")
66+
expect(stdout.toLowerCase()).toContain("successfully converted")
67+
expect(stdout).toContain(customOutputPath)
68+
69+
expect(fs.existsSync(customOutputPath)).toBe(true)
70+
}, 20_000)
71+
72+
test("convert command handles file not found", async () => {
73+
const { runCommand } = await getCliTestFixture({ loggedIn: true })
74+
75+
const { stdout, stderr } = await runCommand(
76+
"tsci convert /nonexistent/file.kicad_mod",
77+
)
78+
79+
expect(stderr.toLowerCase()).toContain("file not found")
80+
}, 20_000)
81+
82+
test("convert command handles wrong file extension", async () => {
83+
const { runCommand, tmpDir } = await getCliTestFixture({ loggedIn: true })
84+
85+
const wrongFile = path.join(tmpDir, "wrong.txt")
86+
fs.writeFileSync(wrongFile, "test content")
87+
88+
const { stdout, stderr } = await runCommand(`tsci convert ${wrongFile}`)
89+
90+
expect(stderr.toLowerCase()).toContain("must be a .kicad_mod file")
91+
}, 20_000)

0 commit comments

Comments
 (0)