fde2e-bu 0.0.0 → 1.2.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/README.md +65 -0
- package/bin.js +13 -0
- package/package.json +30 -6
- package/src/args.mjs +92 -0
- package/src/cli.mjs +396 -0
- package/src/configdir.mjs +16 -0
- package/src/mcp/bridge.mjs +208 -0
- package/src/mcp/commands-snapshot.json +340 -0
- package/src/mcp/config-cli.mjs +172 -0
- package/src/mcp/config.mjs +257 -0
- package/src/mcp/http.mjs +250 -0
- package/src/mcp/schema.mjs +175 -0
- package/src/mcp/serve.mjs +203 -0
- package/src/misc.mjs +241 -0
- package/src/nm/bind.mjs +229 -0
- package/src/nm/host.mjs +294 -0
- package/src/skills/frontmatter.mjs +150 -0
- package/src/skills/skills.mjs +323 -0
- package/index.js +0 -1
package/src/nm/bind.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// bind install/status/remove(change bu-bind-http-publish A3)——Native Messaging
|
|
2
|
+
// host 落盘物管理。设计要点(design §1.2):
|
|
3
|
+
// - NM 的 manifest path 必须是单个可执行文件(不能内嵌参数)→ 生成包装脚本
|
|
4
|
+
// <configDir>/nm-host.sh(0755):exec env BU_CONFIG_DIR='<configDir>' '<node>'
|
|
5
|
+
// '<cli/bin.js 绝对路径>' __nm-host(路径 POSIX 单引号转义——P2-2;env 注入使
|
|
6
|
+
// 配置根对真实 host 生效——P2-3)
|
|
7
|
+
// - manifest com.fde2e.bu.json 写浏览器 NM 目录(macOS:Chrome/Chromium),字段:
|
|
8
|
+
// {name, description, path:<nm-host.sh 绝对路径>, type:'stdio',
|
|
9
|
+
// allowed_origins:['chrome-extension://<扩展稳定 ID>/']}
|
|
10
|
+
// 扩展稳定 ID=meojillndgamkbfjijfehjaefolnfopn(manifest 固定 key 派生——
|
|
11
|
+
// B1 落地;本 CLI 只锚定该常量)。
|
|
12
|
+
// - --manifests-dir 显式重定向 manifest 落点(测试沙箱);--config-dir 重定向配置根
|
|
13
|
+
// (等价 BU_CONFIG_DIR)。幂等:install 覆盖写,remove 未装跳过。
|
|
14
|
+
import { existsSync, readFileSync, statSync, unlinkSync, chmodSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { atomicWrite } from '../mcp/config.mjs';
|
|
19
|
+
import { UsageError } from '../args.mjs';
|
|
20
|
+
import { NM_HOST_NAME, readPairing, defaultProfileId, pairingPath } from './host.mjs';
|
|
21
|
+
|
|
22
|
+
/** 扩展稳定 ID(manifest 固定 key 派生)——allowed_origins 锚定点。 */
|
|
23
|
+
export const EXTENSION_ID = 'meojillndgamkbfjijfehjaefolnfopn';
|
|
24
|
+
export const ALLOWED_ORIGIN = `chrome-extension://${EXTENSION_ID}/`;
|
|
25
|
+
|
|
26
|
+
/** P1-5(CI 实证修正):bind 支持 macOS 与 Linux(两者 NM 清单都是文件落盘,
|
|
27
|
+
* 仅目录不同);Windows 需要 HKCU 注册表写入,暂如实拒绝——不静默错装。 */
|
|
28
|
+
export function assertBindPlatform(platform = process.platform) {
|
|
29
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
30
|
+
throw new UsageError('bind 目前支持 macOS 与 Linux(Windows 的 Native Messaging 需注册表写入,待后续版本)');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MANIFEST_FILE = `${NM_HOST_NAME}.json`;
|
|
35
|
+
const binPath = join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), 'bin.js'); // src/nm → cli/bin.js
|
|
36
|
+
|
|
37
|
+
/** 浏览器 NM 目录(按 Chrome 规范路径;platform 可注入供测试):
|
|
38
|
+
* macOS:~/Library/Application Support/{Google Chrome,Chromium}/NativeMessagingHosts
|
|
39
|
+
* Linux:~/.config/{google-chrome,chromium}/NativeMessagingHosts */
|
|
40
|
+
export function browserManifestDirs(home = homedir(), platform = process.platform) {
|
|
41
|
+
if (platform === 'linux') {
|
|
42
|
+
const cfg = join(home, '.config');
|
|
43
|
+
return {
|
|
44
|
+
chrome: join(cfg, 'google-chrome', 'NativeMessagingHosts'),
|
|
45
|
+
chromium: join(cfg, 'chromium', 'NativeMessagingHosts'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const appSupport = join(home, 'Library', 'Application Support');
|
|
49
|
+
return {
|
|
50
|
+
chrome: join(appSupport, 'Google', 'Chrome', 'NativeMessagingHosts'),
|
|
51
|
+
chromium: join(appSupport, 'Chromium', 'NativeMessagingHosts'),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* manifest 落点集合:--browser 收窄(缺省 chrome;both=两处;重定向时两浏览器同文件
|
|
57
|
+
* 自动去重);--manifests-dir 显式重定向(测试/沙箱——目录直接当 NM 目录用)。
|
|
58
|
+
* @returns {{ label:string, path:string }[]}
|
|
59
|
+
*/
|
|
60
|
+
export function manifestTargets({ browser = 'chrome', manifestsDir, home = homedir() }) {
|
|
61
|
+
const browsers = browser === 'both' ? ['chrome', 'chromium'] : [browser];
|
|
62
|
+
const dirs = browserManifestDirs(home);
|
|
63
|
+
const targets = browsers.map((b) => ({
|
|
64
|
+
// 重定向落点的 label 如实标注(浏览器目录合一,不再区分 chrome/chromium)
|
|
65
|
+
label: manifestsDir ? 'manifests-dir' : b,
|
|
66
|
+
path: manifestsDir ? join(resolve(String(manifestsDir)), MANIFEST_FILE) : join(dirs[b], MANIFEST_FILE),
|
|
67
|
+
}));
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
return targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** nm-host.sh 路径(配置根下)。 */
|
|
73
|
+
export function nmHostScriptPath(configDir) {
|
|
74
|
+
return join(configDir, 'nm-host.sh');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 原子写可执行文件:atomicWrite(0600 temp+rename)→ chmod 0755。
|
|
79
|
+
* (atomicWrite 固定收紧 0600,对脚本必须再放开执行位——NM 只认可执行文件。)
|
|
80
|
+
*/
|
|
81
|
+
function writeExecutable(path, text) {
|
|
82
|
+
atomicWrite(path, text);
|
|
83
|
+
chmodSync(path, 0o755);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* POSIX 单引号字面量(P2-2):把任意字面量安全包进 sh 单引号——单引号内除单引号
|
|
88
|
+
* 本身外全部字面(空格/`$`/反引号/通配符一律不展开);嵌单引号用 `'\''` 断开重开。
|
|
89
|
+
*/
|
|
90
|
+
export function shQuote(s) {
|
|
91
|
+
return `'${String(s).replaceAll("'", `'\\''`)}'`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 生成 nm-host.sh 内容(单可执行文件约束:exec 换进程,不带内嵌参数)。
|
|
96
|
+
* P2-2/P2-3:路径一律 shQuote 单引号转义(configDir/execPath/binPath 含空格、
|
|
97
|
+
* `$`、单引号均字面传递);BU_CONFIG_DIR 经 `exec env` 注入——真实 host 进程的
|
|
98
|
+
* 落盘点由此与 manifest 的配置根一致(--config-dir 语义对真实 host 生效)。
|
|
99
|
+
*/
|
|
100
|
+
export function buildNmHostScript(configDir) {
|
|
101
|
+
return `#!/bin/sh\n# fde2e-bu Native Messaging host 包装(bind install 生成;可重复生成覆盖)\nexec env BU_CONFIG_DIR=${shQuote(resolve(String(configDir)))} ${shQuote(process.execPath)} ${shQuote(resolve(binPath))} __nm-host\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* host 脚本在位校验(P2-9):路径存在、是普通文件、带任一执行位。
|
|
106
|
+
* manifest.path 空挂(脚本被删/移动/失执行位)时由 status/doctor warn。
|
|
107
|
+
*/
|
|
108
|
+
export function hostScriptExecutable(path) {
|
|
109
|
+
try {
|
|
110
|
+
return typeof path === 'string' && path !== '' && existsSync(path)
|
|
111
|
+
&& statSync(path).isFile() && (statSync(path).mode & 0o111) !== 0;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 生成 host manifest 对象(字段逐一对表 Chrome NM 规范)。 */
|
|
118
|
+
export function buildManifest({ scriptPath }) {
|
|
119
|
+
return {
|
|
120
|
+
name: NM_HOST_NAME,
|
|
121
|
+
description: 'FDE2E Browser Use CLI 配对 host(扩展↔CLI 一次性配对;https://fde2e.com)',
|
|
122
|
+
path: resolve(String(scriptPath)),
|
|
123
|
+
type: 'stdio',
|
|
124
|
+
allowed_origins: [ALLOWED_ORIGIN],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 读 manifest:不存在/坏 JSON → null。 */
|
|
129
|
+
export function readManifest(path) {
|
|
130
|
+
if (!existsSync(path)) return null;
|
|
131
|
+
try {
|
|
132
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
133
|
+
return data && typeof data === 'object' ? data : null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------- 子命令实现(打印面 + 落盘面;返回 exit code) ----------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* bind install:写 nm-host.sh(0755)+ 各浏览器 manifest;幂等覆盖;打印下一步指引。
|
|
143
|
+
* @returns {number} exit code(恒 0——IO 失败抛异常由 bin.js 兜底 exit 1)
|
|
144
|
+
*/
|
|
145
|
+
export function cmdBindInstall({ browser = 'chrome', manifestsDir, configDir, home = homedir() } = {}) {
|
|
146
|
+
mkdirSync(configDir, { recursive: true });
|
|
147
|
+
const scriptPath = nmHostScriptPath(configDir);
|
|
148
|
+
writeExecutable(scriptPath, buildNmHostScript(configDir));
|
|
149
|
+
console.log(`已写入 host 包装脚本(0755):${scriptPath}`);
|
|
150
|
+
for (const t of manifestTargets({ browser, manifestsDir, home })) {
|
|
151
|
+
atomicWrite(t.path, `${JSON.stringify(buildManifest({ scriptPath }), null, 2)}\n`);
|
|
152
|
+
console.log(`已写入 manifest(${t.label}):${t.path}`);
|
|
153
|
+
}
|
|
154
|
+
console.log('下一步:打开 FDE2E 扩展 options → 绑定管理 → 点「CLI 配对」按钮完成配对。');
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* bind status:manifest 各落点在否/内容摘要 + nm-host.sh 状态 + pairing 摘要。
|
|
160
|
+
* @returns {number} 0=至少一处 manifest 在;1=全缺(脚本可判)
|
|
161
|
+
*/
|
|
162
|
+
export function cmdBindStatus({ browser = 'both', manifestsDir, configDir, home = homedir() } = {}) {
|
|
163
|
+
let any = false;
|
|
164
|
+
for (const t of manifestTargets({ browser, manifestsDir, home })) {
|
|
165
|
+
const m = readManifest(t.path);
|
|
166
|
+
if (!m) {
|
|
167
|
+
console.log(`✗ manifest(${t.label}):${t.path}(未安装)`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
any = true;
|
|
171
|
+
console.log(`✓ manifest(${t.label}):${t.path}`);
|
|
172
|
+
console.log(` name=${m.name} type=${m.type} path=${m.path}`);
|
|
173
|
+
console.log(` allowed_origins=${(Array.isArray(m.allowed_origins) ? m.allowed_origins : []).join(',')}`);
|
|
174
|
+
// P2-9:manifest.path 的 host 脚本必须在位且可执行——空挂 manifest 点配对必断开
|
|
175
|
+
if (hostScriptExecutable(m.path)) {
|
|
176
|
+
console.log(` ✓ host 脚本(可执行):${m.path}`);
|
|
177
|
+
} else {
|
|
178
|
+
console.log(` ⚠ host 脚本缺失/不可执行(重装:fde2e-bu bind install):${m.path}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const scriptPath = nmHostScriptPath(configDir);
|
|
182
|
+
const scriptOk = hostScriptExecutable(scriptPath);
|
|
183
|
+
console.log(`${scriptOk ? '✓' : '✗'} host 包装脚本${scriptOk ? '(可执行)' : '(缺或不可执行)'}:${scriptPath}`);
|
|
184
|
+
|
|
185
|
+
const pairing = readPairing(configDir);
|
|
186
|
+
if (pairing) {
|
|
187
|
+
const def = defaultProfileId(pairing);
|
|
188
|
+
console.log(`配对:${pairing.bindings.length} 条(缺省 profileId=${def},优先 master)`);
|
|
189
|
+
for (const b of pairing.bindings) {
|
|
190
|
+
console.log(` - ${b.profileId}(${b.role}${b.name ? `,${b.name}` : ''})`);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
console.log('配对:未配对(扩展 options 点「CLI 配对」后生成 pairing.json)');
|
|
194
|
+
}
|
|
195
|
+
if (!any) {
|
|
196
|
+
console.error('未发现任何 manifest——先执行 fde2e-bu bind install');
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* bind remove:删 manifest(幂等);--purge 连 pairing.json 一起删。
|
|
204
|
+
* nm-host.sh 保留在配置根(重装即用;彻底清理手动删配置根)。
|
|
205
|
+
* @returns {number} 恒 0(未装=幂等跳过,不是错误)
|
|
206
|
+
*/
|
|
207
|
+
export function cmdBindRemove({ manifestsDir, configDir, purge = false, home = homedir() } = {}) {
|
|
208
|
+
let removed = 0;
|
|
209
|
+
for (const t of manifestTargets({ browser: 'both', manifestsDir, home })) {
|
|
210
|
+
if (existsSync(t.path)) {
|
|
211
|
+
unlinkSync(t.path);
|
|
212
|
+
removed++;
|
|
213
|
+
console.log(`已删除 manifest(${t.label}):${t.path}`);
|
|
214
|
+
} else {
|
|
215
|
+
console.log(`未安装(幂等跳过)manifest(${t.label}):${t.path}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (purge) {
|
|
219
|
+
const p = pairingPath(configDir);
|
|
220
|
+
if (existsSync(p)) {
|
|
221
|
+
unlinkSync(p);
|
|
222
|
+
console.log(`已删除配对:${p}`);
|
|
223
|
+
} else {
|
|
224
|
+
console.log(`无配对(幂等跳过):${p}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (removed === 0) console.error('未发现任何 manifest(均未安装)');
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
package/src/nm/host.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Native Messaging host(change bu-bind-http-publish A1/A2)——Chrome NM 帧协议
|
|
2
|
+
// 与 bu:pair 配对消息处理。隐蔽子命令 `fde2e-bu __nm-host` 直通本模块 runNmHost。
|
|
3
|
+
//
|
|
4
|
+
// 帧协议(Chrome Native Messaging 规范):
|
|
5
|
+
// 4 字节小端长度前缀 + UTF-8 JSON 载荷;单帧上限 1MB(超限回错误帧,不崩不退出
|
|
6
|
+
// ——长度前缀流无法重同步,按「4+声明长度」流式丢弃该帧字节后继续解后续帧)。
|
|
7
|
+
// 消息(扩展 → CLI,唯一受支持类型):
|
|
8
|
+
// {type:'bu:pair', version:'<manifest version>',
|
|
9
|
+
// bindings:[{profileId, role:'master'|'cluster', name?, server?}]}
|
|
10
|
+
// (bindings 来自扩展绑定表快照;空数组合法——照常落盘并如实显示 0 条。)
|
|
11
|
+
// 应答(CLI → 扩展):
|
|
12
|
+
// 成功 {type:'bu:pair:ok', cli:'fde2e-bu', version:<pkg>, saved:N}
|
|
13
|
+
// 失败 {type:'bu:pair:error', message:<原因>}(未知类型/校验不过/落盘失败)
|
|
14
|
+
// 落盘:pairing.json({version, savedAt, bindings},atomicWrite 0600——复用
|
|
15
|
+
// src/mcp/config.mjs 的原子写底座)。pairing 只含 profileId/role/name/server 等
|
|
16
|
+
// 非敏感标识,绝不含 token/credential。
|
|
17
|
+
// 生命周期:stdin EOF → 退出 0;一切日志只走 stderr(stdout 只有协议帧)。
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { atomicWrite } from '../mcp/config.mjs';
|
|
22
|
+
import { resolveConfigDir } from '../configdir.mjs';
|
|
23
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
24
|
+
|
|
25
|
+
export const NM_HOST_NAME = 'com.fde2e.bu';
|
|
26
|
+
/** 单帧载荷上限(字节)——对齐 Chrome NM「消息 ≤1MB」口径。 */
|
|
27
|
+
export const MAX_FRAME_BYTES = 1024 * 1024;
|
|
28
|
+
/** bindings 上限(与扩展侧 buildPairMessage 同口径,防滥用)。 */
|
|
29
|
+
export const MAX_BINDINGS = 50;
|
|
30
|
+
/** 绑定 id(=profileId)安全字符集——与 packages/protocol/src/redeem.js 的
|
|
31
|
+
* SAFE_PROFILE_ID_RE 同一字符集(字母数字下划线连字符,1~128 位)。 */
|
|
32
|
+
export const SAFE_PROFILE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
33
|
+
|
|
34
|
+
// ---------- 帧编解码(纯函数,可测) ----------
|
|
35
|
+
|
|
36
|
+
/** JSON 文本 → NM 帧(4B 小端长度 + UTF-8 载荷)。超限抛错(调用方只编码应答帧,恒小)。 */
|
|
37
|
+
export function encodeFrame(text) {
|
|
38
|
+
const payload = Buffer.from(String(text), 'utf8');
|
|
39
|
+
if (payload.length > MAX_FRAME_BYTES) {
|
|
40
|
+
throw new Error(`帧超限:${payload.length}B > ${MAX_FRAME_BYTES}B`);
|
|
41
|
+
}
|
|
42
|
+
const head = Buffer.alloc(4);
|
|
43
|
+
head.writeUInt32LE(payload.length, 0);
|
|
44
|
+
return Buffer.concat([head, payload]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 从缓冲头部解一帧(纯函数,不修改入参)。
|
|
49
|
+
* @param {Buffer} buf 已积累的原始字节
|
|
50
|
+
* @returns {{ complete:false } // 字节不足,继续积累
|
|
51
|
+
* | { complete:true, payload:string, rest:Buffer } // 成功解出一帧
|
|
52
|
+
* | { error:'too-large', declared:number }} // 声明长度超限——
|
|
53
|
+
* // 调用方应丢弃整个缓冲
|
|
54
|
+
*/
|
|
55
|
+
export function decodeFrame(buf) {
|
|
56
|
+
if (!(buf instanceof Buffer)) buf = Buffer.from(buf);
|
|
57
|
+
if (buf.length < 4) return { complete: false };
|
|
58
|
+
const declared = buf.readUInt32LE(0);
|
|
59
|
+
if (declared > MAX_FRAME_BYTES) return { error: 'too-large', declared };
|
|
60
|
+
if (buf.length < 4 + declared) return { complete: false };
|
|
61
|
+
return {
|
|
62
|
+
complete: true,
|
|
63
|
+
payload: buf.subarray(4, 4 + declared).toString('utf8'),
|
|
64
|
+
rest: buf.subarray(4 + declared),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- pairing 存储 ----------
|
|
69
|
+
|
|
70
|
+
/** pairing.json 路径(configDir=resolveConfigDir 的产物)。 */
|
|
71
|
+
export function pairingPath(configDir) {
|
|
72
|
+
return join(configDir, 'pairing.json');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 读取 pairing:不存在/坏 JSON/坏形状 → null(读侧宽容——status/doctor 只是展示面)。
|
|
77
|
+
* P2-8:条目逐条过滤——非对象/profileId 非字符串的条目直接丢弃(手工编辑/历史脏
|
|
78
|
+
* 数据防 status/doctor 的 TypeError;过滤后可能得空数组,仍如实返回)。
|
|
79
|
+
*/
|
|
80
|
+
export function readPairing(configDir) {
|
|
81
|
+
const p = pairingPath(configDir);
|
|
82
|
+
if (!existsSync(p)) return null;
|
|
83
|
+
try {
|
|
84
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
85
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.bindings)) return null;
|
|
86
|
+
return { ...data, bindings: data.bindings.filter((b) => b !== null && typeof b === 'object' && !Array.isArray(b) && typeof b.profileId === 'string') };
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** pairing 落盘(atomicWrite:temp+rename+chmod 0600)。 */
|
|
93
|
+
export function savePairing(configDir, pairing) {
|
|
94
|
+
atomicWrite(pairingPath(configDir), `${JSON.stringify(pairing, null, 2)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 缺省 profileId:优先 master,无 master 取第一条;空/无 pairing → null。 */
|
|
98
|
+
export function defaultProfileId(pairing) {
|
|
99
|
+
if (!pairing || !Array.isArray(pairing.bindings) || pairing.bindings.length === 0) return null;
|
|
100
|
+
const master = pairing.bindings.find((b) => b?.role === 'master');
|
|
101
|
+
return (master ?? pairing.bindings[0]).profileId ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* P1-1:pairing 作为 profileId 缺省来源——readPairing → defaultProfileId 的组合
|
|
106
|
+
* (serve 层在 BU_BRIDGE_PROFILE_ID 未设置时的回退;README/UI 的承诺由此成真)。
|
|
107
|
+
* 任何读取异常 → null(回退缺位不构成启动失败)。
|
|
108
|
+
*/
|
|
109
|
+
export function pairingDefaultProfileId(configDir) {
|
|
110
|
+
try {
|
|
111
|
+
return defaultProfileId(readPairing(configDir));
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- bu:pair 校验(纯函数) ----------
|
|
118
|
+
|
|
119
|
+
const ROLES = new Set(['master', 'cluster']);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 校验 bu:pair 消息并产出规范化 bindings(裁剪到四字段、按 profileId 去重保首条、
|
|
123
|
+
* role 缺省 cluster——与扩展侧 buildPairMessage 同语义)。
|
|
124
|
+
* @returns {{ ok:true, bindings:Array<{profileId:string, role:string, name?:string, server?:string}> }
|
|
125
|
+
* | { ok:false, message:string }}
|
|
126
|
+
*/
|
|
127
|
+
export function validatePairMessage(msg) {
|
|
128
|
+
if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) {
|
|
129
|
+
return { ok: false, message: '消息须为 JSON 对象' };
|
|
130
|
+
}
|
|
131
|
+
if (msg.type !== 'bu:pair') {
|
|
132
|
+
return { ok: false, message: `未知消息类型:${JSON.stringify(msg.type ?? null)}` };
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(msg.bindings)) {
|
|
135
|
+
return { ok: false, message: 'bindings 须为数组' };
|
|
136
|
+
}
|
|
137
|
+
if (msg.bindings.length > MAX_BINDINGS) {
|
|
138
|
+
return { ok: false, message: `bindings 超 ${MAX_BINDINGS} 条上限(收到 ${msg.bindings.length})` };
|
|
139
|
+
}
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
const bindings = [];
|
|
142
|
+
for (const [i, raw] of msg.bindings.entries()) {
|
|
143
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
144
|
+
return { ok: false, message: `bindings[${i}] 须为对象` };
|
|
145
|
+
}
|
|
146
|
+
const { profileId, role, name, server } = raw;
|
|
147
|
+
if (typeof profileId !== 'string' || !SAFE_PROFILE_ID_RE.test(profileId)) {
|
|
148
|
+
return { ok: false, message: `bindings[${i}].profileId 非法(须匹配 ${SAFE_PROFILE_ID_RE.source})` };
|
|
149
|
+
}
|
|
150
|
+
if (role !== undefined && !ROLES.has(role)) {
|
|
151
|
+
return { ok: false, message: `bindings[${i}].role 须为 master|cluster(收到 ${JSON.stringify(role)})` };
|
|
152
|
+
}
|
|
153
|
+
for (const k of ['name', 'server']) {
|
|
154
|
+
const v = raw[k];
|
|
155
|
+
if (v !== undefined && (typeof v !== 'string' || v.length > 256)) {
|
|
156
|
+
return { ok: false, message: `bindings[${i}].${k} 须为 ≤256 字符的字符串` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (seen.has(profileId)) continue; // 同 profileId 去重:保留首条(顺序稳定)
|
|
160
|
+
seen.add(profileId);
|
|
161
|
+
const entry = { profileId, role: role ?? 'cluster' };
|
|
162
|
+
if (name !== undefined) entry.name = name;
|
|
163
|
+
if (server !== undefined) entry.server = server;
|
|
164
|
+
bindings.push(entry);
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, bindings };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------- 消息处理(依赖注入,可测) ----------
|
|
170
|
+
|
|
171
|
+
function errorReply(message) {
|
|
172
|
+
return { type: 'bu:pair:error', message };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 单条 NM 消息 → 应答对象(不碰流;落盘经 deps 注入,纯逻辑可测)。
|
|
177
|
+
* @param {unknown} msg 解析后的 JSON 值
|
|
178
|
+
* @param {{ version?: string, now?: () => string, save?: (pairing:object) => void }} [deps]
|
|
179
|
+
* save 注入落盘动作;缺省=写 resolveConfigDir() 下 pairing.json(调用时才解析
|
|
180
|
+
* env/家目录——测试可用 BU_CONFIG_DIR 重定向)。
|
|
181
|
+
* @returns {{type:'bu:pair:ok', cli:string, version:string, saved:number,
|
|
182
|
+
* received:number, truncated:boolean}
|
|
183
|
+
* |{type:'bu:pair:error', message:string}}
|
|
184
|
+
* P1-2:received=收到的原始条数、truncated=扩展侧是否声明截断——截断要如实
|
|
185
|
+
* 回执给 UI 显示「K 条未同步」,不再只报 saved 装作全量。
|
|
186
|
+
*/
|
|
187
|
+
export function handleNmMessage(msg, deps = {}) {
|
|
188
|
+
const version = deps.version ?? pkg.version;
|
|
189
|
+
const check = validatePairMessage(msg);
|
|
190
|
+
if (!check.ok) return errorReply(check.message);
|
|
191
|
+
const truncated = msg.truncated === true;
|
|
192
|
+
const pairing = {
|
|
193
|
+
version,
|
|
194
|
+
savedAt: (deps.now ?? (() => new Date().toISOString()))(),
|
|
195
|
+
bindings: check.bindings,
|
|
196
|
+
// 仅真截断时落盘(扩展侧 buildPairMessage 超出 50 条时置 true)
|
|
197
|
+
...(truncated ? { truncated: true } : {}),
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
if (deps.save) deps.save(pairing);
|
|
201
|
+
else savePairing(resolveConfigDir(), pairing);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return errorReply(`配对落盘失败:${err?.message ?? err}`);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
type: 'bu:pair:ok',
|
|
207
|
+
cli: 'fde2e-bu',
|
|
208
|
+
version,
|
|
209
|
+
saved: pairing.bindings.length,
|
|
210
|
+
received: msg.bindings.length,
|
|
211
|
+
truncated,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------- host 主循环(stdin 帧流 → stdout 帧流) ----------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* NM host 运行体:读到 stdin EOF 退出 0;畸形/未知消息一律回错误帧,不崩不退出。
|
|
219
|
+
* @param {{ stdin?: object, stdout?: object, stderr?: object, configDir?: string,
|
|
220
|
+
* version?: string, now?: () => string }} [opts]
|
|
221
|
+
* @returns {Promise<number>} exit code(恒 0——协议层错误不退出码化,Chrome 只看断开)
|
|
222
|
+
*/
|
|
223
|
+
export function runNmHost({ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, configDir, version, now } = {}) {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
const deps = {
|
|
226
|
+
...(version !== undefined ? { version } : {}),
|
|
227
|
+
...(now !== undefined ? { now } : {}),
|
|
228
|
+
// configDir 显式注入时固定落盘点;缺省让 handleNmMessage 走 resolveConfigDir()
|
|
229
|
+
...(configDir !== undefined ? { save: (pairing) => savePairing(configDir, pairing) } : {}),
|
|
230
|
+
};
|
|
231
|
+
const writeFrame = (obj) => {
|
|
232
|
+
try {
|
|
233
|
+
stdout.write(encodeFrame(JSON.stringify(obj)));
|
|
234
|
+
} catch (err) {
|
|
235
|
+
stderr.write(`nm-host: 应答帧写出失败:${err?.message ?? err}\n`);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
let buf = Buffer.alloc(0);
|
|
239
|
+
// P2-1:超限帧流式丢弃——不清空整个缓冲(那会把同批到达的后续合法帧一起杀
|
|
240
|
+
// 掉),改为按「4+声明长度」记账,随到达字节扣减;扣完即恢复常规解帧。
|
|
241
|
+
let discard = 0;
|
|
242
|
+
let settled = false;
|
|
243
|
+
const finish = (code) => {
|
|
244
|
+
if (settled) return;
|
|
245
|
+
settled = true;
|
|
246
|
+
resolve(code);
|
|
247
|
+
};
|
|
248
|
+
stdin.on('data', (chunk) => {
|
|
249
|
+
buf = Buffer.concat([buf, chunk]);
|
|
250
|
+
// 丢弃期内不尝试解帧:新到字节先还超限帧的账
|
|
251
|
+
if (discard > 0) {
|
|
252
|
+
const take = Math.min(buf.length, discard);
|
|
253
|
+
discard -= take;
|
|
254
|
+
buf = buf.subarray(take);
|
|
255
|
+
if (discard > 0) return;
|
|
256
|
+
}
|
|
257
|
+
for (;;) {
|
|
258
|
+
const r = decodeFrame(buf);
|
|
259
|
+
if (r.error === 'too-large') {
|
|
260
|
+
stderr.write(`nm-host: 帧超限(声明 ${r.declared}B > ${MAX_FRAME_BYTES}B)——流式丢弃 ${4 + r.declared}B\n`);
|
|
261
|
+
writeFrame(errorReply(`帧超限:声明 ${r.declared}B > 上限 ${MAX_FRAME_BYTES}B`));
|
|
262
|
+
const total = 4 + r.declared;
|
|
263
|
+
const take = Math.min(buf.length, total);
|
|
264
|
+
discard = total - take;
|
|
265
|
+
buf = buf.subarray(take);
|
|
266
|
+
if (discard > 0) break; // 余量等后续字节到达(超限帧载荷尚未送完)
|
|
267
|
+
continue; // 本批字节已覆盖超限帧全长——紧随其后的合法帧继续解
|
|
268
|
+
}
|
|
269
|
+
if (!r.complete) break;
|
|
270
|
+
buf = r.rest;
|
|
271
|
+
let msg;
|
|
272
|
+
try {
|
|
273
|
+
msg = JSON.parse(r.payload);
|
|
274
|
+
} catch {
|
|
275
|
+
stderr.write('nm-host: 帧载荷不是合法 JSON\n');
|
|
276
|
+
writeFrame(errorReply('帧载荷不是合法 JSON'));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
writeFrame(handleNmMessage(msg, deps));
|
|
281
|
+
} catch (err) {
|
|
282
|
+
stderr.write(`nm-host: 消息处理异常:${err?.message ?? err}\n`);
|
|
283
|
+
writeFrame(errorReply(`处理异常:${err?.message ?? err}`));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
stdin.on('end', () => finish(0)); // 客户端断开(Chrome 端 port.disconnect)→ 干净退出 0
|
|
288
|
+
stdin.on('close', () => finish(0));
|
|
289
|
+
stdin.on('error', (err) => {
|
|
290
|
+
stderr.write(`nm-host: stdin 错误:${err?.message ?? err}\n`);
|
|
291
|
+
finish(0);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// SKILL.md frontmatter 解析器(task 2.1/2.2)——手写最小 YAML 子集,不引 yaml 依赖
|
|
2
|
+
// (零依赖原则;agentskills.io frontmatter 实际用到的语法面极小:
|
|
3
|
+
// 顶层标量键值 + 一~两层嵌套 map + 行内 flow 数组,足够覆盖 name/description/
|
|
4
|
+
// version/metadata.version 的渐进披露读取)。
|
|
5
|
+
//
|
|
6
|
+
// 支持的子集:
|
|
7
|
+
// key: value 标量(裸值 / 单引号 / 双引号字符串;行尾 " #" 注释)
|
|
8
|
+
// key: 空值或嵌套块(下一行更深缩进 → 子 map)
|
|
9
|
+
// key: [a, b, c] 行内 flow 数组(字符串元素;引号内逗号不切分——r1 状态机)
|
|
10
|
+
// 键字符=\w+CJK+.-(r1:允许非 ASCII 键,如 `名称:`)
|
|
11
|
+
// '#' 开头整行注释、空行跳过
|
|
12
|
+
// 其余(多行字符串|、锚点、复杂 flow 等)报 PARSE_ERROR——渐进披露不需要。
|
|
13
|
+
|
|
14
|
+
/** @returns {{ ok: true, data: Record<string, unknown> } | { ok: false, reason: string }} */
|
|
15
|
+
export function parseFrontmatter(text) {
|
|
16
|
+
if (typeof text !== 'string') return { ok: false, reason: '输入不是字符串' };
|
|
17
|
+
const lines = text.split('\n').map((l) => l.replace(/\r$/, ''));
|
|
18
|
+
if (lines[0] === undefined || lines[0].trim() !== '---') {
|
|
19
|
+
return { ok: false, reason: '首行不是 --- 分隔符(无 frontmatter)' };
|
|
20
|
+
}
|
|
21
|
+
let end = -1;
|
|
22
|
+
for (let i = 1; i < lines.length; i++) {
|
|
23
|
+
const t = lines[i].trim();
|
|
24
|
+
if (t === '---' || t === '...') { end = i; break; }
|
|
25
|
+
if (t === '') continue; // 分隔符前允许空行
|
|
26
|
+
}
|
|
27
|
+
if (end === -1) return { ok: false, reason: 'frontmatter 未闭合(缺第二个 --- 行)' };
|
|
28
|
+
try {
|
|
29
|
+
return { ok: true, data: parseYamlSubset(lines.slice(1, end)) };
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return { ok: false, reason: err.message };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const indentOf = (s) => { const m = /^ */.exec(s); return m ? m[0].length : 0; };
|
|
36
|
+
const isSignificant = (l) => l.trim() !== '' && !l.trim().startsWith('#');
|
|
37
|
+
|
|
38
|
+
/** 标量解析:剥引号(支持双/单引号与 \" 转义)、剥行尾注释;一律返回字符串。 */
|
|
39
|
+
function parseScalar(raw) {
|
|
40
|
+
let s = raw.trim();
|
|
41
|
+
if (s.startsWith('"')) {
|
|
42
|
+
const m = /^"((?:[^"\\]|\\.)*)"/.exec(s);
|
|
43
|
+
if (!m) throw new Error(`双引号字符串未闭合:${raw}`);
|
|
44
|
+
return m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
45
|
+
}
|
|
46
|
+
if (s.startsWith("'")) {
|
|
47
|
+
const m = /^'([^']*)'/.exec(s);
|
|
48
|
+
if (!m) throw new Error(`单引号字符串未闭合:${raw}`);
|
|
49
|
+
return m[1];
|
|
50
|
+
}
|
|
51
|
+
const hash = s.indexOf(' #');
|
|
52
|
+
if (hash !== -1) s = s.slice(0, hash).trim(); // 行尾注释(裸值才会出现 " #")
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 行内 flow 数组:[a, "b", 'c'] → string[]。
|
|
57
|
+
* r1:逗号切分走引号状态机——引号内的逗号(如 tags: ["a,b", c])不得切开。 */
|
|
58
|
+
function parseFlowArray(raw) {
|
|
59
|
+
const s = raw.trim();
|
|
60
|
+
if (!s.endsWith(']')) throw new Error(`flow 数组未闭合:${raw}`);
|
|
61
|
+
const inner = s.slice(1, -1).trim();
|
|
62
|
+
if (inner === '') return [];
|
|
63
|
+
return splitFlowElements(inner).map((el) => parseScalar(el));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 引号保护切分:单/双引号内的分隔符与转义(\")原样保留,引号外逗号才分段。 */
|
|
67
|
+
function splitFlowElements(inner) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
let cur = '';
|
|
70
|
+
let quote = null; // null | '"' | "'"
|
|
71
|
+
for (let i = 0; i < inner.length; i++) {
|
|
72
|
+
const ch = inner[i];
|
|
73
|
+
if (quote !== null) {
|
|
74
|
+
cur += ch;
|
|
75
|
+
if (ch === '\\' && quote === '"' && i + 1 < inner.length) { cur += inner[++i]; continue; }
|
|
76
|
+
if (ch === quote) quote = null;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
|
80
|
+
if (ch === ',') { parts.push(cur); cur = ''; continue; }
|
|
81
|
+
cur += ch;
|
|
82
|
+
}
|
|
83
|
+
parts.push(cur);
|
|
84
|
+
return parts.map((p) => p.trim()).filter((p) => p !== '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 缩进递归下降解析(行数组 → 普通对象)。
|
|
89
|
+
* @param {string[]} lines
|
|
90
|
+
* @param {number} [baseIndent] 当前块基准缩进
|
|
91
|
+
*/
|
|
92
|
+
function parseYamlSubset(lines, baseIndent = 0) {
|
|
93
|
+
const map = {};
|
|
94
|
+
let i = 0;
|
|
95
|
+
const nextSignificant = (from) => {
|
|
96
|
+
for (let j = from; j < lines.length; j++) if (isSignificant(lines[j])) return j;
|
|
97
|
+
return -1;
|
|
98
|
+
};
|
|
99
|
+
while (i < lines.length) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
if (!isSignificant(line)) { i++; continue; }
|
|
102
|
+
const ind = indentOf(line);
|
|
103
|
+
if (ind < baseIndent) break;
|
|
104
|
+
if (ind > baseIndent) throw new Error(`意外缩进(无宿主键):${line.trim()}`);
|
|
105
|
+
// r1:键允许非 ASCII(技能作者用中文键,如 `名称: …`);\w=[A-Za-z0-9_],另放行 CJK 与 .-
|
|
106
|
+
const m = /^([\w\u4e00-\u9fff.-]+):\s*(.*)$/.exec(line.trim());
|
|
107
|
+
if (!m) throw new Error(`无法解析的 frontmatter 行:${line.trim()}`);
|
|
108
|
+
const key = m[1];
|
|
109
|
+
const rest = m[2];
|
|
110
|
+
if (rest === '') {
|
|
111
|
+
const nxt = nextSignificant(i + 1);
|
|
112
|
+
if (nxt !== -1 && indentOf(lines[nxt]) > baseIndent) {
|
|
113
|
+
map[key] = parseYamlSubset(lines.slice(nxt), indentOf(lines[nxt]));
|
|
114
|
+
// 块值消费到「下一行首」——continue 免得多跳一行(nxt+count 已指向未消费行)
|
|
115
|
+
i = nxt + countConsumed(lines, nxt, indentOf(lines[nxt]));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
map[key] = null;
|
|
119
|
+
} else if (rest.trim().startsWith('[')) {
|
|
120
|
+
map[key] = parseFlowArray(rest);
|
|
121
|
+
} else {
|
|
122
|
+
map[key] = parseScalar(rest);
|
|
123
|
+
}
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
return map;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 子块被递归解析后,计算其在原数组中占用的行数(到下一个不缩进行或末尾)。 */
|
|
130
|
+
function countConsumed(lines, start, childIndent) {
|
|
131
|
+
let n = 0;
|
|
132
|
+
for (let j = start; j < lines.length; j++) {
|
|
133
|
+
if (isSignificant(lines[j]) && indentOf(lines[j]) < childIndent) break;
|
|
134
|
+
n++;
|
|
135
|
+
}
|
|
136
|
+
return n;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 渐进披露取值:version 取顶层,缺失回落 metadata.version(agentskills 规范位置)。 */
|
|
140
|
+
export function disclosedFields(data) {
|
|
141
|
+
const metadata = data && typeof data.metadata === 'object' && data.metadata !== null ? data.metadata : {};
|
|
142
|
+
return {
|
|
143
|
+
name: typeof data?.name === 'string' ? data.name : '',
|
|
144
|
+
description: typeof data?.description === 'string' ? data.description : '',
|
|
145
|
+
version:
|
|
146
|
+
typeof data?.version === 'string' && data.version !== '' ? data.version
|
|
147
|
+
: typeof metadata.version === 'string' && metadata.version !== '' ? metadata.version
|
|
148
|
+
: '',
|
|
149
|
+
};
|
|
150
|
+
}
|