aicodeswitch 5.2.13 → 6.0.1

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.
@@ -32,8 +32,6 @@ const os_1 = __importDefault(require("os"));
32
32
  const auth_1 = require("./auth");
33
33
  const version_check_1 = require("./version-check");
34
34
  const utils_1 = require("./utils");
35
- const tools_service_1 = require("./tools-service");
36
- const websocket_service_1 = require("./websocket-service");
37
35
  const rules_status_service_1 = require("./rules-status-service");
38
36
  const type_migration_1 = require("./type-migration");
39
37
  const config_metadata_1 = require("./config-metadata");
@@ -185,6 +183,30 @@ function applyWriteLocalRecords(proxyServer) {
185
183
  auth.OPENAI_API_KEY = key.apiKey;
186
184
  (0, config_merge_1.atomicWriteFile)(authPath, JSON.stringify(auth, null, 2));
187
185
  }
186
+ else if (target === 'opencode') {
187
+ // 将真实 Key 写入 opencode.json 的 provider.aicodeswitch.options.apiKey
188
+ const opencodeConfigPath = (0, config_metadata_1.getOpencodeConfigPath)();
189
+ const opencodeDir = path_1.default.dirname(opencodeConfigPath);
190
+ if (!fs_1.default.existsSync(opencodeDir)) {
191
+ fs_1.default.mkdirSync(opencodeDir, { recursive: true });
192
+ }
193
+ let oc = {};
194
+ if (fs_1.default.existsSync(opencodeConfigPath)) {
195
+ try {
196
+ oc = JSON.parse(fs_1.default.readFileSync(opencodeConfigPath, 'utf-8'));
197
+ }
198
+ catch ( /* ignore */_c) { /* ignore */ }
199
+ }
200
+ if (!oc.provider)
201
+ oc.provider = {};
202
+ if (!oc.provider.aicodeswitch || typeof oc.provider.aicodeswitch !== 'object') {
203
+ oc.provider.aicodeswitch = { npm: '@ai-sdk/openai-compatible', name: 'AICodeSwitch', options: {} };
204
+ }
205
+ if (!oc.provider.aicodeswitch.options)
206
+ oc.provider.aicodeswitch.options = {};
207
+ oc.provider.aicodeswitch.options.apiKey = key.apiKey;
208
+ (0, config_merge_1.atomicWriteFile)(opencodeConfigPath, JSON.stringify(oc, null, 2));
209
+ }
188
210
  }
189
211
  catch (error) {
190
212
  console.error(`[WriteLocal] Failed to apply key ${record.accessKeyId} to ${target}:`, error);
@@ -664,6 +686,142 @@ const restoreCodexConfig = () => __awaiter(void 0, void 0, void 0, function* ()
664
686
  return false;
665
687
  }
666
688
  });
689
+ /**
690
+ * 默认 OpenCode 模型(当用户未配置 opencodeDefaultModel 时使用)
691
+ */
692
+ const DEFAULT_OPENCODE_MODEL = 'claude-sonnet-4-20250514';
693
+ /**
694
+ * 写入 OpenCode 配置(~/.config/opencode/opencode.json)
695
+ *
696
+ * 注入一个自定义 provider `aicodeswitch`,经 @ai-sdk/openai-compatible 指向本代理
697
+ * 的 /opencode/v1 端点(OpenAI Chat Completions 格式)。仅托管 provider.aicodeswitch
698
+ * 段与 model/small_model/mcp 字段,其余用户配置(其它 provider、agent、command 等)保留。
699
+ */
700
+ const writeOpencodeConfig = (_dbManager_1, defaultModel_1, ...args_1) => __awaiter(void 0, [_dbManager_1, defaultModel_1, ...args_1], void 0, function* (_dbManager, defaultModel, options = {}) {
701
+ var _a;
702
+ try {
703
+ const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 4567;
704
+ const configPath = (0, config_metadata_1.getOpencodeConfigPath)();
705
+ const configBakPath = `${configPath}.aicodeswitch_backup`;
706
+ const configDir = path_1.default.dirname(configPath);
707
+ const configStatus = (0, config_metadata_1.checkOpencodeConfigStatus)();
708
+ const isRuntimeRefresh = options.allowOverwriteRefresh === true && configStatus.isOverwritten;
709
+ if (configStatus.isOverwritten && !isRuntimeRefresh) {
710
+ console.error('OpenCode config has already been overwritten. Please restore the original config first.');
711
+ return false;
712
+ }
713
+ let originalHash = isRuntimeRefresh
714
+ ? (_a = configStatus.metadata) === null || _a === void 0 ? void 0 : _a.originalHash
715
+ : undefined;
716
+ if (!isRuntimeRefresh) {
717
+ if (!fs_1.default.existsSync(configBakPath)) {
718
+ if (fs_1.default.existsSync(configPath)) {
719
+ originalHash = (0, crypto_1.createHash)('sha256').update(fs_1.default.readFileSync(configPath, 'utf-8')).digest('hex');
720
+ fs_1.default.renameSync(configPath, configBakPath);
721
+ }
722
+ }
723
+ else {
724
+ console.log('OpenCode backup file already exists, skipping backup step');
725
+ }
726
+ }
727
+ if (!fs_1.default.existsSync(configDir)) {
728
+ fs_1.default.mkdirSync(configDir, { recursive: true });
729
+ }
730
+ // 读取当前配置(保留用户其它 provider/agent/command 等)
731
+ let currentConfig = {};
732
+ if (fs_1.default.existsSync(configPath)) {
733
+ try {
734
+ currentConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
735
+ }
736
+ catch (error) {
737
+ console.warn('Failed to parse current opencode.json, using empty object:', error);
738
+ }
739
+ }
740
+ const model = (defaultModel && typeof defaultModel === 'string' && defaultModel.trim())
741
+ ? defaultModel.trim()
742
+ : DEFAULT_OPENCODE_MODEL;
743
+ // 构建代理配置
744
+ const proxyConfig = {
745
+ provider: {
746
+ aicodeswitch: {
747
+ npm: '@ai-sdk/openai-compatible',
748
+ name: 'AICodeSwitch',
749
+ options: {
750
+ baseURL: `http://${clientHost}:${port}/opencode/v1`,
751
+ apiKey: 'api_key'
752
+ },
753
+ models: {
754
+ [model]: { name: model }
755
+ }
756
+ }
757
+ },
758
+ model: `aicodeswitch/${model}`
759
+ };
760
+ const mergedConfig = (0, config_merge_1.mergeJsonConfig)(proxyConfig, currentConfig, config_managed_fields_1.OPENCODE_CONFIG_MANAGED_FIELDS);
761
+ (0, config_merge_1.atomicWriteFile)(configPath, JSON.stringify(mergedConfig, null, 2));
762
+ // 保存元数据
763
+ const currentHash = (0, crypto_1.createHash)('sha256').update(fs_1.default.readFileSync(configPath, 'utf-8')).digest('hex');
764
+ const metadata = {
765
+ configType: 'opencode',
766
+ timestamp: Date.now(),
767
+ originalHash,
768
+ proxyMarker: `http://${host}:${port}/opencode`,
769
+ files: [
770
+ {
771
+ originalPath: configPath,
772
+ backupPath: configBakPath,
773
+ currentHash
774
+ }
775
+ ]
776
+ };
777
+ (0, config_metadata_1.saveMetadata)(metadata);
778
+ return true;
779
+ }
780
+ catch (error) {
781
+ console.error('Failed to write OpenCode config file:', error);
782
+ return false;
783
+ }
784
+ });
785
+ const restoreOpencodeConfig = () => __awaiter(void 0, void 0, void 0, function* () {
786
+ try {
787
+ const configPath = (0, config_metadata_1.getOpencodeConfigPath)();
788
+ const configBakPath = `${configPath}.aicodeswitch_backup`;
789
+ let restoredAnyFile = false;
790
+ if (fs_1.default.existsSync(configBakPath)) {
791
+ const backupConfig = JSON.parse(fs_1.default.readFileSync(configBakPath, 'utf-8'));
792
+ let currentConfig = {};
793
+ if (fs_1.default.existsSync(configPath)) {
794
+ try {
795
+ currentConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
796
+ }
797
+ catch (error) {
798
+ console.warn('Failed to parse current opencode.json during restore, using empty object:', error);
799
+ }
800
+ }
801
+ // 备份作为基础,合并当前的非管理字段
802
+ const mergedConfig = (0, config_merge_1.mergeJsonConfig)(backupConfig, currentConfig, config_managed_fields_1.OPENCODE_CONFIG_MANAGED_FIELDS);
803
+ (0, config_merge_1.atomicWriteFile)(configPath, JSON.stringify(mergedConfig, null, 2));
804
+ fs_1.default.unlinkSync(configBakPath);
805
+ restoredAnyFile = true;
806
+ }
807
+ (0, config_metadata_1.deleteMetadata)('opencode');
808
+ return restoredAnyFile;
809
+ }
810
+ catch (error) {
811
+ console.error('Failed to restore OpenCode config file:', error);
812
+ return false;
813
+ }
814
+ });
815
+ const checkOpencodeBackupExists = () => {
816
+ try {
817
+ const status = (0, config_metadata_1.checkOpencodeConfigStatus)();
818
+ return status.hasBackup;
819
+ }
820
+ catch (error) {
821
+ console.error('Failed to check OpenCode backup:', error);
822
+ return false;
823
+ }
824
+ };
667
825
  const checkClaudeBackupExists = () => {
668
826
  try {
669
827
  // 清理可能的无效元数据
@@ -705,6 +863,8 @@ const syncConfigsOnServerStartup = (dbManager) => __awaiter(void 0, void 0, void
705
863
  : DEFAULT_CODEX_REASONING_EFFORT;
706
864
  const codexWritten = yield writeCodexConfig(dbManager, modelReasoningEffort, config.codexDefaultModel, config.codexEnableMemories);
707
865
  console.log(`[Startup Config Sync] Codex config ${codexWritten ? 'written' : 'skipped'}`);
866
+ const opencodeWritten = yield writeOpencodeConfig(dbManager, config.opencodeDefaultModel);
867
+ console.log(`[Startup Config Sync] OpenCode config ${opencodeWritten ? 'written' : 'skipped'}`);
708
868
  });
709
869
  const syncConfigsOnGlobalConfigUpdate = (dbManager) => __awaiter(void 0, void 0, void 0, function* () {
710
870
  const config = dbManager.getConfig();
@@ -718,6 +878,8 @@ const syncConfigsOnGlobalConfigUpdate = (dbManager) => __awaiter(void 0, void 0,
718
878
  : DEFAULT_CODEX_REASONING_EFFORT;
719
879
  const codexUpdated = yield writeCodexConfig(dbManager, modelReasoningEffort, config.codexDefaultModel, config.codexEnableMemories, { allowOverwriteRefresh: true });
720
880
  console.log(`[Config Update Sync] Codex config ${codexUpdated ? 'written' : 'skipped'}`);
881
+ const opencodeUpdated = yield writeOpencodeConfig(dbManager, config.opencodeDefaultModel, { allowOverwriteRefresh: true });
882
+ console.log(`[Config Update Sync] OpenCode config ${opencodeUpdated ? 'written' : 'skipped'}`);
721
883
  });
722
884
  const getCentralSkillsDir = () => {
723
885
  return path_1.default.join(os_1.default.homedir(), '.aicodeswitch', 'skills');
@@ -736,12 +898,20 @@ function getSkillDirByName(name) {
736
898
  return path_1.default.join(centralDir, sanitizedName);
737
899
  }
738
900
  const getSkillSymlinkPath = (skillId, targetType) => {
901
+ if (targetType === 'opencode') {
902
+ // OpenCode 没有 skills 目录,映射为全局 command:~/.config/opencode/commands/<skillId>.md
903
+ return path_1.default.join(os_1.default.homedir(), '.config', 'opencode', 'commands', `${skillId}.md`);
904
+ }
739
905
  const baseDir = targetType === 'claude-code' ? '.claude' : '.codex';
740
906
  return path_1.default.join(os_1.default.homedir(), baseDir, 'skills', skillId);
741
907
  };
742
908
  function isSkillSymlinkExists(skillId, targetType) {
743
909
  const symlinkPath = getSkillSymlinkPath(skillId, targetType);
744
910
  try {
911
+ if (targetType === 'opencode') {
912
+ // OpenCode 是普通文件,不是 symlink
913
+ return fs_1.default.existsSync(symlinkPath);
914
+ }
745
915
  const stats = fs_1.default.lstatSync(symlinkPath);
746
916
  return stats.isSymbolicLink();
747
917
  }
@@ -749,15 +919,77 @@ function isSkillSymlinkExists(skillId, targetType) {
749
919
  return false;
750
920
  }
751
921
  }
922
+ /**
923
+ * 从 SKILL.md 内容中剥离前导 YAML frontmatter,返回 { description?, body }
924
+ * 没有 frontmatter 时,description 为 undefined,body 为原文。
925
+ */
926
+ function parseSkillMd(skillMdContent) {
927
+ const trimmed = skillMdContent.replace(/^/, '');
928
+ const fmMatch = trimmed.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
929
+ if (!fmMatch) {
930
+ return { body: trimmed };
931
+ }
932
+ const frontmatter = fmMatch[1];
933
+ const body = fmMatch[2];
934
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
935
+ return {
936
+ description: descMatch ? descMatch[1].replace(/^["']|["']$/g, '').trim() : undefined,
937
+ body: body.trim(),
938
+ };
939
+ }
940
+ /**
941
+ * 为 OpenCode 生成 command markdown 文件内容。
942
+ * frontmatter 用 description(OpenCode 在 TUI 展示),正文用 SKILL.md 的 body。
943
+ */
944
+ function buildOpencodeSkillCommandMarkdown(skillDir, skillId, fallbackDescription) {
945
+ let description = fallbackDescription || '';
946
+ let body = '';
947
+ const skillMdPath = path_1.default.join(skillDir, 'SKILL.md');
948
+ if (fs_1.default.existsSync(skillMdPath)) {
949
+ try {
950
+ const parsed = parseSkillMd(fs_1.default.readFileSync(skillMdPath, 'utf-8'));
951
+ body = parsed.body || '';
952
+ if (!description && parsed.description) {
953
+ description = parsed.description;
954
+ }
955
+ }
956
+ catch ( /* ignore */_a) { /* ignore */ }
957
+ }
958
+ if (!description) {
959
+ description = skillId;
960
+ }
961
+ const bodySection = body ? `\n${body}\n` : '\n';
962
+ return `---\ndescription: ${description.replace(/\n/g, ' ')}\nagent: build\n---\n${bodySection}`;
963
+ }
752
964
  function createSkillSymlink(skillId, targetType) {
753
965
  return __awaiter(this, void 0, void 0, function* () {
966
+ var _a;
754
967
  try {
755
968
  const centralDir = getCentralSkillsDir();
756
969
  const skillDir = path_1.default.join(centralDir, skillId);
757
- const symlinkPath = getSkillSymlinkPath(skillId, targetType);
758
970
  if (!fs_1.default.existsSync(skillDir)) {
759
971
  return { success: false, error: 'Skill目录不存在' };
760
972
  }
973
+ // OpenCode:生成 command markdown 写入 ~/.config/opencode/commands/<skillId>.md
974
+ if (targetType === 'opencode') {
975
+ const commandPath = getSkillSymlinkPath(skillId, targetType);
976
+ const commandDir = path_1.default.dirname(commandPath);
977
+ if (!fs_1.default.existsSync(commandDir)) {
978
+ fs_1.default.mkdirSync(commandDir, { recursive: true });
979
+ }
980
+ let fallbackDescription;
981
+ const metadataPath = path_1.default.join(skillDir, 'skill.json');
982
+ if (fs_1.default.existsSync(metadataPath)) {
983
+ try {
984
+ fallbackDescription = (_a = JSON.parse(fs_1.default.readFileSync(metadataPath, 'utf-8'))) === null || _a === void 0 ? void 0 : _a.description;
985
+ }
986
+ catch ( /* ignore */_b) { /* ignore */ }
987
+ }
988
+ const content = buildOpencodeSkillCommandMarkdown(skillDir, skillId, fallbackDescription);
989
+ (0, config_merge_1.atomicWriteFile)(commandPath, content);
990
+ return { success: true };
991
+ }
992
+ const symlinkPath = getSkillSymlinkPath(skillId, targetType);
761
993
  const targetBaseDir = path_1.default.dirname(symlinkPath);
762
994
  if (!fs_1.default.existsSync(targetBaseDir)) {
763
995
  fs_1.default.mkdirSync(targetBaseDir, { recursive: true });
@@ -788,6 +1020,13 @@ function removeSkillSymlink(skillId, targetType) {
788
1020
  return __awaiter(this, void 0, void 0, function* () {
789
1021
  try {
790
1022
  const symlinkPath = getSkillSymlinkPath(skillId, targetType);
1023
+ if (targetType === 'opencode') {
1024
+ // OpenCode:删除 command 文件
1025
+ if (fs_1.default.existsSync(symlinkPath)) {
1026
+ fs_1.default.unlinkSync(symlinkPath);
1027
+ }
1028
+ return { success: true };
1029
+ }
791
1030
  if (!fs_1.default.existsSync(symlinkPath)) {
792
1031
  return { success: true };
793
1032
  }
@@ -1046,7 +1285,7 @@ const listInstalledSkills = () => {
1046
1285
  try {
1047
1286
  const metadata = JSON.parse(fs_1.default.readFileSync(metadataPath, 'utf-8'));
1048
1287
  const enabledTargets = [];
1049
- ['claude-code', 'codex'].forEach((targetType) => {
1288
+ ['claude-code', 'codex', 'opencode'].forEach((targetType) => {
1050
1289
  if (isSkillSymlinkExists(skillId, targetType)) {
1051
1290
  enabledTargets.push(targetType);
1052
1291
  }
@@ -1198,7 +1437,7 @@ const registerRoutes = (dbManager, proxyServer) => __awaiter(void 0, void 0, voi
1198
1437
  if (!tool || !routeId) {
1199
1438
  return res.status(400).json({ error: 'tool and routeId are required' });
1200
1439
  }
1201
- if (tool !== 'claude-code' && tool !== 'codex') {
1440
+ if (tool !== 'claude-code' && tool !== 'codex' && tool !== 'opencode') {
1202
1441
  return res.status(400).json({ error: 'Invalid tool name' });
1203
1442
  }
1204
1443
  const route = dbManager.getRoute(routeId);
@@ -1219,7 +1458,7 @@ const registerRoutes = (dbManager, proxyServer) => __awaiter(void 0, void 0, voi
1219
1458
  })));
1220
1459
  app.post('/api/tool-bindings/deactivate', asyncHandler((req, res) => __awaiter(void 0, void 0, void 0, function* () {
1221
1460
  const { tool } = req.body;
1222
- if (!tool || (tool !== 'claude-code' && tool !== 'codex')) {
1461
+ if (!tool || (tool !== 'claude-code' && tool !== 'codex' && tool !== 'opencode')) {
1223
1462
  return res.status(400).json({ error: 'Invalid tool name' });
1224
1463
  }
1225
1464
  const result = yield dbManager.deactivateToolRoute(tool);
@@ -2007,7 +2246,7 @@ ${instruction}
2007
2246
  app.post('/api/skills/:skillId/enable', asyncHandler((req, res) => __awaiter(void 0, void 0, void 0, function* () {
2008
2247
  const { skillId } = req.params;
2009
2248
  const { targetType } = req.body;
2010
- if (!targetType || (targetType !== 'claude-code' && targetType !== 'codex')) {
2249
+ if (!targetType || (targetType !== 'claude-code' && targetType !== 'codex' && targetType !== 'opencode')) {
2011
2250
  res.status(400).json({ success: false, error: '无效的目标类型' });
2012
2251
  return;
2013
2252
  }
@@ -2038,7 +2277,7 @@ ${instruction}
2038
2277
  app.post('/api/skills/:skillId/disable', asyncHandler((req, res) => __awaiter(void 0, void 0, void 0, function* () {
2039
2278
  const { skillId } = req.params;
2040
2279
  const { targetType } = req.body;
2041
- if (!targetType || (targetType !== 'claude-code' && targetType !== 'codex')) {
2280
+ if (!targetType || (targetType !== 'claude-code' && targetType !== 'codex' && targetType !== 'opencode')) {
2042
2281
  res.status(400).json({ success: false, error: '无效的目标类型' });
2043
2282
  return;
2044
2283
  }
@@ -2067,7 +2306,7 @@ ${instruction}
2067
2306
  res.status(404).json({ success: false, error: 'Skill不存在' });
2068
2307
  return;
2069
2308
  }
2070
- ['claude-code', 'codex'].forEach((targetType) => __awaiter(void 0, void 0, void 0, function* () {
2309
+ ['claude-code', 'codex', 'opencode'].forEach((targetType) => __awaiter(void 0, void 0, void 0, function* () {
2071
2310
  yield removeSkillSymlink(skillId, targetType);
2072
2311
  }));
2073
2312
  fs_1.default.rmSync(skillDir, { recursive: true, force: true });
@@ -2109,6 +2348,15 @@ ${instruction}
2109
2348
  applyWriteLocalRecords(proxyServer);
2110
2349
  res.json(result);
2111
2350
  })));
2351
+ app.post('/api/write-config/opencode', asyncHandler((req, res) => __awaiter(void 0, void 0, void 0, function* () {
2352
+ var _a;
2353
+ const appConfig = dbManager.getConfig();
2354
+ const requestedModel = typeof ((_a = req.body) === null || _a === void 0 ? void 0 : _a.defaultModel) === 'string' ? req.body.defaultModel : undefined;
2355
+ const defaultModel = requestedModel || appConfig.opencodeDefaultModel;
2356
+ const result = yield writeOpencodeConfig(dbManager, defaultModel);
2357
+ applyWriteLocalRecords(proxyServer);
2358
+ res.json(result);
2359
+ })));
2112
2360
  // 兼容接口:更新全局 Agent Teams 配置
2113
2361
  app.post('/api/update-claude-agent-teams', asyncHandler((req, res) => __awaiter(void 0, void 0, void 0, function* () {
2114
2362
  const { enableAgentTeams } = req.body;
@@ -2162,12 +2410,19 @@ ${instruction}
2162
2410
  const result = yield restoreCodexConfig();
2163
2411
  res.json(result);
2164
2412
  })));
2413
+ app.post('/api/restore-config/opencode', asyncHandler((_req, res) => __awaiter(void 0, void 0, void 0, function* () {
2414
+ const result = yield restoreOpencodeConfig();
2415
+ res.json(result);
2416
+ })));
2165
2417
  app.get('/api/check-backup/claude', (_req, res) => {
2166
2418
  res.json({ exists: checkClaudeBackupExists() });
2167
2419
  });
2168
2420
  app.get('/api/check-backup/codex', (_req, res) => {
2169
2421
  res.json({ exists: checkCodexBackupExists() });
2170
2422
  });
2423
+ app.get('/api/check-backup/opencode', (_req, res) => {
2424
+ res.json({ exists: checkOpencodeBackupExists() });
2425
+ });
2171
2426
  // 新的详细配置状态 API 端点
2172
2427
  app.get('/api/config-status/claude', (_req, res) => {
2173
2428
  const status = (0, config_metadata_1.checkClaudeConfigStatus)();
@@ -2177,6 +2432,10 @@ ${instruction}
2177
2432
  const status = (0, config_metadata_1.checkCodexConfigStatus)();
2178
2433
  res.json(status);
2179
2434
  });
2435
+ app.get('/api/config-status/opencode', (_req, res) => {
2436
+ const status = (0, config_metadata_1.checkOpencodeConfigStatus)();
2437
+ res.json(status);
2438
+ });
2180
2439
  // API 路径路由映射
2181
2440
  app.get('/api/api-path-bindings', (_req, res) => {
2182
2441
  res.json({ bindings: dbManager.getApiPathBindings(), models: dbManager.getApiPathModels() });
@@ -2514,19 +2773,6 @@ ${instruction}
2514
2773
  res.json({ success: false });
2515
2774
  }
2516
2775
  })));
2517
- // 工具安装检测相关路由
2518
- app.get('/api/tools/status', asyncHandler((_req, res) => __awaiter(void 0, void 0, void 0, function* () {
2519
- console.log('[API] GET /api/tools/status - 获取工具安装状态');
2520
- try {
2521
- const status = yield (0, tools_service_1.getToolsInstallationStatus)();
2522
- console.log('[API] 工具安装状态:', status);
2523
- res.json(status);
2524
- }
2525
- catch (error) {
2526
- console.error('[API] 获取工具状态失败:', error);
2527
- res.status(500).json({ error: '获取工具状态失败' });
2528
- }
2529
- })));
2530
2776
  // MCP 工具管理相关路由
2531
2777
  app.get('/api/mcps', (_req, res) => {
2532
2778
  res.json(dbManager.getMCPs());
@@ -2954,6 +3200,31 @@ ${instruction}
2954
3200
  (0, config_merge_1.atomicWriteFile)(authPath, JSON.stringify(auth, null, 2));
2955
3201
  results['codex'] = true;
2956
3202
  }
3203
+ else if (target === 'opencode') {
3204
+ // 写入 ~/.config/opencode/opencode.json 的 provider.aicodeswitch.options.apiKey
3205
+ const opencodeConfigPath = (0, config_metadata_1.getOpencodeConfigPath)();
3206
+ const opencodeDir = path_1.default.dirname(opencodeConfigPath);
3207
+ if (!fs_1.default.existsSync(opencodeDir)) {
3208
+ fs_1.default.mkdirSync(opencodeDir, { recursive: true });
3209
+ }
3210
+ let oc = {};
3211
+ if (fs_1.default.existsSync(opencodeConfigPath)) {
3212
+ try {
3213
+ oc = JSON.parse(fs_1.default.readFileSync(opencodeConfigPath, 'utf-8'));
3214
+ }
3215
+ catch ( /* ignore */_c) { /* ignore */ }
3216
+ }
3217
+ if (!oc.provider)
3218
+ oc.provider = {};
3219
+ if (!oc.provider.aicodeswitch || typeof oc.provider.aicodeswitch !== 'object') {
3220
+ oc.provider.aicodeswitch = { npm: '@ai-sdk/openai-compatible', name: 'AICodeSwitch', options: {} };
3221
+ }
3222
+ if (!oc.provider.aicodeswitch.options)
3223
+ oc.provider.aicodeswitch.options = {};
3224
+ oc.provider.aicodeswitch.options.apiKey = key.apiKey;
3225
+ (0, config_merge_1.atomicWriteFile)(opencodeConfigPath, JSON.stringify(oc, null, 2));
3226
+ results['opencode'] = true;
3227
+ }
2957
3228
  }
2958
3229
  catch (error) {
2959
3230
  console.error(`Failed to write local config for ${target}:`, error);
@@ -3334,6 +3605,76 @@ ${instruction}
3334
3605
  console.log(`[MCP] Codex MCP config written: ${writtenMcpIds.length} server(s)`);
3335
3606
  return true;
3336
3607
  }
3608
+ else if (targetType === 'opencode') {
3609
+ // OpenCode 使用 JSON 格式的 opencode.json,MCP 配置位于 mcp 段
3610
+ // 格式:local → { type:"local", command:[...], enabled:true, env? }
3611
+ // remote → { type:"remote", url, enabled:true, headers? }
3612
+ const opencodeConfigPath = (0, config_metadata_1.getOpencodeConfigPath)();
3613
+ const opencodeDir = path_1.default.dirname(opencodeConfigPath);
3614
+ if (!fs_1.default.existsSync(opencodeDir)) {
3615
+ fs_1.default.mkdirSync(opencodeDir, { recursive: true });
3616
+ }
3617
+ let currentConfig = {};
3618
+ if (fs_1.default.existsSync(opencodeConfigPath)) {
3619
+ try {
3620
+ currentConfig = JSON.parse(fs_1.default.readFileSync(opencodeConfigPath, 'utf-8'));
3621
+ }
3622
+ catch (error) {
3623
+ console.warn('[MCP] Failed to parse opencode.json:', error);
3624
+ }
3625
+ }
3626
+ // 清除代理上次写入的 mcp 条目(通过 metadata 追踪,避免误删用户自配的 mcp)
3627
+ const mcpMetaPath = path_1.default.join(opencodeDir, '.aicodeswitch_mcp_servers.json');
3628
+ let previousMcpIds = [];
3629
+ if (fs_1.default.existsSync(mcpMetaPath)) {
3630
+ try {
3631
+ previousMcpIds = JSON.parse(fs_1.default.readFileSync(mcpMetaPath, 'utf8'));
3632
+ for (const id of previousMcpIds) {
3633
+ if (currentConfig.mcp && currentConfig.mcp[id]) {
3634
+ delete currentConfig.mcp[id];
3635
+ }
3636
+ }
3637
+ }
3638
+ catch (_b) {
3639
+ // ignore
3640
+ }
3641
+ }
3642
+ if (!currentConfig.mcp) {
3643
+ currentConfig.mcp = {};
3644
+ }
3645
+ const writtenMcpIds = [];
3646
+ for (const mcp of mcps) {
3647
+ const mcpConfig = { enabled: true };
3648
+ if (mcp.type === 'stdio') {
3649
+ mcpConfig.type = 'local';
3650
+ const cmdParts = [mcp.command || ''];
3651
+ if (Array.isArray(mcp.args)) {
3652
+ cmdParts.push(...mcp.args);
3653
+ }
3654
+ mcpConfig.command = cmdParts.filter((c, i) => i === 0 ? c !== '' : true);
3655
+ if (mcp.env && Object.keys(mcp.env).length > 0) {
3656
+ mcpConfig.environment = Object.assign({}, mcp.env);
3657
+ }
3658
+ }
3659
+ else {
3660
+ // http / sse 均使用 remote 类型
3661
+ mcpConfig.type = 'remote';
3662
+ mcpConfig.url = mcp.url || '';
3663
+ if (mcp.headers && Object.keys(mcp.headers).length > 0) {
3664
+ mcpConfig.headers = Object.assign({}, mcp.headers);
3665
+ }
3666
+ }
3667
+ currentConfig.mcp[mcp.id] = mcpConfig;
3668
+ writtenMcpIds.push(mcp.id);
3669
+ }
3670
+ if (Object.keys(currentConfig.mcp).length === 0) {
3671
+ delete currentConfig.mcp;
3672
+ }
3673
+ (0, config_merge_1.atomicWriteFile)(opencodeConfigPath, JSON.stringify(currentConfig, null, 2));
3674
+ fs_1.default.writeFileSync(mcpMetaPath, JSON.stringify(writtenMcpIds, null, 2));
3675
+ console.log(`[MCP] OpenCode MCP config written: ${writtenMcpIds.length} server(s)`);
3676
+ return true;
3677
+ }
3337
3678
  return false;
3338
3679
  }
3339
3680
  catch (error) {
@@ -3396,6 +3737,41 @@ ${instruction}
3396
3737
  }
3397
3738
  return true;
3398
3739
  }
3740
+ else if (targetType === 'opencode') {
3741
+ const opencodeConfigPath = (0, config_metadata_1.getOpencodeConfigPath)();
3742
+ if (!fs_1.default.existsSync(opencodeConfigPath)) {
3743
+ return true;
3744
+ }
3745
+ let currentConfig = {};
3746
+ try {
3747
+ currentConfig = JSON.parse(fs_1.default.readFileSync(opencodeConfigPath, 'utf-8'));
3748
+ }
3749
+ catch (error) {
3750
+ console.warn('[MCP] Failed to parse opencode.json for removal:', error);
3751
+ return false;
3752
+ }
3753
+ if (currentConfig.mcp && currentConfig.mcp[mcpId]) {
3754
+ delete currentConfig.mcp[mcpId];
3755
+ if (Object.keys(currentConfig.mcp).length === 0) {
3756
+ delete currentConfig.mcp;
3757
+ }
3758
+ (0, config_merge_1.atomicWriteFile)(opencodeConfigPath, JSON.stringify(currentConfig, null, 2));
3759
+ const opencodeDir = path_1.default.dirname(opencodeConfigPath);
3760
+ const mcpMetaPath = path_1.default.join(opencodeDir, '.aicodeswitch_mcp_servers.json');
3761
+ if (fs_1.default.existsSync(mcpMetaPath)) {
3762
+ try {
3763
+ const previousIds = JSON.parse(fs_1.default.readFileSync(mcpMetaPath, 'utf8'));
3764
+ const updatedIds = previousIds.filter(id => id !== mcpId);
3765
+ fs_1.default.writeFileSync(mcpMetaPath, JSON.stringify(updatedIds, null, 2));
3766
+ }
3767
+ catch (_b) {
3768
+ // ignore
3769
+ }
3770
+ }
3771
+ console.log(`[MCP] Removed MCP ${mcpId} from OpenCode config`);
3772
+ }
3773
+ return true;
3774
+ }
3399
3775
  return false;
3400
3776
  }
3401
3777
  catch (error) {
@@ -3563,26 +3939,12 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3563
3939
  }
3564
3940
  setImmediate(() => process.exit(1));
3565
3941
  });
3566
- // 创建 WebSocket 服务器用于工具安装
3567
- const toolInstallWss = (0, websocket_service_1.createToolInstallationWSServer)();
3568
3942
  // 设置黑名单检查函数,用于在规则状态同步时检查黑名单是否已过期
3569
3943
  rules_status_service_1.rulesStatusBroadcaster.setBlacklistChecker((serviceId, routeId, contentType) => __awaiter(void 0, void 0, void 0, function* () {
3570
3944
  // 检查服务��否在黑名单中
3571
3945
  const isBlacklisted = yield dbManager.isServiceBlacklisted(serviceId, routeId, contentType);
3572
3946
  return isBlacklisted;
3573
3947
  }));
3574
- // 将 WebSocket 服务器附加到 HTTP 服务器(仅用于工具安装)
3575
- server.on('upgrade', (request, socket, head) => {
3576
- if (request.url === '/api/tools/install') {
3577
- toolInstallWss.handleUpgrade(request, socket, head, (ws) => {
3578
- toolInstallWss.emit('connection', ws, request);
3579
- });
3580
- }
3581
- else {
3582
- socket.destroy();
3583
- }
3584
- });
3585
- console.log(`WebSocket server for tool installation attached to ws://${clientHost}:${port}/api/tools/install`);
3586
3948
  let isShuttingDown = false;
3587
3949
  let shutdownPromise = null;
3588
3950
  const shutdown = (signal) => __awaiter(void 0, void 0, void 0, function* () {
@@ -3594,10 +3956,16 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3594
3956
  console.log(`[Server] Received ${signal}, shutting down...`);
3595
3957
  // 立即停止监听以释放端口(同步关闭监听句柄,端口马上可用),
3596
3958
  // 避免在漫长的清理流程期间端口仍被占用,导致此时重启产生 EADDRINUSE 冲突。
3597
- // 现有连接会继续处理直到完成或超时;其回调与下面的清理流程并行等待。
3598
3959
  const serverClosedPromise = new Promise((resolve) => {
3599
3960
  server.close(() => resolve());
3600
3961
  });
3962
+ // 强制断开所有现存连接(含浏览器长连的 SSE:agent-map stream、rules-status
3963
+ // 广播等)。否则这些连接永不自行关闭,server.close 的回调要等满下面 5s 超时
3964
+ // 才触发,表现为 Ctrl+C 后「卡很久才打印 Server stopped.」。closeAllConnections
3965
+ // 立即销毁所有 socket,让 close 回调几乎瞬时完成。
3966
+ if (typeof server.closeAllConnections === 'function') {
3967
+ server.closeAllConnections();
3968
+ }
3601
3969
  // 服务终止前恢复配置文件(适用于 aicos stop 与 Ctrl+C)
3602
3970
  try {
3603
3971
  const claudeRestored = yield restoreClaudeConfig();
@@ -3613,6 +3981,13 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3613
3981
  catch (error) {
3614
3982
  console.error('[Shutdown ...] Failed to restore Codex config:', error);
3615
3983
  }
3984
+ try {
3985
+ const opencodeRestored = yield restoreOpencodeConfig();
3986
+ console.log(`[Shutdown ...] OpenCode config ${opencodeRestored ? 'restored' : 'was not modified'}`);
3987
+ }
3988
+ catch (error) {
3989
+ console.error('[Shutdown ...] Failed to restore OpenCode config:', error);
3990
+ }
3616
3991
  // Shutdown AccessKey module
3617
3992
  try {
3618
3993
  yield accessKeyModule.shutdown();