@mebular/mcp 0.0.0-stage → 0.1.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/bin/mebular.mjs +1133 -0
- package/package.json +30 -4
- package/scripts/publish-manifest.mjs +44 -0
- package/src/admin.mjs +1320 -0
- package/src/apply-supervisor.mjs +48 -0
- package/src/build-sha.mjs +36 -0
- package/src/client.mjs +49 -0
- package/src/clients.mjs +1242 -0
- package/src/cloud-switch.mjs +258 -0
- package/src/config-apply.mjs +251 -0
- package/src/config-schema.mjs +690 -0
- package/src/config.mjs +265 -0
- package/src/fleet-events.mjs +207 -0
- package/src/home.mjs +16 -0
- package/src/jointoken.mjs +31 -0
- package/src/lan-host.mjs +36 -0
- package/src/profiles.mjs +681 -0
- package/src/provision.mjs +673 -0
- package/src/qr.mjs +4 -0
- package/src/runtime-file.mjs +91 -0
- package/src/serve.mjs +3478 -0
- package/src/server.mjs +416 -0
- package/src/service-control.mjs +107 -0
- package/src/service-facade.mjs +43 -0
- package/src/task-tools.mjs +45 -0
- package/src/tool-envelope.mjs +50 -0
- package/src/tools.mjs +397 -0
- package/README.md +0 -3
package/src/clients.mjs
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
// UX-2 · 「连接 AI 助手」注册表(助手 = 数据,不是代码)
|
|
2
|
+
//
|
|
3
|
+
// 契约:`.opencode/skills/mebular-assistant-integration/SKILL.md`
|
|
4
|
+
// 规格:`docs/design/config-journey-v1.md`(§3 模型 / §5.3 / §5.4 / §9 A-1..A-19)
|
|
5
|
+
//
|
|
6
|
+
// 硬性写入规则(本模块唯一实现,供 admin 读写端点复用):
|
|
7
|
+
// 1. 只**合并**助手自己的一条键(opencode: `mcp.servers.mebular`;json-mcpServers: `mcpServers.mebular`;
|
|
8
|
+
// hermes: `mcp_servers.mebular`);**A-17**:opencode 正确键位是 `mcp.servers.<name>`(非 `mcp.<name>`)。
|
|
9
|
+
// 2. 写前**字节级备份** `<path>.bak-<ts>`(merge-key 与 cli-set 都必须)。
|
|
10
|
+
// 3. **原子写**(临时文件 + rename);cli-set 走助手官方 CLI(Hermes: `hermes config set --force`)。
|
|
11
|
+
// 4. 解析失败 / 结构未知 → **不写**,返回 `needsManual` + 片段(原文件不动)。
|
|
12
|
+
// 5. 重复接入**幂等**(条目已等价 → changed:false,不写不备份);写入失败**回滚**。
|
|
13
|
+
// 6. **禁写** `landing.forbiddenPaths`(如 OpenChamber 私有的 `opencode.managed.json`)。
|
|
14
|
+
// 7. **A-19**:本模块**不得**调用 `opencode reload`(生效由用户在 App 侧重载)。
|
|
15
|
+
// 9. **F-FIX-A**:assistant 相关路径以**用户主目录**为基(`userHome`/`MEBULAR_USER_HOME`/`os.homedir()`),
|
|
16
|
+
// **绝不**用 MEBULAR_HOME(那是 Mebular 自有文件的家);spawn 的子进程统一注入**增强 PATH**。
|
|
17
|
+
// 8. **F-PERF-2**:请求路径的探针一律**异步**(exec promisify)+ **短 TTL 会话内缓存**(单飞去重、
|
|
18
|
+
// 写后失效、`refresh` 绕过)——不得在事件循环里 `spawnSync` 阻塞(并发 GET 会互相排队)。
|
|
19
|
+
//
|
|
20
|
+
// 纯函数式:home/cwd/platform/env/run 可注入,便于单元测试(不触碰真实用户目录)。
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
24
|
+
import { exec, spawnSync } from 'node:child_process';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { delimiter, dirname, join, sep } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// 格式适配器(落面形状的唯一真源;A-17 以 `opencode mcp add` 产出为准)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 条目形状注册表(**可序列化 id → 构造函数**)。F-FIX-B:守护运行中 **stdio `mebular mcp` 会被拒**
|
|
36
|
+
* (MCP_STORAGE_LOCKED,F-CLI-2 设计如此)⇒ 注入条目必须指向**守护 HTTP `/mcp`**;
|
|
37
|
+
* 只有拿不到守护端点时才回落 stdio(并保留"同机"前提与指引)。
|
|
38
|
+
*/
|
|
39
|
+
export const ENTRY_SHAPES = {
|
|
40
|
+
'opencode-remote': (url) => ({ type: 'remote', url }),
|
|
41
|
+
'opencode-stdio': () => ({ type: 'local', command: ['mebular', 'mcp'] }),
|
|
42
|
+
'claude-http': (url) => ({ type: 'http', url }),
|
|
43
|
+
'url-only': (url) => ({ url }),
|
|
44
|
+
'stdio-mcpServers': () => ({ command: 'mebular', args: ['mcp'] }),
|
|
45
|
+
'hermes-url': (url) => ({ url, enabled: true }),
|
|
46
|
+
'hermes-stdio': () => ({ command: 'mebular', args: ['mcp'], enabled: true }),
|
|
47
|
+
'dsh-http': (url) => ({ name: '@deepseek-ai/dsh-mcp-client', config: { serverName: 'mebular', transport: 'streamable-http', url } }),
|
|
48
|
+
'dsh-stdio': () => ({ name: '@deepseek-ai/dsh-mcp-client', config: { serverName: 'mebular', transport: 'stdio', command: 'mebular', args: ['mcp'] } }),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** 取某形状的条目;未知形状 → null(调用方回落)。 */
|
|
52
|
+
export function entryForShape(shape, url) {
|
|
53
|
+
const build = ENTRY_SHAPES[shape];
|
|
54
|
+
return build ? build(url) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 适配器注册表(内置)。`keyPath` 含叶子名(助手的 mcp 条目名固定 `mebular`)。 */
|
|
58
|
+
export const FORMAT_ADAPTERS = {
|
|
59
|
+
// opencode v2(含 OpenChamber 宿主):真源 = `opencode mcp add` 产出 `{"mcp":{"servers":{...}}}`
|
|
60
|
+
'opencode-json': {
|
|
61
|
+
id: 'opencode-json', serialization: 'json', strategy: 'merge-key', keyPath: ['mcp', 'servers', 'mebular'],
|
|
62
|
+
legacyKeyPath: ['mcp', 'mebular'], // A-17:旧版错形状,仅用于「诚实标注不生效」
|
|
63
|
+
httpShape: 'opencode-remote', stdioShape: 'opencode-stdio',
|
|
64
|
+
},
|
|
65
|
+
'json-mcpServers': {
|
|
66
|
+
id: 'json-mcpServers', serialization: 'json', strategy: 'merge-key', keyPath: ['mcpServers', 'mebular'],
|
|
67
|
+
httpShape: 'url-only', stdioShape: 'stdio-mcpServers',
|
|
68
|
+
},
|
|
69
|
+
// Hermes:YAML,但写入**必须**经官方 CLI(ruamel 保注释);无法 env 沙箱 ⇒ 备份/回滚是硬要求。
|
|
70
|
+
'hermes-yaml': {
|
|
71
|
+
id: 'hermes-yaml', serialization: 'yaml', strategy: 'cli-set', keyPath: ['mcp_servers', 'mebular'],
|
|
72
|
+
httpShape: 'hermes-url', stdioShape: 'hermes-stdio',
|
|
73
|
+
},
|
|
74
|
+
// 遗留 DSH(YAML 插件列表):DDH 插件支持 streamable-http(与 `mebular print-config --client dsh` 同源)。
|
|
75
|
+
dsh: {
|
|
76
|
+
id: 'dsh', serialization: 'yaml', strategy: 'merge-key', keyPath: ['plugins', 'mebular'],
|
|
77
|
+
httpShape: 'dsh-http', stdioShape: 'dsh-stdio',
|
|
78
|
+
},
|
|
79
|
+
toml: { id: 'toml', serialization: 'toml', strategy: 'manual', keyPath: ['mcp_servers', 'mebular'], httpShape: 'url-only', stdioShape: 'stdio-mcpServers' },
|
|
80
|
+
yaml: { id: 'yaml', serialization: 'yaml', strategy: 'manual', keyPath: ['mcp_servers', 'mebular'], httpShape: 'url-only', stdioShape: 'stdio-mcpServers' },
|
|
81
|
+
'generic-snippet': { id: 'generic-snippet', serialization: 'text', strategy: 'manual', keyPath: ['mcpServers', 'mebular'], httpShape: 'url-only', stdioShape: 'stdio-mcpServers' },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** 遗留 format 名 → 适配器 id(保持既有调用方与片段兼容)。 */
|
|
85
|
+
const LEGACY_FORMAT = { opencode: 'opencode-json', mcpServers: 'json-mcpServers', generic: 'generic-snippet', dsh: 'dsh' };
|
|
86
|
+
|
|
87
|
+
export function adapterFor(format) {
|
|
88
|
+
return FORMAT_ADAPTERS[LEGACY_FORMAT[format] ?? format] ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 守护 HTTP `/mcp` 端点(**单一真源**:`mebular print-config --client <id> --url …` 同此形态)。
|
|
93
|
+
* 由请求 Host/scheme 推导;`0.0.0.0`/`::` 归一为 `127.0.0.1`(同 `mebular console`)。
|
|
94
|
+
*/
|
|
95
|
+
export function daemonMcpUrl({ host, encrypted } = {}) {
|
|
96
|
+
const raw = typeof host === 'string' && host.length > 0 ? host : '127.0.0.1:7331';
|
|
97
|
+
const [hostPart, portPart] = raw.split(':');
|
|
98
|
+
const target = hostPart === '0.0.0.0' || hostPart === '::' || hostPart === undefined || hostPart === '' ? '127.0.0.1' : hostPart;
|
|
99
|
+
const port = portPart !== undefined && /^[0-9]+$/.test(portPart) ? portPart : '7331';
|
|
100
|
+
return `${encrypted ? 'https' : 'http'}://${target}:${port}/mcp`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 形状选择:给 url → HTTP 形态;否则 stdio(descriptor 可用 `landing.httpShape` 覆盖)。 */
|
|
104
|
+
function shapeFor(format, options = {}) {
|
|
105
|
+
const adapter = adapterFor(format);
|
|
106
|
+
if (!adapter) return null;
|
|
107
|
+
return options.url
|
|
108
|
+
? (options.httpShape ?? adapter.httpShape ?? 'url-only')
|
|
109
|
+
: (options.stdioShape ?? adapter.stdioShape ?? 'stdio-mcpServers');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 本机 MCP 条目(供合并/写入):**有守护 url 即指向 HTTP `/mcp`**;无 url 回落 stdio。
|
|
114
|
+
* F-FIX-B:守护运行中 stdio `mebular mcp` 会被 MCP_STORAGE_LOCKED 拒(F-CLI-2 设计如此)。
|
|
115
|
+
*/
|
|
116
|
+
export function mebularEntry(format, options = {}) {
|
|
117
|
+
const adapter = adapterFor(format);
|
|
118
|
+
if (!adapter) return { command: 'mebular', args: ['mcp'] };
|
|
119
|
+
return entryForShape(shapeFor(format, options), options.url) ?? { command: 'mebular', args: ['mcp'] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 各客户端 MCP 片段(与 `mebular print-config --client …` 同源);有 `url` → HTTP 形态。 */
|
|
123
|
+
export function snippetFor(format, options = {}) {
|
|
124
|
+
if (format === 'opencode-remote') {
|
|
125
|
+
return snippetFor('opencode-json', { url: options.url ?? 'http://127.0.0.1:7331/mcp' });
|
|
126
|
+
}
|
|
127
|
+
const url = options.url ?? null;
|
|
128
|
+
const adapter = adapterFor(format);
|
|
129
|
+
const id = adapter?.id ?? format;
|
|
130
|
+
const entry = mebularEntry(format, { ...options, url });
|
|
131
|
+
if (id === 'opencode-json') {
|
|
132
|
+
return JSON.stringify({ $schema: 'https://opencode.ai/config.json', mcp: { servers: { mebular: entry } } }, null, 2);
|
|
133
|
+
}
|
|
134
|
+
if (id === 'dsh') {
|
|
135
|
+
const cfg = entry.config ?? {};
|
|
136
|
+
return cfg.transport === 'streamable-http'
|
|
137
|
+
? `plugins:\n - name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: mebular\n transport: streamable-http\n url: ${cfg.url}`
|
|
138
|
+
: "plugins:\n - name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: mebular\n transport: stdio\n command: mebular\n args: ['mcp']";
|
|
139
|
+
}
|
|
140
|
+
if (id === 'hermes-yaml') {
|
|
141
|
+
return url
|
|
142
|
+
? ['mcp_servers:', ' mebular:', ` url: ${url}`, ' enabled: true'].join('\n')
|
|
143
|
+
: ['mcp_servers:', ' mebular:', ' command: mebular', ' args:', ' - mcp', ' enabled: true'].join('\n');
|
|
144
|
+
}
|
|
145
|
+
if (id === 'toml') return url ? `[mcp_servers.mebular]\nurl = "${url}"\nenabled = true` : '[mcp_servers.mebular]\ncommand = "mebular"\nargs = ["mcp"]';
|
|
146
|
+
if (id === 'yaml') return url ? `mcp_servers:\n mebular:\n url: ${url}\n enabled: true` : 'mcp_servers:\n mebular:\n command: mebular\n args: [mcp]';
|
|
147
|
+
return JSON.stringify({ mcpServers: { mebular: entry } }, null, 2);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// A-18:探针/验证输出卫生(只打印键名/变量名,绝不回显凭据值)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** 抹掉形如 `token=…` / `api_key: …` / `Authorization: Bearer …` 的值。 */
|
|
155
|
+
export function redactSecrets(text) {
|
|
156
|
+
return String(text ?? '')
|
|
157
|
+
.replace(/(\bBearer\s+)[A-Za-z0-9\-._~+/=]+/gi, '$1<redacted>')
|
|
158
|
+
.replace(/((?:\b|["'])(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|authorization|credential|private[_-]?key)(?:\b|["'])\s*[:=]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1<redacted>');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// 内置条目(descriptor 六要素)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const HOME_OPENCODE = ['~', '.config', 'opencode', 'opencode.json'];
|
|
166
|
+
const HOME_HERMES = ['~', '.hermes', 'config.yaml'];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* assistant 落面/探测路径的**用户主目录**(F-FIX-A):
|
|
170
|
+
* 显式 `options.userHome` > `MEBULAR_USER_HOME` > `os.homedir()`。
|
|
171
|
+
* **绝不**回退到 Mebular home(`MEBULAR_HOME`)——两者语义不同,混用会把路径拼成
|
|
172
|
+
* `<MEBULAR_HOME>/.config/…`(真机事故:Hermes 被误报未安装、OpenChamber 写入错路径)。
|
|
173
|
+
*/
|
|
174
|
+
export function assistantUserHome(options = {}) {
|
|
175
|
+
const explicit = options.userHome;
|
|
176
|
+
if (typeof explicit === 'string' && explicit.length > 0) return explicit;
|
|
177
|
+
const fromEnv = options.env?.MEBULAR_USER_HOME ?? process.env.MEBULAR_USER_HOME;
|
|
178
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
|
|
179
|
+
return homedir();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 桌面壳/守护常驻的 opencode 捆绑目录(存在才注入 PATH)。 */
|
|
183
|
+
export const OPENCODE_BUNDLE_BIN = '/Applications/OpenChamber.app/Contents/Resources/opencode-cli';
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 增强 PATH(跨平台:用 `path.delimiter` 分割/拼接,绝不用 ':' 硬编码):
|
|
187
|
+
* 现有 PATH + `~/.local/bin`;非 Windows 追加 `/usr/local/bin`、`/opt/homebrew/bin`;
|
|
188
|
+
* darwin 且**存在**时追加 opencode 捆绑目录(launchd 服务 env 的 PATH 极简)。
|
|
189
|
+
*/
|
|
190
|
+
export function enhancedPath(basePath = process.env.PATH, userHome = assistantUserHome(), platform = process.platform, pathDelimiter = delimiter) {
|
|
191
|
+
const base = typeof basePath === 'string' && basePath.length > 0 ? basePath.split(pathDelimiter) : [];
|
|
192
|
+
const extra = [join(userHome, '.local', 'bin')];
|
|
193
|
+
if (platform !== 'win32') extra.push('/usr/local/bin', '/opt/homebrew/bin');
|
|
194
|
+
if (platform === 'darwin' && existsSync(OPENCODE_BUNDLE_BIN)) extra.push(OPENCODE_BUNDLE_BIN);
|
|
195
|
+
return [...new Set([...base, ...extra].filter((p) => typeof p === 'string' && p.length > 0))].join(pathDelimiter);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 子进程环境:继承进程环境 + 增强 PATH(launchd 服务 env 的 PATH 极简 → rc=127)。 */
|
|
199
|
+
export function spawnEnv(options = {}) {
|
|
200
|
+
const userHome = assistantUserHome(options);
|
|
201
|
+
return { ...process.env, ...(options.env ?? {}), PATH: enhancedPath(process.env.PATH, userHome, options.platform ?? process.platform) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function expandHome(parts, home) {
|
|
205
|
+
return join(home, ...parts.slice(1));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 内置助手注册表(六要素;落面/写入/验证值以 F-EXP-1 实测为准)。 */
|
|
209
|
+
export function builtinAssistants(options = {}) {
|
|
210
|
+
const userHome = assistantUserHome(options);
|
|
211
|
+
const cwd = options.cwd ?? process.cwd();
|
|
212
|
+
const platform = options.platform ?? process.platform;
|
|
213
|
+
const env = options.env ?? process.env;
|
|
214
|
+
const appData = platform === 'darwin'
|
|
215
|
+
? join(userHome, 'Library', 'Application Support')
|
|
216
|
+
: platform === 'win32'
|
|
217
|
+
? (env.APPDATA ?? join(userHome, 'AppData', 'Roaming'))
|
|
218
|
+
: (env.XDG_CONFIG_HOME ?? join(userHome, '.config'));
|
|
219
|
+
const opencodePath = expandHome(HOME_OPENCODE, userHome);
|
|
220
|
+
const hermesPath = expandHome(HOME_HERMES, userHome);
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
id: 'openchamber', name: 'OpenChamber', kind: 'file', format: 'opencode-json',
|
|
225
|
+
landing: {
|
|
226
|
+
primaryPath: opencodePath,
|
|
227
|
+
keyPath: 'mcp.servers.mebular',
|
|
228
|
+
forbiddenPaths: [join(userHome, '.config', 'openchamber', 'opencode.managed.json')],
|
|
229
|
+
format: 'opencode-json',
|
|
230
|
+
},
|
|
231
|
+
probe: {
|
|
232
|
+
signals: [
|
|
233
|
+
{ kind: 'config-path', paths: [opencodePath] },
|
|
234
|
+
{ kind: 'cli', command: 'opencode --version', timeoutMs: 1500 },
|
|
235
|
+
{ kind: 'app-bundle', paths: ['/Applications/OpenChamber.app'] },
|
|
236
|
+
{ kind: 'process', pattern: 'opencode serve --service' },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true },
|
|
240
|
+
verify: { cli: { command: 'opencode mcp list', expect: 'mebular', timeoutMs: 4000 }, configReadback: true },
|
|
241
|
+
activation: { kind: 'user-reload', note: 'OpenChamber:由用户在 App 侧重载/重启生效;产品不得发 opencode reload 干扰运行服务' },
|
|
242
|
+
risks: [
|
|
243
|
+
'opencode.managed.json 为 App 所有且会被重写,禁写',
|
|
244
|
+
'opencode mcp list 经运行服务回答,只读但勿频繁调用',
|
|
245
|
+
],
|
|
246
|
+
skillsDirs: [join(userHome, '.config', 'opencode', 'skills')],
|
|
247
|
+
reuseOf: 'opencode',
|
|
248
|
+
note: 'OpenChamber 复用 OpenCode 的 global 配置(接一次即可)',
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: 'hermes', name: 'Hermes', kind: 'file', format: 'hermes-yaml',
|
|
252
|
+
landing: { primaryPath: hermesPath, keyPath: 'mcp_servers.mebular', forbiddenPaths: [], format: 'hermes-yaml' },
|
|
253
|
+
probe: {
|
|
254
|
+
signals: [
|
|
255
|
+
{ kind: 'config-path', paths: [hermesPath] },
|
|
256
|
+
{ kind: 'cli', command: 'hermes --version', timeoutMs: 2500 },
|
|
257
|
+
{ kind: 'path', paths: [join(userHome, '.local', 'bin', 'hermes')] },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
writer: { strategy: 'cli-set', command: 'hermes config set --force mcp_servers.mebular', readbackCommand: 'hermes config get mcp_servers', backup: 'byte-level', atomic: false },
|
|
261
|
+
verify: {
|
|
262
|
+
cli: { command: 'hermes mcp test mebular', expect: '✓ Connected', toolsPattern: 'Tools discovered:\\s*(\\d+)', minTools: 1, timeoutMs: 30000 },
|
|
263
|
+
configReadback: true,
|
|
264
|
+
},
|
|
265
|
+
activation: { kind: 'hot', note: 'Hermes 免重启:新会话即用,或已开会话 /reload-mcp' },
|
|
266
|
+
risks: [
|
|
267
|
+
'Hermes 无法 env 沙箱(HERMES_CONFIG_PATH 被剥离)→ 写前字节级备份 + 失败回滚为硬要求',
|
|
268
|
+
'写入仅经 `hermes config set --force`(ruamel 保注释);未知子键必须 --force',
|
|
269
|
+
],
|
|
270
|
+
skillsDirs: [join(userHome, '.agents', 'skills')],
|
|
271
|
+
reuseOf: null,
|
|
272
|
+
note: 'Hermes 的 MCP 配置在 ~/.hermes/config.yaml',
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: 'opencode', name: 'OpenCode', kind: 'file', format: 'opencode-json',
|
|
276
|
+
landing: { primaryPath: opencodePath, keyPath: 'mcp.servers.mebular', forbiddenPaths: [], format: 'opencode-json' },
|
|
277
|
+
probe: {
|
|
278
|
+
signals: [
|
|
279
|
+
{ kind: 'config-path', paths: [opencodePath] },
|
|
280
|
+
{ kind: 'cli', command: 'opencode --version', timeoutMs: 1500 },
|
|
281
|
+
{ kind: 'dir', paths: [join(userHome, '.config', 'opencode')] },
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true },
|
|
285
|
+
verify: { cli: { command: 'opencode mcp list', expect: 'mebular', timeoutMs: 4000 }, configReadback: true },
|
|
286
|
+
activation: { kind: 'user-reload', note: 'OpenCode 侧重载/重启后生效' },
|
|
287
|
+
risks: ['opencode mcp list 经运行服务回答,只读但勿频繁调用'],
|
|
288
|
+
skillsDirs: [join(userHome, '.config', 'opencode', 'skills'), join(userHome, '.agents', 'skills'), join(cwd, '.agents', 'skills'), join(cwd, '.opencode', 'skills')],
|
|
289
|
+
reuseOf: null,
|
|
290
|
+
note: null,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'claude-code', name: 'Claude Code', kind: 'file', format: 'json-mcpServers',
|
|
294
|
+
landing: { primaryPath: join(userHome, '.claude.json'), keyPath: 'mcpServers.mebular', forbiddenPaths: [], format: 'json-mcpServers', httpShape: 'claude-http' },
|
|
295
|
+
probe: { signals: [{ kind: 'config-path', paths: [join(userHome, '.claude.json')] }, { kind: 'cli', command: 'claude --version', timeoutMs: 1500 }] },
|
|
296
|
+
writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true },
|
|
297
|
+
verify: { configReadback: true },
|
|
298
|
+
activation: { kind: 'user-reload', note: '重启 Claude Code 后生效' },
|
|
299
|
+
risks: [],
|
|
300
|
+
skillsDirs: [join(userHome, '.agents', 'skills'), join(cwd, '.agents', 'skills')],
|
|
301
|
+
reuseOf: null, note: null,
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: 'claude-desktop', name: 'Claude Desktop', kind: 'file', format: 'json-mcpServers',
|
|
305
|
+
landing: { primaryPath: join(appData, 'Claude', 'claude_desktop_config.json'), keyPath: 'mcpServers.mebular', forbiddenPaths: [], format: 'json-mcpServers', httpShape: 'claude-http' },
|
|
306
|
+
probe: { signals: [{ kind: 'config-path', paths: [join(appData, 'Claude', 'claude_desktop_config.json')] }, { kind: 'dir', paths: [join(appData, 'Claude')] }] },
|
|
307
|
+
writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true },
|
|
308
|
+
verify: { configReadback: true },
|
|
309
|
+
activation: { kind: 'user-reload', note: '完全退出并重开 Claude Desktop 后生效' },
|
|
310
|
+
risks: [],
|
|
311
|
+
skillsDirs: [], reuseOf: null, note: null,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: 'cursor', name: 'Cursor', kind: 'file', format: 'json-mcpServers',
|
|
315
|
+
landing: { primaryPath: join(userHome, '.cursor', 'mcp.json'), keyPath: 'mcpServers.mebular', forbiddenPaths: [], format: 'json-mcpServers' },
|
|
316
|
+
probe: { signals: [{ kind: 'config-path', paths: [join(userHome, '.cursor', 'mcp.json')] }, { kind: 'dir', paths: [join(userHome, '.cursor')] }] },
|
|
317
|
+
writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true },
|
|
318
|
+
verify: { configReadback: true },
|
|
319
|
+
activation: { kind: 'user-reload', note: '重启 Cursor 后生效' },
|
|
320
|
+
risks: ['HTTP /mcp 默认只监听回环:客户端与守护须**同机**(跨机请在 config 里启用 TLS+鉴权并用可达主机名)'], skillsDirs: [], reuseOf: null, note: null,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: 'dsh', name: 'DeepSeek Harness (DSH)', kind: 'file', format: 'dsh',
|
|
324
|
+
landing: { primaryPath: join(cwd, '.dsh', 'cordis.yml'), keyPath: 'plugins', forbiddenPaths: [], format: 'dsh' },
|
|
325
|
+
probe: { signals: [{ kind: 'config-path', paths: [join(cwd, '.dsh', 'cordis.yml')] }, { kind: 'dir', paths: [join(cwd, '.dsh')] }] },
|
|
326
|
+
writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true },
|
|
327
|
+
verify: { configReadback: true },
|
|
328
|
+
activation: { kind: 'user-reload', note: '重启 DSH 后生效' },
|
|
329
|
+
risks: ['DSH 配置为 YAML 插件列表:结构未知时不自动改写', 'HTTP /mcp 默认只监听回环:客户端与守护须同机(跨机请启用 TLS+鉴权并用可达主机名)'],
|
|
330
|
+
skillsDirs: [join(cwd, '.dsh', 'skills'), join(userHome, '.agents', 'skills')], reuseOf: null, note: null,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'generic', name: '其他 AI 助手(复制片段)', kind: 'snippet', format: 'generic-snippet',
|
|
334
|
+
landing: { primaryPath: null, keyPath: 'mcpServers.mebular', forbiddenPaths: [], format: 'generic-snippet' },
|
|
335
|
+
probe: { signals: [{ kind: 'always', reason: '通用条目:不探测,仅提供片段' }] },
|
|
336
|
+
writer: { strategy: 'manual', command: null, backup: 'n/a', atomic: false },
|
|
337
|
+
verify: { configReadback: false },
|
|
338
|
+
activation: { kind: 'user-reload', note: '按你的助手文档重载后生效' },
|
|
339
|
+
risks: ['不支持自动写入:复制片段粘贴到你的 MCP 配置', '片段默认指向本机守护 HTTP /mcp(http://127.0.0.1:<port>/mcp);跨机请用可达主机名并启用 TLS+鉴权'],
|
|
340
|
+
skillsDirs: [], reuseOf: null, note: '不支持自动写入:复制片段粘贴到你的 MCP 配置',
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// 自定义条目(`<home>/assistants.custom.json`;红线五:与内置同 schema/同判据)
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
export function customAssistantsPath(home = homedir()) {
|
|
350
|
+
return join(home, 'assistants.custom.json');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const CUSTOM_SCHEMA_HINT = 'assistants.custom.json 需为 {"assistants":[{id,name,landing,probe,writer,verify,activation}]}';
|
|
354
|
+
|
|
355
|
+
/** 校验一条自定义 descriptor(与内置同 schema;至少一条 probe 信号)。 */
|
|
356
|
+
export function validateAssistant(entry) {
|
|
357
|
+
const errors = [];
|
|
358
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
359
|
+
if (!isObj(entry)) return ['条目不是对象'];
|
|
360
|
+
if (typeof entry.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(entry.id)) errors.push('id 必须为小写 kebab-case');
|
|
361
|
+
if (typeof entry.name !== 'string' || entry.name.trim() === '') errors.push('name 必填');
|
|
362
|
+
if (!isObj(entry.landing)) errors.push('landing 必填');
|
|
363
|
+
else {
|
|
364
|
+
if (typeof entry.landing.format !== 'string' || !adapterFor(entry.landing.format)) errors.push(`landing.format 未知:${entry.landing.format}`);
|
|
365
|
+
if (entry.landing.primaryPath !== null && typeof entry.landing.primaryPath !== 'string') errors.push('landing.primaryPath 必须是字符串或 null');
|
|
366
|
+
if (entry.landing.forbiddenPaths !== undefined && !Array.isArray(entry.landing.forbiddenPaths)) errors.push('landing.forbiddenPaths 必须是数组');
|
|
367
|
+
}
|
|
368
|
+
if (!isObj(entry.probe) || !Array.isArray(entry.probe.signals) || entry.probe.signals.length === 0) errors.push('probe.signals 至少一条');
|
|
369
|
+
if (!isObj(entry.writer)) errors.push('writer 必填');
|
|
370
|
+
else if (!['merge-key', 'cli-set', 'manual'].includes(entry.writer.strategy)) errors.push(`writer.strategy 未知:${entry.writer.strategy}`);
|
|
371
|
+
if (entry.writer?.strategy === 'cli-set' && (typeof entry.writer.command !== 'string' || entry.writer.command.trim() === '')) errors.push('cli-set 必须给 writer.command');
|
|
372
|
+
if (entry.writer?.strategy !== 'manual' && entry.writer?.backup !== 'byte-level') errors.push('merge-key/cli-set 必须 writer.backup=byte-level');
|
|
373
|
+
if (!isObj(entry.verify)) errors.push('verify 必填');
|
|
374
|
+
else if (entry.verify.cli === undefined && entry.verify.configReadback !== true) errors.push('verify.cli 缺省时 configReadback 必须为 true');
|
|
375
|
+
if (!isObj(entry.activation) || !['hot', 'user-reload'].includes(entry.activation?.kind)) errors.push('activation.kind 必须是 hot|user-reload');
|
|
376
|
+
return errors;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 读自定义条目;文件损坏 → 返回 error(不静默当空)。 */
|
|
380
|
+
export function loadCustomAssistants(options = {}) {
|
|
381
|
+
const home = options.home ?? homedir();
|
|
382
|
+
const file = customAssistantsPath(home);
|
|
383
|
+
if (!existsSync(file)) return { assistants: [], error: null, path: file };
|
|
384
|
+
let parsed;
|
|
385
|
+
try {
|
|
386
|
+
parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return { assistants: [], error: `${CUSTOM_SCHEMA_HINT}(解析失败:${error instanceof Error ? error.message : String(error)})`, path: file };
|
|
389
|
+
}
|
|
390
|
+
if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.assistants)) {
|
|
391
|
+
return { assistants: [], error: CUSTOM_SCHEMA_HINT, path: file };
|
|
392
|
+
}
|
|
393
|
+
const assistants = [];
|
|
394
|
+
for (const entry of parsed.assistants) {
|
|
395
|
+
if (validateAssistant(entry).length === 0) {
|
|
396
|
+
// 归一化:自定义条目可省略 format/kind(从 landing 推导),与内置同形。
|
|
397
|
+
assistants.push({
|
|
398
|
+
...entry,
|
|
399
|
+
kind: entry.kind ?? (entry.writer?.strategy === 'manual' ? 'snippet' : 'file'),
|
|
400
|
+
format: entry.format ?? entry.landing?.format,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { assistants, error: null, path: file };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function saveCustomAssistants(options, assistants) {
|
|
408
|
+
const home = options.home ?? homedir();
|
|
409
|
+
const file = customAssistantsPath(home);
|
|
410
|
+
await mkdir(dirname(file), { recursive: true });
|
|
411
|
+
await writeFile(file, `${JSON.stringify({ assistants }, null, 2)}\n`, 'utf-8');
|
|
412
|
+
try { await chmod(file, 0o600); } catch { /* Windows 无 POSIX mode */ }
|
|
413
|
+
return file;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 新增自定义条目(id 与内置/自定义冲突 → 失败)。 */
|
|
417
|
+
export async function addCustomAssistant(entry, options = {}) {
|
|
418
|
+
const errors = validateAssistant(entry);
|
|
419
|
+
if (errors.length > 0) return { ok: false, reason: `descriptor 校验失败:${errors.join(';')}` };
|
|
420
|
+
if (builtinAssistants(options).some((b) => b.id === entry.id)) return { ok: false, reason: `id 与内置条目冲突:${entry.id}` };
|
|
421
|
+
const { assistants } = loadCustomAssistants(options);
|
|
422
|
+
if (assistants.some((a) => a.id === entry.id)) return { ok: false, reason: `id 已存在:${entry.id}` };
|
|
423
|
+
const next = [...assistants, entry];
|
|
424
|
+
const path = await saveCustomAssistants(options, next);
|
|
425
|
+
clearAssistantsCache(); // F-PERF-2:写后失效,避免读到陈旧三态
|
|
426
|
+
return { ok: true, added: entry.id, path, count: next.length };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 更新自定义条目(内置不可改)。 */
|
|
430
|
+
export async function updateCustomAssistant(id, patch, options = {}) {
|
|
431
|
+
if (builtinAssistants(options).some((b) => b.id === id)) return { ok: false, reason: `内置条目不可修改:${id}` };
|
|
432
|
+
const { assistants } = loadCustomAssistants(options);
|
|
433
|
+
const idx = assistants.findIndex((a) => a.id === id);
|
|
434
|
+
if (idx < 0) return { ok: false, reason: `自定义条目不存在:${id}` };
|
|
435
|
+
const merged = { ...assistants[idx], ...patch, id };
|
|
436
|
+
const errors = validateAssistant(merged);
|
|
437
|
+
if (errors.length > 0) return { ok: false, reason: `descriptor 校验失败:${errors.join(';')}` };
|
|
438
|
+
const next = assistants.slice();
|
|
439
|
+
next[idx] = merged;
|
|
440
|
+
const path = await saveCustomAssistants(options, next);
|
|
441
|
+
clearAssistantsCache(); // F-PERF-2:写后失效,避免读到陈旧三态
|
|
442
|
+
return { ok: true, updated: id, path };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 删除自定义条目(内置不允许删除)。 */
|
|
446
|
+
export async function removeCustomAssistant(id, options = {}) {
|
|
447
|
+
if (builtinAssistants(options).some((b) => b.id === id)) return { ok: false, reason: `内置条目不可删除:${id}` };
|
|
448
|
+
const { assistants } = loadCustomAssistants(options);
|
|
449
|
+
const next = assistants.filter((a) => a.id !== id);
|
|
450
|
+
if (next.length === assistants.length) return { ok: false, reason: `自定义条目不存在:${id}` };
|
|
451
|
+
const path = await saveCustomAssistants(options, next);
|
|
452
|
+
clearAssistantsCache(); // F-PERF-2:写后失效,避免读到陈旧三态
|
|
453
|
+
return { ok: true, removed: id, path, count: next.length };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** 注册表 = 内置 ∪ 自定义(自定义可覆盖同名内置?——不允许:id 冲突在登记时已拒绝)。 */
|
|
457
|
+
export function registry(options = {}) {
|
|
458
|
+
const custom = loadCustomAssistants(options);
|
|
459
|
+
return { assistants: [...builtinAssistants(options), ...custom.assistants], customError: custom.error, customPath: custom.path };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// 兼容投影(既有调用方:clientDescriptors / descriptorFor)
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
export function clientDescriptors(options = {}) {
|
|
467
|
+
return builtinAssistants(options).map((a) => ({
|
|
468
|
+
id: a.id, name: a.name, format: a.format, kind: a.kind,
|
|
469
|
+
configPath: a.landing.primaryPath,
|
|
470
|
+
skillsDirs: a.skillsDirs ?? [],
|
|
471
|
+
requiresRestart: a.activation.kind === 'user-reload',
|
|
472
|
+
reuseOf: a.reuseOf ?? null, note: a.note ?? null,
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function descriptorFor(id, options = {}) {
|
|
477
|
+
return builtinAssistants(options).find((d) => d.id === id)
|
|
478
|
+
?? loadCustomAssistants(options).assistants.find((d) => d.id === id)
|
|
479
|
+
?? null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// 合并(JSON:只并助手一条键;结构未知 → 不写)
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
function keyPathOf(format, keyPathOverride) {
|
|
487
|
+
if (Array.isArray(keyPathOverride)) return keyPathOverride;
|
|
488
|
+
const adapter = adapterFor(format);
|
|
489
|
+
return adapter ? adapter.keyPath : ['mcpServers', 'mebular'];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function walkGet(root, path) {
|
|
493
|
+
let node = root;
|
|
494
|
+
for (const key of path) {
|
|
495
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) return { missing: true, badAt: key };
|
|
496
|
+
if (!(key in node)) return { missing: true, value: undefined };
|
|
497
|
+
node = node[key];
|
|
498
|
+
}
|
|
499
|
+
return { value: node };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* JSON 合并:设置或删除 `<keyPath>`(默认含叶子 `mebular`),保留其余键。
|
|
504
|
+
* 中间键存在但非对象 → error(结构未知,不自动改写)。
|
|
505
|
+
*/
|
|
506
|
+
export function mergeJsonEntry(existingText, { format, entry, keyPath } = {}) {
|
|
507
|
+
const path = keyPathOf(format, keyPath);
|
|
508
|
+
let parsed;
|
|
509
|
+
if (existingText === null || existingText === undefined || existingText.trim() === '') {
|
|
510
|
+
parsed = {};
|
|
511
|
+
} else {
|
|
512
|
+
try {
|
|
513
|
+
parsed = JSON.parse(existingText);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return { error: `配置不是合法 JSON(${error instanceof Error ? error.message : String(error)})` };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
519
|
+
return { error: '配置顶层不是对象(结构未知,不自动改写)' };
|
|
520
|
+
}
|
|
521
|
+
const parents = path.slice(0, -1);
|
|
522
|
+
const leaf = path[path.length - 1];
|
|
523
|
+
const next = { ...parsed };
|
|
524
|
+
let cursor = next;
|
|
525
|
+
for (const key of parents) {
|
|
526
|
+
const current = cursor[key];
|
|
527
|
+
if (current === undefined) {
|
|
528
|
+
cursor[key] = {};
|
|
529
|
+
} else if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
|
530
|
+
return { error: `字段 ${key} 结构未知(非对象,不自动改写)` };
|
|
531
|
+
} else {
|
|
532
|
+
cursor[key] = { ...current };
|
|
533
|
+
}
|
|
534
|
+
cursor = cursor[key];
|
|
535
|
+
}
|
|
536
|
+
const changed = JSON.stringify(cursor[leaf] ?? null) !== JSON.stringify(entry ?? null);
|
|
537
|
+
if (entry === undefined) delete cursor[leaf];
|
|
538
|
+
else cursor[leaf] = entry;
|
|
539
|
+
return { changed, next, text: `${JSON.stringify(next, null, 2)}\n` };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** DSH(YAML) 合并:空文件 → 写片段;已含 serverName: mebular → 幂等;结构未知 → 不写。 */
|
|
543
|
+
export function mergeDsh(existingText, options = {}) {
|
|
544
|
+
const snippet = snippetFor('dsh', options);
|
|
545
|
+
if (existingText === null || existingText.trim() === '') return { changed: true, text: `${snippet}\n` };
|
|
546
|
+
if (/serverName:\s*['"]?mebular['"]?/.test(existingText)) {
|
|
547
|
+
// 已接入:仅当**形态等价**(HTTP vs stdio)才算幂等,否则需改写
|
|
548
|
+
const wantHttp = Boolean(options.url);
|
|
549
|
+
const hasHttp = /transport:\s*streamable-http/.test(existingText);
|
|
550
|
+
if (wantHttp === hasHttp) return { changed: false, text: existingText };
|
|
551
|
+
return { changed: true, text: `${snippet}\n` };
|
|
552
|
+
}
|
|
553
|
+
return { error: 'DSH 配置已存在但未接入:YAML 结构未知,未自动改写(请按片段人工合并)' };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** 行级 diff(信任增强:写入前后预览)。 */
|
|
557
|
+
export function lineDiff(before, after) {
|
|
558
|
+
const a = (before ?? '').split('\n');
|
|
559
|
+
const b = (after ?? '').split('\n');
|
|
560
|
+
const out = [];
|
|
561
|
+
const max = Math.max(a.length, b.length);
|
|
562
|
+
for (let i = 0; i < max; i++) {
|
|
563
|
+
if (a[i] === b[i]) continue;
|
|
564
|
+
if (a[i] !== undefined) out.push(`- ${a[i]}`);
|
|
565
|
+
if (b[i] !== undefined) out.push(`+ ${b[i]}`);
|
|
566
|
+
}
|
|
567
|
+
return out.join('\n');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function safeReadText(path) {
|
|
571
|
+
try { return readFileSync(path, 'utf-8'); } catch { return null; }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** YAML 行式判据:`<parent>:` 下出现 2 空格缩进的 `<child>:`(Hermes 用;避免引 yaml 依赖)。 */
|
|
575
|
+
export function yamlHasKey(text, parent, child) {
|
|
576
|
+
if (typeof text !== 'string') return false;
|
|
577
|
+
const lines = text.split('\n');
|
|
578
|
+
let inParent = false;
|
|
579
|
+
for (const line of lines) {
|
|
580
|
+
if (new RegExp(`^${parent}\\s*:`).test(line)) { inParent = true; continue; }
|
|
581
|
+
if (!inParent) continue;
|
|
582
|
+
const trimmed = line.trim();
|
|
583
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue; // 注释/空行不终止父段(与 yamlEntryBlock 一致)
|
|
584
|
+
if (/^\S/.test(line)) break; // 真正的顶格非注释行 → 父段结束
|
|
585
|
+
if (new RegExp(`^\\s{1,4}${child}\\s*:`).test(line)) return true;
|
|
586
|
+
}
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** 提取 YAML 中 `<parent>:` 下 `<child>:` 的块文本(行式,供语义判据;无需 yaml 依赖)。 */
|
|
591
|
+
export function yamlEntryBlock(text, parent, child) {
|
|
592
|
+
const lines = String(text ?? '').split('\n');
|
|
593
|
+
let inParent = false;
|
|
594
|
+
let block = null;
|
|
595
|
+
for (const line of lines) {
|
|
596
|
+
if (new RegExp(`^${parent}\\s*:`).test(line)) { inParent = true; continue; }
|
|
597
|
+
if (!inParent) continue;
|
|
598
|
+
const trimmed = line.trim();
|
|
599
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue; // 顶格注释/空行不终止父段,也不进子块
|
|
600
|
+
if (/^\S/.test(line)) break; // 真正的顶格非注释行 → 父段结束(**保留**已收集的块,勿清空)
|
|
601
|
+
if (new RegExp(`^\\s{1,6}${child}\\s*:`).test(line)) { block = [line]; continue; }
|
|
602
|
+
if (block !== null) block.push(line); // 子块内的缩进行
|
|
603
|
+
}
|
|
604
|
+
return block === null ? null : block.join('\n');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** 该格式下「HTTP/remote」形态(valid);`stdio` 形态守护运行中会被拒(MCP_STORAGE_LOCKED)。 */
|
|
608
|
+
export function entryIsHttp(format, entry) {
|
|
609
|
+
if (entry === null || typeof entry !== 'object') return false;
|
|
610
|
+
if (format === 'opencode-json') return entry.type === 'remote' || entry.type === 'http';
|
|
611
|
+
if (format === 'dsh') return entry.config?.transport === 'streamable-http';
|
|
612
|
+
return typeof entry.url === 'string' && entry.url.length > 0;
|
|
613
|
+
}
|
|
614
|
+
export function entryIsStdio(format, entry) {
|
|
615
|
+
if (entry === null || typeof entry !== 'object') return false;
|
|
616
|
+
if (format === 'opencode-json') return entry.type === 'local' && Array.isArray(entry.command);
|
|
617
|
+
if (format === 'dsh') return entry.config?.transport === 'stdio';
|
|
618
|
+
return typeof entry.command === 'string' && entry.url === undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 该助手在落面是否已有**正确形状**的 mebular 条目;并如实报 legacy 错形状。 */
|
|
622
|
+
export function landingState(assistant, options = {}) {
|
|
623
|
+
const primary = assistant.landing?.primaryPath ?? null;
|
|
624
|
+
const format = assistant.landing?.format;
|
|
625
|
+
const adapter = adapterFor(format);
|
|
626
|
+
if (!primary) return { exists: false, present: false, legacyShape: false, text: null };
|
|
627
|
+
const text = safeReadText(primary);
|
|
628
|
+
if (text === null) return { exists: false, present: false, legacyShape: false, text: null };
|
|
629
|
+
if (format === 'dsh') {
|
|
630
|
+
const has = /serverName:\s*['"]?mebular['"]?/.test(text);
|
|
631
|
+
const present = has && /transport:\s*streamable-http/.test(text);
|
|
632
|
+
return { exists: true, present, legacyShape: false, staleStdio: has && /transport:\s*stdio/.test(text) && !present, text };
|
|
633
|
+
}
|
|
634
|
+
if (adapter?.serialization === 'yaml') {
|
|
635
|
+
const path = keyPathOf(format);
|
|
636
|
+
const block = yamlEntryBlock(text, path[0], path[1]);
|
|
637
|
+
const hasUrl = block !== null && /^\s*url\s*:\s*\S+/m.test(block);
|
|
638
|
+
const hasCommand = block !== null && /^\s*command\s*:\s*\S+/m.test(block);
|
|
639
|
+
// 语义判据:remote(有 url)→ present;stdio(有 command 无 url)→ stale;旧「逐字段错位追加」也归 stale
|
|
640
|
+
const present = hasUrl && !hasCommand;
|
|
641
|
+
return { exists: true, present, legacyShape: false, staleStdio: !present && hasCommand, text };
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const parsed = JSON.parse(text);
|
|
645
|
+
const path = keyPathOf(format);
|
|
646
|
+
const stored = walkGet(parsed, path).value ?? null;
|
|
647
|
+
const legacy = adapter?.legacyKeyPath
|
|
648
|
+
? walkGet(parsed, adapter.legacyKeyPath).value !== undefined
|
|
649
|
+
: false;
|
|
650
|
+
// 语义判据(兼容迁移):remote/http 形态 → present;stdio 形态或**端口与当前守护端点不符** → stale
|
|
651
|
+
const isHttp = entryIsHttp(format, stored);
|
|
652
|
+
const storedUrl = typeof stored?.url === 'string' ? stored.url : null;
|
|
653
|
+
const urlOk = options.mcpUrl === undefined || options.mcpUrl === null || storedUrl === options.mcpUrl;
|
|
654
|
+
const present = isHttp && urlOk;
|
|
655
|
+
const staleStdio = !present && (entryIsStdio(format, stored) || (isHttp && !urlOk));
|
|
656
|
+
return { exists: true, present, legacyShape: legacy && !present, staleStdio, text };
|
|
657
|
+
} catch {
|
|
658
|
+
return { exists: true, present: false, legacyShape: false, text, parseError: true };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// 探测(多信号并集,逐信号可证伪;红线一/二)
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
export const DEFAULT_CLI_TIMEOUT_MS = 1500;
|
|
667
|
+
|
|
668
|
+
function defaultRun(command, timeoutMs, options = {}) {
|
|
669
|
+
const res = spawnSync(command, { shell: true, encoding: 'utf-8', timeout: timeoutMs, env: spawnEnv(options) });
|
|
670
|
+
return { ok: res.status === 0, code: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function defaultProcessList(options = {}) {
|
|
674
|
+
try {
|
|
675
|
+
const res = spawnSync('ps', ['-Ao', 'command'], { encoding: 'utf-8', timeout: 2000, env: spawnEnv(options) });
|
|
676
|
+
return res.status === 0 ? (res.stdout ?? '') : '';
|
|
677
|
+
} catch {
|
|
678
|
+
return '';
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const execAsync = promisify(exec);
|
|
683
|
+
|
|
684
|
+
/** 异步命令执行(请求路径唯一入口;`exec` 自带 shell 语义,跨平台)。 */
|
|
685
|
+
async function defaultRunAsync(command, timeoutMs, options = {}) {
|
|
686
|
+
try {
|
|
687
|
+
const { stdout, stderr } = await execAsync(command, { timeout: timeoutMs, encoding: 'utf-8', maxBuffer: 1 << 20, windowsHide: true, env: spawnEnv(options) });
|
|
688
|
+
return { ok: true, code: 0, stdout: stdout ?? '', stderr: stderr ?? '' };
|
|
689
|
+
} catch (error) {
|
|
690
|
+
return {
|
|
691
|
+
ok: false,
|
|
692
|
+
code: typeof error?.code === 'number' ? error.code : 1,
|
|
693
|
+
stdout: error?.stdout ?? '',
|
|
694
|
+
stderr: error?.stderr ?? '',
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function defaultProcessListAsync(options = {}) {
|
|
700
|
+
const r = await defaultRunAsync('ps -Ao command', 2000, options);
|
|
701
|
+
return r.ok ? r.stdout : '';
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** 评估一条 probe 信号;返回 `{ kind, source, hit, detail }`(detail 一律脱敏)。 */
|
|
705
|
+
export function probeSignal(signal, options = {}) {
|
|
706
|
+
const run = options.run ?? defaultRun;
|
|
707
|
+
const source = signal.paths?.[0] ?? signal.command ?? signal.pattern ?? signal.kind;
|
|
708
|
+
try {
|
|
709
|
+
if (signal.kind === 'always') return { kind: signal.kind, source, hit: true, detail: signal.reason ?? 'n/a' };
|
|
710
|
+
if (signal.kind === 'config-path' || signal.kind === 'app-bundle' || signal.kind === 'dir' || signal.kind === 'path') {
|
|
711
|
+
const paths = Array.isArray(signal.paths) ? signal.paths : [];
|
|
712
|
+
const hit = paths.some((p) => existsSync(p));
|
|
713
|
+
return { kind: signal.kind, source, hit, detail: hit ? `存在:${paths.find((p) => existsSync(p))}` : `不存在:${paths.join(' / ')}` };
|
|
714
|
+
}
|
|
715
|
+
if (signal.kind === 'cli') {
|
|
716
|
+
const r = run(signal.command, signal.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS, options);
|
|
717
|
+
return { kind: signal.kind, source, hit: r.ok === true, detail: `rc=${r.code}` };
|
|
718
|
+
}
|
|
719
|
+
if (signal.kind === 'process') {
|
|
720
|
+
const list = options.processList ?? (() => defaultProcessList(options));
|
|
721
|
+
const out = typeof list === 'function' ? list() : String(list ?? '');
|
|
722
|
+
const hit = out.includes(signal.pattern);
|
|
723
|
+
return { kind: signal.kind, source, hit, detail: hit ? '进程命中' : '进程未命中' };
|
|
724
|
+
}
|
|
725
|
+
return { kind: signal.kind, source, hit: false, detail: `未知信号类型:${signal.kind}` };
|
|
726
|
+
} catch (error) {
|
|
727
|
+
return { kind: signal.kind, source, hit: false, detail: `探测异常:${redactSecrets(error?.message ?? error)}` };
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
/** `probeSignal` 的**异步**版本(请求路径用;语义完全一致,仅把 spawnSync 换成 exec)。 */
|
|
733
|
+
export async function probeSignalAsync(signal, options = {}) {
|
|
734
|
+
const runAsync = options.runAsync ?? (options.run
|
|
735
|
+
? (cmd, t) => Promise.resolve(options.run(cmd, t))
|
|
736
|
+
: defaultRunAsync);
|
|
737
|
+
const listAsync = options.processListAsync ?? (options.processList !== undefined
|
|
738
|
+
? () => Promise.resolve(options.processList)
|
|
739
|
+
: () => defaultProcessListAsync(options));
|
|
740
|
+
const source = signal.paths?.[0] ?? signal.command ?? signal.pattern ?? signal.kind;
|
|
741
|
+
try {
|
|
742
|
+
if (signal.kind === 'always') return { kind: signal.kind, source, hit: true, detail: signal.reason ?? 'n/a' };
|
|
743
|
+
if (signal.kind === 'config-path' || signal.kind === 'app-bundle' || signal.kind === 'dir' || signal.kind === 'path') {
|
|
744
|
+
const paths = Array.isArray(signal.paths) ? signal.paths : [];
|
|
745
|
+
const hit = paths.some((p) => existsSync(p));
|
|
746
|
+
return { kind: signal.kind, source, hit, detail: hit ? `存在:${paths.find((p) => existsSync(p))}` : `不存在:${paths.join(' / ')}` };
|
|
747
|
+
}
|
|
748
|
+
if (signal.kind === 'cli') {
|
|
749
|
+
// F-PERF-2:CLI 版本类探针按**短 TTL**缓存(默认 30s)——真实 CLI 启动可达数百毫秒
|
|
750
|
+
// (本机 hermes --version ≈763ms),`refresh` 只重算**列表/落面**,不必重复 spawn 版本探测。
|
|
751
|
+
// 注:注入的 `run`/`runAsync`(测试)不缓存,保证替身语义确定。
|
|
752
|
+
const injected = options.runAsync !== undefined || options.run !== undefined;
|
|
753
|
+
const cacheKey = `${signal.command}::${signal.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS}`;
|
|
754
|
+
if (!injected && options.refreshProbes !== true) {
|
|
755
|
+
const cachedCli = cliProbeCache.get(cacheKey);
|
|
756
|
+
if (cachedCli && Date.now() - cachedCli.at < PROBE_CACHE_TTL_MS) {
|
|
757
|
+
return { kind: signal.kind, source, hit: cachedCli.hit, detail: `rc=${cachedCli.code}` };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const r = await runAsync(signal.command, signal.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS, options);
|
|
761
|
+
if (!injected) cliProbeCache.set(cacheKey, { at: Date.now(), hit: r.ok === true, code: r.code });
|
|
762
|
+
return { kind: signal.kind, source, hit: r.ok === true, detail: `rc=${r.code}` };
|
|
763
|
+
}
|
|
764
|
+
if (signal.kind === 'process') {
|
|
765
|
+
const out = String((await listAsync()) ?? '');
|
|
766
|
+
const hit = out.includes(signal.pattern);
|
|
767
|
+
return { kind: signal.kind, source, hit, detail: hit ? '进程命中' : '进程未命中' };
|
|
768
|
+
}
|
|
769
|
+
return { kind: signal.kind, source, hit: false, detail: `未知信号类型:${signal.kind}` };
|
|
770
|
+
} catch (error) {
|
|
771
|
+
return { kind: signal.kind, source, hit: false, detail: `探测异常:${redactSecrets(error?.message ?? error)}` };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
// 三态 + 派生态(A-1/A-2/A-4/A-6 数据面)
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
/** 单条助手状态(含 evidence / hasVerify / activation / kind)。 */
|
|
780
|
+
function builtinsCount(reg, options) {
|
|
781
|
+
return Math.min(builtinAssistants(options).length, reg.assistants.length);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** 条目选项(形状 + url):由 descriptor 的 `landing` 与当前守护端点决定。 */
|
|
785
|
+
function entryOptionsFor(assistant, options = {}) {
|
|
786
|
+
const url = options.mcpUrl ?? null;
|
|
787
|
+
const httpShape = assistant.landing?.httpShape;
|
|
788
|
+
return { ...(url ? { url } : {}), ...(httpShape ? { httpShape } : {}) };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function buildStateItem(assistant, signals, landing, options = {}) {
|
|
792
|
+
const anyHit = signals.some((s) => s.hit);
|
|
793
|
+
const anyError = signals.some((s) => typeof s.detail === 'string' && s.detail.startsWith('探测异常'));
|
|
794
|
+
const strategy = assistant.writer?.strategy ?? 'merge-key';
|
|
795
|
+
let state;
|
|
796
|
+
if (strategy === 'manual' && !landing.present) state = anyHit ? 'manual' : 'not-installed';
|
|
797
|
+
else if (landing.present) state = 'connected';
|
|
798
|
+
else if (landing.staleStdio) state = 'stale'; // 已写入但过时(stdio)→ 升级直连
|
|
799
|
+
else if (!anyHit) state = anyError ? 'detect-failed' : 'not-installed';
|
|
800
|
+
else state = 'not-connected';
|
|
801
|
+
const STATE_LABEL = { 'not-installed': '未安装', 'not-connected': '未接入', connected: '已接入 ✓', manual: '需手动', 'detect-failed': '检测失败', stale: '已写入·需升级为直连' };
|
|
802
|
+
return {
|
|
803
|
+
id: assistant.id,
|
|
804
|
+
name: assistant.name,
|
|
805
|
+
kind: assistant.registryKind ?? 'builtin', // builtin | custom(UI 区分来源)
|
|
806
|
+
type: assistant.kind === 'snippet' ? 'snippet' : 'file', // file | snippet(descriptor 形态)
|
|
807
|
+
registryKind: assistant.registryKind ?? 'builtin',
|
|
808
|
+
state,
|
|
809
|
+
stateLabel: STATE_LABEL[state] ?? state,
|
|
810
|
+
evidence: signals.map((s) => ({ ...s, detail: redactSecrets(s.detail) })),
|
|
811
|
+
anySignalHit: anyHit,
|
|
812
|
+
landing: {
|
|
813
|
+
primaryPath: assistant.landing?.primaryPath ?? null,
|
|
814
|
+
keyPath: assistant.landing?.keyPath ?? null,
|
|
815
|
+
forbiddenPaths: assistant.landing?.forbiddenPaths ?? [],
|
|
816
|
+
format: assistant.landing?.format ?? assistant.format,
|
|
817
|
+
strategy,
|
|
818
|
+
present: landing.present,
|
|
819
|
+
legacyShape: landing.legacyShape === true,
|
|
820
|
+
staleStdio: landing.staleStdio === true,
|
|
821
|
+
exists: landing.exists,
|
|
822
|
+
parseError: landing.parseError === true,
|
|
823
|
+
},
|
|
824
|
+
hasVerify: Boolean(assistant.verify?.cli?.command),
|
|
825
|
+
verify: assistant.verify ?? null,
|
|
826
|
+
activation: assistant.activation ?? { kind: 'user-reload' },
|
|
827
|
+
writer: assistant.writer ?? null,
|
|
828
|
+
risks: assistant.risks ?? [],
|
|
829
|
+
skillsDirs: assistant.skillsDirs ?? [],
|
|
830
|
+
format: assistant.format,
|
|
831
|
+
configPath: assistant.landing?.primaryPath ?? null,
|
|
832
|
+
reuseOf: assistant.reuseOf ?? null,
|
|
833
|
+
note: assistant.note ?? null,
|
|
834
|
+
snippet: snippetFor(assistant.format, entryOptionsFor(assistant, options)),
|
|
835
|
+
mcpUrl: options.mcpUrl ?? null,
|
|
836
|
+
// 兼容既有 `/admin/api/clients` 字段(只扩展不破坏;UI 亦可继续用)
|
|
837
|
+
installed: signals.some((s) => s.kind !== 'always' && s.hit),
|
|
838
|
+
connected: state === 'connected',
|
|
839
|
+
requiresRestart: (assistant.activation?.kind ?? 'user-reload') === 'user-reload',
|
|
840
|
+
needsManual: state === 'manual',
|
|
841
|
+
parseError: landing.parseError === true ? '配置无法解析(不自动改写)' : null,
|
|
842
|
+
skillsInstalled: (assistant.skillsDirs ?? []).some((d) => existsSync(join(d, 'mebular-memory', 'SKILL.md'))),
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** 单条助手状态(**同步**;测试与离线场景用)。 */
|
|
847
|
+
export function assistantState(assistant, options = {}) {
|
|
848
|
+
const signals = (assistant.probe?.signals ?? []).map((s) => probeSignal(s, options));
|
|
849
|
+
return buildStateItem(assistant, signals, landingState(assistant, options), options);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** 单条助手状态(**异步**;请求路径用——不阻塞事件循环)。 */
|
|
853
|
+
export async function assistantStateAsync(assistant, options = {}) {
|
|
854
|
+
const signals = await Promise.all((assistant.probe?.signals ?? []).map((s) => probeSignalAsync(s, options)));
|
|
855
|
+
return buildStateItem(assistant, signals, landingState(assistant, options), options);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
// F-PERF-2:会话内短 TTL 缓存(+ 单飞去重),避免每请求重复 spawn 探针
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
/** 默认 TTL(ms);可用 `MEBULAR_CLIENTS_CACHE_MS` 覆盖(0 = 不缓存)。 */
|
|
863
|
+
export const ASSISTANTS_CACHE_TTL_MS = Number(process.env.MEBULAR_CLIENTS_CACHE_MS ?? 1500);
|
|
864
|
+
|
|
865
|
+
/** CLI 探针结果缓存 TTL(ms);`MEBULAR_PROBE_CACHE_MS=0` 关闭。 */
|
|
866
|
+
export const PROBE_CACHE_TTL_MS = Number(process.env.MEBULAR_PROBE_CACHE_MS ?? 30000);
|
|
867
|
+
|
|
868
|
+
const cliProbeCache = new Map();
|
|
869
|
+
|
|
870
|
+
let assistantsCache = { key: null, at: 0, value: null };
|
|
871
|
+
let assistantsInflight = { key: null, promise: null };
|
|
872
|
+
|
|
873
|
+
/** 清空缓存(写操作后调用,避免读到陈旧状态)。 */
|
|
874
|
+
export function clearAssistantsCache() {
|
|
875
|
+
assistantsCache = { key: null, at: 0, value: null };
|
|
876
|
+
assistantsInflight = { key: null, promise: null };
|
|
877
|
+
cliProbeCache.clear();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function computeListAssistantsAsync(options) {
|
|
881
|
+
const reg = registry(options);
|
|
882
|
+
const builtinCount = builtinAssistants(options).length;
|
|
883
|
+
const items = await Promise.all(reg.assistants.map(async (a, i) => {
|
|
884
|
+
const registryKind = i < builtinCount ? 'builtin' : 'custom';
|
|
885
|
+
return { ...(await assistantStateAsync(a, options)), registryKind, kind: registryKind };
|
|
886
|
+
}));
|
|
887
|
+
return { assistants: items, customError: reg.customError, customPath: reg.customPath, cached: false };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* 注册表驱动的完整列表(**异步**,请求路径唯一入口)。
|
|
892
|
+
* - TTL 内命中直接返回(`cached:true`);`refresh:true` 或 `ttlMs:0` 绕过;
|
|
893
|
+
* - 并发请求**单飞**:同一 key 的在途计算被共享(避免并发 10 发 → 10 次 spawn)。
|
|
894
|
+
*/
|
|
895
|
+
export async function listAssistantsAsync(options = {}) {
|
|
896
|
+
const key = `${assistantUserHome(options)}::${options.home ?? ''}::${options.cwd ?? process.cwd()}::${options.mcpUrl ?? ''}`;
|
|
897
|
+
const ttl = Number.isFinite(options.ttlMs) ? options.ttlMs : ASSISTANTS_CACHE_TTL_MS;
|
|
898
|
+
if (options.refresh !== true) {
|
|
899
|
+
if (ttl > 0 && assistantsCache.key === key && assistantsCache.value && Date.now() - assistantsCache.at < ttl) {
|
|
900
|
+
return { ...assistantsCache.value, cached: true };
|
|
901
|
+
}
|
|
902
|
+
if (assistantsInflight.key === key && assistantsInflight.promise) return assistantsInflight.promise;
|
|
903
|
+
}
|
|
904
|
+
const promise = computeListAssistantsAsync(options)
|
|
905
|
+
.then((value) => {
|
|
906
|
+
assistantsCache = { key, at: Date.now(), value };
|
|
907
|
+
assistantsInflight = { key: null, promise: null };
|
|
908
|
+
return value;
|
|
909
|
+
})
|
|
910
|
+
.catch((error) => {
|
|
911
|
+
assistantsInflight = { key: null, promise: null };
|
|
912
|
+
throw error;
|
|
913
|
+
});
|
|
914
|
+
assistantsInflight = { key, promise };
|
|
915
|
+
return promise;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** 注册表驱动的完整列表(**同步**;测试/离线)。 */
|
|
919
|
+
export function listAssistants(options = {}) {
|
|
920
|
+
const reg = registry(options);
|
|
921
|
+
const items = reg.assistants.map((a, i) => {
|
|
922
|
+
const registryKind = i < builtinsCount(reg, options) ? 'builtin' : 'custom';
|
|
923
|
+
return { ...assistantState(a, options), registryKind, kind: registryKind };
|
|
924
|
+
});
|
|
925
|
+
return { assistants: items, customError: reg.customError, customPath: reg.customPath };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** 兼容投影:既有 `/admin/api/clients` 的字段面(只扩展不破坏)。 */
|
|
929
|
+
export function detectClients(options = {}) {
|
|
930
|
+
return listAssistants(options).assistants;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
// 写入(merge-key / cli-set / manual):备份 → 写 → 失败回滚
|
|
935
|
+
// ---------------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
function tsStamp() {
|
|
938
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function atomicWrite(path, text) {
|
|
942
|
+
await mkdir(dirname(path), { recursive: true });
|
|
943
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
944
|
+
await writeFile(tmp, text, 'utf-8');
|
|
945
|
+
await rename(tmp, path);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function isForbidden(assistant, path) {
|
|
949
|
+
const forbidden = assistant.landing?.forbiddenPaths ?? [];
|
|
950
|
+
return forbidden.some((f) => f === path);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function computeMerged(assistant, existing, options = {}) {
|
|
954
|
+
if (assistant.format === 'dsh') return mergeDsh(existing, entryOptionsFor(assistant, options));
|
|
955
|
+
return mergeJsonEntry(existing, {
|
|
956
|
+
format: assistant.format,
|
|
957
|
+
entry: mebularEntry(assistant.format, entryOptionsFor(assistant, options)),
|
|
958
|
+
keyPath: keyPathOf(assistant.landing?.format ?? assistant.format),
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export function previewClient(id, options = {}) {
|
|
963
|
+
const assistant = descriptorFor(id, options);
|
|
964
|
+
if (!assistant) return { ok: false, reason: `未知客户端:${id}` };
|
|
965
|
+
const primary = assistant.landing?.primaryPath ?? null;
|
|
966
|
+
if (!primary || assistant.writer?.strategy === 'manual') {
|
|
967
|
+
return { ok: true, needsManual: true, snippet: snippetFor(assistant.format, entryOptionsFor(assistant, options)), preview: '' };
|
|
968
|
+
}
|
|
969
|
+
const existing = safeReadText(primary);
|
|
970
|
+
const merged = computeMerged(assistant, existing, options);
|
|
971
|
+
if (merged.error) return { ok: true, needsManual: true, reason: merged.error, snippet: snippetFor(assistant.format, entryOptionsFor(assistant, options)), preview: '' };
|
|
972
|
+
return { ok: true, changed: merged.changed, preview: lineDiff(existing ?? '', merged.text), configPath: primary };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* cli-set 写入(Hermes):**一次写入整棵子树** `mcp_servers.mebular`(F-EXP-1 已证结构化字面量可解析)。
|
|
977
|
+
*
|
|
978
|
+
* 真机事故:逐字段 `hermes config set --force mcp_servers.mebular.<field>` 会让 ruamel round-trip
|
|
979
|
+
* 把**新建嵌套映射错位**(块被追加到文件末尾、丢 `mcp_servers:` 键 → 回读 `mcp_servers.mebular`
|
|
980
|
+
* 不存在 = 不生效)。故:一次写入整棵子树 → **回读校验放置**(`hermes config get mcp_servers`)
|
|
981
|
+
* → 放置错/写失败一律**整文件回滚**(字节级备份)。
|
|
982
|
+
*
|
|
983
|
+
* F-PERF-2/F-FIX-B:请求路径一律 `runAsync`(`exec`),**不得 `spawnSync`**(会阻塞事件循环,
|
|
984
|
+
* 让宿主的健康探测超时)。
|
|
985
|
+
*/
|
|
986
|
+
async function connectViaCli(assistant, options = {}) {
|
|
987
|
+
const runAsync = options.runAsync ?? (options.run
|
|
988
|
+
? (cmd, t) => Promise.resolve(options.run(cmd, t)) // 注入替身(测试):同步 run 也可用
|
|
989
|
+
: (cmd, t) => defaultRunAsync(cmd, t ?? 30000, options));
|
|
990
|
+
const primary = assistant.landing.primaryPath;
|
|
991
|
+
const base = assistant.writer.command;
|
|
992
|
+
const entry = mebularEntry(assistant.format, entryOptionsFor(assistant, options));
|
|
993
|
+
// 幂等:已等价 → 不写、不备份
|
|
994
|
+
const before = landingState(assistant, options);
|
|
995
|
+
if (before.present) {
|
|
996
|
+
return { ok: true, changed: false, configPath: primary, backupPath: null, commands: [], preview: '', note: '已接入(幂等:无需改动)' };
|
|
997
|
+
}
|
|
998
|
+
const backupPath = existsSync(primary) ? `${primary}.bak-${tsStamp()}` : null;
|
|
999
|
+
if (backupPath) await copyFile(primary, backupPath);
|
|
1000
|
+
const rollback = async () => { if (backupPath) await copyFile(backupPath, primary); clearAssistantsCache(); };
|
|
1001
|
+
const commands = [];
|
|
1002
|
+
|
|
1003
|
+
// ① 一次写整棵子树(单次 CLI 调用:4 次逐字段 → 1 次)
|
|
1004
|
+
const cmd = `${base} ${JSON.stringify(JSON.stringify(entry))}`;
|
|
1005
|
+
const res = await runAsync(cmd, assistant.verify?.cli?.timeoutMs ?? 30000);
|
|
1006
|
+
commands.push({ cmd, code: res.code ?? (res.ok ? 0 : 1) });
|
|
1007
|
+
if (res.ok !== true) {
|
|
1008
|
+
await rollback();
|
|
1009
|
+
return { ok: false, changed: false, rolledBack: true, backupPath, commands, reason: '写入失败已回滚', needsManual: true, snippet: snippetFor(assistant.format, entryOptionsFor(assistant, options)) };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ② 放置校验:条目必须落在 `mcp_servers` 段内(真机事故即此处失守)
|
|
1013
|
+
const readbackCommand = assistant.writer.readbackCommand;
|
|
1014
|
+
if (typeof readbackCommand === 'string' && readbackCommand.length > 0) {
|
|
1015
|
+
const rb = await runAsync(readbackCommand, 30000);
|
|
1016
|
+
commands.push({ cmd: readbackCommand, code: rb.code ?? (rb.ok ? 0 : 1) });
|
|
1017
|
+
const placed = /^\s*mebular\s*:/m.test(`${rb.stdout ?? ''}\n${rb.stderr ?? ''}`);
|
|
1018
|
+
if (rb.ok !== true || !placed) {
|
|
1019
|
+
await rollback();
|
|
1020
|
+
return {
|
|
1021
|
+
ok: false, changed: false, rolledBack: true, backupPath, commands,
|
|
1022
|
+
reason: '写入放置错位(条目不在 mcp_servers 段内)已回滚',
|
|
1023
|
+
needsManual: true, snippet: snippetFor(assistant.format, entryOptionsFor(assistant, options)),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
clearAssistantsCache(); // F-PERF-2:写后失效
|
|
1029
|
+
return {
|
|
1030
|
+
ok: true, changed: true, backupPath, commands, configPath: primary,
|
|
1031
|
+
preview: lineDiff(safeReadText(primary) ?? '', snippetFor(assistant.format, entryOptionsFor(assistant, options))),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** 接入:merge-key(备份 → 原子写)/ cli-set(官方 CLI)/ manual(仅片段)。 */
|
|
1036
|
+
export async function connectClient(id, options = {}) {
|
|
1037
|
+
const assistant = descriptorFor(id, options);
|
|
1038
|
+
if (!assistant) return { ok: false, reason: `未知客户端:${id}` };
|
|
1039
|
+
const strategy = assistant.writer?.strategy ?? 'merge-key';
|
|
1040
|
+
const primary = assistant.landing?.primaryPath ?? null;
|
|
1041
|
+
if (strategy === 'manual' || !primary) {
|
|
1042
|
+
return { ok: false, needsManual: true, snippet: snippetFor(assistant.format), reason: '该客户端不支持自动写入:请复制片段' };
|
|
1043
|
+
}
|
|
1044
|
+
if (isForbidden(assistant, primary)) {
|
|
1045
|
+
return { ok: false, reason: `禁写路径(landing.forbiddenPaths):${primary}` };
|
|
1046
|
+
}
|
|
1047
|
+
if (strategy === 'cli-set') return connectViaCli(assistant, options);
|
|
1048
|
+
const existing = safeReadText(primary);
|
|
1049
|
+
const merged = computeMerged(assistant, existing, options);
|
|
1050
|
+
if (merged.error) {
|
|
1051
|
+
return { ok: false, needsManual: true, snippet: snippetFor(assistant.format, entryOptionsFor(assistant, options)), reason: merged.error, configPath: primary };
|
|
1052
|
+
}
|
|
1053
|
+
const preview = lineDiff(existing ?? '', merged.text);
|
|
1054
|
+
if (!merged.changed) {
|
|
1055
|
+
return { ok: true, changed: false, configPath: primary, backupPath: null, preview, note: '已接入(幂等:无需改动)' };
|
|
1056
|
+
}
|
|
1057
|
+
const backupPath = existing !== null ? `${primary}.bak-${tsStamp()}` : null;
|
|
1058
|
+
if (backupPath) await copyFile(primary, backupPath);
|
|
1059
|
+
try {
|
|
1060
|
+
await atomicWrite(primary, merged.text);
|
|
1061
|
+
clearAssistantsCache(); // F-PERF-2:写后失效
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
if (backupPath) await copyFile(backupPath, primary); // 失败回滚
|
|
1064
|
+
return { ok: false, changed: false, rolledBack: Boolean(backupPath), backupPath, reason: `写入失败已回滚:${redactSecrets(error?.message ?? error)}` };
|
|
1065
|
+
}
|
|
1066
|
+
return { ok: true, changed: true, configPath: primary, backupPath, preview };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** 回滚:显式备份优先;否则最近一次备份;无备份则删除 mebular 条目(DSH 除外)。 */
|
|
1070
|
+
/** 备份候选是否已含 mebular 条目——**与状态探针同一判据**(landingState:present ∪ staleStdio ∪ legacyShape),
|
|
1071
|
+
* 避免自建弱判据(如 yamlHasKey)漏报导致回滚选错快照。 */
|
|
1072
|
+
function backupHasMebularEntry(assistant, backupPath) {
|
|
1073
|
+
const st = landingState({ ...assistant, landing: { ...assistant.landing, primaryPath: backupPath } }, {});
|
|
1074
|
+
return Boolean(st.present || st.staleStdio || st.legacyShape);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
export async function rollbackClient(id, options = {}) {
|
|
1078
|
+
clearAssistantsCache(); // F-PERF-2:回滚会改变落面
|
|
1079
|
+
const assistant = descriptorFor(id, options);
|
|
1080
|
+
if (!assistant || !assistant.landing?.primaryPath) return { ok: false, reason: `未知客户端:${id}` };
|
|
1081
|
+
const primary = assistant.landing.primaryPath;
|
|
1082
|
+
const existing = safeReadText(primary);
|
|
1083
|
+
if (existing === null) return { ok: false, reason: '配置文件不存在,无需回滚' };
|
|
1084
|
+
const dir = dirname(primary);
|
|
1085
|
+
const base = `${primary.slice(primary.lastIndexOf(sep) + 1)}.bak-`;
|
|
1086
|
+
// 备份候选:**新 → 旧**;目标 = 最新一个**不含 mebular 条目**的备份(= 真正的「接入前」快照)
|
|
1087
|
+
// 旧实现取「最新备份」:连续连接/多次回滚的安全备份本身已含 mebular → 回滚实为无操作(真机走查事故)
|
|
1088
|
+
const candidates = readdirSync(dir).filter((f) => f.startsWith(base)).sort().reverse();
|
|
1089
|
+
const target = options.backupPath
|
|
1090
|
+
?? candidates.map((f) => join(dir, f)).find((p) => !backupHasMebularEntry(assistant, p)) ?? null;
|
|
1091
|
+
const safety = `${primary}.bak-${tsStamp()}`;
|
|
1092
|
+
await copyFile(primary, safety);
|
|
1093
|
+
if (target) {
|
|
1094
|
+
await atomicWrite(primary, await readFile(target, 'utf-8'));
|
|
1095
|
+
clearAssistantsCache();
|
|
1096
|
+
return { ok: true, restoredFrom: target, backupPath: safety, removed: false };
|
|
1097
|
+
}
|
|
1098
|
+
if (assistant.format === 'dsh') return { ok: false, reason: 'DSH 无备份可回滚:请按片段人工移除' };
|
|
1099
|
+
const merged = mergeJsonEntry(existing, { format: assistant.format, entry: undefined, keyPath: keyPathOf(assistant.landing?.format ?? assistant.format) });
|
|
1100
|
+
if (merged.error) return { ok: false, reason: merged.error };
|
|
1101
|
+
await atomicWrite(primary, merged.text);
|
|
1102
|
+
clearAssistantsCache();
|
|
1103
|
+
return { ok: true, restoredFrom: null, backupPath: safety, removed: true };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
// 验证闭环(A-9/A-10/A-11)
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
/** 回读校验:条目存在 ∧ 原配置其余键无损(有备份时逐键比对)。 */
|
|
1111
|
+
export function readbackCheck(assistant, options = {}) {
|
|
1112
|
+
const landing = landingState(assistant, options);
|
|
1113
|
+
if (!landing.exists) return { ok: false, detail: '配置文件不存在' };
|
|
1114
|
+
if (!landing.present) {
|
|
1115
|
+
if (landing.legacyShape) return { ok: false, detail: '只找到旧形状(不生效),建议重新接入' };
|
|
1116
|
+
if (landing.staleStdio) return { ok: false, detail: '条目是 stdio 形态:守护运行中会被拒(MCP_STORAGE_LOCKED),建议重新接入为 HTTP /mcp' };
|
|
1117
|
+
return { ok: false, detail: '未找到 mebular 条目' };
|
|
1118
|
+
}
|
|
1119
|
+
const primary = assistant.landing.primaryPath;
|
|
1120
|
+
let intact = true;
|
|
1121
|
+
try {
|
|
1122
|
+
const dir = dirname(primary);
|
|
1123
|
+
const base = `${primary.slice(primary.lastIndexOf(sep) + 1)}.bak-`;
|
|
1124
|
+
const backups = readdirSync(dir).filter((f) => f.startsWith(base)).sort();
|
|
1125
|
+
if (backups.length > 0 && (assistant.landing.format ?? assistant.format) !== 'hermes-yaml') {
|
|
1126
|
+
const before = JSON.parse(readFileSync(join(dir, backups[backups.length - 1]), 'utf-8'));
|
|
1127
|
+
const after = JSON.parse(landing.text);
|
|
1128
|
+
const path = keyPathOf(assistant.landing.format ?? assistant.format);
|
|
1129
|
+
const pruneEmpty = (node) => {
|
|
1130
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) return node;
|
|
1131
|
+
const out = {};
|
|
1132
|
+
for (const [k, v] of Object.entries(node)) {
|
|
1133
|
+
const pruned = pruneEmpty(v);
|
|
1134
|
+
if (pruned !== null && typeof pruned === 'object' && !Array.isArray(pruned) && Object.keys(pruned).length === 0) continue;
|
|
1135
|
+
out[k] = pruned;
|
|
1136
|
+
}
|
|
1137
|
+
return out;
|
|
1138
|
+
};
|
|
1139
|
+
const strip = (obj) => {
|
|
1140
|
+
const clone = JSON.parse(JSON.stringify(obj));
|
|
1141
|
+
let node = clone;
|
|
1142
|
+
for (const key of path.slice(0, -1)) { if (node?.[key] === undefined) return clone; node = node[key]; }
|
|
1143
|
+
if (node && typeof node === 'object') delete node[path[path.length - 1]];
|
|
1144
|
+
return pruneEmpty(clone);
|
|
1145
|
+
};
|
|
1146
|
+
intact = JSON.stringify(strip(before)) === JSON.stringify(strip(after));
|
|
1147
|
+
}
|
|
1148
|
+
} catch {
|
|
1149
|
+
intact = false;
|
|
1150
|
+
}
|
|
1151
|
+
return { ok: intact, detail: intact ? 'mebular 条目已写入,其余键无损' : '其余键与备份不一致' };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** 解析 `verify.cli` 输出:expect 命中 ∧(可选)toolsPattern 的 N ≥ minTools。 */
|
|
1155
|
+
export function parseVerifyOutput(verifyCli, output) {
|
|
1156
|
+
const text = String(output ?? '');
|
|
1157
|
+
const expect = verifyCli?.expect;
|
|
1158
|
+
const expectHit = typeof expect === 'string' ? text.includes(expect) : true;
|
|
1159
|
+
let tools = null;
|
|
1160
|
+
if (typeof verifyCli?.toolsPattern === 'string') {
|
|
1161
|
+
const m = new RegExp(verifyCli.toolsPattern).exec(text);
|
|
1162
|
+
tools = m ? Number(m[1]) : null;
|
|
1163
|
+
}
|
|
1164
|
+
const minTools = Number.isFinite(verifyCli?.minTools) ? verifyCli.minTools : null;
|
|
1165
|
+
const toolsOk = minTools === null ? true : (tools ?? -1) >= minTools;
|
|
1166
|
+
return { ok: expectHit && toolsOk, expectHit, tools, toolsOk };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* 验证:① 回读(必有)→ ② CLI(有 verify.cli 则必跑)→ 终结态。
|
|
1171
|
+
* `userConfirmedReload` 由 UI 的「我已重载,重新验证」按钮传入(A-10)。
|
|
1172
|
+
*/
|
|
1173
|
+
export async function verifyAssistant(id, options = {}) {
|
|
1174
|
+
const assistant = descriptorFor(id, options);
|
|
1175
|
+
if (!assistant) return { ok: false, reason: `未知客户端:${id}` };
|
|
1176
|
+
const steps = [];
|
|
1177
|
+
const readback = readbackCheck(assistant, options);
|
|
1178
|
+
steps.push({ name: 'readback', label: '回读校验', ok: readback.ok, detail: redactSecrets(readback.detail) });
|
|
1179
|
+
let cliResult = null;
|
|
1180
|
+
const cli = assistant.verify?.cli;
|
|
1181
|
+
if (cli?.command) {
|
|
1182
|
+
const runAsync = options.runAsync ?? (options.run
|
|
1183
|
+
? (cmd, t) => Promise.resolve(options.run(cmd, t))
|
|
1184
|
+
: (cmd, t) => defaultRunAsync(cmd, t ?? cli.timeoutMs ?? 5000, options));
|
|
1185
|
+
const res = await runAsync(cli.command, cli.timeoutMs ?? 5000);
|
|
1186
|
+
const parsed = parseVerifyOutput(cli, `${res.stdout ?? ''}\n${res.stderr ?? ''}`);
|
|
1187
|
+
const ok = res.ok === true && parsed.ok;
|
|
1188
|
+
cliResult = { code: res.code ?? (res.ok ? 0 : 1), parsed, output: redactSecrets(`${res.stdout ?? ''}${res.stderr ?? ''}`).slice(-800) };
|
|
1189
|
+
steps.push({ name: 'cli', label: 'CLI 验证', ok, detail: ok ? `命中 ${cli.expect ?? ''}`.trim() : `未通过(rc=${cliResult.code}${parsed.tools !== null ? `, tools=${parsed.tools}` : ''})`, output: cliResult.output });
|
|
1190
|
+
}
|
|
1191
|
+
const allOk = steps.every((s) => s.ok);
|
|
1192
|
+
const hot = (assistant.activation?.kind ?? 'user-reload') === 'hot';
|
|
1193
|
+
let state;
|
|
1194
|
+
if (!allOk) state = 'verify-failed';
|
|
1195
|
+
else if (hot || options.userConfirmedReload === true) state = 'connected';
|
|
1196
|
+
else state = 'pending-restart';
|
|
1197
|
+
const STATE_LABEL = { connected: '已接入 ✓', 'pending-restart': '待生效', 'verify-failed': '验证未过' };
|
|
1198
|
+
return {
|
|
1199
|
+
ok: allOk,
|
|
1200
|
+
id, state, stateLabel: STATE_LABEL[state] ?? state,
|
|
1201
|
+
steps,
|
|
1202
|
+
activation: assistant.activation,
|
|
1203
|
+
needsUserReload: state === 'pending-restart',
|
|
1204
|
+
backupHint: options.backupPath ?? null,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ---------------------------------------------------------------------------
|
|
1209
|
+
// Skill 安装
|
|
1210
|
+
// ---------------------------------------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
function moduleSkillRoot() {
|
|
1213
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skill');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** 安装 mebular-memory + mebular-tasks 到该客户端首个已知 Skill 目录。 */
|
|
1217
|
+
export async function installSkills(id, options = {}) {
|
|
1218
|
+
const desc = descriptorFor(id, options);
|
|
1219
|
+
if (!desc) return { ok: false, reason: `未知客户端:${id}` };
|
|
1220
|
+
const dirs = (desc.skillsDirs ?? []).filter(Boolean);
|
|
1221
|
+
if (dirs.length === 0) return { ok: false, reason: '该客户端没有已知的 Skill 目录' };
|
|
1222
|
+
const candidates = [
|
|
1223
|
+
options.skillRoot,
|
|
1224
|
+
moduleSkillRoot(),
|
|
1225
|
+
join(options.repoRoot ?? process.cwd(), 'packages', 'skill'),
|
|
1226
|
+
].filter((p) => typeof p === 'string' && p.length > 0);
|
|
1227
|
+
const rootDir = candidates.find((p) => existsSync(join(p, 'SKILL.md')));
|
|
1228
|
+
if (!rootDir) {
|
|
1229
|
+
return { ok: false, reason: `未找到 Skill 源(试过:${candidates.join('、')})`, snippet: '请在仓库内运行,或手动复制 Skill 目录' };
|
|
1230
|
+
}
|
|
1231
|
+
const target = dirs[0];
|
|
1232
|
+
const mem = join(target, 'mebular-memory');
|
|
1233
|
+
const tasks = join(target, 'mebular-tasks');
|
|
1234
|
+
await mkdir(mem, { recursive: true });
|
|
1235
|
+
await mkdir(tasks, { recursive: true });
|
|
1236
|
+
await copyFile(join(rootDir, 'SKILL.md'), join(mem, 'SKILL.md'));
|
|
1237
|
+
await copyFile(join(rootDir, 'MEMORY_POLICY.md'), join(mem, 'MEMORY_POLICY.md'));
|
|
1238
|
+
await copyFile(join(rootDir, 'SETUP.md'), join(mem, 'SETUP.md'));
|
|
1239
|
+
await copyFile(join(rootDir, 'tasks', 'SKILL.md'), join(tasks, 'SKILL.md'));
|
|
1240
|
+
await copyFile(join(rootDir, 'SETUP.md'), join(tasks, 'SETUP.md'));
|
|
1241
|
+
return { ok: true, installed: [mem, tasks] };
|
|
1242
|
+
}
|