dsh-armory 1.9.4 → 2.0.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.
@@ -527,6 +527,127 @@ function parseFailure(error, text) {
527
527
  return 0
528
528
  }
529
529
 
530
+ // ---------------------------------------------------------------------------
531
+ // VS Code `${input:...}`:逐条降级,不整份拒绝
532
+ // ---------------------------------------------------------------------------
533
+
534
+ /**
535
+ * VS Code 的 `${input:名称}`:值由人在**导入时**填写,DSH 读配置的时候无从知道。
536
+ * 它是 VS Code 专属写法,出现在别的客户端配置里也代表同一件事。
537
+ */
538
+ const VSCODE_INPUT = /\$\{input:([^}]*)\}/g
539
+
540
+ /** 一个字符串里出现的 input 名,按出现顺序去重。 */
541
+ function vscodeInputNames(value) {
542
+ if (typeof value !== 'string' || !value.includes('${input:')) return []
543
+ const names = []
544
+ for (const match of value.matchAll(VSCODE_INPUT)) {
545
+ const name = match[1].trim()
546
+ if (name && !names.includes(name)) names.push(name)
547
+ }
548
+ return names
549
+ }
550
+
551
+ /** 报错/提示里统一用 `${input:x}` 原样回显名字(名字本身不是密钥)。 */
552
+ const showInputs = (names) => names.map((name) => `\${input:${name}}`).join('、')
553
+
554
+ /** 条目会走哪条传输:只用于把不支持项说清楚,不参与归一化判定。 */
555
+ function guessTransport(raw) {
556
+ if (typeof raw.type === 'string' && raw.type.trim()) return raw.type.trim()
557
+ if (typeof raw.transport === 'string' && raw.transport.trim()) return raw.transport.trim()
558
+ if (typeof raw.command === 'string' && raw.command.trim()) return 'stdio'
559
+ const url = raw.url ?? raw.serverUrl ?? raw.server_url
560
+ return typeof url === 'string' && url.trim() ? 'streamable-http' : 'unknown'
561
+ }
562
+
563
+ /**
564
+ * 把一条 VS Code 条目里的 `${input:...}` 按位置分三类处理:
565
+ *
566
+ * - **url / command / cwd**:地址或可执行文件本身就是交互值,剔掉就没得导了,
567
+ * 所以整条标记为不支持、不进 `entries`。
568
+ * - **env / headers 的某一项**:先剔掉那一项,剩下的照常导入——少一个环境变量
569
+ * 会变成一个看得见的启动/鉴权失败,比整条消失更容易被用户理解和补救。
570
+ * - **args 的某一个元素**:同上,剔掉那个参数。
571
+ *
572
+ * 返回 `blocked` 非空表示整条不可用;否则 `raw` 是剔干净后的副本。
573
+ */
574
+ function splitVscodeInputs(name, raw) {
575
+ // 顺序固定成 url → command → cwd → args → env → headers:前面出现就整条判死,
576
+ // 后面即使也用到 input 也一并记进 names,方便用户一次看全。
577
+ const hard = []
578
+ for (const field of ['url', 'serverUrl', 'server_url', 'command', 'cwd']) {
579
+ for (const found of vscodeInputNames(raw[field])) if (!hard.includes(found)) hard.push(found)
580
+ }
581
+ if (hard.length) {
582
+ for (const field of ['args', 'env', 'headers']) {
583
+ for (const value of field === 'args' && Array.isArray(raw.args) ? raw.args : Object.values(isObject(raw[field]) ? raw[field] : {})) {
584
+ for (const found of vscodeInputNames(value)) if (!hard.includes(found)) hard.push(found)
585
+ }
586
+ }
587
+ return { blocked: hard, raw: null, warnings: [] }
588
+ }
589
+ const warnings = []
590
+ const soft = []
591
+ const clean = { ...raw }
592
+ const args = stripFromArgs(name, raw.args, soft, warnings)
593
+ if (args === undefined) delete clean.args
594
+ else clean.args = args
595
+ for (const field of ['env', 'headers']) {
596
+ // 不是对象就原样留给下游报错:这里静默删掉会把"env 写错了"变成"没有 env"。
597
+ if (!isObject(raw[field])) continue
598
+ const table = stripFromMap(name, field, raw[field], soft, warnings)
599
+ // 整个字段被剔空时删掉它,别让下游把 `env: {}` 当成"用户写了空表"。
600
+ if (table === undefined) delete clean[field]
601
+ else setOwn(clean, field, table)
602
+ }
603
+ return { blocked: [], raw: clean, warnings, names: soft }
604
+ }
605
+
606
+ /**
607
+ * args 里带 `${input:...}` 的元素剔除。剔除后可能剩一个空数组——那就把字段删掉,
608
+ * 别让下游把 `args: []` 当成"用户显式写了空参数表"。
609
+ */
610
+ function stripFromArgs(name, args, soft, warnings) {
611
+ // `args` 写成单个字符串时下游会原样当成一个参数:它带 input 就同样不可用。
612
+ if (typeof args === 'string') {
613
+ const found = vscodeInputNames(args)
614
+ if (!found.length) return args
615
+ for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
616
+ warnings.push(`${name}.args 里的 ${showInputs(found)} 需要交互取值,已跳过这个参数`)
617
+ return undefined
618
+ }
619
+ if (!Array.isArray(args)) return args
620
+ const kept = []
621
+ for (const item of args) {
622
+ const found = vscodeInputNames(item)
623
+ if (!found.length) {
624
+ kept.push(item)
625
+ continue
626
+ }
627
+ for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
628
+ warnings.push(`${name}.args 里的 ${showInputs(found)} 需要交互取值,已跳过这个参数`)
629
+ }
630
+ return kept.length || args.length === 0 ? kept : undefined
631
+ }
632
+
633
+ /** env / headers 里值带 `${input:...}` 的项剔除。整表剔空时删掉字段。 */
634
+ function stripFromMap(name, field, table, soft, warnings) {
635
+ if (!isObject(table)) return undefined
636
+ const kept = {}
637
+ let dropped = 0
638
+ for (const [key, value] of Object.entries(table)) {
639
+ const found = vscodeInputNames(value)
640
+ if (!found.length) {
641
+ setOwn(kept, key, value)
642
+ continue
643
+ }
644
+ dropped += 1
645
+ for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
646
+ warnings.push(`${name}.${field}.${key} 需要交互取值(${showInputs(found)}),已跳过`)
647
+ }
648
+ return dropped && !Object.keys(kept).length ? undefined : kept
649
+ }
650
+
530
651
  /**
531
652
  * 按来源解析文本并归一化成 DSH spec。逐条目容错:一条不支持不影响其余条目。
532
653
  * 返回的 `spec` 是 Host 侧内部值(含 `!!js` 表达式与字面密钥),**不得**直接发给浏览器。
@@ -541,33 +662,50 @@ export function normalizeSourceText(source, text, format = 'json') {
541
662
  parsed = parseSourceText(format, content)
542
663
  } catch (error) {
543
664
  const line = parseFailure(error, content)
544
- return { entries: [], warnings: [], error: `不是合法 ${PARSE_LABEL[format] || format}${line ? `(第 ${line} 行附近)` : ''}` }
665
+ return { entries: [], warnings: [], unsupportedEntries: [], error: `不是合法 ${PARSE_LABEL[format] || format}${line ? `(第 ${line} 行附近)` : ''}` }
545
666
  }
546
- if (!isObject(parsed)) return { entries: [], warnings: [], error: '配置根节点必须是对象' }
547
- // VS Code 的 ${input:...} 需要交互取值,normalizeMcpImport 会整份拒绝;
548
- // 在取段落之前就给出同样的判断,避免一条输入变量吞掉整份文件。
667
+ if (!isObject(parsed)) return { entries: [], warnings: [], unsupportedEntries: [], error: '配置根节点必须是对象' }
668
+ // OPT-17:VS Code 的 `${input:...}` 原本在这里整份拒绝,结果是一条交互变量吞掉
669
+ // 文件里其余 19 条能用的服务。改成逐条降级:位置可剔的(env/headers/args)剔掉后
670
+ // 照常导入并在 warnings 里说清楚,位置不可剔的(url/command/cwd)只把那一条记进
671
+ // unsupportedEntries,`error` 只留给"整份文件读不懂"。
549
672
  const warnings = []
550
- if (Array.isArray(parsed.inputs) && parsed.inputs.length > 0) {
551
- return { entries: [], warnings, error: 'VS Code inputs 需要交互式取值,DSH 当前不支持安全转换' }
552
- }
673
+ const unsupportedEntries = []
674
+ // 声明了 `inputs` 只说明这份文件"可能"用到交互变量,不再是拒绝理由;真正的判定
675
+ // 落在每个条目上——没被引用的 inputs 声明不该影响任何导入,也不该冒出一条噪音提示。
553
676
  const selected = source.select(parsed, warnings)
554
- if (selected === null) return { entries: [], warnings, error: '没有找到该客户端使用的 MCP 段落' }
677
+ if (selected === null) return { entries: [], warnings, unsupportedEntries, error: '没有找到该客户端使用的 MCP 段落' }
555
678
  const entries = []
556
679
  for (const item of selected) {
557
680
  const carried = item.warnings || []
558
681
  if (item.error) {
559
- entries.push({ name: item.name, spec: null, error: item.error, warnings: carried })
682
+ entries.push({ name: item.name, spec: null, error: item.error, warnings: carried, unsupported: null })
683
+ continue
684
+ }
685
+ const split = splitVscodeInputs(item.name, item.raw)
686
+ const merged = [...carried, ...split.warnings]
687
+ // url / command / cwd 本身是交互值:这条没法安全转换。不进 `entries`(下游只认
688
+ // 可导入的 spec),改在 `unsupportedEntries` 里留一份可展示信息,别让它凭空消失。
689
+ if (split.blocked.length) {
690
+ unsupportedEntries.push({
691
+ name: item.name,
692
+ transport: guessTransport(item.raw),
693
+ reason: `地址/命令里用到 ${showInputs(split.blocked)},需要交互取值,DSH 无法安全转换`,
694
+ names: split.blocked,
695
+ })
560
696
  continue
561
697
  }
562
698
  try {
563
- const normalized = normalizeMcpImport({ mcpServers: { [item.name]: item.raw } })
564
- entries.push({ name: item.name, spec: normalized.servers[0], error: null, warnings: [...carried, ...normalized.warnings] })
699
+ const normalized = normalizeMcpImport({ mcpServers: { [item.name]: split.raw } })
700
+ entries.push({ name: item.name, spec: normalized.servers[0], error: null, warnings: [...merged, ...normalized.warnings], unsupported: null })
565
701
  } catch (error) {
566
- entries.push({ name: item.name, spec: null, error: String(error?.message ?? error), warnings: carried })
702
+ entries.push({ name: item.name, spec: null, error: String(error?.message ?? error), warnings: merged, unsupported: null })
567
703
  }
568
704
  }
569
- if (!entries.length) return { entries, warnings, error: '这条配置里没有 MCP' }
570
- return { entries, warnings, error: '' }
705
+ // 全部条目都不可用时 `error` 仍然是 null:上层要能说"3 条不支持 / 0 条可导入",
706
+ // 而不是一句"失败"。只有真的一条 MCP 都没找到才落到下面的文案。
707
+ if (!entries.length && !unsupportedEntries.length) return { entries, warnings, unsupportedEntries, error: '这条配置里没有 MCP' }
708
+ return { entries, warnings, unsupportedEntries, error: '' }
571
709
  }
572
710
 
573
711
  export function hashSourceText(text) {
@@ -640,6 +778,7 @@ async function inspectImportLocation(source, location, ctx, readText) {
640
778
  error: '',
641
779
  contentHash: '',
642
780
  entries: [],
781
+ unsupportedEntries: [],
643
782
  warnings: [],
644
783
  }
645
784
  const picked = await pickCandidate(location, readText)
@@ -655,6 +794,7 @@ async function inspectImportLocation(source, location, ctx, readText) {
655
794
  row.contentHash = hashSourceText(picked.text)
656
795
  const normalized = normalizeSourceText(source, picked.text, picked.candidate.format)
657
796
  row.entries = normalized.entries
797
+ row.unsupportedEntries = normalized.unsupportedEntries
658
798
  row.warnings = normalized.warnings
659
799
  row.error = normalized.error
660
800
  return row