@jesonliu/lark-claudecode-bridge 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/assets/web/css/main.css +509 -111
- package/assets/web/index.html +12 -2
- package/assets/web/js/main.js +19 -11
- package/assets/web/js/pages/apps.js +10 -5
- package/assets/web/js/pages/claude.js +15 -5
- package/assets/web/js/pages/mcp.js +265 -0
- package/assets/web/js/pages/overview.js +5 -4
- package/assets/web/js/pages/permissions.js +56 -30
- package/assets/web/js/pages/plugins.js +29 -6
- package/assets/web/js/pages/skills.js +342 -0
- package/assets/web/js/pages/slash.js +11 -5
- package/assets/web/js/pages/workspaces.js +36 -14
- package/assets/web/js/theme.js +23 -0
- package/assets/web/js/ui.js +75 -4
- package/config.example.yaml +2 -3
- package/dist/access/access-control.d.ts +17 -1
- package/dist/access/access-control.js +31 -2
- package/dist/access/access-control.js.map +1 -1
- package/dist/bin/lcb.js +5 -0
- package/dist/bin/lcb.js.map +1 -1
- package/dist/claude-config.d.ts +14 -2
- package/dist/claude-config.js +75 -4
- package/dist/claude-config.js.map +1 -1
- package/dist/cli/setup-wizard.d.ts +0 -1
- package/dist/cli/setup-wizard.js +2 -4
- package/dist/cli/setup-wizard.js.map +1 -1
- package/dist/config.js +39 -14
- package/dist/config.js.map +1 -1
- package/dist/executor/claude-executor.d.ts +1 -1
- package/dist/executor/claude-executor.js +4 -2
- package/dist/executor/claude-executor.js.map +1 -1
- package/dist/executor/notify-server.d.ts +20 -0
- package/dist/executor/notify-server.js +85 -4
- package/dist/executor/notify-server.js.map +1 -1
- package/dist/executor/plugin-discovery.d.ts +6 -0
- package/dist/executor/plugin-discovery.js +14 -0
- package/dist/executor/plugin-discovery.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +60 -24
- package/dist/gateway/card-builder.js +200 -109
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/diff-card.js +1 -1
- package/dist/gateway/diff-card.js.map +1 -1
- package/dist/gateway/feishu-gateway.d.ts +27 -0
- package/dist/gateway/feishu-gateway.js +106 -4
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/progress-card.d.ts +77 -0
- package/dist/gateway/progress-card.js +231 -8
- package/dist/gateway/progress-card.js.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +354 -98
- package/dist/index.js.map +1 -1
- package/dist/notify-sop.d.ts +1 -0
- package/dist/notify-sop.js +33 -0
- package/dist/notify-sop.js.map +1 -0
- package/dist/session/commands.d.ts +1 -1
- package/dist/session/commands.js +25 -2
- package/dist/session/commands.js.map +1 -1
- package/dist/session/session-store.d.ts +3 -0
- package/dist/session/session-store.js.map +1 -1
- package/dist/types.d.ts +23 -5
- package/dist/util/log-tee.d.ts +4 -0
- package/dist/util/log-tee.js +99 -0
- package/dist/util/log-tee.js.map +1 -0
- package/dist/util/runtime-dirs.d.ts +4 -0
- package/dist/util/runtime-dirs.js +27 -0
- package/dist/util/runtime-dirs.js.map +1 -0
- package/dist/util/workspace-diff.js +2 -2
- package/dist/util/workspace-diff.js.map +1 -1
- package/dist/web/lifecycle.d.ts +4 -1
- package/dist/web/lifecycle.js +11 -25
- package/dist/web/lifecycle.js.map +1 -1
- package/dist/web/server.js +404 -11
- package/dist/web/server.js.map +1 -1
- package/dist/web/skills-mcp-api.d.ts +120 -0
- package/dist/web/skills-mcp-api.js +348 -0
- package/dist/web/skills-mcp-api.js.map +1 -0
- package/package.json +3 -1
package/dist/web/server.js
CHANGED
|
@@ -2,28 +2,33 @@
|
|
|
2
2
|
// 安全基线:默认只绑 127.0.0.1;Host/Origin 校验防 DNS rebinding;body ≤1MB;
|
|
3
3
|
// secret 永不出进程(GET 脱敏回显,PUT 空值=不修改)。改动写盘后由现有热重载器/重启消费
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
|
-
import {
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
8
|
import { dirname, extname, join, parse, resolve, sep } from 'node:path';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
import { parseDocument, stringify } from 'yaml';
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
+
import AdmZip from 'adm-zip';
|
|
12
|
+
import { CONFIG_DIR, CONFIG_PATH, SLASH_COMMAND_RE, loadConfig, parseConfigText } from '../config.js';
|
|
13
|
+
import { DEFAULT_CLAUDE_DIR, MANAGED_CLAUDE_DIR, initManagedClaudeDir, resolveClaudeDir } from '../claude-config.js';
|
|
11
14
|
import { switchProfile } from '../claude-profile.js';
|
|
12
15
|
import { hasClaudeAuth } from '../auth-precheck.js';
|
|
13
16
|
import { defaultPermissionsDoc, defaultServerDoc } from '../config-defaults.js';
|
|
14
17
|
import { VERSION } from '../version.js';
|
|
15
18
|
import { appStatusSummary, applyPermissionDisplayDefaults, applySecrets, claudeSettingsSummary, computeRestartRequired, docForClient, isAllowedHost, isAllowedOrigin } from './config-api.js';
|
|
19
|
+
import { BRIDGE_MCP_JSON, MCP_NAME_RE, SKILL_NAME_RE, USER_CLAUDE_JSON, listAllSkills, listMcpServers, listSkills, parseClaudeMcpAdd, readMcpServersFromJsonFile, readPluginMcpFromManifest, resolveEnvRefs, } from './skills-mcp-api.js';
|
|
16
20
|
import { fetchModelList, resolveModelFetchParams } from './model-list.js';
|
|
17
21
|
import { SLASH_COMMAND_META } from '../session/commands.js';
|
|
18
22
|
import { DEFAULT_ALLOW_TOOLS_LIST, DEFAULT_DANGEROUS_COMMAND_SOURCES } from '../executor/permission-gate.js';
|
|
19
23
|
import { openBrowser } from '../util/open-browser.js';
|
|
24
|
+
import { ensureRuntimeDirs } from '../util/runtime-dirs.js';
|
|
20
25
|
import { builtinCommands, createSlashApiClient, ensureBuiltins, expectedCommands, syncSlashCommands } from '../feishu/slash-commands.js';
|
|
21
26
|
import { runPluginCli, updateAllPlugins } from '../executor/plugin-manager.js';
|
|
22
|
-
import { invalidatePluginCache, listAvailablePlugins, listInstalledPlugins } from '../executor/plugin-discovery.js';
|
|
27
|
+
import { invalidatePluginCache, listAvailablePlugins, listInstalledPlugins, loadEnabledPlugins } from '../executor/plugin-discovery.js';
|
|
23
28
|
import { bridgeStatus, resolveLcbEntry, restartBridgeWithHelper, spawnBridgeDetached, stopBridgeByPid } from './lifecycle.js';
|
|
24
29
|
import { checkUpdate, installMode, runUpdate } from './update.js';
|
|
25
30
|
/** PUT /api/config 参与整段替换的顶级键;body 未携带的键保持磁盘原文(含注释) */
|
|
26
|
-
const PUT_SECTIONS = ['apps', 'workspaces', 'defaults', 'concurrency', 'permissions', 'server', 'claude', 'slash_commands', 'transcripts'];
|
|
31
|
+
const PUT_SECTIONS = ['apps', 'workspaces', 'defaults', 'concurrency', 'permissions', 'server', 'claude', 'slash_commands', 'transcripts', 'session', 'card'];
|
|
27
32
|
const WEB_ROOT = fileURLToPath(new URL('../../assets/web', import.meta.url));
|
|
28
33
|
const INDEX_HTML_PATH = join(WEB_ROOT, 'index.html');
|
|
29
34
|
/** 静态模块仅限 js/css 两前缀 + 扩展名白名单(纵深防御:目录内误放的敏感文件不可被 serve) */
|
|
@@ -38,14 +43,14 @@ function json(res, status, body) {
|
|
|
38
43
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
39
44
|
res.end(text);
|
|
40
45
|
}
|
|
41
|
-
function readBody(req) {
|
|
46
|
+
function readBody(req, limit = BODY_LIMIT) {
|
|
42
47
|
return new Promise((resolve, reject) => {
|
|
43
48
|
let size = 0;
|
|
44
49
|
const chunks = [];
|
|
45
50
|
req.on('data', (c) => {
|
|
46
51
|
size += c.length;
|
|
47
|
-
if (size >
|
|
48
|
-
reject(new Error(
|
|
52
|
+
if (size > limit) {
|
|
53
|
+
reject(new Error(`请求体超过 ${Math.floor(limit / 1024 / 1024)}MB 上限`));
|
|
49
54
|
req.destroy();
|
|
50
55
|
return;
|
|
51
56
|
}
|
|
@@ -55,8 +60,8 @@ function readBody(req) {
|
|
|
55
60
|
req.on('error', reject);
|
|
56
61
|
});
|
|
57
62
|
}
|
|
58
|
-
function readJsonBody(req) {
|
|
59
|
-
return readBody(req).then((text) => {
|
|
63
|
+
function readJsonBody(req, limit = BODY_LIMIT) {
|
|
64
|
+
return readBody(req, limit).then((text) => {
|
|
60
65
|
if (!text.trim())
|
|
61
66
|
return {};
|
|
62
67
|
const v = JSON.parse(text);
|
|
@@ -147,6 +152,7 @@ function readRawDoc(configPath) {
|
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
export async function startWebServer(opts = {}) {
|
|
155
|
+
ensureRuntimeDirs(); // 独立入口兜底:即使不经 lcb CLI(测试/嵌入式调用),首装写盘也有目录
|
|
150
156
|
const serverCfg = opts.server ?? defaultServerDoc();
|
|
151
157
|
if (serverCfg.enabled === false)
|
|
152
158
|
return null;
|
|
@@ -539,6 +545,393 @@ async function handle(req, res, ctx) {
|
|
|
539
545
|
return json(res, 200, r);
|
|
540
546
|
}
|
|
541
547
|
}
|
|
548
|
+
// ---- Skills 管理(#12):三来源聚合(用户级·本机 / 用户级·bridge / 项目级·工作区)+ create/delete + zip 导入。
|
|
549
|
+
// 首装(无 config.yaml)不拦截:两个用户级目录照常可看可管,仅项目级来源为空 ----
|
|
550
|
+
/** 磁盘 config 的 workspaces(project 来源与 zip 导入目标依据);解析失败/首装返回空数组 */
|
|
551
|
+
const currentWorkspaces = () => {
|
|
552
|
+
const doc = readRawDoc(ctx.configPath);
|
|
553
|
+
const ws = doc?.workspaces;
|
|
554
|
+
if (!Array.isArray(ws))
|
|
555
|
+
return [];
|
|
556
|
+
return ws.filter((w) => !!w && typeof w === 'object' && typeof w.name === 'string' && typeof w.path === 'string');
|
|
557
|
+
};
|
|
558
|
+
/** skill 生效目录(用户级):managed 模式 → bridge 自管 claude/skills;否则共享本机 ~/.claude/skills */
|
|
559
|
+
const effectiveSkillsDir = () => {
|
|
560
|
+
const doc = readRawDoc(ctx.configPath);
|
|
561
|
+
return doc?.claude?.mode === 'managed'
|
|
562
|
+
? join(CONFIG_DIR, 'claude', 'skills')
|
|
563
|
+
: join(homedir(), '.claude', 'skills');
|
|
564
|
+
};
|
|
565
|
+
/** 插件清单(#12 插件来源补全):扫描双 claude 目录的 installed_plugins.json(本机必扫;
|
|
566
|
+
* managed 时额外扫 bridge 自管目录——managed 模式生效目录),合并去重(installPath 主键)。
|
|
567
|
+
* enabled 字段联合两目录 settings.json 的 enabledPlugins 判定(任一处开启即视为启用——
|
|
568
|
+
* 保守显示启用,与 executeTask 任一处目录可加载语义一致) */
|
|
569
|
+
const currentPlugins = () => {
|
|
570
|
+
const doc = readRawDoc(ctx.configPath);
|
|
571
|
+
const isManaged = doc?.claude?.mode === 'managed';
|
|
572
|
+
const dirs = [DEFAULT_CLAUDE_DIR];
|
|
573
|
+
if (isManaged)
|
|
574
|
+
dirs.push(MANAGED_CLAUDE_DIR);
|
|
575
|
+
const enabledKeys = new Set();
|
|
576
|
+
for (const d of dirs)
|
|
577
|
+
for (const k of loadEnabledPlugins(d))
|
|
578
|
+
enabledKeys.add(k);
|
|
579
|
+
const byPath = new Map();
|
|
580
|
+
for (const d of dirs) {
|
|
581
|
+
for (const p of listInstalledPlugins(d)) {
|
|
582
|
+
if (!p.path)
|
|
583
|
+
continue;
|
|
584
|
+
if (byPath.has(p.path))
|
|
585
|
+
continue; // installPath 重复(双目录登记同一 install)按先到先得
|
|
586
|
+
byPath.set(p.path, { name: p.name, path: p.path, enabled: enabledKeys.has(p.key) });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return [...byPath.values()];
|
|
590
|
+
};
|
|
591
|
+
if (path === '/api/skills' && req.method === 'GET') {
|
|
592
|
+
const plugins = currentPlugins();
|
|
593
|
+
return json(res, 200, {
|
|
594
|
+
skills: listAllSkills(currentWorkspaces(), plugins),
|
|
595
|
+
effectiveDir: effectiveSkillsDir(),
|
|
596
|
+
pluginStats: { total: plugins.length, withSkills: plugins.filter((p) => listSkills(join(p.path, 'skills'), 'plugin').length > 0).length },
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
if (path === '/api/skills/action' && req.method === 'POST') {
|
|
600
|
+
const body = await readJsonBody(req);
|
|
601
|
+
const op = String(body.op ?? '');
|
|
602
|
+
const name = String(body.name ?? '').trim();
|
|
603
|
+
if (!SKILL_NAME_RE.test(name))
|
|
604
|
+
return json(res, 400, { error: `skill 名 "${name}" 须为 1-64 位字母/数字/下划线/连字符` });
|
|
605
|
+
if (op === 'create') {
|
|
606
|
+
const description = String(body.description ?? '').trim();
|
|
607
|
+
const md = `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\n(请补充 skill 说明)\n`;
|
|
608
|
+
const skillDir = join(effectiveSkillsDir(), name);
|
|
609
|
+
if (existsSync(skillDir))
|
|
610
|
+
return json(res, 409, { error: `skill "${name}" 已存在(${skillDir})` });
|
|
611
|
+
try {
|
|
612
|
+
mkdirSync(skillDir, { recursive: true });
|
|
613
|
+
writeAtomic(join(skillDir, 'SKILL.md'), md);
|
|
614
|
+
}
|
|
615
|
+
catch (e) {
|
|
616
|
+
return json(res, 500, { error: `创建 skill 失败:${e instanceof Error ? e.message : String(e)}` });
|
|
617
|
+
}
|
|
618
|
+
return json(res, 200, { ok: true, skill: { name, description, path: skillDir } });
|
|
619
|
+
}
|
|
620
|
+
if (op === 'delete') {
|
|
621
|
+
// 按来源定位目录:machine-user / bridge(自管) / project(workspaceName 定位工作区)
|
|
622
|
+
const source = String(body.source ?? 'machine-user');
|
|
623
|
+
const root = source === 'bridge'
|
|
624
|
+
? join(CONFIG_DIR, 'claude', 'skills')
|
|
625
|
+
: source === 'project'
|
|
626
|
+
? join(currentWorkspaces().find((w) => w.name === String(body.workspaceName ?? ''))?.path ?? '\0not-found', '.claude', 'skills')
|
|
627
|
+
: join(homedir(), '.claude', 'skills');
|
|
628
|
+
const skillDir = join(root, name);
|
|
629
|
+
if (!existsSync(skillDir))
|
|
630
|
+
return json(res, 400, { error: `skill "${name}" 不存在(${root})` });
|
|
631
|
+
try {
|
|
632
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
return json(res, 500, { error: `删除 skill 失败:${e instanceof Error ? e.message : String(e)}` });
|
|
636
|
+
}
|
|
637
|
+
return json(res, 200, { ok: true });
|
|
638
|
+
}
|
|
639
|
+
return json(res, 400, { error: `未知 op "${op}"(支持 create / delete)` });
|
|
640
|
+
}
|
|
641
|
+
// ---- Skill 文件浏览(#3):目录列举 + 文件原文读取。
|
|
642
|
+
// 安全:所有路径必须解析后落在任一已知 skill 根目录内(防路径穿越读到 ~/.claude/credentials.json 等);
|
|
643
|
+
// 原文读取另有扩展名白名单 + 1MB 上限 ----
|
|
644
|
+
/** 全部已知 skill 根(用户级本机/bridge 自管/各工作区项目级/各已装插件),resolve 后的绝对路径 */
|
|
645
|
+
const skillRootsAll = () => {
|
|
646
|
+
const roots = [join(homedir(), '.claude', 'skills'), join(CONFIG_DIR, 'claude', 'skills')];
|
|
647
|
+
for (const w of currentWorkspaces())
|
|
648
|
+
roots.push(join(w.path, '.claude', 'skills'));
|
|
649
|
+
for (const p of currentPlugins())
|
|
650
|
+
roots.push(join(p.path, 'skills'));
|
|
651
|
+
return roots.map((r) => resolve(r));
|
|
652
|
+
};
|
|
653
|
+
const isUnderSkillRoots = (p) => {
|
|
654
|
+
const rp = resolve(p);
|
|
655
|
+
return skillRootsAll().some((root) => rp === root || rp.startsWith(root + sep));
|
|
656
|
+
};
|
|
657
|
+
if (path === '/api/skills/files' && req.method === 'GET') {
|
|
658
|
+
const dir = String(url.searchParams.get('path') ?? '');
|
|
659
|
+
if (!dir || !isUnderSkillRoots(dir))
|
|
660
|
+
return json(res, 403, { error: '路径不在允许的 skill 目录内' });
|
|
661
|
+
let dirents;
|
|
662
|
+
try {
|
|
663
|
+
dirents = readdirSync(dir, { withFileTypes: true });
|
|
664
|
+
}
|
|
665
|
+
catch (e) {
|
|
666
|
+
return json(res, 400, { error: `目录读取失败:${e instanceof Error ? e.message : String(e)}` });
|
|
667
|
+
}
|
|
668
|
+
const files = dirents.map((d) => {
|
|
669
|
+
let size = 0;
|
|
670
|
+
let mtime = '';
|
|
671
|
+
try {
|
|
672
|
+
const st = statSync(join(dir, d.name));
|
|
673
|
+
size = st.size;
|
|
674
|
+
mtime = st.mtime.toISOString();
|
|
675
|
+
}
|
|
676
|
+
catch { /* 单条 stat 失败按 0 处理 */ }
|
|
677
|
+
return { name: d.name, isDir: d.isDirectory(), size, mtime };
|
|
678
|
+
}).sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name));
|
|
679
|
+
return json(res, 200, { path: resolve(dir), files });
|
|
680
|
+
}
|
|
681
|
+
if (path === '/api/skills/raw' && req.method === 'GET') {
|
|
682
|
+
const fp = String(url.searchParams.get('path') ?? '');
|
|
683
|
+
if (!fp || !isUnderSkillRoots(fp))
|
|
684
|
+
return json(res, 403, { error: '路径不在允许的 skill 目录内' });
|
|
685
|
+
// 扩展名白名单:只放行文本类文件(防借道读凭证/二进制)
|
|
686
|
+
const RAW_EXTS = new Set(['.md', '.txt', '.json', '.yaml', '.yml', '.sh', '.py', '.ts', '.js', '.mjs', '.cjs', '.toml', '.xml', '.csv', '.html', '.css']);
|
|
687
|
+
const ext = extname(fp).toLowerCase();
|
|
688
|
+
if (!RAW_EXTS.has(ext))
|
|
689
|
+
return json(res, 400, { error: `不支持预览 ${ext || '无扩展名'} 类型文件(仅文本类可查看)` });
|
|
690
|
+
try {
|
|
691
|
+
const st = statSync(fp);
|
|
692
|
+
if (!st.isFile())
|
|
693
|
+
return json(res, 400, { error: '不是文件' });
|
|
694
|
+
if (st.size > 1024 * 1024)
|
|
695
|
+
return json(res, 400, { error: `文件过大(${(st.size / 1024 / 1024).toFixed(1)}MB > 1MB),请本地查看` });
|
|
696
|
+
return json(res, 200, { path: resolve(fp), name: parse(fp).base, ext, size: st.size, content: readFileSync(fp, 'utf8') });
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
return json(res, 400, { error: `文件读取失败:${e instanceof Error ? e.message : String(e)}` });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (path === '/api/skills/import' && req.method === 'POST') {
|
|
703
|
+
// zip 导入(#12):base64 上传,独立放宽到 10MB(全局 1MB 不放 zip);解压后复制进用户级生效目录
|
|
704
|
+
let body;
|
|
705
|
+
try {
|
|
706
|
+
body = await readJsonBody(req, 10 * 1024 * 1024);
|
|
707
|
+
}
|
|
708
|
+
catch (e) {
|
|
709
|
+
return json(res, 413, { error: e instanceof Error ? e.message : String(e) });
|
|
710
|
+
}
|
|
711
|
+
const zipBase64 = String(body.zipBase64 ?? '');
|
|
712
|
+
if (!zipBase64)
|
|
713
|
+
return json(res, 400, { error: '缺少 zipBase64' });
|
|
714
|
+
let entries;
|
|
715
|
+
try {
|
|
716
|
+
const zip = new AdmZip(Buffer.from(zipBase64, 'base64'));
|
|
717
|
+
entries = zip.getEntries().map((e) => ({ entryName: e.entryName, isDirectory: e.isDirectory }));
|
|
718
|
+
// 兼容两种打包结构:根即 skill 目录(SKILL.md 在根)/ 单层子目录是 skill 目录
|
|
719
|
+
const skillMd = entries.find((e) => !e.isDirectory && (e.entryName === 'SKILL.md' || /^[^/\\]+[/\\]SKILL\.md$/.test(e.entryName)));
|
|
720
|
+
if (!skillMd)
|
|
721
|
+
return json(res, 400, { error: 'zip 内未找到 SKILL.md(须为 skill 目录打包,或压缩包根含 SKILL.md)' });
|
|
722
|
+
const prefix = skillMd.entryName === 'SKILL.md' ? '' : skillMd.entryName.replace(/[/\\]SKILL\.md$/, '') + '/';
|
|
723
|
+
const name = prefix ? prefix.replace(/[/\\]$/, '') : String(body.name ?? '').trim();
|
|
724
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
725
|
+
return json(res, 400, { error: `skill 名 "${name}" 不合法(1-64 位字母/数字/下划线/连字符;根打包时请传 name 或改为目录打包)` });
|
|
726
|
+
}
|
|
727
|
+
const targetDir = join(effectiveSkillsDir(), name);
|
|
728
|
+
if (existsSync(targetDir))
|
|
729
|
+
return json(res, 409, { error: `skill "${name}" 已存在(${targetDir}),如需覆盖请先删除` });
|
|
730
|
+
mkdirSync(targetDir, { recursive: true });
|
|
731
|
+
let extracted = 0;
|
|
732
|
+
for (const e of zip.getEntries()) {
|
|
733
|
+
if (e.isDirectory)
|
|
734
|
+
continue;
|
|
735
|
+
if (!e.entryName.startsWith(prefix))
|
|
736
|
+
continue;
|
|
737
|
+
// 路径穿越防御:跳过含 .. 的条目;落盘路径去掉 prefix 前缀
|
|
738
|
+
const rel = e.entryName.slice(prefix.length).replace(/\\/g, '/');
|
|
739
|
+
if (!rel || rel.split('/').some((seg) => seg === '..'))
|
|
740
|
+
continue;
|
|
741
|
+
const dest = join(targetDir, ...rel.split('/'));
|
|
742
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
743
|
+
zip.extractEntryTo(e.entryName, dirname(dest), false, true);
|
|
744
|
+
extracted++;
|
|
745
|
+
}
|
|
746
|
+
if (extracted === 0 || !existsSync(join(targetDir, 'SKILL.md'))) {
|
|
747
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
748
|
+
return json(res, 400, { error: '解压后未得到有效 skill(SKILL.md 缺失),已回滚' });
|
|
749
|
+
}
|
|
750
|
+
return json(res, 200, { ok: true, name, path: targetDir, files: extracted });
|
|
751
|
+
}
|
|
752
|
+
catch (e) {
|
|
753
|
+
return json(res, 500, { error: `zip 导入失败:${e instanceof Error ? e.message : String(e)}` });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// ---- MCP server 管理(#12):页面可管理存储 = bridge 自管 <CONFIG_DIR>/mcp/servers.json(executeTask
|
|
757
|
+
// 注入 SDK 生效);~/.claude.json(用户级·本机)展示+可删不写入;工作区 .mcp.json(项目级)只读 ----
|
|
758
|
+
/** MCP env 多来源合并(#4/#8):优先级 高→低 = 飞书应用 env(apps[].env,多应用按配置序先写胜出)
|
|
759
|
+
* > claude.env > 生效 Claude 目录 settings.json env > process.env。MCP 列表 resolvedEnv
|
|
760
|
+
* 与 check 探测 spawn env 的统一数据源——只查 process.env 会把应用在配置页/env 块里
|
|
761
|
+
* 设的变量误判为「未设置」 */
|
|
762
|
+
const currentMergedEnv = () => {
|
|
763
|
+
const merged = {};
|
|
764
|
+
const put = (src) => {
|
|
765
|
+
if (!src || typeof src !== 'object' || Array.isArray(src))
|
|
766
|
+
return;
|
|
767
|
+
for (const [k, v] of Object.entries(src)) {
|
|
768
|
+
if (typeof v === 'string' && v && !(k in merged))
|
|
769
|
+
merged[k] = v; // 先写胜出:调用顺序即优先级
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
const doc = readRawDoc(ctx.configPath);
|
|
773
|
+
const rawApps = Array.isArray(doc?.apps) ? doc.apps : doc?.feishu ? [doc.feishu] : [];
|
|
774
|
+
for (const a of rawApps)
|
|
775
|
+
put(a.env);
|
|
776
|
+
put(doc?.claude?.env);
|
|
777
|
+
const isManaged = doc?.claude?.mode === 'managed';
|
|
778
|
+
try {
|
|
779
|
+
const s = JSON.parse(readFileSync(join(isManaged ? MANAGED_CLAUDE_DIR : DEFAULT_CLAUDE_DIR, 'settings.json'), 'utf8'));
|
|
780
|
+
put(s.env);
|
|
781
|
+
}
|
|
782
|
+
catch { /* settings.json 不存在/损坏:跳过该来源 */ }
|
|
783
|
+
put(process.env);
|
|
784
|
+
return merged;
|
|
785
|
+
};
|
|
786
|
+
if (path === '/api/mcp' && req.method === 'GET') {
|
|
787
|
+
const plugins = currentPlugins();
|
|
788
|
+
const mergedEnv = currentMergedEnv();
|
|
789
|
+
const servers = listMcpServers(currentWorkspaces(), plugins).map((s) => {
|
|
790
|
+
const { resolved, missing } = resolveEnvRefs(s.config.env, mergedEnv);
|
|
791
|
+
return { ...s, resolvedEnv: resolved, missingEnv: missing };
|
|
792
|
+
});
|
|
793
|
+
return json(res, 200, {
|
|
794
|
+
servers,
|
|
795
|
+
bridgePath: BRIDGE_MCP_JSON,
|
|
796
|
+
pluginStats: { total: plugins.length, withMcp: plugins.filter((p) => Object.keys(readMcpServersFromJsonFile(join(p.path, '.mcp.json')).servers).length > 0
|
|
797
|
+
|| Object.keys(readPluginMcpFromManifest(p.path).servers).length > 0).length },
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
if (path === '/api/mcp/action' && req.method === 'POST') {
|
|
801
|
+
const body = await readJsonBody(req);
|
|
802
|
+
const op = String(body.op ?? '');
|
|
803
|
+
const name = String(body.name ?? '').trim();
|
|
804
|
+
if (!MCP_NAME_RE.test(name))
|
|
805
|
+
return json(res, 400, { error: `mcp 名 "${name}" 须为 1-64 位字母/数字/下划线/点/连字符` });
|
|
806
|
+
if (op === 'add' || op === 'update') {
|
|
807
|
+
// 两种入参:command = claude mcp add / add-json 原生命令文本(后端解析);或 config = 配置对象
|
|
808
|
+
let cfg;
|
|
809
|
+
if (typeof body.command === 'string' && body.command.trim()) {
|
|
810
|
+
const parsed = parseClaudeMcpAdd(String(body.command));
|
|
811
|
+
if (!parsed.ok)
|
|
812
|
+
return json(res, 400, { error: parsed.error });
|
|
813
|
+
if (parsed.name !== name)
|
|
814
|
+
return json(res, 400, { error: `命令中的名字 "${parsed.name}" 与表单名字 "${name}" 不一致` });
|
|
815
|
+
cfg = parsed.config;
|
|
816
|
+
}
|
|
817
|
+
else if (body.config && typeof body.config === 'object') {
|
|
818
|
+
cfg = body.config;
|
|
819
|
+
}
|
|
820
|
+
if (!cfg)
|
|
821
|
+
return json(res, 400, { error: '缺少 config(配置对象)或 command(claude mcp add 命令)' });
|
|
822
|
+
// 宽松校验:stdio 走 command,http/sse 走 url
|
|
823
|
+
if (!(typeof cfg.type === 'string' || typeof cfg.command === 'string' || typeof cfg.url === 'string')) {
|
|
824
|
+
return json(res, 400, { error: 'config 至少需要 type / command / url 之一' });
|
|
825
|
+
}
|
|
826
|
+
const { servers } = readMcpServersFromJsonFile(BRIDGE_MCP_JSON);
|
|
827
|
+
if (op === 'add' && servers[name])
|
|
828
|
+
return json(res, 409, { error: `mcp server "${name}" 已存在(bridge 目录),请改名或用更新` });
|
|
829
|
+
servers[name] = cfg;
|
|
830
|
+
try {
|
|
831
|
+
writeAtomic(BRIDGE_MCP_JSON, JSON.stringify({ mcpServers: servers }, null, 2));
|
|
832
|
+
}
|
|
833
|
+
catch (e) {
|
|
834
|
+
return json(res, 500, { error: `写入 ${BRIDGE_MCP_JSON} 失败:${e instanceof Error ? e.message : String(e)}` });
|
|
835
|
+
}
|
|
836
|
+
return json(res, 200, { ok: true, name, config: cfg });
|
|
837
|
+
}
|
|
838
|
+
if (op === 'remove') {
|
|
839
|
+
const source = String(body.source ?? 'bridge');
|
|
840
|
+
if (source === 'project')
|
|
841
|
+
return json(res, 400, { error: '项目级 .mcp.json 只读(请在对应工作区目录手动修改)' });
|
|
842
|
+
if (source === 'machine-user') {
|
|
843
|
+
// 删 ~/.claude.json 条目:保留 mcpServers 之外的其它键(numStartups 等用户级 CLI 运行时状态)
|
|
844
|
+
let doc = {};
|
|
845
|
+
if (existsSync(USER_CLAUDE_JSON)) {
|
|
846
|
+
try {
|
|
847
|
+
doc = JSON.parse(readFileSync(USER_CLAUDE_JSON, 'utf8'));
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
return json(res, 500, { error: '~/.claude.json 解析失败,请先修复后再操作' });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const mcp = (doc.mcpServers && typeof doc.mcpServers === 'object' ? doc.mcpServers : {});
|
|
854
|
+
if (!mcp[name])
|
|
855
|
+
return json(res, 400, { error: `mcp server "${name}" 不存在(~/.claude.json)` });
|
|
856
|
+
delete mcp[name];
|
|
857
|
+
doc.mcpServers = mcp;
|
|
858
|
+
try {
|
|
859
|
+
writeAtomic(USER_CLAUDE_JSON, JSON.stringify(doc, null, 2));
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
return json(res, 500, { error: `写入 ~/.claude.json 失败:${e instanceof Error ? e.message : String(e)}` });
|
|
863
|
+
}
|
|
864
|
+
return json(res, 200, { ok: true });
|
|
865
|
+
}
|
|
866
|
+
const { servers } = readMcpServersFromJsonFile(BRIDGE_MCP_JSON);
|
|
867
|
+
if (!servers[name])
|
|
868
|
+
return json(res, 400, { error: `mcp server "${name}" 不存在(${BRIDGE_MCP_JSON})` });
|
|
869
|
+
delete servers[name];
|
|
870
|
+
try {
|
|
871
|
+
writeAtomic(BRIDGE_MCP_JSON, JSON.stringify({ mcpServers: servers }, null, 2));
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
return json(res, 500, { error: `写入 ${BRIDGE_MCP_JSON} 失败:${e instanceof Error ? e.message : String(e)}` });
|
|
875
|
+
}
|
|
876
|
+
return json(res, 200, { ok: true });
|
|
877
|
+
}
|
|
878
|
+
return json(res, 400, { error: `未知 op "${op}"(支持 add / update / remove)` });
|
|
879
|
+
}
|
|
880
|
+
if (path === '/api/mcp/check' && req.method === 'POST') {
|
|
881
|
+
// 按需探测(不自动批量探测:stdio spawn 最长 3s,逐行自动探测会拖垮列表加载)
|
|
882
|
+
const body = await readJsonBody(req);
|
|
883
|
+
const name = String(body.name ?? '');
|
|
884
|
+
const source = String(body.source ?? '');
|
|
885
|
+
const entry = listMcpServers(currentWorkspaces(), currentPlugins())
|
|
886
|
+
.find((s) => s.name === name && s.source === source && (s.workspaceName ?? '') === String(body.workspaceName ?? ''));
|
|
887
|
+
if (!entry)
|
|
888
|
+
return json(res, 400, { error: `mcp server "${name}"(${source})不存在,请刷新列表` });
|
|
889
|
+
const cfg = entry.config;
|
|
890
|
+
const isRemote = cfg.type === 'http' || cfg.type === 'sse' || (!cfg.type && typeof cfg.url === 'string');
|
|
891
|
+
if (isRemote) {
|
|
892
|
+
const url = typeof cfg.url === 'string' ? cfg.url : '';
|
|
893
|
+
if (!/^https?:\/\//.test(url))
|
|
894
|
+
return json(res, 200, { status: 'failed', detail: 'url 不是合法的 http(s) 地址' });
|
|
895
|
+
try {
|
|
896
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
897
|
+
// 任何 HTTP 响应都说明网络可达;4xx 多为鉴权/路径问题(MCP 端点通常要求特定握手)
|
|
898
|
+
return json(res, 200, { status: r.status < 500 ? 'ok' : 'failed', detail: `HTTP ${r.status}${r.status >= 400 ? '(可达;鉴权或路径问题)' : ''}` });
|
|
899
|
+
}
|
|
900
|
+
catch (e) {
|
|
901
|
+
return json(res, 200, { status: 'unreachable', detail: e instanceof Error ? e.message : String(e) });
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// stdio:spawn 后短时观察——MCP server 正常行为是启动后等 stdin,存活即「可启动」
|
|
905
|
+
const command = typeof cfg.command === 'string' ? cfg.command : '';
|
|
906
|
+
if (!command)
|
|
907
|
+
return json(res, 200, { status: 'failed', detail: '缺少 command' });
|
|
908
|
+
const mergedEnv = currentMergedEnv();
|
|
909
|
+
const { resolved } = resolveEnvRefs(cfg.env, mergedEnv);
|
|
910
|
+
const child = spawn(command, Array.isArray(cfg.args) ? cfg.args.map(String) : [], {
|
|
911
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
912
|
+
// 探测环境与任务运行时对齐:合并源(含飞书应用 env / claude.env / settings env)垫底,
|
|
913
|
+
// server 自身 env 解析值覆盖(${VAR} 已按同一合并源展开)
|
|
914
|
+
env: { ...process.env, ...mergedEnv, ...resolved },
|
|
915
|
+
windowsHide: true,
|
|
916
|
+
});
|
|
917
|
+
let stderr = '';
|
|
918
|
+
child.stderr?.on('data', (c) => { if (stderr.length < 500)
|
|
919
|
+
stderr += c.toString('utf8'); });
|
|
920
|
+
const result = await new Promise((resolve) => {
|
|
921
|
+
const done = (status, detail) => { clearTimeout(killTimer); resolve({ status, detail }); };
|
|
922
|
+
const killTimer = setTimeout(() => {
|
|
923
|
+
child.kill();
|
|
924
|
+
done('ok', '进程存活(正常:MCP server 启动后等待 stdio 输入)');
|
|
925
|
+
}, 800);
|
|
926
|
+
child.on('error', (e) => done('failed', `无法启动:${e.message}`));
|
|
927
|
+
child.on('exit', (code) => done('failed', `进程退出(code ${code}):${stderr.split('\n')[0] || '无 stderr 输出'}`));
|
|
928
|
+
});
|
|
929
|
+
try {
|
|
930
|
+
child.kill();
|
|
931
|
+
}
|
|
932
|
+
catch { /* 已退出 */ }
|
|
933
|
+
return json(res, 200, result);
|
|
934
|
+
}
|
|
542
935
|
// ---- 桥接器进程启停(页面托管)。embedded 与 lcb ui 独立模式语义不同:embedded 停止/重启
|
|
543
936
|
// 会连带本页所在进程,先回响应再走 selfStop 优雅关闭;独立模式经 PID 文件跨进程操作 ----
|
|
544
937
|
if (path === '/api/bridge/action' && req.method === 'POST') {
|
|
@@ -555,7 +948,7 @@ async function handle(req, res, ctx) {
|
|
|
555
948
|
const r = spawnBridgeDetached();
|
|
556
949
|
if (!r.ok)
|
|
557
950
|
return json(res, 400, { error: r.error });
|
|
558
|
-
return json(res, 200, { ok: true, pid: r.pid, message: '桥接器已在后台启动(运行日志见 ~/.lark-claudecode-bridge/bridge.log)' });
|
|
951
|
+
return json(res, 200, { ok: true, pid: r.pid, message: '桥接器已在后台启动(运行日志见 ~/.lark-claudecode-bridge/logs/bridge-YYYY-MM-DD.log)' });
|
|
559
952
|
}
|
|
560
953
|
if (op === 'stop' || op === 'restart') {
|
|
561
954
|
if (ctx.embedded && !ctx.selfStop)
|