@szc-ft/mcp-szcd-client 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/build.js +118 -0
- package/agents/opencode-extension/agents/szcd-component-expert.md +224 -0
- package/agents/platforms.json +15 -0
- package/agents/qwen-extension/agents/szcd-component-expert.md +8 -0
- package/agents/src/szcd-component-expert.md +8 -0
- package/agents/src/tools.json +5 -0
- package/agents/szcd-component-expert.md +8 -0
- package/agents/szcd-component-expert.qoder.md +9 -0
- package/agents/szcd-component-expert.trae.md +8 -0
- package/commands/szcd-mcp-coding-config.md +4 -6
- package/lib/browser-engine.js +318 -23
- package/lib/shared-deps.js +97 -0
- package/lib/visual-compare.js +7 -4
- package/local-browser-executor.js +17 -5
- package/package.json +1 -7
- package/qwen-extension/agents/szcd-component-expert.md +8 -0
- package/qwen-extension/commands/szcd-mcp-coding-config.md +4 -6
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +101 -5
- package/scripts/lib/opencode.js +510 -22
- package/scripts/postinstall.js +14 -3
- package/scripts/update-mcp-url.js +3 -0
- package/standard-skill/local-browser-test/SKILL.md +101 -5
|
@@ -68,6 +68,10 @@ AI 直接构造测试计划 JSON:
|
|
|
68
68
|
| waitFor | 等待条件 | `selector` 或 `url`, `timeout` | `{ matched, waited }` |
|
|
69
69
|
| evaluate | 执行 JS | `expression` | `{ result }` |
|
|
70
70
|
| checkElement | 元素断言 | `selector`, `expect: {visible, minCount, maxCount, hasText}` | `{ passed, checks, elementCount }` |
|
|
71
|
+
| findPage | 查找已打开的标签页 | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` | `{ found, url, title, totalPages }` |
|
|
72
|
+
| findFrame | 查找微前端 iframe | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` 或 `contentIncludes` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
|
|
73
|
+
| loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
|
|
74
|
+
| aiAssert | 语义断言(截图+审查) | `assertion`, `filename` | `{ screenshotPath, assertion, needsVisualReview }` |
|
|
71
75
|
|
|
72
76
|
### 场景选择策略
|
|
73
77
|
|
|
@@ -85,6 +89,11 @@ AI 直接构造测试计划 JSON:
|
|
|
85
89
|
**场景 D:仅页面检查**
|
|
86
90
|
`navigate → waitFor → screenshot → evaluate(JS错误检查)`
|
|
87
91
|
|
|
92
|
+
**场景 E:微前端 + SSO 登录(最常见生产场景)**
|
|
93
|
+
`findPage(按路径发现标签页) → findFrame(按内容发现iframe) → 在 iframe 内执行功能步骤`
|
|
94
|
+
|
|
95
|
+
> 微前端场景端口可变,优先用 `findPage` + `findFrame` 发现页面,而非硬编码 URL 导航。
|
|
96
|
+
|
|
88
97
|
### 构造步骤的指导原则
|
|
89
98
|
|
|
90
99
|
1. **selector 必须从用户代码中读取,不要猜测**
|
|
@@ -96,19 +105,80 @@ AI 直接构造测试计划 JSON:
|
|
|
96
105
|
- dataSource 有数据 → `expect: { minCount: 1 }`(检查行数)
|
|
97
106
|
4. **compare 的 regions 从设计稿分析结果或组件布局推断**
|
|
98
107
|
|
|
108
|
+
## 微前端测试指南(实战经验)
|
|
109
|
+
|
|
110
|
+
项目多使用 wujie/qiankun 微前端,测试时需注意:
|
|
111
|
+
|
|
112
|
+
### 端口可变问题(重点)
|
|
113
|
+
微前端主应用和子应用的端口会变化,**不要在计划中硬编码 URL**。两种方案:
|
|
114
|
+
|
|
115
|
+
**方案 A:`findPage` 发现已打开的标签页(推荐,connect 模式)**
|
|
116
|
+
用户已在浏览器中打开了目标页面,按路径或标题发现:
|
|
117
|
+
```json
|
|
118
|
+
{ "type": "findPage", "urlIncludes": "knowledge-main" }
|
|
119
|
+
{ "type": "findPage", "titleIncludes": "知识库" }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**方案 B:URL 模板变量 + `--base-url` 运行时传入**
|
|
123
|
+
计划中使用占位符,运行时替换:
|
|
124
|
+
```json
|
|
125
|
+
{ "type": "navigate", "url": "{{baseUrl}}/knowledge-main/", "allowRedirects": true }
|
|
126
|
+
```
|
|
127
|
+
执行时传入:`--base-url http://localhost:9090`
|
|
128
|
+
|
|
129
|
+
### SSO 登录重定向链
|
|
130
|
+
主应用访问时会重定向到登录服务,登录后再跳回。
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "type": "navigate", "url": "{{baseUrl}}/app/", "allowRedirects": true, "redirectWait": 5000 }
|
|
134
|
+
{ "type": "loginWait", "timeout": 120000, "loginSelector": "input[type='password']" }
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `allowRedirects: true` 使用 `domcontentloaded` 避免重定向链导致 page 对象销毁
|
|
138
|
+
- `loginWait` 轮询检测登录页是否消失(用户手动登录)
|
|
139
|
+
|
|
140
|
+
### iframe 内操作
|
|
141
|
+
wujie 微前端子应用渲染在 blob URL iframe 中,**坐标点击无法到达 iframe 内容**。
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{ "type": "findFrame", "contentIncludes": "知识采编" }
|
|
145
|
+
{ "type": "click", "selector": "a.edit-btn" }
|
|
146
|
+
{ "type": "checkElement", "selector": "~.editKnowledge", "expect": { "minCount": 1 } }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- `findFrame` 支持 `urlIncludes`、`titleIncludes`、`contentIncludes` 多种发现方式
|
|
150
|
+
- 优先用 `contentIncludes`(按页面文字内容查找),不依赖 URL/端口
|
|
151
|
+
- `findFrame` 后,后续所有步骤自动在 iframe 内执行
|
|
152
|
+
- iframe 内的 click 使用 `evaluate()` 触发 DOM click,不依赖坐标
|
|
153
|
+
|
|
154
|
+
### CSS Modules 哈希类名
|
|
155
|
+
项目使用 CSS Modules,类名会被哈希化(如 `editKnowledge___ynQgI`)。
|
|
156
|
+
|
|
157
|
+
选择器语法:
|
|
158
|
+
- `~.editKnowledge` → 自动转为 `[class*="editKnowledge"]`(模糊匹配)
|
|
159
|
+
- `~#myId` → 自动转为 `[id*="myId"]`
|
|
160
|
+
- 普通选择器(如 `.my-class`)保持精确匹配
|
|
161
|
+
|
|
162
|
+
### puppeteer-core v22 注意事项
|
|
163
|
+
- `page.$x()` 已移除,用 `evaluate` + `querySelectorAll` 替代
|
|
164
|
+
- SVG 元素的 `className` 是 `SVGAnimatedString` 对象而非字符串,需先检查 `typeof`
|
|
165
|
+
- 所有 `evaluate` 调用内部使用 `safeEval` 包装,自动处理 context destroyed 错误
|
|
166
|
+
|
|
99
167
|
## 执行命令
|
|
100
168
|
|
|
101
169
|
```bash
|
|
102
170
|
node {client_install_path}/local-browser-executor.js \
|
|
103
171
|
--plan '<测试计划JSON>' \
|
|
104
|
-
--output /tmp/browser-test-result.json
|
|
172
|
+
--output /tmp/browser-test-result.json \
|
|
173
|
+
--base-url http://localhost:8088
|
|
105
174
|
```
|
|
106
175
|
|
|
107
176
|
也支持从文件读取计划:
|
|
108
177
|
```bash
|
|
109
178
|
node {client_install_path}/local-browser-executor.js \
|
|
110
179
|
--plan-file /tmp/test-plan.json \
|
|
111
|
-
--output /tmp/browser-test-result.json
|
|
180
|
+
--output /tmp/browser-test-result.json \
|
|
181
|
+
--base-url http://localhost:8088
|
|
112
182
|
```
|
|
113
183
|
|
|
114
184
|
### 可选参数
|
|
@@ -116,11 +186,37 @@ node {client_install_path}/local-browser-executor.js \
|
|
|
116
186
|
- `--cdp-url http://localhost:9223`:指定非默认的 CDP 端口
|
|
117
187
|
- `--page-url http://xxx/page`:connect 模式下指定目标页面
|
|
118
188
|
|
|
119
|
-
##
|
|
189
|
+
## AI 语义断言(aiAssert)
|
|
190
|
+
|
|
191
|
+
`aiAssert` 截图后需要视觉审查,三级降级路由:
|
|
192
|
+
|
|
193
|
+
### Level 1:你能读取图片
|
|
194
|
+
直接读取截图文件,用你的视觉能力判断是否满足断言。
|
|
195
|
+
|
|
196
|
+
### Level 2:你无法读图,但 MCP 服务器可用
|
|
197
|
+
1. 调用 `get_upload_endpoint` 获取上传地址
|
|
198
|
+
2. 用 curl 上传截图
|
|
199
|
+
3. 调用 `assert_page_screenshot` 工具,传入 `upload_id` + 断言描述
|
|
200
|
+
4. 工具返回 `{ passed, confidence, reasoning, details }`
|
|
201
|
+
|
|
202
|
+
### Level 3:都没有
|
|
203
|
+
降级为 `checkElement` 步骤,用 DOM 选择器做替代验证。
|
|
204
|
+
|
|
205
|
+
**构造 aiAssert 步骤示例**:
|
|
206
|
+
```json
|
|
207
|
+
{ "type": "aiAssert", "assertion": "页面显示三栏布局,右侧有滚动条", "filename": "assert-layout.png" }
|
|
208
|
+
{ "type": "aiAssert", "assertion": "表格至少有 5 行数据,包含姓名、手机号列" }
|
|
209
|
+
{ "type": "aiAssert", "assertion": "表单包含必填标记和提交按钮" }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## 依赖管理
|
|
120
213
|
|
|
121
|
-
|
|
214
|
+
浏览器测试依赖(puppeteer-core, pixelmatch, pngjs, sharp)采用**共享缓存**方案:
|
|
215
|
+
- 首次使用时自动安装到 `~/.szcd-mcp/deps/`
|
|
216
|
+
- 后续所有项目共享复用,无需重复安装
|
|
217
|
+
- 如果自动安装失败,引导用户手动执行:
|
|
122
218
|
```bash
|
|
123
|
-
npm install puppeteer-core pixelmatch pngjs sharp
|
|
219
|
+
cd ~/.szcd-mcp/deps && npm install puppeteer-core pixelmatch pngjs sharp --registry=https://npmmirror.com
|
|
124
220
|
```
|
|
125
221
|
|
|
126
222
|
## 结果解读
|
package/scripts/lib/opencode.js
CHANGED
|
@@ -1,37 +1,525 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencode.js — OpenCode
|
|
2
|
+
* opencode.js — OpenCode 全部兼容逻辑
|
|
3
3
|
*
|
|
4
4
|
* 导出 setupOpenCode(deps) 供 postinstall.js 调用。
|
|
5
|
-
*
|
|
5
|
+
* 导出 syncMcpUrl(targetUrl, serverName) 供 update-mcp-url.js 调用。
|
|
6
|
+
*
|
|
7
|
+
* deps 需包含: { getMcpServerUrl, getMcpServerName, getApiKey, getClientConfigHeader,
|
|
8
|
+
* safeExecSync, isCommandAvailable, ensureDirectory, copyFile, writeFile,
|
|
9
|
+
* discoverSkills, PROJECT_ROOT, PACKAGE_ROOT, fs }
|
|
10
|
+
*
|
|
11
|
+
* OpenCode 配置方式(HTTP 直连模式):
|
|
12
|
+
* - 优先使用 CLI: opencode mcp add <name> --scope user --url <http-url>
|
|
13
|
+
* - 回退方案: 直接修改 ~/.config/opencode/opencode.jsonc
|
|
14
|
+
* - Skill 复制: ~/.config/opencode/skills/<serverName>/SKILL.md
|
|
15
|
+
* - Agent 复制: ~/.config/opencode/agents/<agentName>.md
|
|
16
|
+
* - Command 复制: ~/.config/opencode/commands/<commandName>.md
|
|
17
|
+
*
|
|
18
|
+
* OpenCode 目录结构:
|
|
19
|
+
* ~/.config/opencode/
|
|
20
|
+
* ├── opencode.jsonc # 用户级配置(MCP servers 等)
|
|
21
|
+
* ├── agents/ # 子代理
|
|
22
|
+
* │ └── <agentName>.md
|
|
23
|
+
* ├── skills/ # Skills
|
|
24
|
+
* │ └── <skillName>/
|
|
25
|
+
* │ └── SKILL.md
|
|
26
|
+
* └── commands/ # 自定义命令
|
|
27
|
+
* └── <commandName>.md
|
|
28
|
+
*
|
|
29
|
+
* 项目级配置(项目根目录):
|
|
30
|
+
* <project>/
|
|
31
|
+
* ├── opencode.json # 项目级 MCP 配置
|
|
32
|
+
* ├── .opencode/
|
|
33
|
+
* │ ├── agents/
|
|
34
|
+
* │ ├── skills/
|
|
35
|
+
* │ └── commands/
|
|
6
36
|
*/
|
|
7
37
|
|
|
8
38
|
import fs from "node:fs";
|
|
9
39
|
import path from "node:path";
|
|
40
|
+
import os from "node:os";
|
|
41
|
+
import { execSync } from "node:child_process";
|
|
42
|
+
import { getClientConfigHeader as _getClientConfigHeader, getApiKey as _getApiKey } from "./common.js";
|
|
10
43
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
44
|
+
// ==================== 路径工具 ====================
|
|
45
|
+
|
|
46
|
+
function getHomeDir() {
|
|
47
|
+
if (os.platform() === "win32") {
|
|
48
|
+
return process.env.USERPROFILE || process.env.HOME || os.homedir();
|
|
49
|
+
}
|
|
50
|
+
return process.env.HOME || os.homedir();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getOpenCodeConfigDir() {
|
|
54
|
+
return path.join(getHomeDir(), ".config", "opencode");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getOpenCodeConfigPath() {
|
|
58
|
+
// opencode.jsonc 优先,回退 opencode.json
|
|
59
|
+
return path.join(getOpenCodeConfigDir(), "opencode.jsonc");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getOpenCodeConfigPathJson() {
|
|
63
|
+
return path.join(getOpenCodeConfigDir(), "opencode.json");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getOpenCodeSkillsDirectory() {
|
|
67
|
+
return path.join(getOpenCodeConfigDir(), "skills");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getOpenCodeAgentsDirectory() {
|
|
71
|
+
return path.join(getOpenCodeConfigDir(), "agents");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getOpenCodeCommandsDirectory() {
|
|
75
|
+
return path.join(getOpenCodeConfigDir(), "commands");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getOpenCodeProjectConfigPath(projectRoot) {
|
|
79
|
+
return path.join(projectRoot, "opencode.json");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getOpenCodeProjectSkillsDirectory(projectRoot) {
|
|
83
|
+
return path.join(projectRoot, ".opencode", "skills");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getOpenCodeProjectAgentsDirectory(projectRoot) {
|
|
87
|
+
return path.join(projectRoot, ".opencode", "agents");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getOpenCodeProjectCommandsDirectory(projectRoot) {
|
|
91
|
+
return path.join(projectRoot, ".opencode", "commands");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ==================== JSON 读写 ====================
|
|
95
|
+
|
|
96
|
+
function readJsonFile(filePath) {
|
|
97
|
+
if (!fs.existsSync(filePath)) return {};
|
|
98
|
+
try {
|
|
99
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
100
|
+
// jsonc 支持:简单去除注释(// 和 /* */)
|
|
101
|
+
const cleaned = content
|
|
102
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
103
|
+
.replace(/\/\/.*$/gm, "")
|
|
104
|
+
.replace(/,(\s*[}\]])/g, "$1")
|
|
105
|
+
.trim();
|
|
106
|
+
return JSON.parse(cleaned);
|
|
107
|
+
} catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeJsonFile(filePath, data) {
|
|
113
|
+
const dir = path.dirname(filePath);
|
|
114
|
+
if (!fs.existsSync(dir)) {
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ==================== MCP 配置 ====================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 方式1: 通过 CLI 配置 MCP 服务器(推荐)
|
|
124
|
+
* opencode mcp add <name> --scope user --url <http-url>
|
|
125
|
+
*/
|
|
126
|
+
function createOpenCodeUserConfig(deps) {
|
|
127
|
+
if (deps.isCommandAvailable("opencode")) {
|
|
128
|
+
const mcpUrl = `${deps.getMcpServerUrl()}/mcp`;
|
|
129
|
+
const serverName = deps.getMcpServerName();
|
|
130
|
+
|
|
131
|
+
const result = deps.safeExecSync(`opencode mcp add ${serverName} --scope user --url ${mcpUrl}`);
|
|
132
|
+
if (result === true) {
|
|
133
|
+
console.log(`✓ OpenCode user MCP server added via CLI: ${serverName} (${mcpUrl})`);
|
|
134
|
+
} else if (result === "already_exists") {
|
|
135
|
+
console.log(`✓ OpenCode user MCP server already configured: ${serverName} (${mcpUrl})`);
|
|
136
|
+
} else {
|
|
137
|
+
console.warn(`⚠️ Failed to configure OpenCode MCP server via CLI (config file will be updated directly)`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
console.log("⏭️ Skipping OpenCode CLI config: opencode command not found");
|
|
141
|
+
}
|
|
14
142
|
|
|
15
|
-
|
|
16
|
-
|
|
143
|
+
syncOpenCodeConfig(deps);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 方式2: 直接文件操作同步 ~/.config/opencode/opencode.jsonc
|
|
148
|
+
*/
|
|
149
|
+
function syncOpenCodeConfig(deps) {
|
|
150
|
+
const configPath = getOpenCodeConfigPath();
|
|
151
|
+
const fallbackPath = getOpenCodeConfigPathJson();
|
|
152
|
+
const mcpUrl = `${deps.getMcpServerUrl()}/mcp`;
|
|
153
|
+
const serverName = deps.getMcpServerName();
|
|
154
|
+
|
|
155
|
+
// 优先使用 jsonc,不存在则使用 json
|
|
156
|
+
let actualPath = fs.existsSync(configPath) ? configPath : fallbackPath;
|
|
157
|
+
|
|
158
|
+
let config = {};
|
|
159
|
+
if (fs.existsSync(actualPath)) {
|
|
160
|
+
config = readJsonFile(actualPath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!config.mcp) config.mcp = {};
|
|
164
|
+
|
|
165
|
+
const current = config.mcp[serverName];
|
|
166
|
+
const apiKey = deps.getApiKey ? deps.getApiKey() : "";
|
|
167
|
+
const currentHeaders = current && current.headers ? current.headers : {};
|
|
168
|
+
const needsApiKeyUpdate = apiKey && (!current || currentHeaders["Authorization"] !== `Bearer ${apiKey}`);
|
|
169
|
+
|
|
170
|
+
if (current && current.type === "remote" && current.url === mcpUrl && !needsApiKeyUpdate) {
|
|
171
|
+
console.log(`✓ OpenCode ${path.basename(actualPath)} already up-to-date: ${mcpUrl}`);
|
|
17
172
|
return;
|
|
18
173
|
}
|
|
19
174
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
command: ["npx", "szcd-mcp-proxy"],
|
|
28
|
-
environment: {
|
|
29
|
-
MCP_SERVER_URL: deps.getMcpServerUrl(),
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
},
|
|
175
|
+
const headers = {};
|
|
176
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
177
|
+
|
|
178
|
+
config.mcp[serverName] = {
|
|
179
|
+
type: "remote",
|
|
180
|
+
url: mcpUrl,
|
|
181
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
33
182
|
};
|
|
34
183
|
|
|
35
|
-
|
|
36
|
-
|
|
184
|
+
// 确保 $schema 存在
|
|
185
|
+
if (!config.$schema) {
|
|
186
|
+
config.$schema = "https://opencode.ai/config.json";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
writeJsonFile(actualPath, config);
|
|
190
|
+
console.log(`✓ Updated OpenCode ${path.basename(actualPath)}: ${mcpUrl}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 项目级 MCP 配置
|
|
195
|
+
*/
|
|
196
|
+
function createOpenCodeProjectConfig(deps) {
|
|
197
|
+
if (!deps.PROJECT_ROOT || !fs.existsSync(deps.PROJECT_ROOT)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const projectConfigPath = getOpenCodeProjectConfigPath(deps.PROJECT_ROOT);
|
|
202
|
+
const mcpUrl = `${deps.getMcpServerUrl()}/mcp`;
|
|
203
|
+
const serverName = deps.getMcpServerName();
|
|
204
|
+
|
|
205
|
+
// 如果项目已有 opencode.json,检查是否需要更新
|
|
206
|
+
let config = {};
|
|
207
|
+
if (fs.existsSync(projectConfigPath)) {
|
|
208
|
+
config = readJsonFile(projectConfigPath);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!config.mcp) config.mcp = {};
|
|
212
|
+
|
|
213
|
+
const current = config.mcp[serverName];
|
|
214
|
+
const apiKey = deps.getApiKey ? deps.getApiKey() : "";
|
|
215
|
+
const currentHeaders = current && current.headers ? current.headers : {};
|
|
216
|
+
const needsUpdate = !current || current.type !== "remote" || current.url !== mcpUrl || (apiKey && currentHeaders["Authorization"] !== `Bearer ${apiKey}`);
|
|
217
|
+
|
|
218
|
+
if (!needsUpdate) {
|
|
219
|
+
console.log(`✓ OpenCode project opencode.json already up-to-date: ${mcpUrl}`);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const headers = {};
|
|
224
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
225
|
+
|
|
226
|
+
config.mcp[serverName] = {
|
|
227
|
+
type: "remote",
|
|
228
|
+
url: mcpUrl,
|
|
229
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (!config.$schema) {
|
|
233
|
+
config.$schema = "https://opencode.ai/config.json";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
writeJsonFile(projectConfigPath, config);
|
|
237
|
+
console.log(`✓ Created/Updated OpenCode project config: ${projectConfigPath}`);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ==================== Skill 复制 ====================
|
|
242
|
+
|
|
243
|
+
function copySkillToOpenCode(deps, isProjectLevel = false) {
|
|
244
|
+
let openCodeSkillsDir;
|
|
245
|
+
|
|
246
|
+
if (isProjectLevel) {
|
|
247
|
+
openCodeSkillsDir = getOpenCodeProjectSkillsDirectory(deps.PROJECT_ROOT);
|
|
248
|
+
} else {
|
|
249
|
+
openCodeSkillsDir = getOpenCodeSkillsDirectory();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const skills = deps.discoverSkills();
|
|
253
|
+
let allOk = true;
|
|
254
|
+
|
|
255
|
+
for (const skill of skills) {
|
|
256
|
+
const skillDir = path.join(openCodeSkillsDir, skill.name);
|
|
257
|
+
try {
|
|
258
|
+
deps.ensureDirectory(skillDir);
|
|
259
|
+
const skillDest = path.join(skillDir, "SKILL.md");
|
|
260
|
+
deps.copyFile(skill.sourcePath, skillDest);
|
|
261
|
+
console.log(`✓ Copied skill "${skill.name}" to OpenCode ${isProjectLevel ? 'project' : 'user'} directory: ${skillDir}`);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.log(`⚠️ Failed to copy skill "${skill.name}" to OpenCode ${isProjectLevel ? 'project' : 'user'} directory: ${error.message}`);
|
|
264
|
+
allOk = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return allOk;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ==================== Agent 安装 ====================
|
|
272
|
+
|
|
273
|
+
function copyAgentToOpenCode(deps, isProjectLevel = false) {
|
|
274
|
+
// 优先使用构建产物,回退到 agents/ 源目录
|
|
275
|
+
const agentsSourceDir = path.join(deps.PACKAGE_ROOT, "opencode-extension", "agents");
|
|
276
|
+
const fallbackSourceDir = path.join(deps.PACKAGE_ROOT, "agents");
|
|
277
|
+
|
|
278
|
+
let actualSourceDir;
|
|
279
|
+
if (fs.existsSync(agentsSourceDir)) {
|
|
280
|
+
actualSourceDir = agentsSourceDir;
|
|
281
|
+
} else if (fs.existsSync(fallbackSourceDir)) {
|
|
282
|
+
actualSourceDir = fallbackSourceDir;
|
|
283
|
+
} else {
|
|
284
|
+
console.log("⏭️ Skipping agent install: agents source directory not found");
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let openCodeAgentsDir;
|
|
289
|
+
if (isProjectLevel) {
|
|
290
|
+
openCodeAgentsDir = getOpenCodeProjectAgentsDirectory(deps.PROJECT_ROOT);
|
|
291
|
+
} else {
|
|
292
|
+
openCodeAgentsDir = getOpenCodeAgentsDirectory();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 只复制 OpenCode 构建产物中的 agent 文件
|
|
296
|
+
const agentFiles = fs.readdirSync(actualSourceDir).filter(f => f.endsWith(".md"));
|
|
297
|
+
let copied = 0;
|
|
298
|
+
|
|
299
|
+
for (const agentFile of agentFiles) {
|
|
300
|
+
const sourcePath = path.join(actualSourceDir, agentFile);
|
|
301
|
+
const destPath = path.join(openCodeAgentsDir, agentFile);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
deps.ensureDirectory(openCodeAgentsDir);
|
|
305
|
+
deps.copyFile(sourcePath, destPath);
|
|
306
|
+
copied++;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.log(`⚠️ Failed to copy agent ${agentFile}: ${error.message}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (copied > 0) {
|
|
313
|
+
console.log(`✓ Copied ${copied} agent(s) to OpenCode ${isProjectLevel ? 'project' : 'user'} directory: ${openCodeAgentsDir}`);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ==================== Slash Command 安装 ====================
|
|
320
|
+
|
|
321
|
+
function installOpenCodeSlashCommand(deps) {
|
|
322
|
+
const commandsSourceDir = path.join(deps.PACKAGE_ROOT, "commands");
|
|
323
|
+
if (!fs.existsSync(commandsSourceDir)) {
|
|
324
|
+
console.log("⚠️ Commands source directory not found, skipping OpenCode");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const commandFiles = fs.readdirSync(commandsSourceDir).filter(f => f.endsWith(".md"));
|
|
329
|
+
if (commandFiles.length === 0) {
|
|
330
|
+
console.log("⚠️ No command files found, skipping OpenCode");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 全局命令:~/.config/opencode/commands/
|
|
335
|
+
const globalCommandsDir = getOpenCodeCommandsDirectory();
|
|
336
|
+
try {
|
|
337
|
+
deps.ensureDirectory(globalCommandsDir);
|
|
338
|
+
|
|
339
|
+
for (const file of commandFiles) {
|
|
340
|
+
const commandSource = path.join(commandsSourceDir, file);
|
|
341
|
+
const commandDest = path.join(globalCommandsDir, file);
|
|
342
|
+
|
|
343
|
+
if (fs.existsSync(commandDest)) {
|
|
344
|
+
const existingContent = fs.readFileSync(commandDest, "utf8").trim();
|
|
345
|
+
const sourceContent = fs.readFileSync(commandSource, "utf8").trim();
|
|
346
|
+
if (existingContent !== sourceContent) {
|
|
347
|
+
console.log(`⏭️ Skipping OpenCode global command ${file}: already exists (user modified)`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
deps.copyFile(commandSource, commandDest);
|
|
353
|
+
console.log(`✓ Installed OpenCode global command: /${file.replace(".md", "")}`);
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
console.log(`⚠️ Failed to install OpenCode global commands: ${error.message}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 项目级命令暂不启用,全局命令已覆盖所有项目
|
|
360
|
+
/*
|
|
361
|
+
if (!deps.PROJECT_ROOT || !fs.existsSync(deps.PROJECT_ROOT)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const openCodeDir = path.join(deps.PROJECT_ROOT, ".opencode");
|
|
366
|
+
const projectCommandsDir = path.join(openCodeDir, "commands");
|
|
367
|
+
|
|
368
|
+
if (fs.existsSync(openCodeDir) && !fs.statSync(openCodeDir).isDirectory()) {
|
|
369
|
+
console.log(`⚠️ Skipping OpenCode project commands: ${openCodeDir} exists but is not a directory`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
deps.ensureDirectory(projectCommandsDir);
|
|
375
|
+
|
|
376
|
+
for (const file of commandFiles) {
|
|
377
|
+
const commandSource = path.join(commandsSourceDir, file);
|
|
378
|
+
const commandDest = path.join(projectCommandsDir, file);
|
|
379
|
+
|
|
380
|
+
if (fs.existsSync(commandDest)) {
|
|
381
|
+
const existingContent = fs.readFileSync(commandDest, "utf8").trim();
|
|
382
|
+
const sourceContent = fs.readFileSync(commandSource, "utf8").trim();
|
|
383
|
+
if (existingContent !== sourceContent) {
|
|
384
|
+
console.log(`⏭️ Skipping OpenCode project command ${file}: already exists (user modified)`);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
deps.copyFile(commandSource, commandDest);
|
|
390
|
+
console.log(`✓ Installed OpenCode project command: /${file.replace(".md", "")}`);
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
console.log(`⚠️ Failed to install OpenCode project commands: ${error.message}`);
|
|
394
|
+
}
|
|
395
|
+
*/
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ==================== MCP URL 同步(供 update-mcp-url.js 调用) ====================
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 不依赖 deps 的 CLI 可用性检测
|
|
402
|
+
*/
|
|
403
|
+
function isCommandAvailable(cmd) {
|
|
404
|
+
try {
|
|
405
|
+
execSync(`which ${cmd} 2>/dev/null`, { stdio: "pipe", timeout: 3000 });
|
|
406
|
+
return true;
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 不依赖 deps 的安全命令执行
|
|
414
|
+
*/
|
|
415
|
+
function safeExecSync(command) {
|
|
416
|
+
try {
|
|
417
|
+
execSync(command, { stdio: "pipe", timeout: 10000 });
|
|
418
|
+
return true;
|
|
419
|
+
} catch (e) {
|
|
420
|
+
const stderr = e.stderr?.toString() || "";
|
|
421
|
+
if (stderr.includes("already exists")) return "already_exists";
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 同步 OpenCode 配置中的 MCP 服务器 URL
|
|
428
|
+
* @param {string} targetUrl - 新的 MCP 服务器基础 URL(如 http://localhost:3456)
|
|
429
|
+
* @param {string} serverName - MCP 服务器名称
|
|
430
|
+
*/
|
|
431
|
+
export function syncMcpUrl(targetUrl, serverName) {
|
|
432
|
+
const mcpUrl = `${targetUrl}/mcp`;
|
|
433
|
+
|
|
434
|
+
// 1. CLI 命令同步
|
|
435
|
+
if (isCommandAvailable("opencode")) {
|
|
436
|
+
safeExecSync(`opencode mcp remove ${serverName} --scope user 2>/dev/null`);
|
|
437
|
+
const result = safeExecSync(`opencode mcp add ${serverName} --scope user --url ${mcpUrl}`);
|
|
438
|
+
if (result === true || result === "already_exists") {
|
|
439
|
+
console.log(`✓ OpenCode CLI synced: ${mcpUrl}`);
|
|
440
|
+
} else {
|
|
441
|
+
console.warn(`⚠️ OpenCode CLI sync failed (config file will be updated directly)`);
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
console.log("⏭️ Skipping OpenCode CLI sync: opencode not found");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 2. 直接文件操作同步(确保最终状态正确)
|
|
448
|
+
syncOpenCodeConfigDirect(targetUrl, serverName);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 直接文件操作同步 opencode.jsonc(独立于 deps)
|
|
453
|
+
*/
|
|
454
|
+
function syncOpenCodeConfigDirect(targetUrl, serverName) {
|
|
455
|
+
const configPath = getOpenCodeConfigPath();
|
|
456
|
+
const fallbackPath = getOpenCodeConfigPathJson();
|
|
457
|
+
const mcpUrl = `${targetUrl}/mcp`;
|
|
458
|
+
|
|
459
|
+
// 优先使用 jsonc,不存在则使用 json
|
|
460
|
+
let actualPath = fs.existsSync(configPath) ? configPath : fallbackPath;
|
|
461
|
+
|
|
462
|
+
let config = {};
|
|
463
|
+
if (fs.existsSync(actualPath)) {
|
|
464
|
+
config = readJsonFile(actualPath);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!config.mcp) config.mcp = {};
|
|
468
|
+
|
|
469
|
+
const current = config.mcp[serverName];
|
|
470
|
+
const apiKey = _getApiKey();
|
|
471
|
+
const currentHeaders = current && current.headers ? current.headers : {};
|
|
472
|
+
const needsApiKeyUpdate = apiKey && (!current || currentHeaders["Authorization"] !== `Bearer ${apiKey}`);
|
|
473
|
+
|
|
474
|
+
if (current && current.type === "remote" && current.url === mcpUrl && !needsApiKeyUpdate) {
|
|
475
|
+
console.log(`⏭️ OpenCode ${path.basename(actualPath)} already up-to-date: ${mcpUrl}`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const headers = {};
|
|
480
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
481
|
+
|
|
482
|
+
config.mcp[serverName] = {
|
|
483
|
+
type: "remote",
|
|
484
|
+
url: mcpUrl,
|
|
485
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
if (!config.$schema) {
|
|
489
|
+
config.$schema = "https://opencode.ai/config.json";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
writeJsonFile(actualPath, config);
|
|
493
|
+
console.log(`✓ Updated OpenCode ${path.basename(actualPath)}: ${mcpUrl}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ==================== 导出 ====================
|
|
497
|
+
|
|
498
|
+
export function setupOpenCode(deps) {
|
|
499
|
+
createOpenCodeUserConfig(deps);
|
|
500
|
+
copySkillToOpenCode(deps, false);
|
|
501
|
+
copyAgentToOpenCode(deps, false);
|
|
502
|
+
installOpenCodeSlashCommand(deps);
|
|
503
|
+
|
|
504
|
+
// 项目级配置暂不启用,用户级已足够
|
|
505
|
+
/*
|
|
506
|
+
let openCodeProjectInstalled = false;
|
|
507
|
+
if (deps.PROJECT_ROOT && fs.existsSync(deps.PROJECT_ROOT)) {
|
|
508
|
+
try {
|
|
509
|
+
openCodeProjectInstalled = createOpenCodeProjectConfig(deps);
|
|
510
|
+
if (openCodeProjectInstalled) {
|
|
511
|
+
copySkillToOpenCode(deps, true);
|
|
512
|
+
copyAgentToOpenCode(deps, true);
|
|
513
|
+
}
|
|
514
|
+
} catch (e) {
|
|
515
|
+
console.log(`⚠️ Failed to install OpenCode project config: ${e.message}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
*/
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
openCodeProjectInstalled: false,
|
|
522
|
+
skillsDirectory: getOpenCodeSkillsDirectory(),
|
|
523
|
+
agentsDirectory: getOpenCodeAgentsDirectory(),
|
|
524
|
+
};
|
|
37
525
|
}
|