dsh-eda-mcp 0.6.2 → 0.7.1

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/lib/client.js CHANGED
@@ -1097,6 +1097,22 @@ function NumberField(props) {
1097
1097
  }
1098
1098
  ) });
1099
1099
  }
1100
+ function TextField(props) {
1101
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { label: props.label, hint: props.hint, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1102
+ "input",
1103
+ {
1104
+ type: "text",
1105
+ value: props.value ?? "",
1106
+ placeholder: props.placeholder,
1107
+ disabled: props.disabled,
1108
+ onChange: (event) => {
1109
+ const text = event.target.value.trim();
1110
+ props.onChange(text === "" ? void 0 : text);
1111
+ },
1112
+ style: inputStyle
1113
+ }
1114
+ ) });
1115
+ }
1100
1116
  function BooleanField(props) {
1101
1117
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { label: props.label, hint: props.hint, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1102
1118
  import_dsh_client_ui_primitives.Switch,
@@ -1235,6 +1251,20 @@ function EdaMcpSettingsCard(props) {
1235
1251
  }
1236
1252
  }
1237
1253
  ),
1254
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1255
+ TextField,
1256
+ {
1257
+ label: t("settings.edaExecutable"),
1258
+ hint: t("settings.edaExecutableHint"),
1259
+ value: draft.edaExecutable,
1260
+ placeholder: "lceda-pro.exe",
1261
+ disabled,
1262
+ onChange: (value) => {
1263
+ clearNotice();
1264
+ update("edaExecutable", value);
1265
+ }
1266
+ }
1267
+ ),
1238
1268
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1239
1269
  BooleanField,
1240
1270
  {
@@ -1253,7 +1283,6 @@ function EdaMcpSettingsCard(props) {
1253
1283
  "a",
1254
1284
  {
1255
1285
  href: "/api/dsh-eda-mcp/extension.zip",
1256
- download: "dsh-eda-bridge-extension.zip",
1257
1286
  style: { ...secondaryButtonStyle, textDecoration: "none", display: "inline-flex", width: "fit-content" },
1258
1287
  children: t("settings.downloadExtension")
1259
1288
  }
@@ -1417,6 +1446,8 @@ var zh = {
1417
1446
  "settings.toolCallTimeoutMsHint": "\u5355\u6B21 EDA API \u8C03\u7528\u8D85\u65F6\uFF0C\u9ED8\u8BA4 60000\u3002",
1418
1447
  "settings.allowRawApi": "\u5141\u8BB8\u901A\u7528 eda_api_call",
1419
1448
  "settings.allowRawApiHint": "\u5F00\u542F\u540E MCP \u5DE5\u5177 eda_api_call \u53EF\u8C03\u7528\u767D\u540D\u5355\u5185\u7684 JLCEDA \u6269\u5C55 API\u3002",
1449
+ "settings.edaExecutable": "\u5609\u7ACB\u521BEDA \u53EF\u6267\u884C\u6587\u4EF6\uFF08\u53EF\u9009\uFF09",
1450
+ "settings.edaExecutableHint": "\u7559\u7A7A\u5373\u81EA\u52A8\u63A2\u6D4B\uFF1A\u73AF\u5883\u53D8\u91CF \u2192 \u6CE8\u518C\u8868\u767B\u8BB0\u7684\u6587\u4EF6\u5173\u8054 \u2192 \u5E38\u89C1\u5B89\u88C5\u76EE\u5F55 \u2192 PATH\u3002\u53EA\u6709\u81EA\u52A8\u63A2\u6D4B\u5931\u8D25\u65F6\u624D\u9700\u8981\u586B\u5B8C\u6574\u8DEF\u5F84\u3002",
1420
1451
  "settings.save": "\u4FDD\u5B58",
1421
1452
  "settings.saving": "\u4FDD\u5B58\u4E2D\u2026",
1422
1453
  "settings.saved": "\u5DF2\u4FDD\u5B58",
@@ -1445,6 +1476,8 @@ var en = {
1445
1476
  "settings.toolCallTimeoutMsHint": "Timeout for one EDA API call; default 60000.",
1446
1477
  "settings.allowRawApi": "Allow generic eda_api_call",
1447
1478
  "settings.allowRawApiHint": "Expose the eda_api_call tool restricted to the allow-listed JLCEDA APIs.",
1479
+ "settings.edaExecutable": "JLCEDA Pro executable (optional)",
1480
+ "settings.edaExecutableHint": "Leave empty to auto-detect: environment variable, the file-association command in the registry, conventional install directories, then PATH. Set a full path only when auto-detection fails.",
1448
1481
  "settings.save": "Save",
1449
1482
  "settings.saving": "Saving\u2026",
1450
1483
  "settings.saved": "Saved",
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
5
6
  import { dirname as dirname2 } from "node:path";
6
7
 
7
8
  // src/guidance.ts
@@ -23,6 +24,7 @@ var EDA_MCP_GUIDANCE = [
23
24
 
24
25
  // src/routes.ts
25
26
  import { createReadStream } from "node:fs";
27
+ import { basename } from "node:path";
26
28
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
27
29
 
28
30
  // src/loopback.ts
@@ -196,7 +198,8 @@ function makeEdaMcpRoutes(deps) {
196
198
  if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
197
199
  res.writeHead(200, {
198
200
  "content-type": "application/zip",
199
- "content-disposition": 'attachment; filename="dsh-eda-bridge-extension.zip"',
201
+ // Keep the versioned file name the browser saves it as.
202
+ "content-disposition": 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
200
203
  "cache-control": "no-store"
201
204
  });
202
205
  const stream = createReadStream(deps.extensionZipPath);
@@ -492,7 +495,9 @@ var Config = z.object({
492
495
  enabled: z.boolean().default(true).volatile(),
493
496
  announceToAgent: z.boolean().default(true).volatile(),
494
497
  toolCallTimeoutMs: z.number().min(1e3).max(3e5).default(6e4).volatile(),
495
- allowRawApi: z.boolean().default(true).volatile()
498
+ allowRawApi: z.boolean().default(true).volatile(),
499
+ /** Optional path to lceda-pro.exe; empty means "let eda_launch discover it". */
500
+ edaExecutable: z.string().default("").volatile()
496
501
  });
497
502
  var FIBER_DISPOSED = 4;
498
503
  var FIBER_UNLOADING = 5;
@@ -530,7 +535,19 @@ function installSettingsSection(ctx, scoped, ns, schema, entry, hooks) {
530
535
  }
531
536
  var inject = ["webServer", "systemPrompt"];
532
537
  var BRIDGE_URL = new URL("../lib/bridge.mjs", import.meta.url);
533
- var EXTENSION_ZIP_URL = new URL("../dsh-eda-bridge-extension.zip", import.meta.url);
538
+ function resolveExtensionZipPath() {
539
+ const plain = fileURLToPath(new URL("../dsh-eda-bridge-extension.zip", import.meta.url));
540
+ try {
541
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../jlc-extension/extension.json", import.meta.url)), "utf8"));
542
+ const version = typeof manifest.version === "string" ? manifest.version : "";
543
+ if (version !== "") {
544
+ const versioned = fileURLToPath(new URL("../dsh-eda-bridge-extension-" + version + ".zip", import.meta.url));
545
+ if (existsSync2(versioned)) return versioned;
546
+ }
547
+ } catch {
548
+ }
549
+ return plain;
550
+ }
534
551
  var BRIDGE_PATH = fileURLToPath(BRIDGE_URL);
535
552
  function bridgeLaunchFor(active) {
536
553
  const runtime = resolveBridgeRuntime();
@@ -544,7 +561,9 @@ function bridgeLaunchFor(active) {
544
561
  env: {
545
562
  ...runtime.extraEnv,
546
563
  DSH_EDA_MCP_ALLOW_RAW_API: allowRawApi ? "1" : "0",
547
- DSH_EDA_MCP_TOOL_TIMEOUT_MS: String(toolCallTimeoutMs)
564
+ DSH_EDA_MCP_TOOL_TIMEOUT_MS: String(toolCallTimeoutMs),
565
+ // eda_launch falls back to the registry / install dirs / PATH when empty.
566
+ DSH_EDA_EXECUTABLE: typeof active.edaExecutable === "string" ? active.edaExecutable : ""
548
567
  },
549
568
  cwd: dirname2(BRIDGE_PATH),
550
569
  toolCallTimeoutMs,
@@ -610,7 +629,7 @@ function apply(ctx, config) {
610
629
  ctx.effect(() => {
611
630
  const disposers = makeEdaMcpRoutes({
612
631
  getConfig: () => current(),
613
- extensionZipPath: fileURLToPath(EXTENSION_ZIP_URL),
632
+ extensionZipPath: resolveExtensionZipPath(),
614
633
  reconnect: remountClient,
615
634
  bridgeLaunch: describeBridgeLaunch,
616
635
  diagnostics: () => diagnostics.liveSnapshot(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-eda-mcp",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "DeepSeek Harness (DSH) plugin — connects DSH to JLCEDA Pro (EasyEDA Pro) via a local MCP bridge and draws schematics through the JLCEDA extension API.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -49,7 +49,7 @@
49
49
  "test",
50
50
  "docs",
51
51
  "jlc-extension",
52
- "dsh-eda-bridge-extension.zip",
52
+ "dsh-eda-bridge-extension-*.zip",
53
53
  "cordis.patch.yml",
54
54
  "CHANGELOG.md",
55
55
  "CONTRIBUTING.md",
@@ -72,9 +72,10 @@
72
72
  "scripts": {
73
73
  "build": "node build.mjs",
74
74
  "typecheck": "tsc --noEmit",
75
- "test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts",
75
+ "test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts",
76
76
  "seam-check": "node scripts/seam-check.mjs",
77
- "check": "pnpm typecheck && pnpm test && pnpm build",
77
+ "contract-check": "node scripts/contract-check.mjs",
78
+ "check": "pnpm typecheck && pnpm test && pnpm contract-check && pnpm build",
78
79
  "docs": "node scripts/gen-tools-doc.mjs",
79
80
  "prepublishOnly": "pnpm check",
80
81
  "prepare": "pnpm build"
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Launching JLCEDA Pro (EasyEDA Pro) from a tool call.
3
+ *
4
+ * Nothing here is machine-specific: the executable is resolved from an explicit
5
+ * override, an environment variable, the registry entry the installer creates, a
6
+ * list of conventional install directories, and finally PATH. Every step is
7
+ * skipped when its inputs are absent.
8
+ *
9
+ * String operations only (no regex literals): the discovery rules stay readable
10
+ * and the resolution order is unit-tested without spawning anything.
11
+ */
12
+
13
+ export type Platform = 'win32' | 'darwin' | 'linux' | (string & {})
14
+
15
+ /** Everything the resolver needs from the outside world. */
16
+ export interface LaunchProbe {
17
+ platform: Platform
18
+ env: Record<string, string | undefined>
19
+ exists: (path: string) => boolean
20
+ /** Raw stdout of the registry query, or undefined when the key does not exist. */
21
+ registryQuery: (key: string, valueName?: string) => string | undefined
22
+ }
23
+
24
+ export interface ResolveOptions {
25
+ executable?: string
26
+ project?: string
27
+ }
28
+
29
+ export type ExecutableSource = 'config' | 'env' | 'registry' | 'install-dir' | 'path'
30
+
31
+ export interface ResolvedLaunch {
32
+ executable: string
33
+ source: ExecutableSource
34
+ }
35
+
36
+ export const EXECUTABLE_ENV_KEYS = ['DSH_EDA_EXECUTABLE', 'JLCEDA_EXECUTABLE'] as const
37
+ export const EXECUTABLE_NAMES = ['lceda-pro.exe', 'lceda-pro', 'LCEDA Pro'] as const
38
+ export const PROJECT_EXTENSIONS = ['.eprj2', '.eprj'] as const
39
+
40
+ const CN_PRO_EDITION = '嘉立创EDA专业版'
41
+ const CLASSES = 'Software\\Classes'
42
+
43
+ export const INSTALL_SUFFIXES = [
44
+ 'lceda-pro',
45
+ 'Program Files\\lceda-pro',
46
+ 'Program Files (x86)\\lceda-pro',
47
+ 'Program Files\\' + CN_PRO_EDITION,
48
+ 'Program Files (x86)\\' + CN_PRO_EDITION,
49
+ ] as const
50
+
51
+ /** Strip trailing path separators from a directory. */
52
+ function trimSeparator(value: string): string {
53
+ let end = value.length
54
+ while (end > 0) {
55
+ const ch = value[end - 1]
56
+ if (ch !== '\\' && ch !== '/') break
57
+ end -= 1
58
+ }
59
+ return value.slice(0, end)
60
+ }
61
+
62
+ /** Strip leading path separators. */
63
+ function trimLeadingSeparators(value: string): string {
64
+ let start = 0
65
+ while (start < value.length && (value[start] === '\\' || value[start] === '/')) start += 1
66
+ return value.slice(start)
67
+ }
68
+
69
+ function joinPath(platform: Platform, ...parts: string[]): string {
70
+ const separator = platform === 'win32' ? '\\' : '/'
71
+ return parts
72
+ .filter(part => part !== '')
73
+ .map((part, index) => (index === 0 ? part : trimLeadingSeparators(part)))
74
+ .join(separator)
75
+ }
76
+
77
+ /** Split an install suffix into path segments, on either separator. */
78
+ function splitSegments(value: string): string[] {
79
+ return value.split('\\').join('/').split('/').filter(part => part !== '')
80
+ }
81
+
82
+ /**
83
+ * Pull the executable out of a registered open command such as
84
+ * `"D:\\lceda-pro\\lceda-pro.exe" "%1"` or `C:\\App\\lceda-pro.exe %1`.
85
+ */
86
+ export function parseExecutableFromCommand(command: string | undefined): string | undefined {
87
+ const text = (command ?? '').trim()
88
+ if (text === '') return undefined
89
+ if (text.startsWith('"')) {
90
+ const end = text.indexOf('"', 1)
91
+ if (end === -1) return undefined
92
+ const quoted = text.slice(1, end).trim()
93
+ return quoted === '' ? undefined : quoted
94
+ }
95
+ const space = text.indexOf(' ')
96
+ const tab = text.indexOf(String.fromCharCode(9))
97
+ const stops = [space, tab].filter(index => index > 0)
98
+ const end = stops.length === 0 ? text.length : Math.min(...stops)
99
+ const first = text.slice(0, end).trim()
100
+ return first === '' ? undefined : first
101
+ }
102
+
103
+ /**
104
+ * Decode `reg` output. The tool writes in the console code page (GBK on a Chinese
105
+ * Windows), and progid names are non-ASCII, so a UTF-8 decode can produce
106
+ * replacement characters that no longer match a registry key. When that happens,
107
+ * decode again as GBK (both decoders ship with the Node the host provides).
108
+ */
109
+ export function decodeRegistryOutput(raw: Uint8Array): string {
110
+ const utf8 = new TextDecoder('utf-8').decode(raw)
111
+ if (!utf8.includes(String.fromCharCode(0xFFFD))) return utf8
112
+ try {
113
+ return new TextDecoder('gbk').decode(raw)
114
+ } catch {
115
+ return utf8
116
+ }
117
+ }
118
+
119
+ /** Take the string payload of a registry line (`... REG_SZ <value>`). */
120
+ export function parseRegStringValue(output: string | undefined): string | undefined {
121
+ for (const raw of (output ?? '').split(String.fromCharCode(10))) {
122
+ const line = raw.trim()
123
+ const index = line.toUpperCase().indexOf('REG_SZ')
124
+ if (index === -1) continue
125
+ const value = line.slice(index + 'REG_SZ'.length).trim()
126
+ // `reg query` prints "(value not set)" for an unset default; it is not a value.
127
+ if (value === '' || value.startsWith('(')) continue
128
+ return value
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ /** Value names of a registry listing (the first column of every value line). */
134
+ export function parseRegValueNames(output: string | undefined): string[] {
135
+ const names: string[] = []
136
+ for (const raw of (output ?? '').split(String.fromCharCode(10))) {
137
+ const line = raw.trim()
138
+ const marker = line.indexOf('REG_')
139
+ if (marker <= 0) continue
140
+ const name = line.slice(0, marker).trim()
141
+ if (name !== '' && !name.startsWith('HKEY')) names.push(name)
142
+ }
143
+ return names
144
+ }
145
+
146
+ function firstExisting(probe: LaunchProbe, candidates: string[]): string | undefined {
147
+ for (const candidate of candidates) {
148
+ if (candidate !== '' && probe.exists(candidate)) return candidate
149
+ }
150
+ return undefined
151
+ }
152
+
153
+ /** Candidate executables derived from environment variables (no registry access). */
154
+ export function envCandidates(probe: LaunchProbe): string[] {
155
+ const env = probe.env
156
+ const roots = [
157
+ env.ProgramFiles,
158
+ env['ProgramFiles(x86)'],
159
+ env.LOCALAPPDATA === undefined ? undefined : joinPath(probe.platform, env.LOCALAPPDATA, 'Programs'),
160
+ env.APPDATA === undefined ? undefined : joinPath(probe.platform, env.APPDATA, 'Programs'),
161
+ env.SystemDrive,
162
+ env.USERPROFILE,
163
+ env.HOME,
164
+ ].filter((root): root is string => typeof root === 'string' && root !== '')
165
+
166
+ const candidates: string[] = []
167
+ for (const root of roots) {
168
+ for (const suffix of INSTALL_SUFFIXES) {
169
+ for (const name of EXECUTABLE_NAMES) {
170
+ candidates.push(joinPath(probe.platform, trimSeparator(root), ...splitSegments(suffix), name))
171
+ }
172
+ }
173
+ }
174
+ return candidates
175
+ }
176
+
177
+ /** Look for the executable in the directories listed on PATH. */
178
+ export function pathCandidates(probe: LaunchProbe): string[] {
179
+ const raw = probe.env.PATH ?? probe.env.Path ?? ''
180
+ const separator = probe.platform === 'win32' ? ';' : ':'
181
+ return raw
182
+ .split(separator)
183
+ .filter(dir => dir.trim() !== '')
184
+ .flatMap(dir => EXECUTABLE_NAMES.map(name => joinPath(probe.platform, trimSeparator(dir), name)))
185
+ }
186
+
187
+ /**
188
+ * Ask the registry where the installer registered the project handler.
189
+ *
190
+ * The extension's default progid is often empty while `OpenWithProgids` lists the
191
+ * real one, so both are consulted. HKCU wins over HKLM; HKCR is skipped because
192
+ * it merges the two.
193
+ */
194
+ export function registryCandidates(probe: LaunchProbe): string[] {
195
+ if (probe.platform !== 'win32') return []
196
+ const found: string[] = []
197
+ for (const hive of ['HKCU', 'HKLM']) {
198
+ for (const extension of PROJECT_EXTENSIONS) {
199
+ const base = hive + '\\' + CLASSES + '\\' + extension
200
+ const defaults = [parseRegStringValue(probe.registryQuery(base, '')), parseRegStringValue(probe.registryQuery(base))]
201
+ const progids = [
202
+ ...defaults.filter((value): value is string => value !== undefined),
203
+ ...parseRegValueNames(probe.registryQuery(base + '\\OpenWithProgids')),
204
+ ]
205
+ for (const progid of progids) {
206
+ if (progid.startsWith('Applications' + '\\')) continue
207
+ const command = parseRegStringValue(
208
+ probe.registryQuery(hive + '\\' + CLASSES + '\\' + progid + '\\shell\\open\\command'),
209
+ )
210
+ const executable = parseExecutableFromCommand(command)
211
+ if (executable !== undefined) found.push(executable)
212
+ }
213
+ }
214
+ }
215
+ return found
216
+ }
217
+
218
+ /** Resolve the executable, or undefined when nothing on this machine matches. */
219
+ export function resolveExecutable(probe: LaunchProbe, options: ResolveOptions = {}): ResolvedLaunch | undefined {
220
+ const override = options.executable?.trim()
221
+ if (override !== undefined && override !== '' && probe.exists(override)) {
222
+ return { executable: override, source: 'config' }
223
+ }
224
+
225
+ for (const key of EXECUTABLE_ENV_KEYS) {
226
+ const value = probe.env[key]?.trim()
227
+ if (value !== undefined && value !== '' && probe.exists(value)) return { executable: value, source: 'env' }
228
+ }
229
+
230
+ const fromRegistry = firstExisting(probe, registryCandidates(probe))
231
+ if (fromRegistry !== undefined) return { executable: fromRegistry, source: 'registry' }
232
+
233
+ const fromInstallDir = firstExisting(probe, envCandidates(probe))
234
+ if (fromInstallDir !== undefined) return { executable: fromInstallDir, source: 'install-dir' }
235
+
236
+ const fromPath = firstExisting(probe, pathCandidates(probe))
237
+ if (fromPath !== undefined) return { executable: fromPath, source: 'path' }
238
+
239
+ return undefined
240
+ }
241
+
242
+ /** Arguments used to open the app (and optionally a project) on each platform. */
243
+ export function buildLaunchArgs(platform: Platform, executable: string, project?: string): { command: string; args: string[] } {
244
+ const target = project?.trim()
245
+ const hasProject = target !== undefined && target !== ''
246
+ if (platform === 'darwin') {
247
+ return hasProject
248
+ ? { command: 'open', args: ['-a', executable, target as string] }
249
+ : { command: 'open', args: ['-a', executable] }
250
+ }
251
+ return hasProject ? { command: executable, args: [target as string] } : { command: executable, args: [] }
252
+ }
253
+
254
+ /**
255
+ * Windows script used to hand the launch to the running Explorer shell.
256
+ *
257
+ * Spawning `lceda-pro.exe` directly from a host process (or even through
258
+ * `Start-Process`) makes it start and exit immediately, while double-clicking the
259
+ * same executable works. Explorer runs in the interactive desktop, so executing a
260
+ * script through it reproduces the double-click context; the script then starts the
261
+ * app with the optional project argument.
262
+ */
263
+ export function buildLaunchScript(executable: string, project?: string): string {
264
+ const target = project?.trim()
265
+ const args = target === undefined || target === '' ? '' : ' "' + target + '"'
266
+ return ['@echo off', 'start "" "' + executable + '"' + args, ''].join(String.fromCharCode(13, 10))
267
+ }