dsh-eda-mcp 0.6.2 → 0.7.0

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.
@@ -10,6 +10,7 @@ export interface EdaMcpSettings {
10
10
  announceToAgent?: boolean
11
11
  toolCallTimeoutMs?: number
12
12
  allowRawApi?: boolean
13
+ edaExecutable?: string
13
14
  }
14
15
 
15
16
  export interface EdaMcpSettingsCardState {
@@ -266,6 +267,33 @@ function NumberField(props: NumberFieldProps) {
266
267
  )
267
268
  }
268
269
 
270
+ interface TextFieldProps {
271
+ label: string
272
+ hint: string
273
+ value: string | undefined
274
+ placeholder?: string
275
+ disabled?: boolean
276
+ onChange: (value: string | undefined) => void
277
+ }
278
+
279
+ function TextField(props: TextFieldProps) {
280
+ return (
281
+ <Field label={props.label} hint={props.hint}>
282
+ <input
283
+ type="text"
284
+ value={props.value ?? ''}
285
+ placeholder={props.placeholder}
286
+ disabled={props.disabled}
287
+ onChange={(event) => {
288
+ const text = event.target.value.trim()
289
+ props.onChange(text === '' ? undefined : text)
290
+ }}
291
+ style={inputStyle}
292
+ />
293
+ </Field>
294
+ )
295
+ }
296
+
269
297
  interface BooleanFieldProps {
270
298
  label: string
271
299
  hint: string
@@ -407,6 +435,14 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
407
435
  disabled={disabled}
408
436
  onChange={(value) => { clearNotice(); update('toolCallTimeoutMs', value) }}
409
437
  />
438
+ <TextField
439
+ label={t('settings.edaExecutable')}
440
+ hint={t('settings.edaExecutableHint')}
441
+ value={draft.edaExecutable}
442
+ placeholder="lceda-pro.exe"
443
+ disabled={disabled}
444
+ onChange={(value) => { clearNotice(); update('edaExecutable', value) }}
445
+ />
410
446
  <BooleanField
411
447
  label={t('settings.allowRawApi')}
412
448
  hint={t('settings.allowRawApiHint')}
@@ -9,6 +9,8 @@ export const zh = {
9
9
  'settings.toolCallTimeoutMsHint': '单次 EDA API 调用超时,默认 60000。',
10
10
  'settings.allowRawApi': '允许通用 eda_api_call',
11
11
  'settings.allowRawApiHint': '开启后 MCP 工具 eda_api_call 可调用白名单内的 JLCEDA 扩展 API。',
12
+ 'settings.edaExecutable': '嘉立创EDA 可执行文件(可选)',
13
+ 'settings.edaExecutableHint': '留空即自动探测:环境变量 → 注册表登记的文件关联 → 常见安装目录 → PATH。只有自动探测失败时才需要填完整路径。',
12
14
  'settings.save': '保存',
13
15
  'settings.saving': '保存中…',
14
16
  'settings.saved': '已保存',
@@ -38,6 +40,8 @@ export const en = {
38
40
  'settings.toolCallTimeoutMsHint': 'Timeout for one EDA API call; default 60000.',
39
41
  'settings.allowRawApi': 'Allow generic eda_api_call',
40
42
  'settings.allowRawApiHint': 'Expose the eda_api_call tool restricted to the allow-listed JLCEDA APIs.',
43
+ 'settings.edaExecutable': 'JLCEDA Pro executable (optional)',
44
+ '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.',
41
45
  'settings.save': 'Save',
42
46
  'settings.saving': 'Saving…',
43
47
  'settings.saved': 'Saved',
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ export interface EdaMcpConfig {
20
20
  announceToAgent?: boolean
21
21
  toolCallTimeoutMs?: number
22
22
  allowRawApi?: boolean
23
+ /** Path to lceda-pro.exe; empty lets eda_launch discover it. */
24
+ edaExecutable?: string
23
25
  }
24
26
 
25
27
  /**
@@ -33,6 +35,8 @@ export const Config = z.object({
33
35
  announceToAgent: z.boolean().default(true).volatile(),
34
36
  toolCallTimeoutMs: z.number().min(1000).max(300000).default(60000).volatile(),
35
37
  allowRawApi: z.boolean().default(true).volatile(),
38
+ /** Optional path to lceda-pro.exe; empty means "let eda_launch discover it". */
39
+ edaExecutable: z.string().default('').volatile(),
36
40
  })
37
41
 
38
42
  /** Hooks a settings registration hands to the DSH settings seam. */
@@ -167,6 +171,8 @@ function bridgeLaunchFor(active: EdaMcpConfig) {
167
171
  ...runtime.extraEnv,
168
172
  DSH_EDA_MCP_ALLOW_RAW_API: allowRawApi ? '1' : '0',
169
173
  DSH_EDA_MCP_TOOL_TIMEOUT_MS: String(toolCallTimeoutMs),
174
+ // eda_launch falls back to the registry / install dirs / PATH when empty.
175
+ DSH_EDA_EXECUTABLE: typeof active.edaExecutable === 'string' ? active.edaExecutable : '',
170
176
  },
171
177
  cwd: dirname(BRIDGE_PATH),
172
178
  toolCallTimeoutMs,
@@ -0,0 +1,114 @@
1
+ /**
2
+ * eda-launch: executable discovery and launch argument shaping.
3
+ *
4
+ * `node --experimental-strip-types test/eda-launch.test.ts`
5
+ *
6
+ * No process is spawned: the resolver takes a `LaunchProbe` seam and the registry
7
+ * output is fed as text, so the cases below run identically on any machine.
8
+ */
9
+ import {
10
+ buildLaunchArgs, buildLaunchScript, decodeRegistryOutput, envCandidates, parseExecutableFromCommand, parseRegStringValue,
11
+ parseRegValueNames, pathCandidates, registryCandidates, resolveExecutable,
12
+ type LaunchProbe,
13
+ } from '../src/bridge/eda-launch.ts'
14
+
15
+ let failures = 0
16
+ function check(label: string, actual: unknown, expected: unknown): void {
17
+ const ok = JSON.stringify(actual) === JSON.stringify(expected)
18
+ if (!ok) failures += 1
19
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`)
20
+ if (!ok) console.log(` got ${JSON.stringify(actual)}\n expected ${JSON.stringify(expected)}`)
21
+ }
22
+
23
+ function probe(overrides: Partial<LaunchProbe> & { files?: string[] } = {}): LaunchProbe {
24
+ const files = new Set(overrides.files ?? [])
25
+ return {
26
+ platform: overrides.platform ?? 'win32',
27
+ env: overrides.env ?? {},
28
+ exists: overrides.exists ?? ((path: string) => files.has(path)),
29
+ registryQuery: overrides.registryQuery ?? (() => undefined),
30
+ }
31
+ }
32
+
33
+ // --- command line parsing ------------------------------------------------
34
+ check('quoted exe with %1', parseExecutableFromCommand('"D:\\lceda-pro\\lceda-pro.exe" "%1"'), 'D:\\lceda-pro\\lceda-pro.exe')
35
+ check('unquoted exe with %1', parseExecutableFromCommand('C:\\App\\lceda-pro.exe %1'), 'C:\\App\\lceda-pro.exe')
36
+ check('quoted exe without args', parseExecutableFromCommand('"C:\\Program Files\\LCEDA Pro\\lceda-pro.exe"'), 'C:\\Program Files\\LCEDA Pro\\lceda-pro.exe')
37
+ check('empty command', parseExecutableFromCommand(''), undefined)
38
+ check('undefined command', parseExecutableFromCommand(undefined), undefined)
39
+ check('unterminated quote is rejected', parseExecutableFromCommand('"C:\\broken'), undefined)
40
+
41
+ // --- registry output decoding -------------------------------------------
42
+ const gbkBytes = Buffer.from([0xBC, 0xD1, 0xC1, 0xA2, 0xB4, 0xB4]) // 佳立创 in GBK
43
+ check('GBK bytes decode to the right characters', decodeRegistryOutput(gbkBytes), '佳立创')
44
+ check('plain ASCII stays as-is', decodeRegistryOutput(Buffer.from(' X REG_SZ abc', 'utf8')), ' X REG_SZ abc')
45
+ check('valid UTF-8 is not re-decoded', decodeRegistryOutput(Buffer.from('嘉立创', 'utf8')), '嘉立创')
46
+
47
+ // --- registry output parsing --------------------------------------------
48
+ const regDefault = `\r\nHKEY_CURRENT_USER\\Software\\Classes\\嘉立创EDA库文件\\shell\\open\\command\r\n (默认) REG_SZ "D:\\lceda-pro\\lceda-pro.exe" "%1"\r\n\r\n`
49
+ check('REG_SZ payload', parseRegStringValue(regDefault), '"D:\\lceda-pro\\lceda-pro.exe" "%1"')
50
+ check('missing REG_SZ', parseRegStringValue('no such key'), undefined)
51
+ check('OpenWithProgids names', parseRegValueNames('HKEY_CURRENT_USER\\Software\\Classes\\.eprj2\\OpenWithProgids\r\n MyProgid REG_NONE\r\n Other REG_NONE\r\n'), ['MyProgid', 'Other'])
52
+ check('empty OpenWithProgids', parseRegValueNames(''), [])
53
+
54
+ // --- resolution order ----------------------------------------------------
55
+ const exeFromRegistry = 'D:\\lceda-pro\\lceda-pro.exe'
56
+ const registryOutput = (key: string): string | undefined => {
57
+ if (key.endsWith('.eprj2\\OpenWithProgids')) return ' JlcProEdaFile REG_NONE\r\n'
58
+ if (key.endsWith('JlcProEdaFile\\shell\\open\\command')) return ' (默认) REG_SZ "D:\\lceda-pro\\lceda-pro.exe" "%1"\r\n'
59
+ return undefined
60
+ }
61
+
62
+ check('registry hit', resolveExecutable(probe({ files: [exeFromRegistry], registryQuery: registryOutput })), { executable: exeFromRegistry, source: 'registry' })
63
+
64
+ check('config override wins', resolveExecutable(
65
+ probe({ files: ['C:\\custom\\lceda.exe', exeFromRegistry], registryQuery: registryOutput }),
66
+ { executable: 'C:\\custom\\lceda.exe' },
67
+ ), { executable: 'C:\\custom\\lceda.exe', source: 'config' })
68
+
69
+ check('a missing config override is skipped', resolveExecutable(
70
+ probe({ files: [exeFromRegistry], registryQuery: registryOutput }),
71
+ { executable: 'C:\\does\\not\\exist.exe' },
72
+ ), { executable: exeFromRegistry, source: 'registry' })
73
+
74
+ check('env var used when config is absent', resolveExecutable(
75
+ probe({ env: { DSH_EDA_EXECUTABLE: 'E:\\eda\\lceda-pro.exe' }, files: ['E:\\eda\\lceda-pro.exe', exeFromRegistry], registryQuery: registryOutput }),
76
+ ), { executable: 'E:\\eda\\lceda-pro.exe', source: 'env' })
77
+
78
+ check('second env key is honoured', resolveExecutable(
79
+ probe({ env: { JLCEDA_EXECUTABLE: 'E:\\eda2\\lceda-pro.exe' }, files: ['E:\\eda2\\lceda-pro.exe'] }),
80
+ ), { executable: 'E:\\eda2\\lceda-pro.exe', source: 'env' })
81
+
82
+ const programFilesExe = 'C:\\Program Files\\lceda-pro\\lceda-pro.exe'
83
+ check('install dir candidate', resolveExecutable(
84
+ probe({ env: { ProgramFiles: 'C:\\Program Files' }, files: [programFilesExe] }),
85
+ ), { executable: programFilesExe, source: 'install-dir' })
86
+
87
+ check('PATH candidate', resolveExecutable(
88
+ probe({ env: { PATH: 'C:\\tools;D:\\bin' }, files: ['D:\\bin\\lceda-pro.exe'] }),
89
+ ), { executable: 'D:\\bin\\lceda-pro.exe', source: 'path' })
90
+
91
+ check('nothing found', resolveExecutable(probe({ env: {} })), undefined)
92
+ check('registry is ignored off Windows', registryCandidates(probe({ platform: 'linux' })), [])
93
+
94
+ // --- candidate generation ------------------------------------------------
95
+ check('install candidates include Program Files', envCandidates(probe({ env: { ProgramFiles: 'C:\\Program Files' } })).includes(programFilesExe), true)
96
+ check('PATH candidates split on ;', pathCandidates(probe({ env: { PATH: 'A;B' } })), ['A\\lceda-pro.exe', 'A\\lceda-pro', 'A\\LCEDA Pro', 'B\\lceda-pro.exe', 'B\\lceda-pro', 'B\\LCEDA Pro'])
97
+ check('PATH candidates split on : off Windows', pathCandidates(probe({ platform: 'linux', env: { PATH: '/usr/bin:/opt' } })).slice(0, 2), ['/usr/bin/lceda-pro.exe', '/usr/bin/lceda-pro'])
98
+
99
+ // --- launch arguments ----------------------------------------------------
100
+ check('windows without project', buildLaunchArgs('win32', 'D:\\lceda-pro\\lceda-pro.exe'), { command: 'D:\\lceda-pro\\lceda-pro.exe', args: [] })
101
+ check('windows with project', buildLaunchArgs('win32', 'D:\\lceda-pro\\lceda-pro.exe', 'D:\\p\\a.eprj2'), { command: 'D:\\lceda-pro\\lceda-pro.exe', args: ['D:\\p\\a.eprj2'] })
102
+ check('blank project is ignored', buildLaunchArgs('win32', 'X.exe', ' '), { command: 'X.exe', args: [] })
103
+ check('macOS opens the app bundle', buildLaunchArgs('darwin', '/Applications/LCEDA Pro.app'), { command: 'open', args: ['-a', '/Applications/LCEDA Pro.app'] })
104
+ check('macOS opens the document', buildLaunchArgs('darwin', '/Applications/LCEDA Pro.app', '/p/a.eprj2'), { command: 'open', args: ['-a', '/Applications/LCEDA Pro.app', '/p/a.eprj2'] })
105
+ check('linux runs the binary', buildLaunchArgs('linux', '/opt/lceda-pro', '/p/a.eprj2'), { command: '/opt/lceda-pro', args: ['/p/a.eprj2'] })
106
+
107
+ // --- Windows hand-off script (explorer launch) ---------------------------
108
+ const scriptLines = buildLaunchScript('D:\\lceda-pro\\lceda-pro.exe').split('\r\n')
109
+ check('script starts with echo off', scriptLines[0], '@echo off')
110
+ check('script starts the app', scriptLines[1], 'start "" "D:\\lceda-pro\\lceda-pro.exe"')
111
+ check('script with a project quotes both paths', buildLaunchScript('C:\\a b\\lceda-pro.exe', 'D:\\p q\\x.eprj2').split('\r\n')[1], 'start "" "C:\\a b\\lceda-pro.exe" "D:\\p q\\x.eprj2"')
112
+
113
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`)
114
+ process.exit(failures === 0 ? 0 : 1)
@@ -15,6 +15,20 @@ import { pathToFileURL } from 'node:url'
15
15
  const seamRoot = process.env.DSH_SEAM_ROOT ?? process.cwd()
16
16
  let failures = 0
17
17
 
18
+ // --- extension version single source of truth ----------------------------
19
+ // The extension announces its version over the WebSocket. It once carried a
20
+ // hand-written constant that went stale (manifest 0.3.6, runtime 0.3.5), so the
21
+ // bridge reported a version mismatch no matter what was imported. The build
22
+ // injects the manifest version into the placeholder; keep it that way.
23
+ {
24
+ const extensionDir = join(process.cwd(), 'jlc-extension')
25
+ const manifest = JSON.parse(readFileSync(join(extensionDir, 'extension.json'), 'utf8')) as { version: string }
26
+ const bridgeSource = readFileSync(join(extensionDir, 'api-bridge.js'), 'utf8')
27
+ report('extension manifest declares a version', /^\d+\.\d+\.\d+$/.test(manifest.version), manifest.version)
28
+ report('api-bridge.js keeps the version placeholder', bridgeSource.includes('__EXTENSION_VERSION__'))
29
+ report('api-bridge.js has no hard-coded version constant', !/var VERSION = '\d/.test(bridgeSource))
30
+ }
31
+
18
32
  function report(label: string, ok: boolean, detail = ''): void {
19
33
  if (!ok) failures += 1
20
34
  console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail === '' ? '' : ' — ' + detail}`)