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.
- package/CHANGELOG.md +49 -1
- package/CONTRIBUTING.md +2 -1
- package/LICENSE +55 -55
- package/README.md +24 -1
- package/README.zh.md +23 -2
- package/docs/tools.md +30 -0
- package/dsh-eda-bridge-extension-0.4.0.zip +0 -0
- package/dsh-eda-bridge-extension.zip +0 -0
- package/jlc-extension/api-bridge.js +65 -11
- package/jlc-extension/extension.json +1 -1
- package/lib/bridge.mjs +678 -14
- package/lib/client.js +34 -0
- package/lib/index.js +6 -2
- package/package.json +5 -3
- package/src/bridge/eda-launch.ts +267 -0
- package/src/bridge/index.ts +579 -15
- package/src/client/EdaMcpSettingsCard.tsx +36 -0
- package/src/client/locales.ts +4 -0
- package/src/index.ts +6 -0
- package/test/eda-launch.test.ts +114 -0
- package/test/seams.test.ts +14 -0
|
@@ -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')}
|
package/src/client/locales.ts
CHANGED
|
@@ -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)
|
package/test/seams.test.ts
CHANGED
|
@@ -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}`)
|