diff --git a/plugins/Fectivnfy112357/crossplug/LICENSE b/plugins/Fectivnfy112357/crossplug/LICENSE new file mode 100644 index 0000000..125be1b --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright 2026 MCode Plugins contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/Fectivnfy112357/crossplug/README.md b/plugins/Fectivnfy112357/crossplug/README.md new file mode 100644 index 0000000..72f5300 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/README.md @@ -0,0 +1,68 @@ +# crossplug + +Bidirectional plugin converter between **DSH** (DeepSeek Harness) and **mcode** (MiniMax Code / pi). + +## The problem + +Migrating a plugin between AI coding tools usually means rewriting it for the +target tool's plugin system. crossplug converts plugins in **both directions** +while keeping the original logic intact (runtime bridging): the source plugin +code runs unmodified inside the converted package, and every mapping decision +is recorded in `CONVERSION-REPORT.md`. + +## Commands + +The converter core lives in `core/` inside this plugin (zero npm dependencies). +Every command is fully local and writes **only** to the directory you pass via `--out`. + +```text +node core/run.js dsh2mcode [--out ] # DSH → mcode +node core/run.js mcode2dsh [--out ] [--host] # mcode → DSH +node core/run.js list --side dsh|mcode # 只读列出两侧已安装插件 +node core/run.js version # 版本 +``` + +## Try it + +Install the plugin from `/plugins` → **Local**, then use the slash command: + +```text +/convert-plugin dsh2mcode ~/.dsh/.agent-presets/my-agent +``` + +**Expected result**: an agent-plugins.org 1.0.0 package (`plugin.json` + +`skills/`) in `./out/`, ready to copy to `~/.minimax/plugins//`, plus a +`CONVERSION-REPORT.md` explaining every mapped row. + +## Requirements + +- Node.js 18+ (the converter core is zero-dependency CommonJS) +- For mcode output: MiniMax Code with Agent Plugins 1.0 support +- For DSH output: DeepSeek Harness with agent presets or host composition + +## Data, network and side effects + +- **No network access.** Conversion and listing are fully local. +- **No credentials, no telemetry, no external services, no subprocesses.** + The package never executes `npm`, `git`, or any other child process. +- **Reads**: the input path you pass, plus (only for `list`) a read-only scan + of `~/.dsh/.agent-presets`, `~/.minimax/plugins`, and `~/.pi/agent/extensions`. +- **Writes**: only inside the `--out` directory of `dsh2mcode`/`mcode2dsh` + (existing files with the same name are overwritten; other files in that + directory are left untouched). No user configuration or plugin directory + (`~/.dsh`, `~/.minimax`, `~/.pi`) is ever modified. +- **No installer is bundled.** Community-plugin policy forbids installers that + write into user homes; installation is manual: copy the converted package to + the target directory, then refresh the plugin list (`/plugins` → Ctrl+R) or + restart the tool. + +## Automated tests + +`core/run.test.mjs` runs with the repository test runner (`npm test` → `node --test`) +and covers input path boundaries, output-overwrite behavior, symlink / path- +traversal safety, loadability of generated manifests and MCP servers, and +failure behavior that never destroys pre-existing directories. + +## License + +Apache-2.0 \ No newline at end of file diff --git a/plugins/Fectivnfy112357/crossplug/core/dsh2mcode.js b/plugins/Fectivnfy112357/crossplug/core/dsh2mcode.js new file mode 100644 index 0000000..d26d88a --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/dsh2mcode.js @@ -0,0 +1,1322 @@ +// dsh2mcode.js — DSH 插件 → mcode(MiniMax Code / pi)插件包(零依赖) +// +// 两种输入: +// 1. DSH agent preset 目录(preset.yml + agent.cordis.yml [+ skills/]) +// → 生成 mcode 插件包:package.json + extension.js(pi extension)+ 报告 +// 2. DSH 插件源码文件(动态 Cordis 插件 / preset 本地插件 JS) +// → 生成 pi extension:提取 registerTool/defineTool 与 ctx.on 并转写 +// +// 转换是"脚手架 + 映射",不是完全自动等价:无法映射的能力生成带说明的桩。 + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { parsePreset } = require('./yaml.js'); +const { classifyRow } = require('./mapping.js'); +const { jsonSchemaToTypeboxCode, typeboxShimCode } = require('./schema.js'); +const { findCalls, propertyValue, jsLiteralToValue, relativeImports } = require('./extract.js'); + +const TOOL_REGEXES = [ + /harness\.defineTool/g, + /harness\.registerTool/g, + /tools\.register/g, + /defineTool/g, + /registerTool/g, +]; + +// DSH 事件 ↔ pi 事件对照表(2026-08-14 对照 dsh-plugin-spec.md §9 权威会话事件词汇修正) +// +// DSH 侧事件分两类: +// 1. ctx.on 总线事件(插件直接监听,独立于会话日志): +// system-prompt/assemble、session/event、skills/change +// 2. session/event 流的子事件(ctx.on('session/event', (session, event)), +// 按 event.type 过滤,payload 在 event.data —— KNOWN_SESSION_EVENT_TYPES): +// turn/start、turn/end、step/start、step/end、user/message、 +// assistant/message、tool/call、tool/result、todo/write、 +// agent-preset/selected、command/run、command/done、compaction/* … +// +// pi 事件名以 mcode-plugin-spec.md §5.2 为准。 +// bridgeKind:assembly = system-prompt/assemble 形态;turn-end = session/event(turn/end) 形态。 +const EVENT_MAP = { + // ── 1:1 可桥接(生成真正的 pi.on wrapper,调原 DSH handler)── + 'system-prompt/assemble': { + pi: 'before_agent_start', + bridge: true, + bridgeKind: 'assembly', + note: 'DSH host 平面每会话组装系统提示,handler(assembly, context, next) 往 assembly.sections.push({name, text});pi 的 before_agent_start 在每轮 agent 循环前触发,返回 { systemPrompt } 即注入增补(mcode 文档 §5.2 1:1)', + }, + 'session/event': { + pi: 'turn_end', + bridge: true, + bridgeKind: 'turn-end', + note: 'DSH handler(session, event) 按 event.type 过滤子事件(turn/end 驱动 auto-retain 等);pi 的 turn_end 在每轮 LLM 响应 + 工具调用完成后触发,桥接为 turn/end 子事件(mcode 文档 §5.2 1:1)', + }, + // ── 弱对应(生成 stub;语义不直通,需人工移植)── + 'tool/call': { pi: 'tool_call', note: 'DSH 是会话日志子事件(event.type === "tool/call",append-only 不可拦截);pi 的 tool_call 可 block。语义不一致,生成 stub' }, + 'tool/result': { pi: 'tool_result', note: 'DSH 是会话日志子事件(event.type === "tool/result");pi 的 tool_result 是 middleware 可改结果。语义不一致,生成 stub' }, + 'user/message': { pi: 'message_start', note: 'DSH user/message 是用户消息落库子事件;pi message_start 是消息生命周期,弱对应' }, + 'assistant/message': { pi: 'message_end', note: 'DSH assistant/message 是助手消息落库子事件;pi message_end 可替换消息,弱对应' }, + 'turn/start': { pi: 'turn_start', note: 'DSH turn/start 开轮子事件;pi turn_start 每轮开始,弱对应' }, + 'agent-preset/selected': { pi: 'model_select', note: 'DSH 会话切换 preset(仅日志);pi model_select 模型切换,弱对应' }, + 'compaction/start': { pi: 'session_before_compact', note: 'DSH 压缩开始子事件;pi 压缩前可取消/自定义 summary,概念对应' }, + 'compaction/end': { pi: 'session_compact', note: 'DSH 压缩完成子事件;pi 压缩完成,概念对应' }, + 'skills/change': { pi: 'resources_discover', note: 'DSH 技能目录变更失效通知(ctx.on 总线,emit 模式);pi resources_discover 启动/重载时贡献 skillPaths(reason: startup|reload),目录级而非变更级,弱对应' }, + // ── 旧名兼容(deprecated:DSH 侧不存在这些事件,仅避免旧插件转换产物误报)── + 'tools/pre-execute': { pi: 'tool_call', note: '[deprecated 事件名] DSH 真实事件是 tool/call(session/event 子事件,append-only);请源插件改用真实事件名' }, + 'tools/result': { pi: 'tool_result', note: '[deprecated 事件名] DSH 真实事件是 tool/result;请源插件改用真实事件名' }, + 'message/send': { pi: 'message_start', note: '[deprecated 事件名] DSH 真实事件是 user/message / assistant/message;请源插件改用真实事件名' }, +}; + + +// 从源文件提取插件 name 字段(兼容 export default { name: 'x' } 和 const name = 'x') +function extractPluginName(source) { + const re1 = /export\s+default\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/; + const re2 = /(?:^|\n)\s*(?:const|let|var)\s+name\s*=\s*['"]([^'"]+)['"]/; + const m1 = re1.exec(source); + if (m1) return m1[1]; + const m2 = re2.exec(source); + if (m2) return m2[1]; + return null; +} + +// 从源文件提取 inject 字段 +function extractInject(source) { + const m = /inject\s*:\s*\[([^\]]*)\]/.exec(source); + if (!m) return null; + return m[1].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean); +} + +// 把 DSH 非标准 schema 规范化成标准 JSON Schema +// DSH: { query: { type: 'string', required: true } } +// JSON Schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } +function normalizeDshSchema(text) { + if (!text || typeof text !== 'string') return null; + let obj; + try { obj = jsLiteralToValue(text); } + catch { try { obj = JSON.parse(text); } catch { return null; } } + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; + // 已经是标准 JSON Schema(有 type 字段) + if (obj.type) return obj; + // DSH 形态:每个属性自己带 type + const props = {}; + const required = []; + for (const [k, v] of Object.entries(obj)) { + if (v && typeof v === 'object' && 'type' in v) { + const { required: isReq, ...rest } = v; + props[k] = rest; + if (isReq === true) required.push(k); + } else { + props[k] = v; + } + } + return { type: 'object', properties: props, ...(required.length ? { required } : {}) }; +} + +function slugify(id) { + return String(id || 'plugin') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || 'plugin'; +} + +// agent-plugins.org 插件名规则:/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/ +function makePluginName(humanName, fallbackId) { + const ascii = String(humanName || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/--+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + let name = ascii || slugify(fallbackId); + if (!name) name = 'dsh-plugin'; + name = name.replace(/--+/g, '-').replace(/\.\.+/g, '.').replace(/[.-]$/g, ''); + if (!/^[a-z0-9]/.test(name)) name = 'd-' + name; + return name; +} + +// skill 目录名规则:/^(?!.*--)[a-z0-9]+(?:-[a-z0-9]+)*$/ +function safeSkillName(name) { + let n = String(name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/--+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!n) n = 'skill'; + return n; +} + +function writeFileSafe(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content, 'utf8'); +} + +function copyDir(src, dst) { + if (!fs.existsSync(src)) return; + fs.mkdirSync(dst, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dst, entry.name); + if (entry.isDirectory()) copyDir(s, d); + else if (entry.isFile()) fs.copyFileSync(s, d); + } +} + +// ═══ 模式一:DSH agent preset → mcode 插件包 ═══ + +function convertPreset(srcDir, outDir) { + const presetPath = path.join(srcDir, 'preset.yml'); + const compositionPath = path.join(srcDir, 'agent.cordis.yml'); + if (!fs.existsSync(presetPath)) throw new Error(`preset.yml 不存在: ${presetPath}`); + if (!fs.existsSync(compositionPath)) throw new Error(`agent.cordis.yml 不存在: ${compositionPath}`); + + const preset = parsePreset( + fs.readFileSync(presetPath, 'utf8'), + fs.readFileSync(compositionPath, 'utf8'), + { presetPath, compositionPath }, + ); + + const id = slugify(preset.name || path.basename(srcDir)); + const pkgDir = outDir; + const rows = preset.rows || []; + const report = []; + const srcSkills = path.join(srcDir, 'skills'); + + // ── 分析行 → 能力 ── + let personaText = ''; + const stubs = []; // { kind: 'tool'|'command', id, name, note, pi } + const notes = []; + + for (const row of rows) { + const mapping = classifyRow(row); + const display = `${row.id} (${row.name})`; + if (mapping.kind === 'persona') { + // 从 config.text 取 persona(支持嵌套 configRows 里找 text) + let text = ''; + if (typeof row.config === 'string') text = row.config; + else if (row.config && typeof row.config === 'object') { + if (row.config.text) text = row.config.text; + if (row.config.__rows) { + for (const sub of row.config.__rows) { + if (sub.text && typeof sub.text === 'string') text += sub.text + '\n'; + } + } + } + personaText = text || ''; + notes.push(`persona(${display}):文本已写入 /persona 命令,${mapping.note}`); + } else if (mapping.kind === 'tool') { + stubs.push({ kind: 'tool', id: slugify(row.id), label: row.id, name: row.name, pi: mapping.pi, note: mapping.note }); + report.push(`[tool] ${display} → ${mapping.pi}`); + } else if (mapping.kind === 'command') { + stubs.push({ kind: 'command', id: slugify(row.id), label: row.id, name: row.name, pi: mapping.pi, note: mapping.note }); + report.push(`[command] ${display} → ${mapping.pi}`); + } else if (mapping.kind === 'warn') { + notes.push(`⚠ ${display}:${mapping.pi}`); + report.push(`[warn] ${display} → ${mapping.pi}`); + } else { + notes.push(`· ${display}:${mapping.pi}`); + report.push(`[note] ${display} → ${mapping.pi}`); + } + } + + // ═══ 生成 AGENT_PLUGINS_V1 插件包(agent-plugins.org 1.0.0)═══ + // MCode(MiniMax Code)本地插件格式:根 plugin.json + skills/ + 可选 mcp.json, + // 安装到 ~/.minimax/plugins// 后出现在 /plugins 的 Local 标签页。 + const pluginName = makePluginName(preset.name, path.basename(srcDir)); + const manifest = { + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: pluginName, + version: '0.1.0', + description: (preset.description || `由 DSH agent preset「${preset.name || path.basename(srcDir)}」转换生成`).slice(0, 500), + author: { name: 'crossplug' }, + keywords: ['dsh', 'converted', 'bridge'], + extensions: { + dshToMcode: { + source: preset.meta.presetPath || srcDir, + sourceKind: 'dsh-agent-preset', + generatedAt: new Date().toISOString(), + }, + }, + }; + writeFileSafe(path.join(pkgDir, 'plugin.json'), JSON.stringify(manifest, null, 2) + '\n'); + + // ── skills/ ── + // 1) persona → skills/persona/SKILL.md(MCode 插件通过 skill 向 agent 提供知识) + if (personaText) { + writeFileSafe(path.join(pkgDir, 'skills', 'persona', 'SKILL.md'), [ + '---', + 'name: persona', + 'description: 来源 DSH agent preset「' + (preset.name || id) + '」的 persona 文本(转换参考)。', + '---', + '', + '这是由 DSH agent preset 转换的插件。以下为来源 preset 的 persona(系统提示)原文:', + '', + '```text', + personaText, + '```', + '', + ].join('\n') + '\n'); + } + // 2) preset 自带 skills/ 复制(目录名按 agent-plugins 规则规范化) + if (fs.existsSync(srcSkills)) { + for (const entry of fs.readdirSync(srcSkills, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const safe = safeSkillName(entry.name); + copyDir(path.join(srcSkills, entry.name), path.join(pkgDir, 'skills', safe)); + if (safe !== entry.name) { + notes.push(`skill 目录「${entry.name}」重命名为 ${safe}(agent-plugins 名字规则)`); + } + } + } + // 3) 转换说明 skill + writeFileSafe(path.join(pkgDir, 'skills', 'conversion-report', 'SKILL.md'), [ + '---', + 'name: conversion-report', + 'description: 本插件由 DSH agent preset 转换而来(脚手架+映射)。说明来源行与映射结论。', + '---', + '', + '本插件由 crossplug 从 DSH agent preset 转换生成。', + '插件能力承载方式为 skills(+可选的 mcp.json)。DSH preset 中的工具行无法', + '直接映射为 MCode 插件工具(MCode 插件体系为 skills/MCP/apps),逐行映射见', + '包内 CONVERSION-REPORT.md。来源 preset 的 persona 在 skills/persona/。', + '', + ].join('\n') + '\n'); + + // ── 报告 / README ── + writeFileSafe(path.join(pkgDir, 'CONVERSION-REPORT.md'), renderReport(preset, srcDir, report, personaText, stubs, notes)); + writeFileSafe(path.join(pkgDir, 'README.md'), [ + `# ${pluginName}`, + '', + `由 DSH agent preset「${preset.name || id}」转换生成(crossplug),agent-plugins.org 1.0.0 格式。`, + '', + '## 安装到 MiniMax Code(mcode)', + '', + '1. 把本目录复制/安装到 `~/.minimax/plugins//`(即本目录名)。', + '2. 在 MiniMax Code 中打开 `/plugins`,切到 Local 标签页,找到本插件,按 Enter 安装。', + '3. 安装后对话中可调用其 skills(persona / conversion-report / 来源 preset 的技能)。', + '', + '## 转换说明', + '', + '这是"脚手架 + 映射"结果:MCode 插件承载 skills/MCP/apps;DSH 的工具行无法', + '自动移植为插件工具。逐行映射结论见 `CONVERSION-REPORT.md`。', + '', + ].join('\n') + '\n'); + + return { + kind: 'preset', + source: srcDir, + presetName: preset.name || id, + out: pkgDir, + packageName: pluginName, + rows: rows.length, + tools: stubs.filter((s) => s.kind === 'tool').length, + commands: stubs.filter((s) => s.kind === 'command').length, + notes: notes.length, + reportFile: path.join(pkgDir, 'CONVERSION-REPORT.md'), + }; +} + +function renderReport(preset, srcDir, report, personaText, stubs, notes) { + const lines = []; + lines.push(`# 转换报告:DSH preset → mcode 插件包`); + lines.push(''); + lines.push(`- 来源: ${preset.meta.presetPath || srcDir}`); + lines.push(`- 名称: ${preset.name || '-'}`); + lines.push(`- 描述: ${preset.description || '-'}`); + lines.push(`- 生成时间: ${new Date().toISOString()}`); + lines.push(''); + lines.push('## 行映射'); + lines.push(''); + lines.push('| DSH 行 | 转换结果 |'); + lines.push('| --- | --- |'); + for (const r of report) lines.push(`| ${r.split(' → ')[0].replace(/\|/g, '\\|')} | ${(r.split(' → ').slice(1).join(' → ') || '').replace(/\|/g, '\\|')} |`); + lines.push(''); + lines.push('## 桩工具 / 命令'); + lines.push(''); + for (const s of stubs) lines.push(`- \`${s.kind}\` \`${s.id}\`(来源 ${s.name}):${s.pi}${s.note ? ' —— ' + s.note : ''}`); + if (!stubs.length) lines.push('(无)'); + lines.push(''); + lines.push('## 说明项'); + lines.push(''); + for (const n of notes) lines.push(`- ${n}`); + if (!notes.length) lines.push('(无)'); + lines.push(''); + if (personaText) { + lines.push('## persona 文本'); + lines.push(''); + lines.push('```text'); + lines.push(personaText); + lines.push('```'); + } + return lines.join('\n') + '\n'; +} + +// ═══ 模式二:DSH 插件源码 → pi extension ═══ + +function convertPluginSource(srcFile, outDir, opts = {}) { + const source = fs.readFileSync(srcFile, 'utf8'); + const base = path.basename(srcFile, path.extname(srcFile)); + // 源模块类型:ESM(export/import)→ .js;CommonJS(module.exports/require)→ .cjs。 + // 生成的 package.json 是 type:module,.cjs 扩展名强制 CommonJS 解析,避免把 CJS 源误当 ESM。 + const sourceIsEsm = /^\s*(import\s|export\s)/m.test(source); + const vendorFile = sourceIsEsm ? 'dsh-plugin.js' : 'dsh-plugin.cjs'; + const tools = []; + const events = []; + const lostCapabilities = []; + const warnings = []; + + // ── 提取源插件的 name / inject(用于命名 plugin + 报告)── + const sourcePluginName = extractPluginName(source); + const sourceInject = extractInject(source); + if (sourceInject && sourceInject.length) { + warnings.push(`源插件声明 inject: [${sourceInject.join(', ')}];本次只模拟 ${JSON.stringify(sourceInject.includes('tools') ? ['tools'] : sourceInject)},其他服务(agents / effect / on)调用将不工作`); + } + + // ── 提取工具注册 ── + // harness.defineTool / harness.registerTool / tools.register / defineTool / registerTool + const callRe = /(?:harness\.defineTool|harness\.registerTool|tools\.register|defineTool|registerTool)/g; + const calls = findCalls(source, callRe).filter((c, i, arr) => { + // 去重:跳过被更长模式(如 harness.defineTool 内的 defineTool)覆盖的匹配 + return i === 0 || c.start >= arr[i - 1].end; + }); + for (const call of calls) { + let args = call.argsText; + // harness.registerTool(ctx, {...}) 之类的多参数调用:取第一个顶层 '{' 之后的部分 + if (!args.trim().startsWith('{')) { + const braceIdx = firstTopLevelBrace(args); + if (braceIdx >= 0) args = args.slice(braceIdx); + } + if (!args.trim().startsWith('{')) { + warnings.push(`跳过非对象字面量的注册调用: ${args.slice(0, 60)}`); + continue; + } + const name = propertyValue(args, 'name', { topLevel: true }); + const description = propertyValue(args, 'description', { topLevel: true }); + const parameters = propertyValue(args, 'parameters', { topLevel: true }); + if (!name || name.kind !== 'string') { + warnings.push(`跳过缺少 name 的注册调用(可能 name 是变量)`); + continue; + } + // 工具元数据保留下来(name/description/parameters); + // execute 不再字符串内联——改为 vendor 加载 + 模拟 ctx(见 mcp-server.js / extension.js 生成处)。 + tools.push({ + name: name.value, + description: description && description.kind === 'string' ? description.value : '', + parametersText: parameters && parameters.kind === 'code' ? parameters.value : '{}', + }); + } + + // 2) ctx.on('event', ...) 事件 — 用独立 regex 抽取事件名(不走 findCalls/matchParen, + // 因为含 lambda body 的 ctx.on 的 args 含 regex literal + '\u200b' 字符串,matchParen 词法分析扛不住) + const EVENT_NAME_RE = /(?:ctx|host|agent|harness|engine)\.on\s*\(\s*(['"])([^'"]+)\1/g; + for (const m of source.matchAll(EVENT_NAME_RE)) { + const eventName = m[2]; + if (!eventName) continue; + if (events.find((e) => e.event === eventName)) continue; // 去重 + const mapping = EVENT_MAP[eventName]; + events.push({ event: eventName }); + if (!mapping) { + lostCapabilities.push(`ctx.on('${eventName}', ...):mcode 无对应事件,自动触发该能力在转换后丢失(请改手动调用工具或保留 DSH 侧挂载)`); + warnings.push(`事件 ${eventName} 无 mcode 对应,转换后该能力丢失`); + } else if (mapping.bridge) { + // 可桥接:会在 extension.js 里生成真正的 pi.on 包装,不算丢失 + warnings.push(`事件 ${eventName} → ${mapping.pi}(${mapping.note})—— ⚠️ 桥接仅 pi/DSH 运行时有效;mcode CLI 不加载 pi extension,事件无消费者(2026-08-15 实证)`); + } else { + warnings.push(`事件 ${eventName} → ${mapping.pi}(${mapping.note};当前生成 stub,需人工移植业务逻辑)`); + } + } + + if (tools.length === 0 && events.length === 0) { + warnings.push('未识别到任何 tools.register / registerTool / ctx.on 调用,请人工检查源插件形态'); + } + + // ── 若提取到工具:同时生成 agent-plugins MCP 包(MCode /plugins 可直接安装)── + let mcpPluginName; + let mcpToolCount = 0; + if (tools.length > 0) { + // 优先用源 plugin 自带的 name 字段,其次用源文件名(去 dsh- 前缀) + mcpPluginName = makePluginName(sourcePluginName || base.replace(/^dsh-/, '') || base, base); + const serverName = makeMcpServerId(mcpPluginName) || 'tools'; + writeFileSafe(path.join(outDir, 'plugin.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: mcpPluginName, + version: '0.1.0', + description: `由 DSH 插件「${sourcePluginName || base}」转换:提供 ${tools.length} 个 MCP 工具(${tools.map((t) => t.name).join('、')})。`, + author: { name: 'crossplug' }, + keywords: ['dsh', 'converted', 'mcp'], + extensions: { crossplug: { source: srcFile, sourceKind: 'dsh-plugin-tools', sourceName: sourcePluginName || undefined, generatedAt: new Date().toISOString() } }, + }, null, 2) + '\n'); + writeFileSafe(path.join(outDir, 'mcp.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', + mcpServers: { + [serverName]: { type: 'stdio', command: 'node', args: ['./mcp-server.js'] }, + }, + }, null, 2) + '\n'); + // vendor/${vendorFile} 已经在下面 copyFileSync 写过了;mcp-server.js 用 import + 模拟 ctx 加载它 + writeFileSafe(path.join(outDir, 'mcp-server.js'), renderToolsMcpServer(tools, serverName, sourcePluginName || base, vendorFile)); + mcpToolCount = tools.length; + } + + // ── 生成 extension.js ── + // 关键变化:tools 不再"内联 execute 字符串",而是 vendor 加载原插件 + 模拟 ctx,让 apply() 内部注册的工具被收集,然后逐个 pi.registerTool 派发。 + // 闭包(cfg / recall / formatRecall 等)保留在原 apply 作用域里,工具行为真实。 + const lines = []; + lines.push('// 由 DSH 插件源码转换生成 — 源: ' + srcFile); + lines.push('// 生成时间: ' + new Date().toISOString()); + lines.push('// 转换器: crossplug (core/dsh2mcode.js, plugin-source 模式)'); + lines.push('//'); + lines.push(`// 工具执行走 vendor/${vendorFile} 加载 + 模拟 ctx 路径,`); + lines.push('// 闭包(cfg / recall / formatRecall 等)保留在原 apply() 作用域里,行为真实。'); + lines.push(''); + lines.push(`const dshPlugin = await import("./vendor/${vendorFile}");`); + lines.push('import { Type } from "./typebox-shim.js";'); + lines.push(''); + lines.push('// DSH 工具注册的元数据缓存(mockCtx.tools.register 时写入)'); + // 镜像块:与 renderToolsMcpServer 模板中的 mockTools/mockCtx 保持一致(改动需两侧同步) + lines.push('const mockTools = {'); + lines.push(' registered: new Map(),'); + lines.push(' register(spec) {'); + lines.push(' this.registered.set(spec.name, spec);'); + lines.push(' return () => this.registered.delete(spec.name);'); + lines.push(' },'); + lines.push('};'); + lines.push(''); + lines.push('// 模拟 DSH ctx:tools 真注册;on() 记录 handler(不丢弃),供 before_agent_start 等桥接 wrapper 调'); + lines.push('const mockCtx = {'); + lines.push(' get(name) {'); + lines.push(' if (name === "tools") return mockTools;'); + lines.push(' if (name === "agents") return { currentInitiator: () => null };'); + lines.push(' return undefined;'); + lines.push(' },'); + lines.push(' _listeners: {},'); + lines.push(' on(event, handler) {'); + lines.push(' (this._listeners[event] || (this._listeners[event] = [])).push(handler);'); + lines.push(' },'); + lines.push(' effect(fn) { try { fn(); } catch {} },'); + lines.push('}'); + lines.push(''); + lines.push('// 触发原 DSH 插件 apply,populate mockTools'); + lines.push('try {'); + lines.push(' // ESM 命名空间对象:真实默认导出在 .default;要兼容:default 是函数 / default 是 { apply } / 直接是函数 / 直接是 { apply }'); + lines.push(' const target = (dshPlugin && dshPlugin.default) ? dshPlugin.default : dshPlugin;'); + lines.push(' if (typeof target === "function") target(mockCtx, {});'); + lines.push(' else if (target && typeof target.apply === "function") target.apply(mockCtx, {});'); + lines.push(' else throw new Error("vendor 没有 default 导出且无 apply 方法(既不是函数也不是带 apply 的对象)");'); + lines.push('} catch (err) {'); + lines.push(' console.error("[crossplug] vendor apply 抛错: " + (err && err.message ? err.message : err));'); + lines.push('}'); + lines.push(''); + lines.push('export default function (pi) {'); + lines.push(''); + + for (const t of tools) { + let typeboxCode = 'Type.Any()'; + let schemaNote = ''; + const norm = normalizeDshSchema(t.parametersText); + if (norm) { + try { + typeboxCode = jsonSchemaToTypeboxCode(norm); + } catch { + schemaNote = ' // 参数 schema 规范化后仍无法生成 typebox,已用 Type.Any() 兜底'; + } + } else { + schemaNote = ' // 参数 schema 无法解析为 JS 字面量/JSON,已用 Type.Any() 兜底'; + } + lines.push(` // ── 工具 ${t.name} ──`); + if (schemaNote) lines.push(schemaNote); + lines.push(` pi.registerTool({`); + lines.push(` name: ${JSON.stringify(t.name.replace(/[^A-Za-z0-9_]/g, '_'))},`); + lines.push(` label: ${JSON.stringify(t.name)},`); + lines.push(` description: ${JSON.stringify(t.description || '(无描述)')},`); + lines.push(` parameters: ${typeboxCode},`); + lines.push(` async execute(toolCallId, params, _signal, _onUpdate, _ctx) {`); + lines.push(` // 从 mockTools 拿原 DSH 工具 spec(vendor apply 已注册),派发到原 execute(args)`); + lines.push(` const spec = mockTools.registered.get(${JSON.stringify(t.name)});`); + lines.push(` if (!spec || typeof spec.execute !== "function") {`); + lines.push(` return { content: [{ type: "text", text: ${JSON.stringify('[crossplug] 原工具 ' + t.name + ' 在 vendor 加载阶段未注册到 tools')} }], details: {} };`); + lines.push(` }`); + lines.push(` try {`); + lines.push(` // 第二参 exec 上下文(callId/signal)传给原工具,保持 DSH 工具契约`); + lines.push(` const raw = await spec.execute(params, { callId: toolCallId, signal: _signal });`); + lines.push(` // 适配 DSH 的 output.render(如有)或直接走 text 通道`); + lines.push(` if (spec.output && typeof spec.output.render === "function") {`); + lines.push(` const rendered = spec.output.render(params, raw);`); + lines.push(` if (Array.isArray(rendered)) return { content: rendered, details: { raw } };`); + lines.push(` }`); + lines.push(` if (raw && typeof raw === "object" && Array.isArray(raw.content)) return { content: raw.content, details: raw.details || {} };`); + lines.push(` const text = raw && typeof raw === "object" && typeof raw.text === "string" ? raw.text : (typeof raw === "string" ? raw : JSON.stringify(raw, null, 2));`); + lines.push(` return { content: [{ type: "text", text }], details: { raw } };`); + lines.push(` } catch (err) {`); + lines.push(` return { content: [{ type: "text", text: ${JSON.stringify('[crossplug] ' + t.name + ' 执行失败: ') + ' + (err && err.message ? err.message : String(err))'} }], details: {} };`); + lines.push(` }`); + lines.push(` },`); + lines.push(` });`); + lines.push(''); + } + + for (const ev of events) { + const mapped = EVENT_MAP[ev.event]; + if (mapped && mapped.bridge) { + if (mapped.bridgeKind === 'turn-end') { + // 桥接 pi turn_end → DSH session/event(turn/end 子事件)。 + // DSH handler 签名 (session, event),按 event.type 过滤;payload 在 event.data。 + // 注:mockSession 是占位(无真实消息),deriveMessages 返回空 —— 需要完整消息的 + // handler 需人工适配(见 doc/event-mapping-questions.md)。 + lines.push(` // ── 事件 ${ev.event}(turn/end) → pi.${mapped.pi}(桥接:把 pi turn_end 转成 DSH turn/end 子事件)──`); + lines.push(` pi.on(${JSON.stringify(mapped.pi)}, async (event, _ctx) => {`); + lines.push(` const handlers = (mockCtx._listeners && mockCtx._listeners[${JSON.stringify(ev.event)}]) || [];`); + lines.push(` if (!handlers.length) return;`); + lines.push(` // DSH session/event handler 收到 (session, event),按 event.type === "turn/end" 过滤`); + lines.push(` // Q2 答复:pi turn_end 事件含 message(AgentMessage),传给 DSH handler 的 session.deriveMessages()`); + lines.push(` const mockSession = { id: "bridged", deriveMessages: () => {`); + lines.push(` const m = event && event.message;`); + lines.push(` if (m === undefined || m === null) return [];`); + lines.push(` if (Array.isArray(m)) return m;`); + lines.push(` return [typeof m === "string" ? { role: "assistant", content: [{ type: "text", text: m }] } : m];`); + lines.push(` } };`); + lines.push(` // Q2 答复:pi turn_end 事件含 turnIndex(轮次序号),传给 DSH turn/end 的 data.turn`); + lines.push(` const dshEvent = { type: "turn/end", seq: 0, time: Date.now(), data: { turn: (event && event.turnIndex !== undefined) ? event.turnIndex : 0, reason: { kind: "completed" } } };`); + lines.push(` for (const handler of handlers) {`); + lines.push(` try {`); + lines.push(` await handler(mockSession, dshEvent);`); + lines.push(` } catch (err) {`); + lines.push(` console.error("[crossplug] " + ${JSON.stringify(ev.event)} + " handler 抛错: " + (err && err.message ? err.message : err));`); + lines.push(` }`); + lines.push(` }`); + lines.push(` });`); + lines.push(''); + } else { + // bridgeKind === 'assembly':桥接 DSH (assembly, context, next) → pi before_agent_start。 + // DSH handler 往 assembly.sections.push({name, text});pi 返回 { systemPrompt } 注入增补。 + lines.push(` // ── 事件 ${ev.event} → pi.${mapped.pi}(桥接:调原 DSH handler)──`); + lines.push(` pi.on(${JSON.stringify(mapped.pi)}, async (event, _ctx) => {`); + lines.push(` const handlers = (mockCtx._listeners && mockCtx._listeners[${JSON.stringify(ev.event)}]) || [];`); + lines.push(` if (!handlers.length) return;`); + lines.push(` // 模拟 DSH 的 assembly:原 handler 会往 assembly.sections.push({name, text})`); + lines.push(` const assembly = { sections: [] };`); + lines.push(` const fakeContext = { scope: { _marker: true } };`); + lines.push(` for (const handler of handlers) {`); + lines.push(` try {`); + lines.push(` await handler(assembly, fakeContext, () => {});`); + lines.push(` } catch (err) {`); + lines.push(` console.error("[crossplug] " + ${JSON.stringify(ev.event)} + " handler 抛错: " + (err && err.message ? err.message : err));`); + lines.push(` }`); + lines.push(` }`); + lines.push(` if (!assembly.sections.length) return;`); + lines.push(` // 把 DSH 注入的 sections 合并成 system prompt 增补`); + lines.push(` const injected = assembly.sections.map((s) => s.text || "").join("\\n\\n");`); + lines.push(` const base = (event && typeof event.systemPrompt === "string") ? event.systemPrompt : "";`); + lines.push(` return { systemPrompt: base + (base ? "\\n\\n" : "") + injected };`); + lines.push(` });`); + lines.push(''); + } + } else if (mapped) { + // 有映射但不可桥接:保留 stub + lines.push(` // ── 事件 ${ev.event} → pi.${mapped.pi}(${mapped.note};语义不直通,需人工移植)──`); + lines.push(` pi.on(${JSON.stringify(mapped.pi)}, async (_event, _ctx) => {`); + lines.push(` // 原 DSH 处理逻辑需人工移植(事件语义不 1:1)`); + lines.push(` });`); + lines.push(''); + } else { + lines.push(` // ⚠ 事件 ${ev.event} 无 mcode/pi 对应,已跳过(能力在转换后丢失)`); + lines.push(''); + } + } + + if (!tools.length && !events.length) { + lines.push(` // ⚠ 未在源码中识别到 tools.register 或 ctx.on 调用。`); + lines.push(` // 原插件源码完整保留在 vendor/ 目录,请人工移植。`); + lines.push(''); + } + + lines.push('}'); + lines.push(''); + + const pkgName = `dsh-plugin-${slugify(sourcePluginName || base)}-bridge`; + writeFileSafe(path.join(outDir, 'extension.js'), lines.join('\n')); + writeFileSafe(path.join(outDir, 'typebox-shim.js'), typeboxShimCode()); + writeFileSafe(path.join(outDir, 'package.json'), JSON.stringify({ + name: pkgName, + version: '0.1.0', + description: `由 DSH 插件源码 ${path.basename(srcFile)} 转换生成的 pi extension`, + type: 'module', + main: 'extension.js', + dependencies: { typebox: '*' }, + dshToMcode: { source: srcFile, sourceKind: 'dsh-plugin-source', generatedAt: new Date().toISOString() }, + }, null, 2) + '\n'); + // 保留原源码作 vendor 加载源 + fs.mkdirSync(path.join(outDir, 'vendor'), { recursive: true }); + // vendor 固定名(dsh-plugin.js=ESM / dsh-plugin.cjs=CJS):.cjs 强制 CommonJS,规避本包 type:module 把 CJS 源误当 ESM + fs.copyFileSync(srcFile, path.join(outDir, 'vendor', vendorFile)); + // 复制源插件相对导入的本地依赖(./helper.js 等),让多文件插件可加载 + copyRelativeDeps(path.dirname(srcFile), path.join(outDir, 'vendor'), source, vendorFile, warnings); + writeFileSafe(path.join(outDir, 'CONVERSION-REPORT.md'), [ + `# 转换报告:DSH 插件源码 → mcode 插件包`, + '', + `- 来源: ${srcFile}`, + `- 源 plugin name: ${sourcePluginName || '(未识别,使用文件名 ' + base + ')'}`, + `- 生成时间: ${new Date().toISOString()}`, + '', + `## 工具(${tools.length})`, + '', + ...tools.map((t) => `- \`${t.name}\`(execute 走 vendor 模拟 ctx 调用,闭包保留)${t.description ? ':' + t.description : ''}`), + '', + `## 事件(${events.length})`, + '', + ...events.map((e) => `- \`${e.event}\`${EVENT_MAP[e.event] ? ' → pi.' + EVENT_MAP[e.event].pi : ' → ❌ 丢失(无 mcode/pi 对应)'}${e.event === 'session/event' ? '(仅 turn/end 子事件桥接;turn/start、tool/call、tool/result、user/message、assistant/message 等子事件不桥接)' : ''}`), + '', + `## 丢失能力`, + '', + ...(lostCapabilities.length + ? lostCapabilities.map((c) => `- ${c}`) + : ['(无)']), + '', + `## 产物`, + '', + ...(mcpPluginName + ? [`- agent-plugins MCP 包:\`plugin.json\` + \`mcp.json\` + \`mcp-server.js\`(${mcpToolCount} 个 MCP 工具)→ 复制到 ~/.minimax/plugins/${mcpPluginName}/,MCode /plugins → Local 安装`] + : []), + [`- vendor:原 DSH 插件源码 \`vendor/${vendorFile}\`(被 mcp-server.js / extension.js 加载)`], + [`- pi extension:\`extension.js\` + \`typebox-shim.js\` —— ⚠️ 仅独立 pi CLI 生效;mcode CLI 不加载 pi extension(2026-08-15 实证,mcode-plugin-spec.md §1.1);mcode 侧生效载体是上面的 agent-plugins MCP 包`], + '', + `## 警告`, + '', + ...(warnings.length ? warnings.map((w) => `- ${w}`) : ['(无)']), + '', + ].join('\n') + '\n'); + + return { + kind: 'plugin-source', + source: srcFile, + out: outDir, + packageName: pkgName, + mcpPluginName: mcpPluginName || undefined, + mcpTools: mcpToolCount, + tools: tools.length, + events: events.length, + warnings: warnings.length, + reportFile: path.join(outDir, 'CONVERSION-REPORT.md'), + }; +} + +// 工具注册型插件 → MCP stdio 服务器(newline JSON-RPC) +// vendor 加载 + 模拟 ctx:原 DSH 插件的 execute 闭包完整保留(cfg / recall / formatRecall 等不变) +function renderToolsMcpServer(tools, serverName, sourceBase, vendorFile) { + // MCP 广播名(sanitized)→ vendor 注册的原始工具名:MCP 客户端按广播名调用, + // 派发时必须还原原始名查 mockTools.registered(原名可含 - 等 MCP 广播不允许的字符)。 + const nameMap = {}; + const toolDefs = tools.map((t) => { + let inputSchema = { type: 'object', properties: {} }; + const norm = normalizeDshSchema(t.parametersText); + if (norm && typeof norm === 'object') inputSchema = norm; + const safeName = String(t.name).replace(/[^A-Za-z0-9_]/g, '_'); + nameMap[safeName] = t.name; + return { + name: safeName, + description: t.description || `由 DSH 插件「${sourceBase}」转换的工具(crossplug 桥接)`, + inputSchema, + }; + }); + + // 不再内联 execute 字符串;用 mockTools.registered 派发到 vendor 加载的原 spec + return `#!/usr/bin/env node +// 由 crossplug 生成:把 DSH 插件(${sourceBase})的工具注册桥接为 MCP stdio 服务器。 +// 协议:newline-delimited JSON-RPC 2.0(initialize / tools/list / tools/call / ping / notifications/*)。 +// 执行路径:vendor/${vendorFile} → mockCtx.tools.register → mockTools.registered → 本文件派发。 +// 闭包(cfg / recall / formatRecall 等)保留在原 apply() 作用域里。 +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; +import { dirname, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// 加载原 DSH 插件(按源模块类型:ESM=.js / CJS=.cjs) +const dshPlugin = await import(pathToFileURL(resolvePath(__dirname, "./vendor/${vendorFile}")).href); + +// 镜像块:与 convertPluginSource 生成的 extension.js 中的 mockTools/mockCtx 保持一致(改动需两侧同步) +// DSH 工具注册表(apply() 触发后填充) +const mockTools = { + registered: new Map(), + register(spec) { + this.registered.set(spec.name, spec); + return () => this.registered.delete(spec.name); + }, +}; + +// 模拟 DSH ctx:tools 真注册;on() 记录 handler(不丢弃),供桥接 wrapper 调用 +const mockCtx = { + get(name) { + if (name === "tools") return mockTools; + if (name === "agents") return { currentInitiator: () => null }; + return undefined; + }, + _listeners: {}, + on(event, handler) { + (this._listeners[event] || (this._listeners[event] = [])).push(handler); + }, + effect(fn) { try { fn(); } catch {} }, +}; + +// 触发原 DSH 插件 apply +// ESM 命名空间对象:真实默认导出在 .default;要兼容:default 是函数 / default 是 { apply } / 直接是函数 / 直接是 { apply } +let applyPromise; +try { + const target = (dshPlugin && dshPlugin.default) ? dshPlugin.default : dshPlugin; + if (typeof target === "function") applyPromise = target(mockCtx, {}); + else if (target && typeof target.apply === "function") applyPromise = target.apply(mockCtx, {}); + else throw new Error("vendor 没有 default 导出且无 apply 方法(既不是函数也不是带 apply 的对象)"); +} catch (err) { + process.stderr.write("[crossplug] vendor apply 抛错: " + (err && err.message ? err.message : err) + "\\n"); +} +// 等待异步 apply(如 async 初始化)完成后再派发;超时兜底避免永久挂起 +const ready = Promise.resolve(applyPromise).catch(() => {}); +async function waitReady() { + let timer; + const timeout = new Promise((r) => { + timer = setTimeout(r, 10000); + timer.unref(); // 超时兜底不阻止进程退出(stdin EOF 后进程可立刻退出,不留孤儿) + }); + try { + await Promise.race([ready, timeout]); + } finally { + clearTimeout(timer); + } +} + +const TOOLS = ${JSON.stringify(toolDefs, null, 2)}; + +// 广播名 → 原始注册名(名字含 - 等字符的工具经此还原) +const NAME_MAP = ${JSON.stringify(nameMap, null, 2)}; + +function respond(id, result) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n"); +} +function respondError(id, code, message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\\n"); +} + +const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); +rl.on("line", async (line) => { + if (!line.trim()) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + const { id, method, params } = msg; + try { + if (method === "initialize") { + respond(id, { + protocolVersion: (params && params.protocolVersion) || "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "crossplug-${serverName}", version: "1.0.0" }, + }); + } else if (method === "notifications/initialized" || method === "notifications/cancelled") { + // 通知类:无需回复 + } else if (method === "ping") { + respond(id, {}); + } else if (method === "tools/list") { + respond(id, { tools: TOOLS }); + } else if (method === "tools/call") { + await waitReady(); // 等 vendor apply(可能 async)完成注册 + const name = params && params.name; + const args = (params && params.arguments) || {}; + const originalName = NAME_MAP[name] || name; + const spec = mockTools.registered.get(originalName); + if (!spec || typeof spec.execute !== "function") { + return respondError(id, -32602, "unknown tool or tool not registered by vendor: " + name); + } + try { + // 第二参 exec 上下文:MCP 无 callId/signal,传 undefined 保持 DSH 工具契约(工具内可选链安全) + const raw = await spec.execute(args, { callId: undefined, signal: undefined }); + let text; + if (spec.output && typeof spec.output.render === "function") { + const rendered = spec.output.render(args, raw); + if (Array.isArray(rendered)) { + text = rendered.map((c) => (c && typeof c.text === "string" ? c.text : JSON.stringify(c))).join("\\n"); + } else { + text = JSON.stringify(rendered); + } + } else if (raw && typeof raw === "object" && typeof raw.text === "string") { + text = raw.text; + } else if (typeof raw === "string") { + text = raw; + } else { + text = JSON.stringify(raw, null, 2); + } + respond(id, { content: [{ type: "text", text: text || "(空结果)" }] }); + } catch (err) { + respondError(id, -32603, "[crossplug] tool execute failed: " + (err && err.message ? err.message : String(err))); + } + } else { + respondError(id, -32601, "method not found: " + method); + } + } catch (e) { + respondError(id, -32603, String(e && e.message ? e.message : e)); + } +}); +// stdin 关闭(父进程退出 / EOF)即自退:避免 mcode 被杀后留下孤儿 MCP server 占住插件目录 +rl.on("close", () => process.exit(0)); +`; +} + +// 向上查找源插件的 package.json,取其 name +function findPluginPackageName(srcFile) { + let dir = path.dirname(srcFile); + for (let i = 0; i < 3; i++) { + const pkg = path.join(dir, 'package.json'); + if (fs.existsSync(pkg)) { + try { + const parsed = JSON.parse(fs.readFileSync(pkg, 'utf8')); + if (parsed && typeof parsed.name === 'string' && parsed.name.trim()) return parsed.name.trim(); + } catch { /* 忽略坏 package.json */ } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +function isInsideDir(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +// 递归复制源码相对导入的本地文件到输出 vendor/(限制在源目录内;越界/缺失跳过) +function copyRelativeDeps(srcDir, destDir, source, skipName, warnings, seen) { + const visited = seen || new Set(); + for (const rel of relativeImports(source)) { + const srcFile = path.resolve(srcDir, rel); + if (!isInsideDir(srcDir, srcFile)) { + if (!visited.has('out:' + rel)) { visited.add('out:' + rel); warnings.push('相对导入超出源目录,跳过: ' + rel); } + continue; + } + if (!fs.existsSync(srcFile)) { + if (!visited.has('miss:' + rel)) { visited.add('miss:' + rel); warnings.push('相对导入文件不存在,跳过: ' + rel); } + continue; + } + const resolved = path.resolve(srcFile); + if (visited.has(resolved)) continue; + visited.add(resolved); + const dest = path.join(destDir, path.relative(srcDir, resolved)); + if (dest === path.join(destDir, skipName)) continue; // 不覆盖转换产物 + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(resolved, dest); + copyRelativeDeps(path.dirname(resolved), path.dirname(dest), fs.readFileSync(resolved, 'utf8'), skipName, warnings, visited); + } +} + +// MCP 服务器 id:小写连字符(agent-plugins 校验 PLUGIN_NAME 不允许下划线) +function makeMcpServerId(name) { + return String(name) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +// MCP 工具名:小写下划线,去掉 dsh-/web-search/-provider 等词缀 +function sanitizeToolName(name) { + return String(name) + .toLowerCase() + .replace(/^dsh-/, '') + .replace(/^web-?search-/, '') + .replace(/-web-?search/, '') + .replace(/-provider$/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'search'; +} + + +function indentCode(code, pad) { + return code + .split('\n') + .map((l) => (l === '' ? '' : ' '.repeat(pad) + l)) + .join('\n'); +} + +// 找第一个不在字符串/注释中的 '{'(用于多参数调用中定位对象字面量) +function firstTopLevelBrace(text) { + let state = 'code'; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + const n = text[i + 1]; + if (state === 'str-single') { + if (c === '\\') i++; + else if (c === "'") state = 'code'; + continue; + } + if (state === 'str-double') { + if (c === '\\') i++; + else if (c === '"') state = 'code'; + continue; + } + if (state === 'line-comment') { + if (c === '\n') state = 'code'; + continue; + } + if (state === 'block-comment') { + if (c === '*' && n === '/') { state = 'code'; i++; } + continue; + } + if (c === "'") { state = 'str-single'; continue; } + if (c === '"') { state = 'str-double'; continue; } + if (c === '/' && n === '/') { state = 'line-comment'; i++; continue; } + if (c === '/' && n === '*') { state = 'block-comment'; i++; continue; } + if (c === '{') return i; + } + return -1; +} + +// ═══ 模式三:DSH 搜索提供方插件(registerSearchProvider)→ agent-plugins MCP 包 ═══ +// +// 把 ctx.web.registerSearchProvider(...) 型插件转换为 MCode 可安装的 +// agent-plugins.org 1.0.0 插件包:原插件 vendor 进 vendor/(@deepseek-ai 依赖 +// 重写为本地垫片),生成 mcp-server.js(stdio JSON-RPC)把 provider.search() +// 暴露为 MCP 工具 searxng_search,mcp.json 声明 MCP 服务器。 + +// @deepseek-ai 依赖 → 本地垫片文件名映射 +const DSH_IMPORT_SHIMS = { + '@deepseek-ai/schemastery': 'schemastery-shim.js', + '@deepseek-ai/dsh-web': 'dsh-web-shim.js', + '@deepseek-ai/dsh-launch-environment': 'launch-env-shim.js', +}; + +function shimSourceFor(pkg) { + if (pkg === '@deepseek-ai/schemastery') { + return [ + '// 由 crossplug 生成的 schemastery 最小垫片:链式方法全部空操作,', + '// 只满足 Config 声明在模块顶层的求值(运行时校验由宿主负责)。', + 'function chainable(target) {', + ' return new Proxy(target || {}, {', + ' get(t, prop) {', + ' if (prop in t) return t[prop];', + ' if (typeof prop === "symbol") return undefined;', + ' return () => chainable(t);', + ' },', + ' });', + '}', + 'const z = {', + ' object: (shape) => chainable({ type: "object", shape }),', + ' string: () => chainable({ type: "string" }),', + ' number: () => chainable({ type: "number" }),', + ' integer: () => chainable({ type: "integer" }),', + ' boolean: () => chainable({ type: "boolean" }),', + ' array: (item) => chainable({ type: "array", item }),', + ' optional: (v) => v,', + ' union: (v) => chainable({ type: "union", anyOf: v }),', + ' literal: (v) => chainable({ type: "literal", const: v }),', + '};', + 'export default z;', + '', + ].join('\n'); + } + if (pkg === '@deepseek-ai/dsh-web') { + return [ + '// 由 crossplug 生成的 dsh-web 最小垫片:WebError 保持错误码语义。', + 'export class WebError extends Error {', + ' constructor(message, code, options) {', + ' super(message);', + ' this.name = "WebError";', + ' this.code = code;', + ' if (options && options.cause !== undefined) this.cause = options.cause;', + ' }', + '}', + '', + ].join('\n'); + } + if (pkg === '@deepseek-ai/dsh-launch-environment') { + return [ + '// 由 crossplug 生成的 launch-environment 垫片:从 process.env 读取受管变量。', + 'export function launchEnvironmentOf() {', + ' return {', + ' get(name) {', + ' const value = process.env[name] || "";', + ' return value ? { value } : undefined;', + ' },', + ' };', + '}', + '', + ].join('\n'); + } + return undefined; +} + +// 重写 vendor 源码:@deepseek-ai 依赖指向本地垫片;其他 @deepseek-ai/@earendil-works 导入注释掉 +function rewriteProviderImports(source, warnings) { + let out = source; + const used = new Set(); + const lineRe = /^(\s*import\s+[^\n]*?from\s+["'])(@deepseek-ai\/[^"']+|@earendil-works\/[^"']+)(["'][^\n]*)$/gm; + out = out.replace(lineRe, (whole, head, pkg, tail) => { + if (DSH_IMPORT_SHIMS[pkg]) { + used.add(pkg); + return `${head}./${DSH_IMPORT_SHIMS[pkg]}${tail}`; + } + warnings.push(`移除了无法解析的运行时导入 ${pkg}(桥接环境不提供,相关逻辑需人工核对)`); + return `// [crossplug] removed import: ${whole.trim().slice(0, 120)}`; + }); + // 裸导入(无 from):import "@deepseek-ai/xxx" + out = out.replace(/^(\s*import\s+["'])(@deepseek-ai\/[^"']+|@earendil-works\/[^"']+)(["'][^\n]*)$/gm, (whole, head, pkg, tail) => { + warnings.push(`移除了无法解析的裸导入 ${pkg}`); + return `// [crossplug] removed bare import: ${whole.trim().slice(0, 120)}`; + }); + return { source: out, used }; +} + +function renderMcpServer(toolName, description) { + return `#!/usr/bin/env node +// 由 crossplug 生成:把 DSH 搜索提供方插件(registerSearchProvider)包装为 MCP stdio 服务器。 +// 协议:newline-delimited JSON-RPC 2.0(initialize / tools/list / tools/call / ping)。 +// SearXNG 地址从环境变量 SEARXNG_URL 读取(由 mcp.json 的 env 注入)。 +import { createInterface } from "node:readline"; + +// 加载原 DSH 搜索提供方插件:兼容 default 对象 / named apply / 函数导出三种形态 +const dshPlugin = await import("./vendor/index.js"); +const target = (dshPlugin && dshPlugin.default) ? dshPlugin.default : dshPlugin; +const apply = (target && typeof target.apply === "function") + ? target.apply.bind(target) + : (typeof target === "function" ? target : null); +if (typeof apply !== "function") { + process.stderr.write("[crossplug] vendor/index.js 没有可调用的 apply(支持 export default { apply } / export function apply / 函数导出)\\n"); +} + +let provider = null; +if (typeof apply === "function") { + apply( + { + web: { + registerSearchProvider(p) { + provider = p; + }, + }, + }, + { + url: process.env.SEARXNG_URL || "", + maxResults: 30, + categories: "", + language: "", + timeoutMs: 20000, + }, + ); +} + +const TOOLS = [ + { + name: ${JSON.stringify(toolName)}, + description: ${JSON.stringify(description)}, + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "搜索查询" }, + categories: { type: "string", description: "逗号分隔的分类(如 news,it)" }, + language: { type: "string", description: "语言提示;缺省自动检测" }, + maxResults: { type: "integer", description: "最大结果数(缺省 30)" }, + }, + required: ["query"], + }, + }, +]; + +function respond(id, result) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n"); +} +function respondError(id, code, message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\\n"); +} + +const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); +rl.on("line", async (line) => { + if (!line.trim()) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + const { id, method, params } = msg; + try { + if (method === "initialize") { + respond(id, { + protocolVersion: (params && params.protocolVersion) || "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "crossplug-searxng", version: "1.0.0" }, + }); + } else if (method === "notifications/initialized" || method === "notifications/cancelled") { + // 通知类:无需回复 + } else if (method === "ping") { + respond(id, {}); + } else if (method === "tools/list") { + respond(id, { tools: TOOLS }); + } else if (method === "tools/call") { + const name = params && params.name; + const args = (params && params.arguments) || {}; + if (name !== TOOLS[0].name) return respondError(id, -32602, "unknown tool: " + name); + if (!provider) return respondError(id, -32603, "SearXNG provider 未就绪:请设置 SEARXNG_URL(mcp.json env 或系统环境变量)"); + const signal = new AbortController().signal; + const result = await provider.search( + { + query: args.query, + ...(args.categories !== undefined ? { categories: args.categories } : {}), + ...(args.language !== undefined ? { language: args.language } : {}), + ...(args.maxResults !== undefined ? { maxResults: args.maxResults } : {}), + }, + signal, + ); + const text = (result.sources || []) + .map((s) => "- " + (s.title || s.url) + "\\n " + s.url + (s.snippet ? "\\n " + s.snippet : "")) + .join("\\n"); + respond(id, { content: [{ type: "text", text: text || "(无结果)" }] }); + } else { + respondError(id, -32601, "method not found: " + method); + } + } catch (e) { + respondError(id, -32603, String(e && e.message ? e.message : e)); + } +}); +// stdin 关闭(父进程退出 / EOF)即自退:避免 mcode 被杀后留下孤儿 MCP server 占住插件目录 +rl.on("close", () => process.exit(0)); +`; +} + +function convertProviderPlugin(srcFile, outDir) { + const source = fs.readFileSync(srcFile, 'utf8'); + const warnings = []; + const base = path.basename(srcFile, path.extname(srcFile)); + // 插件真实名字:优先从源插件包 package.json 提取(srcFile 可能在 lib/ 子目录) + const pkgName = findPluginPackageName(srcFile); + const pluginName = makePluginName(pkgName || base, base); + const toolName = sanitizeToolName(pluginName) + '_search'; + const pkgDir = outDir; + + // ── vendor:原插件 + import 重写 + 垫片 ── + const rewritten = rewriteProviderImports(source, warnings); + fs.mkdirSync(path.join(pkgDir, 'vendor'), { recursive: true }); + writeFileSafe(path.join(pkgDir, 'vendor', 'index.js'), rewritten.source); + for (const pkg of Object.keys(DSH_IMPORT_SHIMS)) { + if (rewritten.used.has(pkg)) { + writeFileSafe(path.join(pkgDir, 'vendor', DSH_IMPORT_SHIMS[pkg]), shimSourceFor(pkg)); + } + } + + // ── MCP 服务器 ── + const description = `通过 SearXNG 实例搜索网页(由 DSH 插件「${pluginName}」转换,crossplug 桥接)。`; + writeFileSafe(path.join(pkgDir, 'mcp-server.js'), renderMcpServer(toolName, description)); + // mcp-server.js 是 ESM(顶层 import/await):声明 type:module,否则 Node 按 CJS 解析直接语法错误 + writeFileSafe(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: pluginName, + version: '1.0.0', + private: true, + type: 'module', + }, null, 2) + '\n'); + + // ── mcp.json(agent-plugins 1.0.0)── + const searxngUrl = process.env.SEARXNG_URL || ''; + const mcp = { + $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', + mcpServers: { + [makeMcpServerId(toolName)]: { + type: 'stdio', + command: 'node', + args: ['./mcp-server.js'], + env: { SEARXNG_URL: searxngUrl }, + }, + }, + }; + writeFileSafe(path.join(pkgDir, 'mcp.json'), JSON.stringify(mcp, null, 2) + '\n'); + if (!searxngUrl) { + warnings.push('本机未设置 SEARXNG_URL:请在 mcp.json 的 env 或系统环境变量中配置 SearXNG 实例地址'); + } + + // ── plugin.json(agent-plugins 1.0.0)── + writeFileSafe(path.join(pkgDir, 'plugin.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: pluginName, + version: '1.0.0', + description: `由 DSH 插件「${pluginName}」转换:提供 SearXNG 网页搜索(MCP 工具 ${toolName})。`, + author: { name: 'crossplug' }, + keywords: ['dsh', 'converted', 'search', 'searxng'], + extensions: { + crossplug: { source: srcFile, sourceKind: 'dsh-search-provider', generatedAt: new Date().toISOString() }, + }, + }, null, 2) + '\n'); + + // ── 说明 skill + 报告 + README ── + writeFileSafe(path.join(pkgDir, 'skills', 'search-notes', 'SKILL.md'), [ + '---', + 'name: search-notes', + 'description: 本插件由 DSH 搜索提供方插件「' + base + '」转换而来:提供 SearXNG 网页搜索(MCP 工具 ' + toolName + ')。', + '---', + '', + '本插件把 DSH 的 SearXNG 搜索提供方桥接为 MCP 工具。', + '原插件逻辑(vendor/index.js)零移植复用,通过 mcp.json 声明的 stdio MCP 服务器暴露。', + '使用前确认 SearXNG 实例可达:mcp.json 的 env.SEARXNG_URL(或系统环境变量)。', + '调用示例:' + toolName + ' 工具,参数 query="DeepSeek Harness"', + '', + ].join('\n') + '\n'); + + writeFileSafe(path.join(pkgDir, 'CONVERSION-REPORT.md'), [ + `# 转换报告:DSH 搜索提供方插件 → agent-plugins MCP 包`, + '', + `- 来源: ${srcFile}`, + `- 插件名: ${pluginName}`, + `- 生成时间: ${new Date().toISOString()}`, + `- 转换方式: 运行时桥接(vendor + 依赖垫片)`, + '', + `## 能力映射`, + '', + `- \`ctx.web.registerSearchProvider(...)\` → MCP 工具 \`${toolName}\`(mcp.json 声明,stdio 服务器 mcp-server.js)`, + `- 依赖 @deepseek-ai/schemastery、dsh-web、dsh-launch-environment → vendor/ 下本地垫片`, + `- 原搜索逻辑(语言检测 / categories / 归一化)→ 原样复用`, + '', + `## 安装`, + '', + `1. 复制本目录到 ~/.minimax/plugins/${pluginName}/`, + `2. MiniMax Code 中 /plugins → Local → 安装并启用`, + `3. 确认 SearXNG 地址:mcp.json env.SEARXNG_URL 或系统环境变量`, + '', + `## 警告`, + '', + ...(warnings.length ? warnings.map((w) => `- ${w}`) : ['(无)']), + '', + ].join('\n') + '\n'); + + writeFileSafe(path.join(pkgDir, 'README.md'), [ + `# ${pluginName}`, + '', + `由 crossplug 从 DSH 插件「${base}」转换(运行时桥接为 MCP 服务器)。`, + '', + `- MCP 工具:\`${toolName}\`(stdio,node ./mcp-server.js)`, + `- 配置:\`mcp.json\` 的 \`env.SEARXNG_URL\`,或系统环境变量`, + `- 原插件源码:\`vendor/index.js\`(依赖已垫片化)`, + '', + ].join('\n') + '\n'); + + return { + kind: 'provider-bridge', + source: srcFile, + out: pkgDir, + packageName: pluginName, + toolName, + warnings: warnings.length, + reportFile: path.join(pkgDir, 'CONVERSION-REPORT.md'), + }; +} + +// ═══ 入口 ═══ + +function convert(src, outDir) { + const stat = fs.statSync(src); + if (stat.isDirectory()) return convertPreset(src, outDir); + if (stat.isFile()) { + const source = fs.readFileSync(src, 'utf8'); + // 搜索提供方插件(ctx.web.registerSearchProvider):转为 agent-plugins MCP 包 + if (/registerSearchProvider\s*\(/.test(source)) return convertProviderPlugin(src, outDir); + return convertPluginSource(src, outDir); + } + throw new Error(`无法识别的输入: ${src}`); +} + +module.exports = { convert, convertPreset, convertPluginSource, convertProviderPlugin }; diff --git a/plugins/Fectivnfy112357/crossplug/core/extract.js b/plugins/Fectivnfy112357/crossplug/core/extract.js new file mode 100644 index 0000000..08487af --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/extract.js @@ -0,0 +1,411 @@ +// extract.js — JS/TS 源码中的调用与对象字面量提取(零依赖) +// +// 用于: +// - dsh2mcode:从 DSH 插件源码提取 registerTool/defineTool/ctx.on 调用 +// - mcode2dsh:从 pi extension 源码提取 registerTool/registerCommand 调用 +// 实现:词法级扫描(字符串/注释/括号配对),不做完整 JS 解析。 + +'use strict'; + +// 找到所有匹配正则的"调用"并截取平衡括号内的参数文本 +// namePattern: RegExp,匹配函数名(不含 '('),可带捕获组 1 作为"名字"(如事件名) +function findCalls(source, namePattern) { + const re = new RegExp(namePattern.source, namePattern.flags.includes('g') ? namePattern.flags : namePattern.flags + 'g'); + const out = []; + // 注释区间(跳过注释内被匹配的调用,如注释掉的 tools.register 示例) + const comments = commentRanges(source); + let ci = 0; + let m; + while ((m = re.exec(source)) !== null) { + // 跳过注释内的匹配 + while (ci < comments.length && comments[ci][1] <= m.index) ci++; + if (ci < comments.length && m.index >= comments[ci][0]) { + continue; // exec 已自动推进 lastIndex + } + // 跳过作为字符串一部分的匹配:检查前一个非空白字符 + let k = m.index - 1; + while (k >= 0 && /\s/.test(source[k])) k--; + if (k >= 0 && (source[k] === '"' || source[k] === "'" || source[k] === '`')) continue; + // 扫描到下一个 '(' + let i = m.index + m[0].length; + while (i < source.length && /\s/.test(source[i])) i++; + if (source[i] !== '(') continue; + const close = matchParen(source, i); + if (close < 0) continue; + out.push({ + start: m.index, + end: close + 1, + argsStart: i + 1, + argsEnd: close, + argsText: source.slice(i + 1, close), + name: m[1] !== undefined ? m[1] : m[0], + }); + re.lastIndex = close + 1; + } + return out; +} + +// 从 pos 开始的 '(' 找到配对的 ')'(支持嵌套与字符串/注释) +function matchParen(source, pos) { + const stack = []; + let i = pos; + let state = 'code'; // code | str-single | str-double | str-template | line-comment | block-comment + while (i < source.length) { + const c = source[i]; + const n = source[i + 1]; + if (state === 'line-comment') { + if (c === '\n') state = 'code'; + i++; + continue; + } + if (state === 'block-comment') { + if (c === '*' && n === '/') { + state = 'code'; + i += 2; + } else i++; + continue; + } + if (state === 'str-single') { + if (c === '\\') i += 2; + else if (c === "'") state = 'code', i++; + else i++; + continue; + } + if (state === 'str-double') { + if (c === '\\') i += 2; + else if (c === '"') state = 'code', i++; + else i++; + continue; + } + if (state === 'str-template') { + if (c === '\\') i += 2; + else if (c === '`') state = 'code', i++; + else if (c === '$' && n === '{') { + // 模板插值:进入代码态直到匹配的 } + i += 2; + const close = matchParen(source, i - 1); + if (close < 0) return -1; + i = close + 1; + } else i++; + continue; + } + if (state === 'regex') { + // regex literal(如 /\\)/ 里的 ')' 不是闭括号):\x 转义;未转义 / 结束;换行视为非法结束 + if (c === '\\') i += 2; + else if (c === '/') state = 'code', i++; + else if (c === '\n') return -1; + else i++; + continue; + } + // code 态 + if (c === '/' && n === '/') { state = 'line-comment'; i += 2; continue; } + if (c === '/' && n === '*') { state = 'block-comment'; i += 2; continue; } + // regex literal 起始:`/` 在表达式起始位置(操作符/分隔符/关键字后)时按 regex 处理 + if (c === '/' && regexStart(source, i)) { state = 'regex'; i++; continue; } + if (c === "'") { state = 'str-single'; i++; continue; } + if (c === '"') { state = 'str-double'; i++; continue; } + if (c === '`') { state = 'str-template'; i++; continue; } + if (c === '(' || c === '{' || c === '[') { stack.push(c === '(' ? ')' : c === '{' ? '}' : ']'); i++; continue; } + if (c === ')' || c === '}' || c === ']') { + const want = stack.pop(); + if (want === undefined) return -1; + if (want !== c) return -1; + if (stack.length === 0) return i; + i++; + continue; + } + i++; + } + return -1; +} + +// 判断 source[i] 的 '/' 是否是 regex literal 起始(而非除法/注释)。 +// 启发式:前一个非空白字符是行首、开括号/分隔符/运算符,或关键字(return 等)。 +const REGEX_PREFIX_RE = /(?:return|typeof|case|in|of|delete|void|new|instanceof|yield|throw|else|do|if|while|for|switch|await)\s*$/; +function regexStart(source, i) { + let k = i - 1; + while (k >= 0 && /\s/.test(source[k])) k--; + if (k < 0) return true; + const c = source[k]; + if ('([{,;=:!&|?+-*%<>^~'.includes(c)) return true; + if (/[A-Za-z0-9_$]/.test(c)) { + let j = k; + while (j >= 0 && /[A-Za-z0-9_$]/.test(source[j - 1])) j--; + return REGEX_PREFIX_RE.test(source.slice(j, k + 1)); + } + return false; +} + +// 注释区间列表([start, end)),供 findCalls 跳过注释内匹配 +function commentRanges(source) { + const ranges = []; + let i = 0; + const len = source.length; + while (i < len) { + const c = source[i]; + const n = source[i + 1]; + if (c === '/' && n === '/') { + const start = i; + const nl = source.indexOf('\n', i + 2); + i = nl < 0 ? len : nl + 1; + ranges.push([start, i]); + continue; + } + if (c === '/' && n === '*') { + const start = i; + const end = source.indexOf('*/', i + 2); + i = end < 0 ? len : end + 2; + ranges.push([start, i]); + continue; + } + if (c === "'" || c === '"' || c === '`') { + const q = c; + i++; + while (i < len) { + if (source[i] === '\\') i += 2; + else if (source[i] === q) { i++; break; } + else i++; + } + continue; + } + i++; + } + return ranges; +} + +// text[0, pos) 的未闭合括号深度(词法感知),用于判断属性是否处于对象顶层 +function nestingDepthAt(text, pos) { + let depth = 0; + let i = 0; + const len = Math.min(pos, text.length); + let state = 'code'; + while (i < len) { + const c = text[i]; + const n = text[i + 1]; + if (state === 'line-comment') { if (c === '\n') state = 'code'; i++; continue; } + if (state === 'block-comment') { if (c === '*' && n === '/') { state = 'code'; i += 2; } else i++; continue; } + if (state === 'str-single') { if (c === '\\') i += 2; else if (c === "'") { state = 'code'; i++; } else i++; continue; } + if (state === 'str-double') { if (c === '\\') i += 2; else if (c === '"') { state = 'code'; i++; } else i++; continue; } + if (state === 'str-template') { if (c === '\\') i += 2; else if (c === '`') { state = 'code'; i++; } else i++; continue; } + if (state === 'regex') { if (c === '\\') i += 2; else if (c === '/') { state = 'code'; i++; } else i++; continue; } + if (c === '/' && n === '/') { state = 'line-comment'; i += 2; continue; } + if (c === '/' && n === '*') { state = 'block-comment'; i += 2; continue; } + if (c === '/' && regexStart(text, i)) { state = 'regex'; i++; continue; } + if (c === "'") { state = 'str-single'; i++; continue; } + if (c === '"') { state = 'str-double'; i++; continue; } + if (c === '`') { state = 'str-template'; i++; continue; } + if (c === '(' || c === '{' || c === '[') depth++; + else if (c === ')' || c === '}' || c === ']') depth = Math.max(0, depth - 1); + i++; + } + return depth; +} + +// 提取源码中的相对导入路径('./x' / '../x'),去重 +function relativeImports(source) { + const out = []; + const seen = new Set(); + const re = /(?:from\s*|require\s*\(\s*|import\s*\(\s*|import\s+)(["'])(\.{1,2}\/[^"']+)\1/g; + for (const m of source.matchAll(re)) { + if (m[2] && !seen.has(m[2])) { seen.add(m[2]); out.push(m[2]); } + } + return out; +} + +// 从对象字面量文本中提取某个属性的值(平衡截取;字符串返回解引号后的值) +function propertyValue(objText, key, opts = {}) { + const topLevel = !!opts.topLevel; + // 必须带 g flag:循环依赖 exec 的 lastIndex 推进 + const re = new RegExp('(?:^|[,{\\s])' + key + '\\s*:', 'g'); + let m; + while ((m = re.exec(objText)) !== null) { + // 顶层模式:跳过嵌套属性(如 parameters 里的 name 字段)。 + // 顶层对象成员 name 位置的深度恒为 1('{' 之后);嵌套成员 >= 2; + // 不以 '{' 开头的裸文本(depth 0)也视为顶层。 + const namePos = m.index + m[0].indexOf(key); + const depth = nestingDepthAt(objText, namePos); + if (topLevel && !(depth === 1 || (depth === 0 && !objText.startsWith('{')))) { + continue; + } + const value = propertyValueAt(objText, key, m); + if (value !== undefined) return value; + if (!topLevel) return undefined; + } + return undefined; +} + +// 在已匹配的位置解析属性值(供 propertyValue 循环复用) +function propertyValueAt(objText, key, m) { + let i = m.index + m[0].length; + while (i < objText.length && /\s/.test(objText[i])) i++; + if (i >= objText.length) return undefined; + const c = objText[i]; + if (c === '"' || c === "'" || c === '`') { + // 字符串值:读到匹配的结束引号(简单处理转义) + const quote = c; + let j = i + 1; + let out = ''; + while (j < objText.length) { + const ch = objText[j]; + if (ch === '\\') { out += objText[j + 1] ?? ''; j += 2; continue; } + if (ch === quote) break; + out += ch; + j++; + } + return { kind: 'string', value: out, start: i, end: j + 1 }; + } + if (c === '(' || c === '{' || c === '[') { + const close = matchParen(objText, i); + if (close < 0) return undefined; + return { kind: 'code', value: objText.slice(i, close + 1), start: i, end: close + 1 }; + } + // 标量/标识符:读到逗号或行尾(在对象文本内) + let j = i; + let depth = 0; + while (j < objText.length) { + const ch = objText[j]; + if (ch === '(' || ch === '{' || ch === '[') depth++; + if ((ch === ',' || ch === '}') && depth === 0) break; + if (ch === ')' || ch === '}' || ch === ']') depth--; + j++; + } + return { kind: 'code', value: objText.slice(i, j).trim(), start: i, end: j }; +} + +// 提取函数体(含 async)的源码文本:从 'function'/'=>' 起始位置截取完整函数 +// 传入的值文本可能形如 `async function (a, b) { ... }` 或 `(a, b) => { ... }` 或 `async (a,b) => {...}` +function functionText(raw) { + const s = raw.trim(); + const brace = s.indexOf('{'); + if (brace < 0) return undefined; + const close = matchParen(s, brace); + if (close < 0) return undefined; + return s.slice(0, close + 1); +} + +// 提取对象字面量中的方法简写:`async execute(args) { ... }` / `execute(args) { ... }` +// 返回 { kind: 'code', value: 'execute(args) { ... }', start, end } 或 undefined +function propertyMethod(objText, key) { + const re = new RegExp('(?:^|[,\\s])(async\\s+)?' + key + '\\s*\\('); + const m = re.exec(objText); + if (!m) return undefined; + const paramsStart = m.index + m[0].length - 1; // '(' 位置 + const paramsClose = matchParen(objText, paramsStart); + if (paramsClose < 0) return undefined; + let i = paramsClose + 1; + while (i < objText.length && /\s/.test(objText[i])) i++; + if (objText[i] !== '{') return undefined; + const bodyClose = matchParen(objText, i); + if (bodyClose < 0) return undefined; + const start = m.index + (m[1] ? m[1].length : 0); // 从 key 开始(不含 async 前缀?保留 async) + return { + kind: 'code', + value: objText.slice(start, bodyClose + 1), + start, + end: bodyClose + 1, + }; +} + +// 求值 JS 对象字面量子集(单/双引号字符串、数字、布尔、null、undefined、数组、对象) +// 用于把 DSH 参数 schema(ParameterSchemaSpec)从源码文本解析为可用的 JSON。 +function jsLiteralToValue(text) { + const s = text.trim(); + if (s === '') return undefined; + const p = new JsLiteralParser(s); + const v = p.parseValue(); + if (!p.atEnd()) throw new Error('jsLiteral: 意外的尾随内容 @' + p.pos); + return v; +} + +class JsLiteralParser { + constructor(text) { + this.t = text; + this.pos = 0; + } + atEnd() { + while (this.pos < this.t.length && /\s/.test(this.t[this.pos])) this.pos++; + return this.pos >= this.t.length; + } + peek() { + while (this.pos < this.t.length && /\s/.test(this.t[this.pos])) this.pos++; + return this.t[this.pos]; + } + parseValue() { + const c = this.peek(); + if (c === '{') return this.parseObject(); + if (c === '[') return this.parseArray(); + if (c === "'" || c === '"') return this.parseString(); + if (c === 'u' && this.t.startsWith('undefined', this.pos)) { this.pos += 9; return undefined; } + if (c === 'n' && this.t.startsWith('null', this.pos)) { this.pos += 4; return null; } + if (c === 't' && this.t.startsWith('true', this.pos)) { this.pos += 4; return true; } + if (c === 'f' && this.t.startsWith('false', this.pos)) { this.pos += 5; return false; } + const m = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(this.t.slice(this.pos)); + if (m) { this.pos += m[0].length; return Number(m[0]); } + // 无法识别的表达式(变量、函数等) + throw new Error('jsLiteral: 无法解析 @' + this.pos + ': ' + this.t.slice(this.pos, this.pos + 30)); + } + parseString() { + const quote = this.t[this.pos]; + this.pos++; + let out = ''; + while (this.pos < this.t.length) { + const c = this.t[this.pos]; + if (c === '\\') { + const n = this.t[this.pos + 1]; + if (n === 'n') out += '\n'; + else if (n === 't') out += '\t'; + else if (n === 'r') out += '\r'; + else if (n === 'u') { + out += String.fromCharCode(parseInt(this.t.slice(this.pos + 2, this.pos + 6), 16)); + this.pos += 4; + } else out += n; + this.pos += 2; + continue; + } + if (c === quote) { this.pos++; return out; } + out += c; + this.pos++; + } + throw new Error('jsLiteral: 字符串未闭合'); + } + parseArray() { + this.pos++; // [ + const out = []; + while (true) { + const c = this.peek(); + if (c === ']') { this.pos++; return out; } + out.push(this.parseValue()); + const c2 = this.peek(); + if (c2 === ',') { this.pos++; continue; } + if (c2 === ']') { this.pos++; return out; } + throw new Error('jsLiteral: 数组语法错误 @' + this.pos); + } + } + parseObject() { + this.pos++; // { + const out = {}; + while (true) { + const c = this.peek(); + if (c === '}') { this.pos++; return out; } + // key: 标识符或字符串 + let key; + if (c === "'" || c === '"') key = this.parseString(); + else { + const m = /^[A-Za-z_$][\w$]*/.exec(this.t.slice(this.pos)); + if (!m) throw new Error('jsLiteral: 对象键错误 @' + this.pos); + key = m[0]; + this.pos += m[0].length; + } + while (this.pos < this.t.length && /\s/.test(this.t[this.pos])) this.pos++; + if (this.t[this.pos] !== ':') throw new Error('jsLiteral: 缺冒号 @' + this.pos); + this.pos++; + out[key] = this.parseValue(); + const c2 = this.peek(); + if (c2 === ',') { this.pos++; continue; } + if (c2 === '}') { this.pos++; return out; } + throw new Error('jsLiteral: 对象语法错误 @' + this.pos); + } + } +} + +module.exports = { findCalls, matchParen, propertyValue, propertyMethod, functionText, jsLiteralToValue, relativeImports }; diff --git a/plugins/Fectivnfy112357/crossplug/core/mapping.js b/plugins/Fectivnfy112357/crossplug/core/mapping.js new file mode 100644 index 0000000..365bda9 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/mapping.js @@ -0,0 +1,82 @@ +// mapping.js — 已知 DSH 插件行 ↔ pi/mcode 能力映射表(零依赖) +// +// 用途:dsh2mcode 把 DSH agent preset 的每一行翻译成 pi extension 里 +// 可表达的能力,或标记为"无对应能力"的桩。 +// +// kind 取值: +// builtin — pi 运行时自带该能力,无需转换(extension 中只写注释) +// tool — 生成 pi.registerTool 桩(同名、同描述、空参数) +// command — 生成 pi.registerCommand 桩 +// persona — 生成 /persona 命令与启动通知 +// note — 只写说明注释(无运行时动作) +// warn — 无对应能力,生成带警告的桩 + +'use strict'; + +// 已知 @deepseek-ai/dsh-* 插件行的映射 +const ROW_MAP = { + persona: { kind: 'persona', pi: '无直接对应:DSH 的 persona 是系统提示注入,pi 无等价 API;转换后通过 /persona 命令与启动通知保留', note: 'persona 文本会完整写入 extension 注释与 CONVERSION-REPORT.md' }, + 'agent-instructions': { kind: 'note', pi: 'agent-instructions 是指令大小限制,无 pi 对应', note: '' }, + + // ── shell / 文件 ── + 'tool-bash': { kind: 'builtin', pi: 'pi 自带 bash 工具', note: '' }, + 'tool-pwsh': { kind: 'builtin', pi: 'pi 自带 bash 工具;PowerShell 语义需人工核对', note: '' }, + 'tool-fs': { kind: 'builtin', pi: 'pi 自带 read/write/edit 文件工具', note: '' }, + 'tool-fs-search': { kind: 'builtin', pi: 'pi 自带 grep/glob 搜索能力', note: '' }, + + // ── 任务 / 后台 / 目标 ── + 'tool-todo': { kind: 'tool', pi: 'pi 无内建任务清单工具;生成同名桩,逻辑需移植(pi 可用 appendEntry + details 自建持久状态)', note: 'execute 为占位实现' }, + 'tool-jobs': { kind: 'warn', pi: 'pi 无后台任务注册表对应', note: '' }, + 'tool-goal': { kind: 'warn', pi: 'pi 无 goal 服务对应;会话树与 setLabel 可作轻量替代,持续目标由模型自行跟进', note: '' }, + + // ── 网络 ── + 'tool-web': { kind: 'builtin', pi: 'pi 自带网页搜索/抓取能力(如配置了 web 搜索)', note: '' }, + 'tool-web-search': { kind: 'builtin', pi: 'pi 自带网页搜索能力', note: '' }, + + // ── 交互 ── + 'tool-ask-user': { kind: 'tool', pi: 'pi 用 ctx.ui.confirm/input 表达;生成桩工具并注释对应 ctx.ui 用法', note: '建议改写成 ctx.ui 交互' }, + + // ── 委派 / 工作流 ── + // pi 官方 examples/extensions/ 有 subagent/(registerTool + exec)与 plan-mode/ + // 完整实现——以下能力不是"pi 没有",而是"pi 用扩展自行实现"。 + 'tool-subagent': { kind: 'warn', pi: 'pi 无内建子代理工具;但扩展可自行实现(pi 官方示例 examples/extensions/subagent/:registerTool + exec)', note: '' }, + 'tool-subagent-control': { kind: 'warn', pi: '无直接对应;扩展自建子代理时可用 pi.getAllTools() 自省 + setActiveTools 控制', note: '' }, + 'tool-subagent-list-agents': { kind: 'warn', pi: '无直接对应;pi.getAllTools() 返回全部工具元数据(含 sourceInfo)可作自省', note: '' }, + 'tool-workflow': { kind: 'warn', pi: '无 workflow 引擎内建;可用动态工具加载(setActiveTools 纯增量)模拟按需能力', note: '' }, + 'tool-ralph': { kind: 'warn', pi: '无 fresh-agent 循环内建;扩展可自行实现(registerTool + sendUserMessage 接力)', note: '' }, + + // ── Cordis 自指 ── + 'tool-cordis': { kind: 'warn', pi: '无对应:pi 没有运行时内省工具', note: '' }, + + // ── 规划 / 压缩 / 委派分组(组合概念)── + planning: { kind: 'note', pi: 'pi 无内建 plan mode;但扩展可实现(pi 官方示例 examples/extensions/plan-mode/:registerCommand + registerShortcut + registerFlag + setActiveTools)', note: '' }, + compaction: { kind: 'note', pi: '上下文压缩由 pi 自带管理(session_before_compact / session_compact 事件可自定义)', note: '' }, + delegation: { kind: 'note', pi: '子代理/工作流无内建,可经扩展实现(见 tool-subagent 一行)', note: '' }, + + // ── 命令 ── + 'command-compact': { kind: 'command', pi: '生成 /compact 桩命令', note: 'pi 自带压缩;桩仅提示' }, + 'command-plan': { kind: 'command', pi: '生成 /plan 桩命令', note: 'pi 无计划模式;桩仅提示' }, + + // ── 技能 ── + 'skill-filesystem': { kind: 'note', pi: '技能已通过 ~/.agents/skills 共享,无需转换', note: '' }, + 'tool-skill': { kind: 'note', pi: '技能加载机制双方独立;技能内容走共享目录', note: '' }, +}; + +// 兜底:未知 @deepseek-ai/dsh-* 行 +function classifyRow(row) { + const name = row.name || ''; + const base = name.replace(/^@deepseek-ai\//, ''); + // npm 包名形如 dsh-tool-ask-user;映射表键是 tool-ask-user —— 两种都试 + const short = base.replace(/^dsh-/, ''); + // 组行(cordis:group)与本地文件行:按行 id 匹配(如 planning / compaction / delegation) + const idShort = String(row.id || '').replace(/^dsh-/, ''); + if (ROW_MAP[base]) return ROW_MAP[base]; + if (ROW_MAP[short]) return ROW_MAP[short]; + if (ROW_MAP[idShort]) return ROW_MAP[idShort]; + if (base.startsWith('dsh-')) { + return { kind: 'warn', pi: '未知 DSH 插件行,无自动映射;请人工评估', note: '' }; + } + return { kind: 'warn', pi: '非 @deepseek-ai 插件行(可能是本地文件或第三方包),无自动映射', note: '' }; +} + +module.exports = { ROW_MAP, classifyRow }; diff --git a/plugins/Fectivnfy112357/crossplug/core/mcode2dsh.js b/plugins/Fectivnfy112357/crossplug/core/mcode2dsh.js new file mode 100644 index 0000000..5c64c89 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/mcode2dsh.js @@ -0,0 +1,740 @@ +// mcode2dsh.js — mcode(MiniMax Code / pi extension)→ DSH agent preset(零依赖) +// +// 输入: +// 1. pi extension 文件(.js / .ts) +// 2. mcode 插件包目录(package.json 的 main / index.js / extension.js) +// +// 输出:一个自包含的 DSH agent preset: +// preset.yml — 元数据 +// agent.cordis.yml — persona 行 + 桥接插件行(name: ./plugins/bridge.js) +// plugins/bridge.js — 运行时桥接:用 pi-API 垫片调用原 extension 的工厂, +// registerTool → ctx.tools.register,registerCommand → ctx.commands.register +// vendor/ — 原 extension(TS 已剥离类型;typebox 导入重写为本地垫片) +// CONVERSION-REPORT.md — 提取到的工具/命令清单与警告 +// +// 桥接方案让原插件的 execute/命令逻辑原样运行,无需转译逻辑本身。 + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { findCalls, relativeImports } = require('./extract.js'); +const { INLINE_BRIDGE_HELPERS, typeboxShimCode } = require('./schema.js'); + +const EXT_NAMES = ['index.js', 'extension.js', 'main.js', 'index.ts', 'extension.ts', 'main.ts']; + +// ── 定位 extension 文件 ── + +function isInsideDir(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +// 递归复制源码相对导入的本地文件到输出 vendor/(限制在源目录内;越界/缺失跳过) +function copyRelativeDeps(srcDir, destDir, source, skipName, warnings, seen) { + const visited = seen || new Set(); + for (const rel of relativeImports(source)) { + const srcFile = path.resolve(srcDir, rel); + if (!isInsideDir(srcDir, srcFile)) { + if (!visited.has('out:' + rel)) { visited.add('out:' + rel); warnings.push('相对导入超出源目录,跳过: ' + rel); } + continue; + } + if (!fs.existsSync(srcFile)) { + if (!visited.has('miss:' + rel)) { visited.add('miss:' + rel); warnings.push('相对导入文件不存在,跳过: ' + rel); } + continue; + } + const resolved = path.resolve(srcFile); + if (visited.has(resolved)) continue; + visited.add(resolved); + const dest = path.join(destDir, path.relative(srcDir, resolved)); + if (dest === path.join(destDir, skipName)) continue; // 不覆盖转换产物 + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(resolved, dest); + copyRelativeDeps(path.dirname(resolved), path.dirname(dest), fs.readFileSync(resolved, 'utf8'), skipName, warnings, visited); + } +} + +function locateExtension(dir) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.main) { + const main = path.resolve(dir, pkg.main); + if (fs.existsSync(main) && isInsideDir(dir, main)) return { file: main, pkg }; + } + // pi 包格式:package.json 的 "pi": { "extensions": ["./src/index.ts"] } 声明扩展入口 + // (见 pi 官方 docs/extensions.md "Package with dependencies") + if (pkg.pi && Array.isArray(pkg.pi.extensions)) { + for (const entry of pkg.pi.extensions) { + if (typeof entry !== 'string') continue; + const f = path.resolve(dir, entry); + if (fs.existsSync(f) && isInsideDir(dir, f)) return { file: f, pkg }; + } + } + } catch { /* 忽略坏 package.json */ } + } + for (const name of EXT_NAMES) { + const f = path.join(dir, name); + if (fs.existsSync(f)) return { file: f, pkg: undefined }; + } + return undefined; +} + +// ── TS 类型剥离(保守版)── + +function stripTypes(source) { + let out = source; + // 1) 整行类型导入/导出 + out = out.replace(/^\s*import\s+type\b[^\n]*$/gm, ''); + out = out.replace(/^\s*export\s+type\b[^\n]*$/gm, ''); + // 2) interface / type 声明(单行或多行到匹配的 '}')—— 用括号配对处理多行 + out = out.replace(/^\s*(export\s+)?interface\s+\w+[^{]*\{[\s\S]*?\n\}/gm, ''); + out = out.replace(/^\s*(export\s+)?type\s+\w+\s*=[^;]*;/gm, ''); + // 3) 常见类型标注 `: Type` / `: string` 等 + out = out.replace(/:\s*(Type\.\w+|string|number|boolean|unknown|any|void|never|object|Array<[^>]*>|Promise<[^>]*>|Record]*>|ExtensionAPI|ExtensionContext|ExtensionCommandContext|JsonValue|StringEnum<[^>]*>)\b/g, ''); + // 4) `as X` 断言(保留 const 等 enum 场景?as const 也要去) + out = out.replace(/\s+as\s+(const|Type|string|number|boolean|unknown|any|never|JsonValue|ExtensionAPI|ExtensionContext)\b/g, ''); + // 5) 泛型实参 <...>(保守:只处理紧随标识符且内容是类型词汇的) + out = out.replace(/([A-Za-z_$][\w$]*)\s*<([A-Za-z_$][\w$]*|Type\.\w+|string|number|boolean|unknown|any|void)(\s*,\s*[^>]+)?>\s*(?=[(=;{])/g, '$1'); + return out; +} + +function checkSyntax(file) { + // 进程内语法校验(等价 node --check 的 CJS 语义,但不 spawn 子进程) + try { + const code = fs.readFileSync(file, 'utf8'); + new vm.Script(code, { filename: file }); + return { ok: true }; + } catch (err) { + return { ok: false, error: (err && err.message ? err.message : String(err)).trim() }; + } +} + +// ── typebox 导入重写 ── + +function rewriteVendorSource(source, baseName, warnings) { + let out = source; + // 1) 去掉 pi 包的 import type 行 + out = out.replace(/^\s*import\s+type[^\n]*from\s+["']@earendil-works[^\n]*$/gm, ''); + out = out.replace(/^\s*import[^\n]*from\s+["']@earendil-works\/pi-(?:coding-agent|ai|tui|agent-core)[^\n]*$/gm, (m) => { + warnings.push('移除了 pi 包的运行时导入(桥接环境不提供):' + m.trim().slice(0, 80)); + return ''; + }); + // 2) typebox 导入 → 本地垫片 + out = out.replace(/from\s+["']typebox["']/g, 'from "./typebox-shim.js"'); + if (/from\s+["']typebox["']/.test(source)) warnings.push('typebox 导入已重写为本地垫片 vendor/typebox-shim.js'); + // 3) vendor 相对导入修正:extension 平铺进输出 vendor/ 后,原 "./vendor/X"(相对 + // 源包目录)会解析成 vendor/vendor/X —— 重写为 "./X"(X 已随 vendor/ 一起复制)。 + out = out.replace(/from\s+["']\.\/vendor\/([^"']+)["']/g, 'from "./$1"'); + if (/from\s+["']\.\/vendor\//.test(source)) warnings.push('源包的 ./vendor/ 相对导入已修正为平铺路径'); + return out; +} + +// ── 生成 ── + +function convert(extensionFile, outDir, opts = {}) { + const isHost = !!opts.host; // host 模式:输出宿主组合插件(lib/index.js),而非 agent preset + const src = path.resolve(extensionFile); + const ext = path.extname(src); + const isTs = ext === '.ts'; + const source = fs.readFileSync(src, 'utf8'); + const warnings = []; + + // 包元数据(若输入是包目录) + let pkgMeta = undefined; + const srcDir = path.dirname(src); + const pkgPath = path.join(srcDir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { pkgMeta = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { /* 忽略 */ } + } + + // 提取注册信息(用于报告;运行时由桥接器兜底) + const tools = []; + const commands = []; + const events = []; + for (const call of findCalls(source, /(?:pi\.)?registerTool/g)) { + const m = /name:\s*["']([^"']+)["']/.exec(call.argsText); + if (m) tools.push(m[1]); + } + for (const call of findCalls(source, /(?:pi\.)?registerCommand/g)) { + const m = /^\s*["']([^"']+)["']/.exec(call.argsText); + if (m) commands.push(m[1]); + } + for (const call of findCalls(source, /(?:pi\.)?on/g)) { + const m = /^\s*["']([^"']+)["']/.exec(call.argsText); + if (m) events.push(m[1]); + } + + // 名称与描述:目录输入(包)时用目录名作 base,单文件输入用文件名 + const base = opts.dirName || path.basename(src, ext); + const presetId = slugify(pkgMeta ? (pkgMeta.name || base) : base) || 'from-mcode'; + const presetName = pkgMeta ? pkgMeta.name : base; + const presetDesc = pkgMeta && pkgMeta.description ? pkgMeta.description : `由 mcode/pi extension「${base}」转换生成的 DSH agent preset(桥接模式)`; + + // ── vendor 文件准备 ── + const vendorDir = path.join(outDir, 'vendor'); + fs.mkdirSync(vendorDir, { recursive: true }); + // 模块形态:ESM(import/export 开头)→ .js;CommonJS(module.exports)→ .cjs, + // 保证 type:module 输出包内 vendor 以正确语义被 import(CJS 被当 ESM 加载会丢 default)。 + const sourceIsEsm = /^\s*(import\s|export\s)/m.test(source); + const sourceIsCjs = !sourceIsEsm && /(?:^|[^\w$.])module\.exports\b|exports\.\w+\s*=/.test(source); + let vendorFileName = isTs ? path.basename(src, '.ts') + '.js' : (sourceIsCjs ? path.basename(src, path.extname(src)) + '.cjs' : path.basename(src)); + let vendorSource = source; + const stripped = isTs ? stripTypes(source) : null; + if (isTs) { + // 生成剥离类型后的 .js + fs.writeFileSync(path.join(vendorDir, vendorFileName), stripped, 'utf8'); + fs.copyFileSync(src, path.join(vendorDir, path.basename(src))); // 保留原 .ts + const chk = checkSyntax(path.join(vendorDir, vendorFileName)); + if (!chk.ok) { + warnings.push('TS 类型剥离后语法校验失败:' + chk.error.slice(0, 300) + ' —— 请手工把 extension 编译为 JS 后再转换'); + } + warnings.push('TS 源文件已做保守类型剥离(vendor/' + vendorFileName + '),请人工核对'); + } else { + fs.writeFileSync(path.join(vendorDir, vendorFileName), vendorSource, 'utf8'); + } + // 重写:typebox → 本地垫片;pi 运行时导入 → 注释 + const rewritten = rewriteVendorSource(isTs ? stripped : vendorSource, vendorFileName, warnings); + fs.writeFileSync(path.join(vendorDir, vendorFileName), rewritten, 'utf8'); + if (rewritten.includes('from "./typebox-shim.js"')) { + fs.writeFileSync(path.join(vendorDir, 'typebox-shim.js'), typeboxShimCode(), 'utf8'); + } + // 复制源包同目录的辅助文件,让带相对导入的 extension 包(如 crossplug dsh2mcode 产物: + // vendor/dsh-plugin.js + typebox-shim.js)转换后自包含。 + // 不覆盖已生成的 vendorFileName(源 vendor/ 里若有同名文件以转换产物为准)。 + const srcVendorDir = path.join(srcDir, 'vendor'); + if (fs.existsSync(srcVendorDir)) { + fs.mkdirSync(vendorDir, { recursive: true }); + for (const entry of fs.readdirSync(srcVendorDir, { withFileTypes: true })) { + if (!entry.isDirectory() && entry.name === vendorFileName) continue; + const s = path.join(srcVendorDir, entry.name); + const d = path.join(vendorDir, entry.name); + if (entry.isDirectory()) copyDir(s, d); + else if (entry.isFile()) fs.copyFileSync(s, d); + } + warnings.push('源包 vendor/ 依赖已复制到输出 vendor/(' + fs.readdirSync(srcVendorDir).filter((n) => n !== vendorFileName).join('、') + ')'); + } + for (const aux of ['typebox-shim.js', 'typebox-shim.mjs']) { + const p = path.join(srcDir, aux); + if (fs.existsSync(p) && !fs.existsSync(path.join(vendorDir, aux))) { + fs.copyFileSync(p, path.join(vendorDir, aux)); + warnings.push('源包辅助文件 ' + aux + ' 已复制到输出 vendor/'); + } + } + // 复制 extension 相对导入的本地依赖(./helper.js 等),让多文件 extension 自包含 + copyRelativeDeps(srcDir, vendorDir, rewritten, vendorFileName, warnings); + // 包内 skills 目录 + const srcSkills = path.join(srcDir, 'skills'); + const hasSkills = fs.existsSync(srcSkills); + + // ── 生成 agent.cordis.yml(仅 preset 模式)── + if (!isHost) { + const personaText = [ + `你是由 mcode/pi extension「${presetName}」转换而来的 DSH agent。`, + `转换模式:运行时桥接 —— 原 extension 的工厂函数在本会话进程中执行,`, + `其 registerTool / registerCommand 注册的能力已映射为 DSH 工具与命令。`, + presetDesc, + ].join('\n'); + const compositionRows = [ + { id: 'persona', name: '@deepseek-ai/dsh-persona', configText: personaText }, + { id: 'converted-plugin', name: './plugins/bridge.js' }, + ]; + const { renderPresetYaml, renderCompositionYaml } = require('./yaml.js'); + fs.writeFileSync(path.join(outDir, 'preset.yml'), renderPresetYaml({ name: presetName, description: presetDesc, order: 90 }), 'utf8'); + fs.writeFileSync( + path.join(outDir, 'agent.cordis.yml'), + renderCompositionYaml(compositionRows, [ + '由 crossplug (core/mcode2dsh.js) 生成 —— 桥接自 pi extension ' + path.basename(src), + '桥接插件(plugins/bridge.js)在本 preset 目录内自包含,零 npm 依赖。', + '原 extension 与剥离/重写后的副本在 vendor/。', + ]), + 'utf8', + ); + } + // ── 生成 lib/index.js(宿主组合插件形态:所有会话可用,不依赖 preset 选择)── + const bridge = renderBridge(presetId, vendorFileName, tools, commands, events, warnings, isHost); + if (isHost) { + fs.mkdirSync(path.join(outDir, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(outDir, 'lib', 'index.js'), bridge, 'utf8'); + fs.writeFileSync(path.join(outDir, 'package.json'), JSON.stringify({ + name: presetId, + version: '0.1.0', + description: `由 mcode/pi extension「${presetName}」转换生成的 DSH 宿主组合插件(crossplug 桥接)`, + type: 'module', + main: 'lib/index.js', + }, null, 2) + '\n', 'utf8'); + fs.writeFileSync(path.join(outDir, 'README.md'), [ + '# ' + presetId, + '', + '由 crossplug (core/mcode2dsh.js) 从 mcode/pi extension「' + presetName + '」转换生成的宿主组合插件。', + '所有会话可用,不依赖 agent preset。安装:', + ' 1. 复制本目录到 ~/.dsh/profiles//plugins/' + presetId + '/', + ' 2. 在 profile 的 cordis.patch.yml 追加:', + ' - insert:', + ' - id: ' + presetId, + " name: './plugins/" + presetId + "/lib/index.js'", + ' 3. 重启 DSH 进程。', + '', + ].join('\n') + '\n', 'utf8'); + } else { + fs.mkdirSync(path.join(outDir, 'plugins'), { recursive: true }); + fs.writeFileSync(path.join(outDir, 'plugins', 'bridge.js'), bridge, 'utf8'); + } + // ── 报告 ── + const report = [ + `# 转换报告:mcode/pi extension → DSH ${isHost ? '宿主组合插件(host plugin)' : 'agent preset'}`, + '', + `- 来源: ${src}`, + `- 插件 id(建议目录名): ${presetId}`, + `- 生成时间: ${new Date().toISOString()}`, + '', + `## 识别到的注册`, + '', + `- 工具(${tools.length}): ${tools.map((t) => '`' + t + '`').join('、') || '(未识别到)'}`, + `- 命令(${commands.length}): ${commands.map((c) => '/' + c).join('、') || '(未识别到)'}`, + `- 事件(${events.length}): ${events.map((e) => '`' + e + '`').join('、') || '(未识别到)'}`, + '', + `## 警告`, + '', + ...(warnings.length ? warnings.map((w) => `- ${w}`) : ['(无)']), + '', + `## 说明`, + '', + `- 运行时桥接器会把 pi 的 registerTool 映射为 DSH 工具注册(参数 schema 自动转换,输出为宽松 JSON)。`, + `- registerCommand 映射为 DSH 斜杠命令(handler 包装为 CommandResult)。`, + `- 事件桥接:before_agent_start → system-prompt/assemble(1:1);turn_end / turn_start / tool_call / tool_result / message_start / message_end → session/event 子事件(弱/只读转发;pi 的 block/结果改写能力不生效,详见 doc/event-mapping-questions.md)。`, + `- 工具 execute 中引用的 pi 运行时 API(如 ctx.ui)由垫片提供最小实现,复杂交互需人工适配。`, + '', + ].join('\n') + '\n'; + fs.writeFileSync(path.join(outDir, 'CONVERSION-REPORT.md'), report, 'utf8'); + + return { + kind: isHost ? 'mcode-extension-host' : 'mcode-extension', + source: src, + out: outDir, + presetId, + presetName, + tools: tools.length, + commands: commands.length, + events: events.length, + warnings: warnings.length, + reportFile: path.join(outDir, 'CONVERSION-REPORT.md'), + }; +} + +function renderBridge(presetId, vendorFileName, tools, commands, events, warnings, isHost) { + const toolList = JSON.stringify(tools, null, 2); + const commandList = JSON.stringify(commands, null, 2); + const eventList = JSON.stringify(events, null, 2); + return `// 由 crossplug (core/mcode2dsh.js) 生成的运行时桥接插件。 +// 零 npm 依赖:只用 DSH 的 ctx 服务(tools / commands)与 node 内置能力。 +// 它加载 vendor/${vendorFileName}(原 pi extension),用 pi-API 垫片调用其默认导出工厂, +// 把 registerTool / registerCommand 映射为 DSH 注册。 + +${INLINE_BRIDGE_HELPERS} +function piShim(ctx, log) { + const tools = ctx.get('tools'); + const commands = ctx.get('commands'); + const registered = []; + const warned = new Set(); + const warnOnce = (what) => { + if (warned.has(what)) return; + warned.add(what); + log('[bridge] ' + what); + }; + const shimCtx = { + ui: { + notify: (msg) => log('[bridge:ui.notify] ' + String(msg)), + setStatus: (key, text) => log('[bridge:ui.status] ' + key + ' = ' + text), + setWidget: (key, lines) => log('[bridge:ui.widget] ' + key), + confirm: async () => true, + input: async (question) => { log('[bridge:ui.input] ' + question); return ''; }, + select: async () => null, + custom: () => { warnOnce('ctx.ui.custom 无 DSH 对应,已空操作'); return { render() {} }; }, + }, + session: {}, + // 逆向事件桥接的 pi handler 收集桶(on() 对可桥接事件写入) + _piListeners: {}, + }; + + const api = { + registerTool(t) { + if (!t || typeof t.name !== 'string') { warnOnce('registerTool 缺少 name,跳过'); return; } + if (!tools) { warnOnce('DSH tools 服务不可用,工具 ' + t.name + ' 未注册'); return; } + try { + const schema = tbToJsonSchema(t.parameters); + tools.register({ + name: t.name, + description: t.description || '', + parameters: schema, + output: { + schema: {}, + render(_args, value) { + let text = ''; + if (value && typeof value === 'object' && Array.isArray(value.content)) { + text = value.content.map((c) => (c && typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\\n'); + } else { + text = JSON.stringify(value, null, 2); + } + return [{ type: 'text', text }]; + }, + }, + async execute(args, exec) { + if (typeof t.execute !== 'function') { + return { content: [{ type: 'text', text: '[桥接桩] ' + t.name + ' 未提供 execute' }], details: {} }; + } + try { + const r = await t.execute(exec.callId, args, exec.signal, undefined, shimCtx); + if (r && typeof r === 'object' && Array.isArray(r.content)) return r; + return r; + } catch (e) { + throw new Error('[bridge:' + t.name + '] ' + (e && e.message ? e.message : String(e))); + } + }, + }); + registered.push('tool:' + t.name); + } catch (e) { + log('[bridge] 工具 ' + t.name + ' 注册失败: ' + e.message); + } + }, + registerCommand(name, def) { + if (!commands || !name) { warnOnce('commands 服务不可用或命令缺名: ' + name); return; } + try { + commands.register({ + name, + description: (def && def.description) || '', + async handler(invocation) { + try { + const r = await def.handler((invocation.rawInput || '').trim(), shimCtx); + const text = typeof r === 'string' ? r : r === undefined || r === null ? '' : JSON.stringify(r, null, 2); + return { kind: 'success', text }; + } catch (e) { + return { kind: 'error', text: '[bridge:' + name + '] ' + (e && e.message ? e.message : String(e)) }; + } + }, + }); + registered.push('command:' + name); + } catch (e) { + log('[bridge] 命令 /' + name + ' 注册失败: ' + e.message); + } + }, + registerShortcut: (s) => warnOnce('registerShortcut 无 DSH 对应,已跳过: ' + JSON.stringify(s)), + registerFlag: (f) => warnOnce('registerFlag 无 DSH 对应,已跳过: ' + JSON.stringify(f)), + registerProvider: (p) => warnOnce('registerProvider 无 DSH 对应,已跳过: ' + JSON.stringify(p)), + appendEntry: () => warnOnce('appendEntry 无 DSH 对应,已跳过'), + on(event, handler) { + // 可逆向桥接的 pi 事件(对照 mcode-plugin-spec.md §5.2 / §15.3): + // before_agent_start → system-prompt/assemble(1:1) + // turn_end → session/event(turn/end)(1:1) + // turn_start → session/event(turn/start)(弱) + // tool_call → session/event(tool/call)(只读转发;pi 的 block 能力 DSH 侧不存在) + // tool_result → session/event(tool/result)(只读转发;pi 的结果改写能力 DSH 侧不存在) + // message_start → session/event(user/message)(弱) + // message_end → session/event(assistant/message)(弱) + // 其余事件:跳过并记日志(逻辑保留在 vendor 文件中)。 + const BRIDGE_EVENTS = { + before_agent_start: 'system-prompt/assemble', + turn_end: 'session/event', + turn_start: 'session/event', + tool_call: 'session/event', + tool_result: 'session/event', + message_start: 'session/event', + message_end: 'session/event', + }; + if (BRIDGE_EVENTS[event]) { + const list = shimCtx._piListeners[event] || (shimCtx._piListeners[event] = []); + list.push(handler); + return; + } + warnOnce('pi 事件 ' + event + ' 无 1:1 DSH 事件,监听已跳过(逻辑保留在 vendor 文件中)'); + }, + }; + + return { api, registered, shimCtx }; +} + +export default { + // host 模式(宿主组合插件)声明式依赖;preset 模式由组合行内联激活 + inject: ['tools', 'commands'], + async apply(ctx) { + const log = (msg) => { try { console.log('[crossplug:' + ${JSON.stringify(presetId)} + ']', msg); } catch { /* 忽略 */ } }; + try { + const mod = await import('../vendor/${vendorFileName}'); + const factory = mod && (mod.default !== undefined ? mod.default : mod); + if (typeof factory !== 'function') { + log('vendor/${vendorFileName} 没有默认导出工厂,无法桥接'); + return; + } + const { api, registered, shimCtx } = piShim(ctx, log); + await factory(api); + log('桥接完成,注册了: ' + (registered.length ? registered.join(', ') : '(无)')); + + // ── 逆向事件桥接:pi 事件 → DSH 事件 ── + // 1:1:before_agent_start → system-prompt/assemble(DSH host 平面每会话首轮组装, + // handler(assembly, context, next),assembly.sections.push({ name, text }) 注入段落)。 + // 弱/只读:turn_end / turn_start / tool_call / tool_result / message_start / message_end + // → session/event(DSH handler(session, event) 按 event.type 过滤,payload 在 event.data; + // KNOWN_SESSION_EVENT_TYPES:turn/end、turn/start、tool/call、tool/result、 + // user/message、assistant/message)。pi 的 block(tool_call)与结果改写(tool_result) + // 能力在 DSH 侧不存在——只读转发,返回值忽略。 + // 仅当 ctx 提供 on()(宿主组合/测试 mock 可能没有)且收集到 handler 时注册。 + const piListeners = shimCtx._piListeners || {}; + if (ctx && typeof ctx.on === 'function') { + if (piListeners['before_agent_start'] && piListeners['before_agent_start'].length) { + try { + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + for (const handler of piListeners['before_agent_start']) { + try { + const r = await handler({ systemPrompt: '' }, shimCtx); + if (r && typeof r.systemPrompt === 'string' && r.systemPrompt.length) { + assembly.sections.push({ name: 'mcode-bridge:' + ${JSON.stringify(presetId)}, text: r.systemPrompt }); + } + } catch (e) { + log('before_agent_start handler 抛错: ' + (e && e.message ? e.message : String(e))); + } + } + // DSH 的 system-prompt/assemble 是链式监听(原 dsh-hindsight-client 实证): + // 必须调用 next() 继续链,否则宿主组合的组装链中断 + return next(); + }); + log('事件桥接: before_agent_start → system-prompt/assemble'); + } catch (e) { + log('system-prompt/assemble 桥接注册失败: ' + (e && e.message ? e.message : String(e))); + } + } + const hasSessionEvent = ['turn_end', 'turn_start', 'tool_call', 'tool_result', 'message_start', 'message_end'] + .some((k) => piListeners[k] && piListeners[k].length); + if (hasSessionEvent) { + try { + ctx.on('session/event', async (session, event) => { + const type = event && event.type; + const data = event && event.data !== undefined ? event.data : event; + const fire = async (key, ev) => { + for (const handler of (piListeners[key] || [])) { + try { await handler(ev, shimCtx); } + catch (e) { log(key + ' handler 抛错: ' + (e && e.message ? e.message : String(e))); } + } + }; + if (type === 'turn/end') { + // Q2:pi turn_end.message 是 AgentMessage;DSH 侧 session.deriveMessages() 提供消息数组, + // 尽力桥接(vendor 的 auto-retain 等依赖消息文本的逻辑可工作) + let msgs = []; + try { + const dm = session && typeof session.deriveMessages === 'function' ? session.deriveMessages() : null; + if (Array.isArray(dm)) msgs = dm; + } catch { /* 忽略 */ } + await fire('turn_end', { turnIndex: data.turn !== undefined ? data.turn : 0, toolResults: [], message: msgs.length ? msgs : undefined }); + } else if (type === 'turn/start') { + await fire('turn_start', { turnIndex: data.turn !== undefined ? data.turn : 0 }); + } else if (type === 'tool/call') { + // pi ToolCallEvent:{ type, toolCallId, toolName, input }(Q3 已确认) + // DSH arguments 是模型原始 JSON 字符串 → 解析后赋给 input + let input = data.arguments; + if (typeof input === 'string') { try { input = JSON.parse(input); } catch { /* 保留原文 */ } } + await fire('tool_call', { type: 'tool_call', toolCallId: data.callId, toolName: data.name, input }); + } else if (type === 'tool/result') { + // pi ToolResultEvent:{ type, toolCallId, toolName, input, content, details?, isError, usage? }(Q4 已确认) + // DSH tool/result payload 无 callId/name({turn, step, message, error?, meta?})—— + // 从 message(ToolResultMessage,含 callId?/name?/content/details/isError)回溯(Q4 补充答复确认方向正确) + const msg = data.message || {}; + await fire('tool_result', { + type: 'tool_result', + toolCallId: msg.callId, + toolName: msg.name, + input: {}, + content: msg.content, + details: msg.details, + isError: msg.isError, + usage: msg.usage, + }); + } else if (type === 'user/message') { + // pi MessageStartEvent:{ type, message: AgentMessage }(Q5 已确认); + // DSH user/message 本身即用户消息,天然对应 pi 的 user 角色 + await fire('message_start', { type: 'message_start', message: data }); + } else if (type === 'assistant/message') { + await fire('message_end', { type: 'message_end', message: data.message }); + } + }); + log('事件桥接: session/event ← turn_end/turn_start/tool_call/tool_result/message_start/message_end'); + } catch (e) { + log('session/event 桥接注册失败: ' + (e && e.message ? e.message : String(e))); + } + } + } + } catch (e) { + log('桥接失败: ' + (e && e.message ? e.stack || e.message : String(e))); + } + }, +}; + +// ── 生成期提取的注册清单(仅报告参考)── +// tools: ${toolList.replace(/\n/g, '\n// ')} +// commands: ${commandList.replace(/\n/g, '\n// ')} +// events: ${eventList.replace(/\n/g, '\n// ')} +`; +} + +function slugify(id) { + return String(id || 'plugin') + .toLowerCase() + .replace(/^@/, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); +} + +// ═══ 模式二:MCode 插件包(agent-plugins / MiniMax / Claude 兼容 manifest)→ DSH preset ═══ + +const PLUGIN_MANIFESTS = [ + { file: 'plugin.json', kind: 'AGENT_PLUGINS_V1' }, + { file: path.join('.minimax-plugin', 'plugin.json'), kind: 'MINIMAX' }, + { file: path.join('.claude-plugin', 'plugin.json'), kind: 'CLAUDE_CODE' }, +]; + +function hasPluginManifest(dir) { + return PLUGIN_MANIFESTS.some((m) => fs.existsSync(path.join(dir, m.file))); +} + +function convertPluginPackage(srcDir, outDir) { + const entry = PLUGIN_MANIFESTS.find((m) => fs.existsSync(path.join(srcDir, m.file))); + if (!entry) throw new Error('目录中没有插件 manifest(plugin.json / .minimax-plugin/plugin.json / .claude-plugin/plugin.json)'); + const manifestPath = path.join(srcDir, entry.file); + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (e) { + throw new Error(`插件 manifest 解析失败: ${manifestPath}(${e.message})`); + } + const name = manifest.name || path.basename(srcDir); + const description = manifest.description || `由 mcode 插件「${name}」转换生成的 DSH agent preset`; + const presetId = slugify(name) || 'from-mcode-plugin'; + const warnings = []; + + // skills/ 目录复制(名字规范化) + const srcSkills = path.join(srcDir, 'skills'); + const hasSkills = fs.existsSync(srcSkills); + if (hasSkills) { + fs.mkdirSync(path.join(outDir, 'skills'), { recursive: true }); + for (const child of fs.readdirSync(srcSkills, { withFileTypes: true })) { + if (!child.isDirectory()) continue; + const safe = slugify(child.name) || 'skill'; + copyDir(path.join(srcSkills, child.name), path.join(outDir, 'skills', safe)); + if (safe !== child.name) warnings.push(`skill 目录「${child.name}」重命名为 ${safe}`); + } + } + + // mcp.json 读取(仅报告,DSH 侧 MCP 需另行配置) + const mcpPath = path.join(srcDir, 'mcp.json'); + const mcpNames = []; + if (fs.existsSync(mcpPath)) { + try { + const mcp = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); + mcpNames.push(...Object.keys(mcp.mcpServers || {})); + } catch { /* 忽略坏 mcp.json */ } + } + const claudeMcp = path.join(srcDir, '.mcp.json'); + if (fs.existsSync(claudeMcp)) { + try { + const mcp = JSON.parse(fs.readFileSync(claudeMcp, 'utf8')); + mcpNames.push(...Object.keys(mcp.mcpServers || {})); + } catch { /* 忽略 */ } + } + + // 生成 preset + const personaText = [ + `你是由 mcode 插件「${name}」转换而来的 DSH agent(manifest: ${entry.kind})。`, + `插件携带的 skills 已挂载到本 preset(skills/ 目录)。`, + description, + ].join('\n'); + + const compositionRows = [ + { id: 'persona', name: '@deepseek-ai/dsh-persona', configText: personaText }, + ]; + if (hasSkills) { + compositionRows.push({ + id: 'skill-filesystem', + name: '@deepseek-ai/dsh-skill-filesystem', + configScalarList: { + key: 'customSkillDirs', + items: ['!!js "process.getBuiltinModule(\'node:url\').fileURLToPath(new URL(\'skills/\', baseUrl))"'], + }, + }); + } + const { renderPresetYaml, renderCompositionYaml } = require('./yaml.js'); + + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, 'preset.yml'), renderPresetYaml({ name, description, order: 90 }), 'utf8'); + fs.writeFileSync( + path.join(outDir, 'agent.cordis.yml'), + renderCompositionYaml(compositionRows, [ + `由 crossplug (core/mcode2dsh.js) 生成 —— 转换自 mcode 插件包 ${path.basename(srcDir)}(manifest: ${entry.kind})`, + '插件 skills 已复制到本 preset 的 skills/,由 dsh-skill-filesystem 挂载。', + '插件中的 MCP 服务器不随 preset 迁移,需在 DSH 侧另行配置(见 CONVERSION-REPORT.md)。', + ]), + 'utf8', + ); + + // 报告 + const report = [ + `# 转换报告:mcode 插件包 → DSH agent preset`, + '', + `- 来源: ${srcDir}`, + `- manifest: ${manifestPath}(${entry.kind})`, + `- 插件名: ${name}`, + `- preset id(建议目录名): ${presetId}`, + `- 生成时间: ${new Date().toISOString()}`, + '', + `## 能力映射`, + '', + `- skills(${hasSkills ? '已复制到 preset skills/' : '无'})`, + `- MCP 服务器(${mcpNames.length ? mcpNames.join('、') : '无'}):DSH 侧不支持从插件包自动迁移 MCP,需在 DSH 配置中手工添加。`, + `- apps / extensions 字段:不在转换范围内。`, + '', + `## 警告`, + '', + ...(warnings.length ? warnings.map((w) => `- ${w}`) : ['(无)']), + '', + ].join('\n') + '\n'; + fs.writeFileSync(path.join(outDir, 'CONVERSION-REPORT.md'), report, 'utf8'); + + return { + kind: 'mcode-plugin-package', + source: srcDir, + out: outDir, + presetId, + presetName: name, + manifestKind: entry.kind, + skills: hasSkills, + mcpServers: mcpNames, + warnings: warnings.length, + reportFile: path.join(outDir, 'CONVERSION-REPORT.md'), + }; +} + +function copyDir(src, dst) { + fs.mkdirSync(dst, { recursive: true }); + for (const child of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, child.name); + const d = path.join(dst, child.name); + if (child.isDirectory()) copyDir(s, d); + else if (child.isFile()) fs.copyFileSync(s, d); + } +} + +// ═══ 入口 ═══ + +function convertInput(src, outDir, opts = {}) { + const stat = fs.statSync(src); + if (stat.isDirectory()) { + if (hasPluginManifest(src)) return convertPluginPackage(src, outDir); + const found = locateExtension(src); + if (!found) throw new Error(`目录中没有找到 extension 文件(index.js/extension.js/main.js/index.ts 等): ${src}`); + return convert(found.file, outDir, { pkg: found.pkg, dirName: path.basename(src), host: !!opts.host }); + } + if (stat.isFile()) return convert(src, outDir, { host: !!opts.host }); + throw new Error(`无法识别的输入: ${src}`); +} + +module.exports = { convert: convertInput, locateExtension, stripTypes, convertPluginPackage, hasPluginManifest }; diff --git a/plugins/Fectivnfy112357/crossplug/core/package.json b/plugins/Fectivnfy112357/crossplug/core/package.json new file mode 100644 index 0000000..67e69b8 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/package.json @@ -0,0 +1,5 @@ +{ + "name": "crossplug-core", + "private": true, + "type": "commonjs" +} diff --git a/plugins/Fectivnfy112357/crossplug/core/run.js b/plugins/Fectivnfy112357/crossplug/core/run.js new file mode 100644 index 0000000..bda90d5 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/run.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// run.js — crossplug 转换引擎入口(零依赖) +// +// 用法: +// node core/run.js dsh2mcode [--out ] [--json] +// node core/run.js mcode2dsh [--out ] [--json] +// node core/run.js list --side [--json] +// +// 输出:人类可读文本或 --json 的 JSON 对象(供适配器解析)。 +// +// 本托管插件不含安装器(CONTRIBUTING 禁止 installers):转换只写入 --out 目录, +// list 只读扫描用户插件目录;安装由用户手工完成(复制到目标目录 + /plugins 刷新)。 + +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const dsh2mcode = require('./dsh2mcode.js'); +const mcode2dsh = require('./mcode2dsh.js'); + +function dshHome() { + return process.env.DSH_HOME || path.join(os.homedir(), '.dsh'); +} +function minimaxHome() { + return path.join(os.homedir(), '.minimax'); +} +function piHome() { + return path.join(os.homedir(), '.pi'); +} + +function fail(message) { + process.stderr.write('错误: ' + message + '\n'); + process.exit(1); +} + +function argsOf(argv) { + const out = { positional: [], flags: {} }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const eq = a.indexOf('='); + if (eq > 0) { + out.flags[a.slice(2, eq)] = a.slice(eq + 1); + } else { + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + out.flags[a.slice(2)] = next; + i++; + } else { + out.flags[a.slice(2)] = true; + } + } + } else { + out.positional.push(a); + } + } + return out; +} + +function defaultOutDir(src, sub) { + const base = path.basename(path.resolve(src)); + return path.join(process.cwd(), 'out', sub + '-' + base); +} + +function listDsh() { + const userDir = path.join(dshHome(), '.agent-presets'); + const result = []; + if (fs.existsSync(userDir)) { + for (const d of fs.readdirSync(userDir, { withFileTypes: true })) { + if (d.isDirectory() && fs.existsSync(path.join(userDir, d.name, 'agent.cordis.yml'))) { + result.push({ id: d.name, scope: 'user', dir: path.join(userDir, d.name) }); + } + } + } + // 部署自带 preset(通过 npm 全局根定位 @deepseek-ai/dsh 安装目录) + for (const root of globalNpmRoots()) { + const deployDir = path.join(root, '@deepseek-ai', 'dsh', 'config', 'agent-presets'); + if (fs.existsSync(deployDir)) { + for (const d of fs.readdirSync(deployDir, { withFileTypes: true })) { + if (d.isDirectory() && fs.existsSync(path.join(deployDir, d.name, 'agent.cordis.yml'))) { + result.push({ id: d.name, scope: 'deployment', dir: path.join(deployDir, d.name) }); + } + } + break; + } + } + return result; +} + +// 纯路径推导(不执行任何子进程):从 node 可执行文件位置与常见布局推断 npm 全局根 +function globalNpmRoots() { + const roots = []; + try { + const exeDir = path.dirname(process.execPath); + roots.push(path.join(exeDir, 'node_global', 'node_modules')); + roots.push(path.join(path.dirname(exeDir), 'lib', 'node_modules')); + roots.push(path.join(path.dirname(exeDir), 'lib')); + } catch { /* 忽略 */ } + if (process.env.APPDATA) roots.push(path.join(process.env.APPDATA, 'npm', 'node_modules')); + const home = os.homedir(); + roots.push(path.join(home, '.local', 'lib', 'node_modules')); + roots.push(path.join(home, '.npm', 'global', 'node_modules')); + return roots; +} + +function listMcode() { + const result = []; + const pluginsDir = path.join(minimaxHome(), 'plugins'); + if (fs.existsSync(pluginsDir)) { + for (const d of fs.readdirSync(pluginsDir, { withFileTypes: true })) { + if (d.isDirectory()) result.push({ id: d.name, scope: 'minimax-plugins', dir: path.join(pluginsDir, d.name) }); + } + } + const extDir = path.join(piHome(), 'agent', 'extensions'); + if (fs.existsSync(extDir)) { + for (const d of fs.readdirSync(extDir, { withFileTypes: true })) { + const p = path.join(extDir, d.name); + if (d.isDirectory()) result.push({ id: d.name, scope: 'pi-extensions', dir: p }); + else if (d.isFile() && /\.(js|ts)$/.test(d.name)) result.push({ id: d.name, scope: 'pi-extensions', dir: p }); + } + } + return result; +} + +function main() { + const argv = process.argv.slice(2); + const cmd = argv[0]; + if (!cmd) { + process.stdout.write(usage()); + return; + } + if (cmd === 'version' || cmd === '--version' || cmd === '-v') { + process.stdout.write('crossplug 0.1.0\n'); + return; + } + const rest = argv.slice(1); + const a = argsOf(rest); + const json = !!a.flags.json; + + try { + let result; + if (cmd === 'dsh2mcode') { + const src = a.positional[0]; + if (!src) fail('dsh2mcode 需要输入路径(preset 目录或插件源码文件)'); + const out = a.flags.out || defaultOutDir(src, 'mcode'); + result = dsh2mcode.convert(path.resolve(src), path.resolve(out)); + result.ok = true; + } else if (cmd === 'mcode2dsh') { + const src = a.positional[0]; + if (!src) fail('mcode2dsh 需要输入路径(extension 文件或插件包目录)'); + const out = a.flags.out || defaultOutDir(src, 'preset'); + result = mcode2dsh.convert(path.resolve(src), path.resolve(out), { host: !!a.flags.host }); + result.ok = true; + } else if (cmd === 'list') { + const side = a.flags.side; + if (side === 'dsh') result = { ok: true, side, plugins: listDsh() }; + else if (side === 'mcode') result = { ok: true, side, plugins: listMcode() }; + else fail('list 需要 --side dsh|mcode'); + } else { + fail('未知命令: ' + cmd + '\n\n' + usage()); + } + + if (json) { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else { + process.stdout.write(render(result) + '\n'); + } + } catch (e) { + if (json) { + process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + '\n'); + process.exit(1); + } + fail(e.message); + } +} + +function render(r) { + const lines = []; + if (r.ok === false) return '错误: ' + r.error; + if (r.plugins !== undefined) { + lines.push(`【${r.side === 'dsh' ? 'DSH' : 'mcode'}】插件清单(${r.plugins.length}):`); + for (const p of r.plugins) lines.push(` - ${p.id} (${p.scope}) ${p.dir}`); + return lines.join('\n'); + } + if (r.side !== undefined) { + const label = r.side === 'dsh' ? 'DSH' : r.side === 'dsh-host' ? 'DSH(宿主组合)' : 'mcode'; + lines.push(`✔ 已安装到 ${label}:`); + for (const f of r.files) lines.push(` - ${f}`); + lines.push(` 目录: ${r.target || r.extDir}`); + if (r.settingsFile) lines.push(` settings: ${r.settingsFile}`); + if (r.patchFile) lines.push(` patch: ${r.patchFile}(${r.patchAction})`); + lines.push(` 下一步: ${r.next}`); + return lines.join('\n'); + } + lines.push(`✔ 转换完成: ${r.kind}`); + lines.push(` 来源: ${r.source}`); + lines.push(` 输出: ${r.out}`); + if (r.presetName) lines.push(` 名称: ${r.presetName}`); + if (r.packageName) lines.push(` 包名: ${r.packageName}`); + if (r.rows !== undefined) lines.push(` preset 行: ${r.rows},生成桩工具 ${r.tools} / 命令 ${r.commands},说明项 ${r.notes}`); + if (r.tools !== undefined && r.kind === 'mcode-extension') { + lines.push(` 工具: ${r.tools},命令: ${r.commands},事件: ${r.events},警告: ${r.warnings}`); + } + if (r.warnings !== undefined && r.kind !== 'mcode-extension') lines.push(` 警告: ${r.warnings}`); + if (r.reportFile) lines.push(` 报告: ${r.reportFile}`); + return lines.join('\n'); +} + +function usage() { + return `crossplug — DSH 插件 ↔ mcode(MiniMax Code / pi)插件双向转换 + +用法: + node core/run.js dsh2mcode [--out ] + node core/run.js mcode2dsh [--out ] [--host] + node core/run.js list --side dsh|mcode + node core/run.js version + +所有命令支持 --json 输出(供插件/脚本调用)。 +`; +} + +main(); diff --git a/plugins/Fectivnfy112357/crossplug/core/schema.js b/plugins/Fectivnfy112357/crossplug/core/schema.js new file mode 100644 index 0000000..06eaa4a --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/schema.js @@ -0,0 +1,224 @@ +// schema.js — JSON Schema ↔ typebox 相互转换(零依赖) +// +// dsh2mcode(插件代码模式):JSON Schema → typebox 代码字符串 +// mcode2dsh(桥接模式): typebox 运行时对象 → JSON Schema(鸭子类型识别) + +'use strict'; + +// ── JSON Schema → typebox 代码 ────────────────────────────────────────── + +function jsLiteral(v, indent) { + return JSON.stringify(v, null, indent === undefined ? 0 : indent); +} + +function isObjSchema(node) { + return node && typeof node === 'object' && !Array.isArray(node) && typeof node.type === 'string'; +} + +// 把 JSON Schema 节点渲染成 typebox 表达式代码 +function jsonSchemaToTypeboxCode(node, indent = '') { + if (!isObjSchema(node)) { + if (node && typeof node === 'object' && Array.isArray(node.oneOf)) { + const branches = node.oneOf.map((b) => jsonSchemaToTypeboxCode(b, indent)).join(',\n' + indent + ' '); + return `Type.Union([\n${indent} ${branches}\n${indent}])`; + } + return 'Type.Any()'; + } + const t = node.type; + const opts = []; + if (node.description) opts.push(`description: ${jsLiteral(node.description)}`); + const optText = opts.length ? `{ ${opts.join(', ')} }` : ''; + switch (t) { + case 'object': { + const props = Object.entries(node.properties || {}); + const req = (node.required || []).filter((k) => (node.properties || {})[k]); + if (req.length) opts.push(`required: ${jsLiteral(req)}`); + if (node.additionalProperties === false) opts.push('additionalProperties: false'); + const optText2 = opts.length ? `{ ${opts.join(', ')} }` : ''; + if (!props.length) return `Type.Object({}${optText2 ? ', ' + optText2 : ''})`; + const propsText = props + .map(([k, v]) => ` ${JSON.stringify(k)}: ${jsonSchemaToTypeboxCode(v, indent + ' ')}`) + .join(',\n'); + return `Type.Object({\n${propsText}\n }${optText2 ? ', ' + optText2 : ''})`; + } + case 'array': { + const items = node.items ? jsonSchemaToTypeboxCode(node.items, indent) : 'Type.Any()'; + return `Type.Array(${items}${optText ? ', ' + optText : ''})`; + } + case 'string': { + if (node.enum && node.enum.length) { + // mcode-plugin-spec.md §6.2:字符串枚举必须用 StringEnum(Type.Union/Literal + // 的字符串枚举在 Google API 不工作)。typebox-shim.js 提供 Type.StringEnum。 + const enumText = `[${node.enum.map((e) => jsLiteral(e)).join(', ')}]`; + return optText ? `Type.StringEnum(${enumText}, ${optText})` : `Type.StringEnum(${enumText})`; + } + return `Type.String(${optText})`; + } + case 'number': + return `Type.Number(${optText})`; + case 'integer': + return `Type.Integer(${optText})`; + case 'boolean': + return `Type.Boolean(${optText})`; + case 'null': + return `Type.Null()`; + default: + return 'Type.Any()'; + } +} + +// ── typebox 运行时对象 → JSON Schema ──────────────────────────────────── + +// typebox 的 .type 属性返回大写 Kind('Object'/'String'/...), +// 普通 JSON Schema 的 type 是小写 —— 这是可靠的鸭子类型判据。 +function looksTypebox(schema) { + return ( + schema && + typeof schema === 'object' && + typeof schema.type === 'string' && + /^[A-Z]/.test(schema.type) + ); +} + +function typeboxToJsonSchema(schema) { + if (!looksTypebox(schema)) { + // 已是普通 JSON Schema(或无法识别)——原样返回(只做浅拷贝防御) + if (schema && typeof schema === 'object' && !Array.isArray(schema)) return { ...schema }; + return {}; + } + const kind = schema.type; + const out = {}; + if (schema.description) out.description = schema.description; + switch (kind) { + case 'Object': { + out.type = 'object'; + out.properties = {}; + for (const [k, v] of Object.entries(schema.properties || {})) { + out.properties[k] = typeboxToJsonSchema(v); + } + if (Array.isArray(schema.required) && schema.required.length) out.required = [...schema.required]; + if (schema.additionalProperties !== undefined) out.additionalProperties = !!schema.additionalProperties; + return out; + } + case 'Array': + out.type = 'array'; + if (schema.items) out.items = typeboxToJsonSchema(schema.items); + return out; + case 'Union': + if (Array.isArray(schema.anyOf) && schema.anyOf.length >= 2) { + out.oneOf = schema.anyOf.map((b) => typeboxToJsonSchema(b)); + return out; + } + if (Array.isArray(schema.anyOf) && schema.anyOf.length === 1) return typeboxToJsonSchema(schema.anyOf[0]); + return { type: 'object' }; + case 'Optional': { + // Optional(T) 包裹:类型本身不强制 required + return typeboxToJsonSchema(schema.anyOf && schema.anyOf.length ? schema.anyOf[0] : {}); + } + case 'String': + out.type = 'string'; + if (Array.isArray(schema.anyOf) && schema.anyOf.length) { + const literals = schema.anyOf + .filter((b) => b && b.const !== undefined) + .map((b) => b.const); + if (literals.length) out.enum = literals; + } + return out; + case 'Number': + out.type = 'number'; + return out; + case 'Integer': + out.type = 'integer'; + return out; + case 'Boolean': + out.type = 'boolean'; + return out; + case 'Null': + out.type = 'null'; + return out; + case 'Literal': + if (schema.const !== undefined) { + out.const = schema.const; + out.type = typeof schema.const === 'number' ? (Number.isInteger(schema.const) ? 'integer' : 'number') : typeof schema.const === 'boolean' ? 'boolean' : 'string'; + return out; + } + return {}; + default: + return { type: 'object' }; + } +} + +// typebox 最小垫片源码(生成到输出包内,让转换产物零依赖自包含) +function typeboxShimCode() { + return [ + '// 由 crossplug 生成的 typebox 最小垫片:只实现 schema 构造所需子集,', + '// 运行时对象形态与 typebox 一致(大写 type 字段),供桥接器识别。', + 'function kind(t, props) { const s = { type: t, ...props }; return s; }', + 'export const Type = {', + ' Object: (properties = {}, options = {}) => kind("Object", { properties, required: options.required || [], additionalProperties: options.additionalProperties }),', + ' Array: (items, options = {}) => kind("Array", { items, ...(options.description ? { description: options.description } : {}) }),', + ' String: (options = {}) => kind("String", { ...(options.description ? { description: options.description } : {}) }),', + ' Number: (options = {}) => kind("Number", { ...(options.description ? { description: options.description } : {}) }),', + ' Integer: (options = {}) => kind("Integer", { ...(options.description ? { description: options.description } : {}) }),', + ' Boolean: (options = {}) => kind("Boolean", { ...(options.description ? { description: options.description } : {}) }),', + ' Null: () => kind("Null", {}),', + ' Any: () => kind("Any", {}),', + ' Unknown: () => kind("Any", {}),', + ' Literal: (value) => kind("Literal", { const: value }),', + ' Union: (anyOf) => kind("Union", { anyOf }),', + ' Optional: (inner) => kind("Optional", { anyOf: [inner] }),', + ' StringEnum: (values, options = {}) => kind("String", { anyOf: (values || []).map((v) => ({ const: v })), ...(options && options.description ? { description: options.description } : {}) }),', + ' Record: (key, value) => kind("Object", { properties: {}, additionalProperties: true }),', + '};', + '', + ].join('\n'); +} + +// 供生成文件内联的紧凑版(桥接插件运行时使用) +const INLINE_BRIDGE_HELPERS = ` +function tbToJsonSchema(schema) { + if (!schema || typeof schema !== 'object') return {}; + if (typeof schema.type !== 'string' || !/^[A-Z]/.test(schema.type)) return { ...schema }; + const kind = schema.type; + const out = {}; + if (schema.description) out.description = schema.description; + switch (kind) { + case 'Object': { + out.type = 'object'; + out.properties = {}; + for (const k of Object.keys(schema.properties || {})) out.properties[k] = tbToJsonSchema(schema.properties[k]); + if (Array.isArray(schema.required) && schema.required.length) out.required = schema.required.slice(); + if (schema.additionalProperties !== undefined) out.additionalProperties = !!schema.additionalProperties; + return out; + } + case 'Array': + out.type = 'array'; + if (schema.items) out.items = tbToJsonSchema(schema.items); + return out; + case 'Union': + if (Array.isArray(schema.anyOf) && schema.anyOf.length === 1) return tbToJsonSchema(schema.anyOf[0]); + if (Array.isArray(schema.anyOf) && schema.anyOf.length > 1) { out.oneOf = schema.anyOf.map(tbToJsonSchema); return out; } + return { type: 'object' }; + case 'Optional': + if (Array.isArray(schema.anyOf) && schema.anyOf.length) return tbToJsonSchema(schema.anyOf[0]); + return { type: 'object' }; + case 'String': { + out.type = 'string'; + // StringEnum(values) 在垫片里展开为 { type: 'String', anyOf: [{ const: v }] }: + // 提取 const 列表作为 JSON Schema enum,保持枚举参数语义(mcode-plugin-spec.md §6.2)。 + if (Array.isArray(schema.anyOf) && schema.anyOf.length) { + const literals = schema.anyOf.filter((b) => b && b.const !== undefined).map((b) => b.const); + if (literals.length) out.enum = literals; + } + return out; + } + case 'Number': out.type = 'number'; return out; + case 'Integer': out.type = 'integer'; return out; + case 'Boolean': out.type = 'boolean'; return out; + case 'Null': out.type = 'null'; return out; + default: return { type: 'object' }; + } +} +`; + +module.exports = { jsonSchemaToTypeboxCode, typeboxToJsonSchema, looksTypebox, INLINE_BRIDGE_HELPERS, typeboxShimCode }; diff --git a/plugins/Fectivnfy112357/crossplug/core/yaml.js b/plugins/Fectivnfy112357/crossplug/core/yaml.js new file mode 100644 index 0000000..d851c64 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/core/yaml.js @@ -0,0 +1,260 @@ +// yaml.js — DSH agent-preset YAML 子集解析与生成(零依赖) +// +// 只覆盖 DSH preset 实际使用的子集: +// - 顶层标量(name / description / order) +// - 0 缩进的 `- id: xxx` 行条目,嵌套 `name:` / `group:` / `isolate:` / `disabled:` / `config:` +// - `config:` 后跟标量、`|-` 字面块、或更深一层的 `- id:` 列表(group 场景) +// - `!!js` 标签原样保留为字符串 +// 不做通用 YAML 解析;遇到不支持的结构抛错并给出行号。 + +'use strict'; + +function linesOf(text) { + return text.replace(/\r\n/g, '\n').split('\n'); +} + +function indentOf(line) { + const m = /^[ ]*/.exec(line); + return m ? m[0].length : 0; +} + +function stripComment(line) { + // 只剥离行首为 '#' 的整行注释,行内注释保留(避免破坏 URL / 内容) + return line; +} + +function parseScalar(raw) { + const s = raw.trim(); + if (s === '') return ''; + if (s.startsWith('!!js')) return s; // 保留原样 + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + if (s === 'true') return true; + if (s === 'false') return false; + if (s === 'null' || s === '~') return null; + if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s); + return s; +} + +// 解析一个字面块(`|-` / `|`),返回 { text, nextIndex } +function parseLiteralBlock(lines, startIndex, keyIndent) { + const out = []; + let i = startIndex; + for (; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '') { + out.push(''); + continue; + } + const ind = indentOf(line); + if (ind <= keyIndent) break; + out.push(line.slice(Math.min(ind, keyIndent + 2)) === '' ? '' : line.slice(keyIndent + 2)); + } + // 去掉尾随空行 + while (out.length && out[out.length - 1] === '') out.pop(); + return { text: out.join('\n'), nextIndex: i }; +} + +// 解析一个条目块(从 `- id:` 行开始到下一个同级 `- id:` 之前) +function parseEntry(lines, startIndex) { + const entry = { id: '', name: '', disabled: undefined, group: false, isolate: undefined, config: undefined, raw: [] }; + let i = startIndex; + const keyIndent = indentOf(lines[startIndex]); + // 收集 raw(原样文本,用于无损复制) + for (let j = startIndex; j < lines.length; j++) { + const line = lines[j]; + if (j > startIndex && line.trim() !== '' && indentOf(line) <= keyIndent) break; + entry.raw.push(line); + } + let cursor = startIndex; + for (; cursor < lines.length; ) { + const line = lines[cursor]; + if (line.trim() === '' || line.trim().startsWith('#')) { + cursor++; + continue; + } + const ind = indentOf(line); + if (ind <= keyIndent && cursor > startIndex) break; + const trimmed = line.trim(); + if (trimmed.startsWith('- id:')) { + entry.id = trimmed.slice(5).trim(); + cursor++; + continue; + } + const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(trimmed); + if (!m) { + throw new Error(`yaml: 无法解析行 ${cursor + 1}: ${line}`); + } + const key = m[1]; + const rest = m[2]; + if (rest.startsWith('|-') || rest.startsWith('|')) { + const block = parseLiteralBlock(lines, cursor + 1, ind); + entry[key] = block.text; + cursor = block.nextIndex; + continue; + } + if (rest === '') { + // 键后跟更深缩进的嵌套块:整块按原文捕获;config 下的 `- id:` 列表做结构化解析 + let j = cursor + 1; + while (j < lines.length && (lines[j].trim() === '' || lines[j].trim().startsWith('#'))) j++; + if (j < lines.length && indentOf(lines[j]) > ind) { + if (key === 'config' && lines[j].trim().startsWith('- id:')) { + const nested = []; + let k = j; + let ok = true; + while (k < lines.length) { + const l = lines[k]; + if (l.trim() === '') { k++; continue; } + if (indentOf(l) <= ind) break; + if (l.trim().startsWith('- id:')) { + const sub = parseEntry(lines, k); + nested.push(sub); + k = sub.nextIndex; + } else { + ok = false; + break; + } + } + if (ok && nested.length) { + entry.config = { __rows: nested }; + cursor = k; + continue; + } + j = cursor + 1; + while (j < lines.length && (lines[j].trim() === '' || lines[j].trim().startsWith('#'))) j++; + } + const blockLines = []; + while (j < lines.length) { + const l = lines[j]; + if (l.trim() === '') { blockLines.push(''); j++; continue; } + if (indentOf(l) <= ind) break; + blockLines.push(l); + j++; + } + while (blockLines.length && blockLines[blockLines.length - 1] === '') blockLines.pop(); + entry[key] = blockLines.join('\n'); + cursor = j; + continue; + } + entry[key] = ''; + cursor = j; + continue; + } + entry[key] = parseScalar(rest); + cursor++; + } + entry.nextIndex = cursor; + return entry; +} + +// 解析 preset 目录中的两个 YAML 文件 +// 返回 { name, description, order, rows: [...], meta: { presetPath, compositionPath } } +function parsePreset(presetYamlText, compositionYamlText, paths = {}) { + const meta = { presetPath: paths.presetPath, compositionPath: paths.compositionPath }; + const presetInfo = { name: '', description: '', order: undefined }; + if (presetYamlText) { + for (const line of linesOf(presetYamlText)) { + const t = line.trim(); + if (t === '' || t.startsWith('#')) continue; + const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(t); + if (!m) continue; + if (m[1] === 'name' || m[1] === 'description' || m[1] === 'order') { + presetInfo[m[1]] = parseScalar(m[2]); + } + } + } + const rows = []; + if (compositionYamlText) { + const lines = linesOf(compositionYamlText); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const t = line.trim(); + if (t === '' || t.startsWith('#')) { i++; continue; } + if (t.startsWith('- id:')) { + const entry = parseEntry(lines, i); + rows.push(entry); + i = entry.nextIndex; + } else { + throw new Error(`yaml: 组合文件顶层只支持 '- id:' 条目,行 ${i + 1}: ${line}`); + } + } + } + return { ...presetInfo, rows, meta }; +} + +// ── 生成 ──────────────────────────────────────────────────────────────── + +function q(v) { + if (typeof v === 'string') { + if (v === '' || /[:#\[\]{}&*!|>'"%@`]/.test(v) || /^\s|\s$/.test(v)) { + return JSON.stringify(v); + } + return v; + } + return String(v); +} + +function indentBlock(text, pad) { + if (!text) return ''; + return text + .split('\n') + .map((l) => (l === '' ? '' : pad + l)) + .join('\n'); +} + +// 生成 preset.yml +function renderPresetYaml({ name, description, order = 90 }) { + const out = []; + out.push(`name: ${q(name)}`); + out.push(`description: ${q(description)}`); + if (order !== undefined) out.push(`order: ${order}`); + return out.join('\n') + '\n'; +} + +// 生成 agent.cordis.yml +// rows: [{ id, name, configText?, configRows?, configScalarList?, disabled? }] +// configScalarList: { key: string, items: string[] } —— 渲染为 key 下的标量列表(如 customSkillDirs 的 !!js 行) +// headerComment: 字符串或字符串数组 +function renderCompositionYaml(rows, headerComment) { + const out = []; + if (headerComment) { + const comments = Array.isArray(headerComment) ? headerComment : String(headerComment).split('\n'); + for (const l of comments) out.push('# ' + l); + out.push(''); + } + for (const row of rows) { + if (row.disabled === true) out.push(`- id: ${row.id}`); + else out.push(`- id: ${row.id}`); + out.push(` name: ${q(row.name)}`); + if (row.configText !== undefined && row.configText !== null) { + out.push(' config:'); + out.push(' text: |-'); + out.push(indentBlock(row.configText, ' ')); + } else if (row.configRows && row.configRows.length) { + out.push(' config:'); + for (const sub of row.configRows) { + out.push(` - id: ${sub.id}`); + if (sub.name) out.push(` name: ${q(sub.name)}`); + if (sub.configText !== undefined) { + out.push(' config:'); + out.push(' text: |-'); + out.push(indentBlock(sub.configText, ' ')); + } + } + } else if (row.configScalarList) { + out.push(' config:'); + out.push(` ${row.configScalarList.key}:`); + for (const item of row.configScalarList.items) { + out.push(` - ${item}`); + } + } else if (row.config !== undefined) { + out.push(` config: ${q(row.config)}`); + } + if (row.disabled === true) out.push(' disabled: true'); + } + return out.join('\n') + '\n'; +} + +module.exports = { parsePreset, renderPresetYaml, renderCompositionYaml, parseScalar, linesOf }; diff --git a/plugins/Fectivnfy112357/crossplug/plugin.json b/plugins/Fectivnfy112357/crossplug/plugin.json new file mode 100644 index 0000000..e48f0f1 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/plugin.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "crossplug", + "version": "0.1.0", + "description": "Bidirectional plugin converter between DSH (DeepSeek Harness) and mcode (MiniMax Code / pi): convert DSH agent presets / plugin sources into mcode plugin packages (agent-plugins 1.0.0), and mcode / pi extensions into DSH presets or host plugins.", + "author": { + "name": "Fectivnfy112357", + "url": "https://github.com/Fectivnfy112357" + }, + "license": "Apache-2.0", + "keywords": ["dsh", "mcode", "converter", "bridge", "plugin"] +} diff --git a/plugins/Fectivnfy112357/crossplug/skills/convert-plugin/SKILL.md b/plugins/Fectivnfy112357/crossplug/skills/convert-plugin/SKILL.md new file mode 100644 index 0000000..68e0213 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/skills/convert-plugin/SKILL.md @@ -0,0 +1,57 @@ +--- +name: convert-plugin +description: 在 DSH 与 mcode(MiniMax Code / pi)之间双向转换插件。当用户要求把 DSH 插件/preset 转成 mcode 插件、或把 mcode/pi 插件转成 DSH 插件时使用。 +--- + +# 插件转换(DSH ↔ mcode) + +本技能教 Agent 如何执行 DSH 与 mcode 之间的插件转换。转换核心(`core/run.js`)随本插件 +安装在插件目录的 `core/` 下,零 npm 依赖,全程本地执行(无网络访问、无凭据)。 + +副作用与安全边界: +- 转换命令只写入 `--out` 指定的输出目录(同名文件会覆盖;不会删除目录内其他文件)。 +- `list` 只读扫描用户侧插件目录(`~/.dsh/.agent-presets`、`~/.minimax/plugins`、`~/.pi/agent/extensions`),不做任何写入。 +- 本插件**不含安装器**(社区仓库 CONTRIBUTING 禁止 installers):不修改 `~/.dsh`、`~/.minimax`、`~/.pi` 的配置或内容,不执行 `npm` 等子进程。安装由用户手工完成(复制产物到目标目录 + 刷新插件列表)。 + +## 何时使用 + +- 用户想把 **DSH** 的 agent preset 或插件源码搬到 mcode / MiniMax Code; +- 用户想把 **mcode / pi** 的 extension 或插件包搬到 DSH; +- 用户询问两侧已有哪些插件(`list`)。 + +## 转换命令 + +插件目录即本技能所在插件的根目录(MiniMax Code 的 Local 插件中显示为 crossplug): + +```text +node "<插件目录>/core/run.js" dsh2mcode [--out <目录>] +node "<插件目录>/core/run.js" mcode2dsh [--out <目录>] [--host] +node "<插件目录>/core/run.js" list --side dsh|mcode +``` + +`--host`:mcode2dsh 输出 DSH **宿主组合插件**(`lib/index.js`,所有会话可用、不依赖 preset 选择); +缺省输出 agent preset(`preset.yml` + `agent.cordis.yml` + `plugins/bridge.js` + `vendor/`)。 + +## 方向说明 + +### dsh2mcode(DSH → mcode) + +| 输入 | 输出 | +| --- | --- | +| DSH agent preset 目录(`preset.yml` + `agent.cordis.yml`) | **agent-plugins.org 1.0.0 插件包**(`plugin.json` + `skills/`),复制到 `~/.minimax/plugins//` 后经 `/plugins` → Local 安装;工具行按映射表分类,结论逐行写入 `CONVERSION-REPORT.md` | +| DSH 插件源码文件(.js 动态插件) | **agent-plugins MCP 包**(`plugin.json` + `mcp.json` + `mcp-server.js`),工具经 MCP stdio 生效——mcode CLI 用户侧唯一的插件工具路径 | + +### mcode2dsh(mcode → DSH) + +| 输入 | 输出 | +| --- | --- | +| pi extension 文件/包目录 | 桥接 preset 或 host 插件:原 extension 工厂经 pi-API 垫片原样执行(逻辑零移植),工具/命令/事件桥接注册为 DSH 能力 | +| mcode 插件包(`plugin.json` / `.minimax-plugin` / `.claude-plugin`) | 自包含 preset:persona + `skills/` 复制(`dsh-skill-filesystem` 挂载) | + +## 注意事项 + +- 转换是"脚手架 + 映射":无法映射的能力生成带说明的桩,每项结论在 `CONVERSION-REPORT.md`。 +- **mcode CLI 不加载 pi extension** :dsh2mcode 输出的 pi extension 包仅独立 pi CLI 有效, + 面向 mcode 的产物以 agent-plugins MCP 包为准;事件桥接(before_agent_start / turn_end)仅在 pi / DSH 运行时生效。 +- MCP 服务器不随 preset 迁移(DSH 侧需手工配置);技能内容经 `~/.agents/skills` 共享,不转换。 +- 转换结果安装后需刷新插件列表(`/plugins` 中 Ctrl+R)或重启相应工具。 diff --git a/plugins/Fectivnfy112357/crossplug/test/run.test.mjs b/plugins/Fectivnfy112357/crossplug/test/run.test.mjs new file mode 100644 index 0000000..b1a9a73 --- /dev/null +++ b/plugins/Fectivnfy112357/crossplug/test/run.test.mjs @@ -0,0 +1,800 @@ +// run.test.mjs — crossplug 转换器自动化测试(随仓库 npm test → node --test 运行) +// +// 覆盖评审要求: +// 1. 输入路径边界(不存在/缺文件/错误形态 → 干净报错,不产生输出) +// 2. 输出覆盖语义(--out 已存在时:同名覆盖,其余文件不受影响) +// 3. symlink / path traversal 安全(不跟随 symlink 复制;插件名净化) +// 4. 生成 manifest / MCP 的可加载性(JSON 可解析、server 语法可检查) +// 5. 失败时不破坏既有目录(转换失败 → 输出目录原样保留) + +import assert from 'node:assert/strict'; +import { execFile, spawn, spawnSync } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { mkdtemp, mkdir, readFile, rm, writeFile, symlink, readdir, stat, cp } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); + +const coreDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core'); +const runJs = path.join(coreDir, 'run.js'); +const dsh2mcode = require('../core/dsh2mcode.js'); +const mcode2dsh = require('../core/mcode2dsh.js'); + +const PLUGIN_NAME = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; +const SAFE = /^(?!.*(?:--|\.\.)).*$/u; + +async function tempWorkspace(t, prefix) { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix || 'crossplug-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +async function cli(t, args, cwd) { + try { + const { stdout } = await execFileAsync(process.execPath, [runJs, ...args], { cwd: cwd ?? os.tmpdir() }); + return { code: 0, stdout }; + } catch (err) { + return { code: err.code ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }; + } +} + +function writeFixturePreset(srcDir, { name = 'mini-preset' } = {}) { + return Promise.all([ + writeFile(path.join(srcDir, 'preset.yml'), `name: ${name}\ndescription: minimal fixture\norder: 90\n`), + writeFile(path.join(srcDir, 'agent.cordis.yml'), [ + '- id: persona', + ' name: "@deepseek-ai/dsh-persona"', + ' config:', + ' text: |-', + ' 测试 preset 人设', + '- id: converted-plugin', + ' name: ./plugins/bridge.js', + '', + ].join('\n')), + ]); +} + +const DSH_PLUGIN_SOURCE = [ + "module.exports = {", + " name: 'mini-plugin',", + " apply(ctx) {", + " const tools = ctx.get('tools');", + " tools.register({", + " name: 'mini_hello',", + " description: 'say hello',", + " parameters: { type: 'object', properties: {} },", + " async execute(args) { return 'hi'; },", + " });", + " },", + "};", + "", +].join('\n'); + +const PI_EXTENSION = [ + "export default function (pi) {", + " pi.registerTool({", + " name: 'e2e_ping',", + " description: 'e2e ping tool',", + " parameters: { type: 'object', properties: {} },", + " async execute() { return { content: [{ type: 'text', text: 'pong' }], details: {} }; },", + " });", + " pi.registerCommand('e2e-hello', {", + " description: 'e2e command',", + " handler: async (args) => 'hi ' + (args || 'world'),", + " });", + "};", + "", +].join('\n'); + +async function nodeCheck(t, file) { + const r = await execFileAsync(process.execPath, ['--check', file]); + return r; +} + +// 加载生成的 DSH 桥接插件(preset 的 plugins/bridge.js 或 host 的 lib/index.js), +// 用 mock ctx 复刻 DSH cordis 的加载方式(import + 注入 tools/commands + apply), +// 返回注册到的 tools/commands,供执行断言。 +async function loadDshBridge(file) { + const mod = await import(pathToFileURL(file).href); + const plugin = mod.default; + const tools = new Map(); + const commands = new Map(); + const ctx = { + get(name) { + if (name === 'tools') return { register(spec) { tools.set(spec.name, spec); } }; + if (name === 'commands') return { register(spec) { commands.set(spec.name, spec); } }; + return undefined; + }, + on() {}, + }; + await plugin.apply(ctx); + return { tools, commands }; +} + +// ── 1. 输入路径边界 ── + +test('dsh2mcode without input path fails with usage error', async (t) => { + const ws = await tempWorkspace(t); + const r = await cli(t, ['dsh2mcode'], ws); + assert.notEqual(r.code, 0); + assert.match(r.stderr ?? '', /需要输入路径/u); +}); + +test('dsh2mcode with nonexistent source fails cleanly', async (t) => { + const ws = await tempWorkspace(t); + const out = path.join(ws, 'out'); + const r = await cli(t, ['dsh2mcode', path.join(ws, 'nope'), '--out', out], ws); + assert.notEqual(r.code, 0); + assert.match(r.stderr ?? '', /ENOENT|不存在|错误/u); + // 不产生输出目录 + await assert.rejects(stat(out), /ENOENT/u); +}); + +test('dsh2mcode preset missing preset.yml fails without touching out dir', async (t) => { + const ws = await tempWorkspace(t); + const src = path.join(ws, 'src'); + await mkdir(src, { recursive: true }); + await writeFile(path.join(src, 'agent.cordis.yml'), '- id: x\n name: y\n'); + const out = path.join(ws, 'out'); + await mkdir(out, { recursive: true }); + await writeFile(path.join(out, 'sentinel.txt'), 'keep-me'); + const r = await cli(t, ['dsh2mcode', src, '--out', out], ws); + assert.notEqual(r.code, 0); + assert.match(r.stderr ?? '', /preset\.yml 不存在/u); + // 失败不破坏既有目录 + assert.equal(await readFile(path.join(out, 'sentinel.txt'), 'utf8'), 'keep-me'); +}); + +test('mcode2dsh with nonexistent source fails cleanly', async (t) => { + const ws = await tempWorkspace(t); + const r = await cli(t, ['mcode2dsh', path.join(ws, 'nope')], ws); + assert.notEqual(r.code, 0); + assert.match(r.stderr ?? '', /ENOENT|错误/u); +}); + +test('unknown command exits non-zero', async (t) => { + const ws = await tempWorkspace(t); + const r = await cli(t, ['frobnicate'], ws); + assert.notEqual(r.code, 0); + assert.match(r.stderr ?? '', /未知命令/u); +}); + +// ── 2. 转换 + 输出覆盖语义 ── + +test('dsh2mcode converts a minimal preset; output valid and sentinel preserved', async (t) => { + const ws = await tempWorkspace(t); + const src = path.join(ws, 'src'); + await mkdir(path.join(src, 'skills', 'test-skill'), { recursive: true }); + await writeFixturePreset(src); + await writeFile(path.join(src, 'skills', 'test-skill', 'SKILL.md'), + '---\nname: test-skill\ndescription: fixture skill\n---\n\n# Skill\n'); + const out = path.join(ws, 'out'); + await mkdir(out, { recursive: true }); + await writeFile(path.join(out, 'sentinel.txt'), 'keep-me'); + + const result = dsh2mcode.convert(src, out); + assert.equal(result.ok ?? true, true); + + const manifest = JSON.parse(await readFile(path.join(out, 'plugin.json'), 'utf8')); + assert.match(manifest.name, PLUGIN_NAME); + assert.ok(manifest.$schema.includes('agent-plugins.org')); + const skillFile = path.join(out, 'skills', 'test-skill', 'SKILL.md'); + assert.match(await readFile(skillFile, 'utf8'), /^name: test-skill/mu); + assert.ok((await stat(path.join(out, 'CONVERSION-REPORT.md'))).isFile()); + // 不破坏既有文件 + assert.equal(await readFile(path.join(out, 'sentinel.txt'), 'utf8'), 'keep-me'); +}); + +test('dsh2mcode plugin source yields loadable MCP package', async (t) => { + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'mini-plugin.js'); + await writeFile(srcFile, DSH_PLUGIN_SOURCE); + const out = path.join(ws, 'out'); + + const result = dsh2mcode.convert(srcFile, out); + assert.ok(result); + + const manifest = JSON.parse(await readFile(path.join(out, 'plugin.json'), 'utf8')); + assert.match(manifest.name, PLUGIN_NAME); + assert.ok(manifest.name && SAFE.test(manifest.name)); + + const mcp = JSON.parse(await readFile(path.join(out, 'mcp.json'), 'utf8')); + const servers = Object.values(mcp.mcpServers ?? {}); + assert.ok(servers.length >= 1); + assert.equal(servers[0].type, 'stdio'); + assert.equal(servers[0].command, 'node'); + + // 生成 server 语法可检查 + await nodeCheck(t, path.join(out, 'mcp-server.js')); + await nodeCheck(t, path.join(out, 'vendor', 'dsh-plugin.cjs')); +}); + +test('generated MCP server completes initialize, tools/list, and tools/call', async (t) => { + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'mini-plugin.js'); + await writeFile(srcFile, DSH_PLUGIN_SOURCE); + const out = path.join(ws, 'out'); + dsh2mcode.convert(srcFile, out); + + const child = spawn(process.execPath, ['mcp-server.js'], { + cwd: out, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + const responses = []; + lines.on('line', (line) => { try { responses.push(JSON.parse(line)); } catch { /* 忽略非 JSON */ } }); + + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } }) + '\n'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'mini_hello', arguments: {} } }) + '\n'); + + await waitFor(() => responses.filter((r) => r.id !== undefined).length >= 3); + const byId = Object.fromEntries(responses.filter((r) => r.id !== undefined).map((r) => [r.id, r])); + assert.equal(byId[1].result.serverInfo.name, 'crossplug-mini-plugin'); + assert.equal(byId[2].result.tools[0].name, 'mini_hello'); + assert.equal(byId[3].result.content[0].text, 'hi'); + + // 显式收尾:结束 stdin → 杀进程 → 等子进程退出 → 关 readline, + // 避免 t.after 里 rm(ws) 时子进程仍占用 out/ 文件(Windows 下 EBUSY)。 + child.stdin.end(); + child.kill(); + await new Promise((resolve) => child.once('exit', resolve)); + lines.close(); +}); + +test('plugin name from hostile preset is sanitized', async (t) => { + const ws = await tempWorkspace(t); + const src = path.join(ws, 'src'); + await mkdir(src, { recursive: true }); + await writeFixturePreset(src, { name: '../../Evil --Name!' }); + const out = path.join(ws, 'out'); + dsh2mcode.convert(src, out); + const manifest = JSON.parse(await readFile(path.join(out, 'plugin.json'), 'utf8')); + assert.match(manifest.name, PLUGIN_NAME); + assert.ok(SAFE.test(manifest.name)); + assert.ok(!manifest.name.includes('..') && !manifest.name.includes('--')); +}); + +test('mcode2dsh converts a pi extension into a bridge preset', async (t) => { + const ws = await tempWorkspace(t); + const ext = path.join(ws, 'extension.js'); + await writeFile(ext, PI_EXTENSION); + const out = path.join(ws, 'out'); + + const result = mcode2dsh.convert(ext, out); + assert.ok(result); + + const preset = await readFile(path.join(out, 'preset.yml'), 'utf8'); + assert.match(preset, /name:/u); + assert.ok((await stat(path.join(out, 'agent.cordis.yml'))).isFile()); + assert.ok((await stat(path.join(out, 'plugins', 'bridge.js'))).isFile()); + assert.ok((await stat(path.join(out, 'CONVERSION-REPORT.md'))).isFile()); +}); + +test('mcode2dsh --host produces lib/index.js with module package.json', async (t) => { + const ws = await tempWorkspace(t); + const ext = path.join(ws, 'extension.js'); + await writeFile(ext, PI_EXTENSION); + const out = path.join(ws, 'out'); + + const result = mcode2dsh.convert(ext, out, { host: true }); + assert.ok(result); + assert.ok((await stat(path.join(out, 'lib', 'index.js'))).isFile()); + const pkg = JSON.parse(await readFile(path.join(out, 'package.json'), 'utf8')); + assert.equal(pkg.type, 'module'); + assert.equal(pkg.main, 'lib/index.js'); +}); + +test('generated DSH host plugin loads and executes the converted tool', async (t) => { + const ws = await tempWorkspace(t); + const ext = path.join(ws, 'extension.js'); + await writeFile(ext, PI_EXTENSION); + const out = path.join(ws, 'out'); + mcode2dsh.convert(ext, out, { host: true }); + + const { tools, commands } = await loadDshBridge(path.join(out, 'lib', 'index.js')); + assert.ok(tools.has('e2e_ping'), 'e2e_ping 工具应注册'); + assert.ok(commands.has('e2e-hello'), 'e2e-hello 命令应注册'); + + const result = await tools.get('e2e_ping').execute({}, { callId: undefined, signal: undefined }); + assert.equal(result.content[0].text, 'pong'); +}); + +test('generated DSH preset bridge also loads and executes', async (t) => { + const ws = await tempWorkspace(t); + const ext = path.join(ws, 'extension.js'); + await writeFile(ext, PI_EXTENSION); + const out = path.join(ws, 'out'); + mcode2dsh.convert(ext, out); + + const { tools } = await loadDshBridge(path.join(out, 'plugins', 'bridge.js')); + assert.ok(tools.has('e2e_ping')); + const result = await tools.get('e2e_ping').execute({}, { callId: undefined, signal: undefined }); + assert.equal(result.content[0].text, 'pong'); +}); + +test('generated DSH host plugin loads via real @deepseek-ai/cordis DI', async (t) => { + const cordisEntry = resolveCordis(); + if (!cordisEntry) { + t.skip('@deepseek-ai/cordis 未找到 — 需在 DSH 环境跑(设 DSH_NODE_MODULES)'); + return; + } + const { Context } = await import(pathToFileURL(cordisEntry).href); + + const ws = await tempWorkspace(t); + const ext = path.join(ws, 'extension.js'); + await writeFile(ext, PI_EXTENSION); + const out = path.join(ws, 'out'); + mcode2dsh.convert(ext, out, { host: true }); + + const plugin = (await import(pathToFileURL(path.join(out, 'lib', 'index.js')).href)).default; + assert.deepEqual(plugin.inject, ['tools', 'commands']); + + // 真 cordis DI:提供 stub 的 tools/commands 服务,用真 registry 安装插件(校验 inject + 跑 apply) + const tools = new Map(); + const commands = new Map(); + const ctx = new Context(); + ctx.provide('tools', { register(spec) { tools.set(spec.name, spec); } }); + ctx.provide('commands', { register(spec) { commands.set(spec.name, spec); } }); + await ctx.plugin(plugin, {}); + + assert.ok(tools.has('e2e_ping'), '真 cordis 应注册 e2e_ping'); + assert.ok(commands.has('e2e-hello'), '真 cordis 应注册 e2e-hello'); + const result = await tools.get('e2e_ping').execute({}, { callId: undefined, signal: undefined }); + assert.equal(result.content[0].text, 'pong'); +}); + +// ── 3. symlink / path traversal ── + +test('dsh2mcode does not follow symlinks out of the source tree', async (t) => { + const ws = await tempWorkspace(t); + const outside = path.join(ws, 'outside.txt'); + await writeFile(outside, 'top-secret'); + const src = path.join(ws, 'src'); + await mkdir(path.join(src, 'skills', 'test-skill'), { recursive: true }); + await writeFixturePreset(src); + await writeFile(path.join(src, 'skills', 'test-skill', 'SKILL.md'), '---\nname: test-skill\ndescription: s\n---\n'); + // 指向树外的 symlink + try { + await symlink(outside, path.join(src, 'skills', 'test-skill', 'leak.md')); + } catch (err) { + if (err.code === 'EPERM' || err.code === 'EACCES') { + t.skip('symlink creation not permitted on this platform'); + return; + } + throw err; + } + const out = path.join(ws, 'out'); + dsh2mcode.convert(src, out); + const entries = await readdir(path.join(out, 'skills', 'test-skill')); + assert.ok(!entries.includes('leak.md'), 'symlinked file must not be copied'); + assert.equal((await readdir(out)).includes('outside.txt'), false); +}); + +// ── 5. 失败不破坏既有目录(与 1 中预设缺失用例互补:模块级 + 邻目录) ── + +test('module-level failure leaves existing output and neighbor dirs intact', async (t) => { + const ws = await tempWorkspace(t); + const badSrc = path.join(ws, 'bad-src'); + await mkdir(badSrc, { recursive: true }); + await writeFile(path.join(badSrc, 'agent.cordis.yml'), '- id: x\n name: y\n'); + const out = path.join(ws, 'out'); + await mkdir(out, { recursive: true }); + await writeFile(path.join(out, 'sentinel.txt'), 'keep-me'); + const neighbor = path.join(ws, 'neighbor'); + await mkdir(neighbor, { recursive: true }); + await writeFile(path.join(neighbor, 'n.txt'), 'keep-neighbor'); + + assert.throws(() => dsh2mcode.convert(badSrc, out), /preset\.yml 不存在/u); + + assert.equal(await readFile(path.join(out, 'sentinel.txt'), 'utf8'), 'keep-me'); + assert.equal((await readdir(out)).includes('plugin.json'), false); + assert.equal(await readFile(path.join(neighbor, 'n.txt'), 'utf8'), 'keep-neighbor'); +}); + +// ── CLI 冒烟 ── + +test('run.js version and list are read-only and exit 0', async (t) => { + const ws = await tempWorkspace(t); + const v = await cli(t, ['version'], ws); + assert.equal(v.code, 0); + assert.match(v.stdout, /crossplug/u); + + const l = await cli(t, ['list', '--side', 'mcode', '--json'], ws); + assert.equal(l.code, 0); + const parsed = JSON.parse(l.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.side, 'mcode'); + assert.ok(Array.isArray(parsed.plugins)); +}); + +// ── 双向真实 e2e:dsh2mcode 产物被真实 mcode 加载 ── +// 需要本机装 mcode(~/.minimax-code/node_modules/@minimax-ai/code/cli.js); +// CI(无 mcode)自动 skip。设 MCODE_CLI 指向 cli.js、MCODE_NODE 指向 node 可覆盖。 + +function resolveMcodeCli() { + const cli = process.env.MCODE_CLI + || path.join(os.homedir(), '.minimax-code', 'node_modules', '@minimax-ai', 'code', 'cli.js'); + if (!existsSync(cli)) return null; + const node = process.env.MCODE_NODE || process.execPath; + return [node, cli]; +} + +// 解析 @deepseek-ai/cordis(DSH 的 cordis 运行时)入口;找不到返回 null。 +// DSH_NODE_MODULES 可覆盖(默认 ~/.dsh/profiles/node_modules)。 +function resolveCordis() { + const base = process.env.DSH_NODE_MODULES + || path.join(os.homedir(), '.dsh', 'profiles', 'node_modules'); + try { + const req = createRequire(path.join(base, '__cordis_resolve__.js')); + return req.resolve('@deepseek-ai/cordis'); + } catch { + return null; + } +} + +// 杀整个进程树:mcode CLI 会派生常驻 MCP server 子进程(Windows 下孤儿进程 +// 会占住插件目录导致 EBUSY),仅杀顶层进程不够。Windows 用 taskkill /T /F +// 连根拔;非 Windows 用负 pid 发信号(需 detached 进程组,失败忽略)。 +function killTree(pid) { + if (!pid) return; + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); + } catch { /* 忽略 */ } + } else { + try { process.kill(-pid, 'SIGKILL'); } catch { /* 忽略 */ } + } +} + +test('dsh2mcode output is discovered by real mcode (plugin list parses mcp.json)', async (t) => { + const cmd = resolveMcodeCli(); + if (!cmd) { + t.skip('mcode cli.js 未找到 — 设 MCODE_CLI 指向 cli.js 可跑真实 mcode 加载 e2e'); + return; + } + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'mini-plugin.js'); + await writeFile(srcFile, DSH_PLUGIN_SOURCE); + const out = path.join(ws, 'out'); + dsh2mcode.convert(srcFile, out); + + // 安装到 ~/.minimax/plugins/ 让 mcode 自己发现(不清理旧目录/临时文件) + const pluginsDir = path.join(os.homedir(), '.minimax', 'plugins'); + await mkdir(pluginsDir, { recursive: true }); + const dest = path.join(pluginsDir, 'crossplug-e2e-test'); + await cp(out, dest, { recursive: true }); + + // mcode plugin list 也会派生常驻 MCP server;用 spawn 拿到顶层 pid, + // 结束后 killTree 连根杀(Windows 下仅杀顶层会留孤儿进程占住插件目录)。 + const child = spawn( + cmd[0], + [...cmd.slice(1), 'plugin', 'list', '--marketplace', 'local', '--json'], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + await Promise.race([ + new Promise((r) => child.once('exit', r)), + new Promise((r) => setTimeout(r, 60000)), + ]); + try { child.stdin.end(); } catch { /* 忽略 */ } + child.kill(); + killTree(child.pid); + + try { + const parsed = JSON.parse(stdout); + const ours = (parsed.installed || []).find((p) => p.name === 'mini-plugin'); + assert.ok(ours, 'mcode 应发现转换产物 mini-plugin;stderr: ' + stderr.slice(-400)); + assert.ok((ours.capabilities && ours.capabilities.mcpServerCount) >= 1, 'mcode 应解析出 mcp.json 里的 MCP server'); + } finally { + // 不删除安装到 ~/.minimax/plugins/ 的临时插件目录(按需手动清理) + } +}); + +test('mcode really calls the converted tool (exec returns the tool result)', async (t) => { + const cmd = resolveMcodeCli(); + if (!cmd) { + t.skip('mcode cli.js 未找到 — 设 MCODE_CLI 指向 cli.js 可跑真实 mcode 工具调用 e2e'); + return; + } + const MARKER = 'CROSSPLUG_E2E_PONG'; + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'mcode-e2e.js'); + await writeFile(srcFile, [ + "module.exports = {", + " name: 'mcode-e2e',", + " apply(ctx) {", + " const tools = ctx.get('tools');", + " tools.register({", + " name: 'mcode_e2e_ping',", + " description: 'e2e ping tool',", + " parameters: { type: 'object', properties: {} },", + " async execute(args) { return '" + MARKER + "'; },", + " });", + " },", + "};", + "", + ].join('\n')); + const out = path.join(ws, 'out'); + dsh2mcode.convert(srcFile, out); + + const pluginsDir = path.join(os.homedir(), '.minimax', 'plugins'); + await mkdir(pluginsDir, { recursive: true }); + const dest = path.join(pluginsDir, 'crossplug-mcode-e2e'); + await cp(out, dest, { recursive: true }); + + // 关键:mcode exec 调工具后不干净退出(会留常驻 stdio MCP server), + // 所以这里 spawn + 读到 MARKER 即判成功 + 显式 kill,而不是等进程退出。 + const child = spawn( + cmd[0], + [...cmd.slice(1), 'exec', '请调用工具 mcode_e2e_ping(无参数),把它的返回内容原样输出,不要输出其他内容。', '--permission', 'off', '--timeout', '180s'], + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + let collected = ''; + let found = false; + let timer; + const markerPromise = new Promise((resolve) => { + const onData = (chunk) => { + collected += chunk.toString(); + if (collected.includes(MARKER)) { + found = true; + resolve(); + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + }); + + // 最多等 120s(LLM + MCP 握手 + 工具调用);race 结束即 clearTimeout,避免拖住事件循环 + await Promise.race([ + markerPromise, + new Promise((r) => { timer = setTimeout(r, 120000); }), + ]); + clearTimeout(timer); + + try { child.stdin.end(); } catch { /* 忽略 */ } + child.kill(); + killTree(child.pid); // mcode 派生的 MCP server 子进程一并连根杀,不留孤儿占住插件目录 + + // 不删除安装到 ~/.minimax/plugins/ 的临时插件目录(按需手动清理) + + assert.ok(found, 'mcode 应在输出里返回工具结果 ' + MARKER + ';实际输出尾: ' + collected.slice(-400)); +}); + +async function waitFor(predicate) { + const deadline = Date.now() + 5000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error('Timed out waiting for MCP responses'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} +// ───────────────────────────────────────────────────────────── +// 回归:codex 审查 14 条问题修复(2026-08-15) + +test('extract: 注释内注册不提取、regex literal 不丢调用、顶层属性不被嵌套顶替', async (t) => { + const extract = require('../core/extract.js'); + + // [9] 注释里的 tools.register 不应被提取 + const calls = extract.findCalls('// tools.register({ name: "ghost" })\n', /tools\.register/g); + assert.equal(calls.length, 0); + + // [4] 工具体含 regex /\\)/ 时调用不丢失 + const src = "tools.register({\n name: 'rx',\n execute: async () => { const re = /\\)/; return 'ok'; },\n});\n"; + assert.equal(extract.findCalls(src, /tools\.register/g).length, 1); + + // [11] 嵌套 name 不顶替顶层 name + const name = extract.propertyValue("{ parameters: { name: 'nested' }, name: 'real' }", 'name', { topLevel: true }); + assert.equal(name.value, 'real'); +}); + +test('dsh2mcode: mcp.json server key 无下划线、多文件插件 sidecar 复制', async (t) => { + const ws = await tempWorkspace(t); + const srcDir = path.join(ws, 'src'); + await mkdir(srcDir, { recursive: true }); + await writeFile(path.join(srcDir, 'multi-tool.js'), [ + "const { fmt } = require('./fmt.js');", + "module.exports = {", + " name: 'multi-tool',", + " apply(ctx) {", + " const tools = ctx.get('tools');", + " tools.register({ name: 't', execute: async () => fmt() });", + " },", + "};", + "", + ].join('\n')); + await writeFile(path.join(srcDir, 'fmt.js'), "module.exports = { fmt() { return 'hi'; } };\n"); + const out = path.join(ws, 'out'); + const result = dsh2mcode.convert(path.join(srcDir, 'multi-tool.js'), out); + assert.ok(result); + + // [2] server key 必须符合 PLUGIN_NAME(不允许下划线) + const mcp = JSON.parse(await readFile(path.join(out, 'mcp.json'), 'utf8')); + for (const key of Object.keys(mcp.mcpServers)) { + assert.match(key, PLUGIN_NAME, 'mcpServers key 不允许下划线'); + } + + // [6] 相对导入依赖被复制到 vendor/ + assert.ok(existsSync(path.join(out, 'vendor', 'fmt.js')), 'fmt.js 应被复制到 vendor/'); +}); + +test('dsh2mcode: parameters 在 name 前且 schema 含 name 字段时工具不丢/不错名', async (t) => { + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'ordered.js'); + await writeFile(srcFile, [ + "module.exports = {", + " name: 'ordered',", + " apply(ctx) {", + " const tools = ctx.get('tools');", + " tools.register({", + " parameters: { type: 'object', properties: { name: { type: 'string' } } },", + " name: 'ordered_tool',", + " description: 'd',", + " execute: async () => 'ok',", + " });", + " },", + "};", + "", + ].join('\n')); + const out = path.join(ws, 'out'); + const result = dsh2mcode.convert(srcFile, out); + assert.ok(result.mcpTools >= 1, '工具不应被丢弃'); + const server = await readFile(path.join(out, 'mcp-server.js'), 'utf8'); + assert.ok(server.includes('ordered_tool'), '工具名应为 ordered_tool 而非嵌套 name'); +}); + +test('mcode2dsh: TS 源剥离后 vendor JS 可加载、CJS 源 host 模式有工具、sidecar 复制', async (t) => { + const ws = await tempWorkspace(t); + + // [0] TS 源:vendor/*.js 不再残留 TS 语法 + const tsFile = path.join(ws, 'ext.ts'); + await writeFile(tsFile, [ + "import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';", + "export default function (pi: ExtensionAPI) {", + " const s: string = 'x';", + " pi.registerTool({ name: 't1', description: 'd', parameters: { type: 'object', properties: {} }, execute: async () => ({ content: [{ type: 'text', text: s }] }) });", + "}", + "", + ].join('\n')); + const tsOut = path.join(ws, 'ts-out'); + mcode2dsh.convert(tsFile, tsOut, {}); + await nodeCheck(t, path.join(tsOut, 'vendor', 'ext.js')); + + // [5] CJS 源 host 模式:vendor 用 .cjs,桥接加载有工具 + const cjsFile = path.join(ws, 'cjs-ext.js'); + await writeFile(cjsFile, [ + "module.exports = function (pi) {", + " pi.registerTool({ name: 'c1', description: 'd', parameters: { type: 'object', properties: {} }, execute: async () => ({ content: [{ type: 'text', text: 'ok' }] }) });", + "};", + "", + ].join('\n')); + const cjsOut = path.join(ws, 'cjs-out'); + const cjsResult = mcode2dsh.convert(cjsFile, cjsOut, { host: true }); + assert.ok(cjsResult); + assert.ok(existsSync(path.join(cjsOut, 'vendor', 'cjs-ext.cjs')), 'CJS 源 vendor 应为 .cjs'); + const { tools } = await loadDshBridge(path.join(cjsOut, 'lib', 'index.js')); + assert.ok(tools.has('c1'), 'CJS 源转换后工具应可注册'); + + // [1] 多文件 extension:相对导入依赖复制到 vendor/ + const pkgDir = path.join(ws, 'pkg'); + await mkdir(pkgDir, { recursive: true }); + await writeFile(path.join(pkgDir, 'package.json'), JSON.stringify({ name: 'multi', main: 'index.js', type: 'module' })); + await writeFile(path.join(pkgDir, 'index.js'), [ + "import { helper } from './helper.js';", + "export default function (pi) { pi.registerTool({ name: 'm', description: 'd', parameters: { type: 'object', properties: {} }, execute: async () => ({ content: [{ type: 'text', text: helper() }] }) }); }", + "", + ].join('\n')); + await writeFile(path.join(pkgDir, 'helper.js'), "export function helper() { return 'from-helper'; }\n"); + const multiOut = path.join(ws, 'multi-out'); + mcode2dsh.convert(path.join(pkgDir, 'index.js'), multiOut, {}); + assert.ok(existsSync(path.join(multiOut, 'vendor', 'helper.js')), 'helper.js 应被复制到 vendor/'); + const { tools: mt } = await loadDshBridge(path.join(multiOut, 'plugins', 'bridge.js')); + assert.ok(mt.has('m'), '多文件 extension 转换后工具应可注册'); +}); + +test('dsh2mcode: async apply 完成前 tools/call 等待注册(不丢请求)', async (t) => { + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'slow.js'); + await writeFile(srcFile, [ + "module.exports = {", + " name: 'slow',", + " async apply(ctx) {", + " await new Promise((r) => setTimeout(r, 600));", + " const tools = ctx.get('tools');", + " tools.register({ name: 'slow_ping', execute: async () => 'slow-ok' });", + " },", + "};", + "", + ].join('\n')); + const out = path.join(ws, 'out'); + dsh2mcode.convert(srcFile, out); + + const child = spawn(process.execPath, ['mcp-server.js'], { cwd: out, stdio: ['pipe', 'pipe', 'pipe'] }); + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + const responses = []; + lines.on('line', (line) => { try { responses.push(JSON.parse(line)); } catch { /* 忽略 */ } }); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n'); + // 立即(apply 未完成时)调用工具 + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'slow_ping', arguments: {} } }) + '\n'); + await waitFor(() => responses.some((r) => r.id === 2)); + const call = responses.find((r) => r.id === 2); + assert.equal(call.result.content[0].text, 'slow-ok', 'async apply 期间到达的调用应等注册完成后成功'); + child.stdin.end(); + child.kill(); + await new Promise((resolve) => child.once('exit', resolve)); + lines.close(); +}); + +test('dsh2mcode provider: default 形态可启动、有 package.json、server key 合法', async (t) => { + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'provider.js'); + await writeFile(srcFile, [ + "export default {", + " async apply(ctx) {", + " ctx.web.registerSearchProvider({ name: 'sx', async search(query, signal) { return { sources: [] }; } });", + " },", + "};", + "", + ].join('\n')); + const out = path.join(ws, 'out'); + const result = dsh2mcode.convert(srcFile, out); + assert.ok(result); + + // [8] 输出包有 package.json 且 type: module(mcp-server.js 是 ESM) + const pkg = JSON.parse(await readFile(path.join(out, 'package.json'), 'utf8')); + assert.equal(pkg.type, 'module'); + + // [7] default 形态也能通过语法检查,且不再静态 named import apply + await nodeCheck(t, path.join(out, 'mcp-server.js')); + const server = await readFile(path.join(out, 'mcp-server.js'), 'utf8'); + assert.ok(!server.includes('import { apply }'), '不应再静态 named import apply'); + + // [2] provider server key 无下划线 + const mcp = JSON.parse(await readFile(path.join(out, 'mcp.json'), 'utf8')); + for (const key of Object.keys(mcp.mcpServers)) assert.match(key, PLUGIN_NAME); +}); + +test('dsh2mcode provider: 调用参数透传到 provider.search', async (t) => { + const ws = await tempWorkspace(t); + const srcFile = path.join(ws, 'provider.js'); + await writeFile(srcFile, [ + "export async function apply(ctx) {", + " ctx.web.registerSearchProvider({", + " name: 'sx',", + " async search(query, signal) {", + " process.stdout.write('SEEN=' + JSON.stringify(query) + '\\n');", + " return { sources: [{ title: 'x', url: 'https://x', snippet: 'y' }] };", + " },", + " });", + "}", + "", + ].join('\n')); + const out = path.join(ws, 'out'); + dsh2mcode.convert(srcFile, out); + + const child = spawn(process.execPath, ['mcp-server.js'], { cwd: out, stdio: ['pipe', 'pipe', 'pipe'] }); + const chunks = []; + child.stdout.on('data', (d) => chunks.push(d.toString())); + child.stderr.on('data', (d) => chunks.push(d.toString())); + await new Promise((r) => setTimeout(r, 200)); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'provider_search', arguments: { query: 'hello', maxResults: 5 } } }) + '\n'); + await new Promise((r) => setTimeout(r, 600)); + try { child.stdin.end(); } catch { /* 忽略 */ } + child.kill(); + await new Promise((resolve) => child.once('exit', resolve)); + + const all = chunks.join(''); + assert.match(all, /SEEN=\{"query":"hello","maxResults":5\}/, 'maxResults 应透传给 provider.search'); +});