coding-tool-x 3.3.7 → 3.3.9

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +253 -326
  3. package/dist/web/assets/{Analytics-IW6eAy9u.js → Analytics-D6LzK9hk.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-BPtkTMSc.js → ConfigTemplates-BUDYuxRi.js} +1 -1
  5. package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
  6. package/dist/web/assets/Home-D7KX7iF8.js +1 -0
  7. package/dist/web/assets/{PluginManager-BGx9MSDV.js → PluginManager-DTgQ--vB.js} +1 -1
  8. package/dist/web/assets/{ProjectList-BCn-mrCx.js → ProjectList-DMCiGmCT.js} +1 -1
  9. package/dist/web/assets/{SessionList-CzLfebJQ.js → SessionList-CRBsdVRe.js} +1 -1
  10. package/dist/web/assets/{SkillManager-CXz2vBQx.js → SkillManager-DMwx2Q4k.js} +1 -1
  11. package/dist/web/assets/{WorkspaceManager-CHtgMfKc.js → WorkspaceManager-DapB4ljL.js} +1 -1
  12. package/dist/web/assets/{icons-B29onFfZ.js → icons-B5Pl4lrD.js} +1 -1
  13. package/dist/web/assets/index-CL-qpoJ_.js +2 -0
  14. package/dist/web/assets/index-D_5dRFOL.css +1 -0
  15. package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
  16. package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
  17. package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
  18. package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
  19. package/dist/web/index.html +7 -7
  20. package/docs/home.png +0 -0
  21. package/package.json +14 -5
  22. package/src/commands/daemon.js +3 -2
  23. package/src/commands/security.js +1 -2
  24. package/src/commands/toggle-proxy.js +100 -5
  25. package/src/config/paths.js +718 -90
  26. package/src/server/api/agents.js +1 -1
  27. package/src/server/api/channels.js +9 -0
  28. package/src/server/api/claude-hooks.js +13 -8
  29. package/src/server/api/codex-channels.js +9 -0
  30. package/src/server/api/codex-proxy.js +27 -15
  31. package/src/server/api/gemini-proxy.js +22 -11
  32. package/src/server/api/hooks.js +45 -0
  33. package/src/server/api/oauth-credentials.js +163 -0
  34. package/src/server/api/opencode-proxy.js +22 -10
  35. package/src/server/api/plugins.js +2 -1
  36. package/src/server/api/proxy.js +39 -44
  37. package/src/server/api/skills.js +91 -13
  38. package/src/server/api/ui-config.js +5 -0
  39. package/src/server/codex-proxy-server.js +90 -70
  40. package/src/server/gemini-proxy-server.js +107 -88
  41. package/src/server/index.js +2 -0
  42. package/src/server/opencode-proxy-server.js +381 -225
  43. package/src/server/proxy-server.js +86 -60
  44. package/src/server/services/alias.js +3 -3
  45. package/src/server/services/channels.js +21 -24
  46. package/src/server/services/codex-channels.js +158 -255
  47. package/src/server/services/codex-config.js +2 -5
  48. package/src/server/services/codex-env-manager.js +423 -0
  49. package/src/server/services/codex-settings-manager.js +21 -357
  50. package/src/server/services/codex-statistics-service.js +3 -27
  51. package/src/server/services/config-export-service.js +43 -9
  52. package/src/server/services/config-registry-service.js +3 -2
  53. package/src/server/services/config-sync-manager.js +1 -1
  54. package/src/server/services/favorites.js +4 -3
  55. package/src/server/services/gemini-channels.js +14 -12
  56. package/src/server/services/gemini-statistics-service.js +3 -25
  57. package/src/server/services/mcp-service.js +35 -19
  58. package/src/server/services/model-detector.js +4 -3
  59. package/src/server/services/native-keychain.js +243 -0
  60. package/src/server/services/native-oauth-adapters.js +891 -0
  61. package/src/server/services/network-access.js +39 -1
  62. package/src/server/services/notification-hooks.js +951 -0
  63. package/src/server/services/oauth-credentials-service.js +786 -0
  64. package/src/server/services/oauth-utils.js +49 -0
  65. package/src/server/services/opencode-channels.js +19 -15
  66. package/src/server/services/opencode-sessions.js +2 -2
  67. package/src/server/services/opencode-settings-manager.js +169 -16
  68. package/src/server/services/opencode-statistics-service.js +3 -27
  69. package/src/server/services/plugins-service.js +115 -15
  70. package/src/server/services/prompts-service.js +2 -3
  71. package/src/server/services/proxy-log-helper.js +242 -0
  72. package/src/server/services/proxy-runtime.js +6 -4
  73. package/src/server/services/repo-scanner-base.js +12 -4
  74. package/src/server/services/request-logger.js +7 -7
  75. package/src/server/services/security-config.js +4 -4
  76. package/src/server/services/session-cache.js +2 -2
  77. package/src/server/services/sessions.js +2 -2
  78. package/src/server/services/settings-manager.js +13 -0
  79. package/src/server/services/skill-service.js +867 -368
  80. package/src/server/services/statistics-service.js +5 -5
  81. package/src/server/services/ui-config.js +4 -3
  82. package/src/server/services/workspace-service.js +1 -1
  83. package/src/server/websocket-server.js +5 -4
  84. package/dist/web/assets/Home-BsSioaaB.css +0 -1
  85. package/dist/web/assets/Home-obifg_9E.js +0 -1
  86. package/dist/web/assets/index-C7LPdVsN.js +0 -2
  87. package/dist/web/assets/index-eEmjZKWP.css +0 -1
  88. package/docs/bannel.png +0 -0
  89. package/docs/model-redirection.md +0 -251
@@ -0,0 +1,49 @@
1
+ const fs = require('fs');
2
+ const crypto = require('crypto');
3
+
4
+ function maskToken(value) {
5
+ const token = String(value || '').trim();
6
+ if (!token) {
7
+ return '';
8
+ }
9
+ if (token.length <= 8) {
10
+ return `${token.slice(0, 2)}***`;
11
+ }
12
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
13
+ }
14
+
15
+ function decodeJwtPayload(token) {
16
+ if (typeof token !== 'string' || !token.includes('.')) {
17
+ return null;
18
+ }
19
+
20
+ try {
21
+ const payload = token.split('.')[1];
22
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
23
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
24
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function removeFileIfExists(filePath) {
31
+ try {
32
+ if (filePath && fs.existsSync(filePath)) {
33
+ fs.unlinkSync(filePath);
34
+ }
35
+ } catch {
36
+ // ignore cleanup failures
37
+ }
38
+ }
39
+
40
+ function sha256(value) {
41
+ return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
42
+ }
43
+
44
+ module.exports = {
45
+ maskToken,
46
+ decodeJwtPayload,
47
+ removeFileIfExists,
48
+ sha256
49
+ };
@@ -2,6 +2,8 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const { PATHS } = require('../../config/paths');
5
+ const { clearNativeOAuth } = require('./native-oauth-adapters');
6
+ const { setChannelConfig } = require('./opencode-settings-manager');
5
7
 
6
8
  /**
7
9
  * OpenCode 渠道管理服务
@@ -41,17 +43,17 @@ function normalizeChannelName(value) {
41
43
 
42
44
  // 获取渠道存储文件路径
43
45
  function getChannelsFilePath() {
44
- const ccToolDir = PATHS.base;
45
- if (!fs.existsSync(ccToolDir)) {
46
- fs.mkdirSync(ccToolDir, { recursive: true });
46
+ const channelsDir = path.dirname(PATHS.channels.opencode);
47
+ if (!fs.existsSync(channelsDir)) {
48
+ fs.mkdirSync(channelsDir, { recursive: true });
47
49
  }
48
50
  return PATHS.channels.opencode;
49
51
  }
50
52
 
51
53
  function getCodexChannelsFilePath() {
52
- const ccToolDir = PATHS.base;
53
- if (!fs.existsSync(ccToolDir)) {
54
- fs.mkdirSync(ccToolDir, { recursive: true });
54
+ const channelsDir = path.dirname(PATHS.channels.codex);
55
+ if (!fs.existsSync(channelsDir)) {
56
+ fs.mkdirSync(channelsDir, { recursive: true });
55
57
  }
56
58
  return PATHS.channels.codex;
57
59
  }
@@ -189,14 +191,6 @@ function updateChannel(channelId, updates) {
189
191
  console.log(`[OpenCode Single-channel mode] Enabled "${merged.name}", disabled all others`);
190
192
  }
191
193
 
192
- // Prevent disabling last enabled channel when proxy is OFF
193
- if (!isProxyRunning && !merged.enabled && oldChannel.enabled) {
194
- const enabledCount = data.channels.filter(ch => ch.enabled).length;
195
- if (enabledCount === 0) {
196
- throw new Error('无法禁用最后一个启用的渠道。请先启用其他渠道或启动动态切换。');
197
- }
198
- }
199
-
200
194
  saveChannels(data);
201
195
  return data.channels[index];
202
196
  }
@@ -259,6 +253,9 @@ function applyChannelToSettings(channelId) {
259
253
  });
260
254
  saveChannels(data);
261
255
 
256
+ clearNativeOAuth('opencode');
257
+ setChannelConfig(channel);
258
+
262
259
  return channel;
263
260
  }
264
261
 
@@ -371,6 +368,12 @@ async function getEffectiveApiKey(channel) {
371
368
  return candidates[0] || null;
372
369
  }
373
370
 
371
+ function disableAllChannels() {
372
+ const data = loadChannels();
373
+ data.channels.forEach(ch => { ch.enabled = false; });
374
+ saveChannels(data);
375
+ }
376
+
374
377
  module.exports = {
375
378
  getChannels,
376
379
  createChannel,
@@ -380,5 +383,6 @@ module.exports = {
380
383
  saveChannelOrder,
381
384
  applyChannelToSettings,
382
385
  getEffectiveApiKey,
383
- getEffectiveApiKeyCandidates
386
+ getEffectiveApiKeyCandidates,
387
+ disableAllChannels
384
388
  };
@@ -9,8 +9,8 @@ const { NATIVE_PATHS, PATHS } = require('../../config/paths');
9
9
  * 读取 OpenCode SQLite 会话数据
10
10
  */
11
11
 
12
- const PROJECT_ORDER_FILE = path.join(PATHS.base, 'opencode-project-order.json');
13
- const SESSION_ORDER_FILE = path.join(PATHS.base, 'opencode-session-order.json');
12
+ const PROJECT_ORDER_FILE = PATHS.opencodeProjectOrder;
13
+ const SESSION_ORDER_FILE = PATHS.opencodeSessionOrder;
14
14
  const OPENCODE_DB_PATH = path.join(NATIVE_PATHS.opencode.data, 'opencode.db');
15
15
  const COUNTS_CACHE_TTL_MS = 30 * 1000;
16
16
  const EMPTY_COUNTS = Object.freeze({ projectCount: 0, sessionCount: 0 });
@@ -169,6 +169,11 @@ function isManagedProxyProvider(provider) {
169
169
  return apiKey === PROXY_API_KEY && isLocalProxyBaseUrl(baseUrl);
170
170
  }
171
171
 
172
+ function isManagedChannelProvider(provider) {
173
+ if (!provider || typeof provider !== 'object') return false;
174
+ return provider?.[MANAGED_PROVIDER_MARKER] === true;
175
+ }
176
+
172
177
  function isManagedProxyConfig(config) {
173
178
  if (!config || typeof config !== 'object') return false;
174
179
  // Check legacy single-provider format
@@ -244,6 +249,92 @@ function resolveProxyBaseUrl(config) {
244
249
  return '';
245
250
  }
246
251
 
252
+ function collectChannelModelCandidates(channel = {}) {
253
+ const seen = new Set();
254
+ const models = [];
255
+ const add = (value) => {
256
+ if (typeof value !== 'string') return;
257
+ const trimmed = value.trim();
258
+ if (!trimmed) return;
259
+ const key = trimmed.toLowerCase();
260
+ if (seen.has(key)) return;
261
+ seen.add(key);
262
+ models.push(trimmed);
263
+ };
264
+
265
+ add(channel.model);
266
+ add(channel.speedTestModel);
267
+
268
+ if (Array.isArray(channel.allowedModels)) {
269
+ channel.allowedModels.forEach(add);
270
+ }
271
+
272
+ if (channel.modelConfig && typeof channel.modelConfig === 'object') {
273
+ add(channel.modelConfig.model);
274
+ add(channel.modelConfig.opusModel);
275
+ add(channel.modelConfig.sonnetModel);
276
+ add(channel.modelConfig.haikuModel);
277
+ }
278
+
279
+ if (Array.isArray(channel.modelRedirects)) {
280
+ channel.modelRedirects.forEach((rule) => {
281
+ add(rule?.from);
282
+ add(rule?.to);
283
+ });
284
+ }
285
+
286
+ return models;
287
+ }
288
+
289
+ function clearManagedProviders(config) {
290
+ const removedProviderKeys = [];
291
+
292
+ if (!config?.provider || typeof config.provider !== 'object') {
293
+ return removedProviderKeys;
294
+ }
295
+
296
+ if (isLegacyProxyProvider(config.provider[LEGACY_PROVIDER_ID])) {
297
+ delete config.provider[LEGACY_PROVIDER_ID];
298
+ removedProviderKeys.push(LEGACY_PROVIDER_ID);
299
+ }
300
+
301
+ if (isManagedProxyProvider(config.provider[PROXY_PROVIDER_ID])) {
302
+ delete config.provider[PROXY_PROVIDER_ID];
303
+ removedProviderKeys.push(PROXY_PROVIDER_ID);
304
+ }
305
+
306
+ Object.keys(config.provider).forEach((key) => {
307
+ const provider = config.provider[key];
308
+ if (isManagedProxyProvider(provider) || isManagedChannelProvider(provider)) {
309
+ delete config.provider[key];
310
+ removedProviderKeys.push(key);
311
+ }
312
+ });
313
+
314
+ if (Object.keys(config.provider).length === 0) {
315
+ delete config.provider;
316
+ }
317
+
318
+ return removedProviderKeys;
319
+ }
320
+
321
+ function clearManagedModelRef(config, removedProviderKeys = []) {
322
+ const modelRef = String(config?.model || '').trim();
323
+ if (!modelRef) {
324
+ return;
325
+ }
326
+
327
+ if (isOldManagedModelRef(modelRef)) {
328
+ delete config.model;
329
+ return;
330
+ }
331
+
332
+ const providerId = modelRef.includes('/') ? modelRef.split('/')[0] : '';
333
+ if (providerId && removedProviderKeys.includes(providerId)) {
334
+ delete config.model;
335
+ }
336
+ }
337
+
247
338
  function backupConfig(filePath) {
248
339
  ensureConfigDir();
249
340
  const backupPath = getBackupPath(filePath);
@@ -324,23 +415,13 @@ function setProxyConfig(proxyPort, options = {}) {
324
415
  if (!next.provider || typeof next.provider !== 'object') {
325
416
  next.provider = {};
326
417
  }
327
- // 清理历史 openai 代理注入,避免 /models 出现与代理无关的 openai 模型列表。
328
- if (isLegacyProxyProvider(next.provider[LEGACY_PROVIDER_ID])) {
329
- delete next.provider[LEGACY_PROVIDER_ID];
330
- }
331
- if (Object.prototype.hasOwnProperty.call(next.provider[LEGACY_PROVIDER_ID] || {}, 'model')) {
332
- delete next.provider[LEGACY_PROVIDER_ID].model;
333
- }
334
-
335
- // Remove old single ctx-proxy provider (superseded by per-channel providers)
336
- delete next.provider[PROXY_PROVIDER_ID];
418
+ const removedProviderKeys = clearManagedProviders(next);
419
+ clearManagedModelRef(next, removedProviderKeys);
337
420
 
338
- // Remove any previously managed per-channel providers that are no longer in the current list
339
- Object.keys(next.provider).forEach((key) => {
340
- if (isManagedProxyProvider(next.provider[key])) {
341
- delete next.provider[key];
342
- }
343
- });
421
+ // clearManagedProviders 可能在清空所有 provider delete next.provider,重新确保存在
422
+ if (!next.provider || typeof next.provider !== 'object') {
423
+ next.provider = {};
424
+ }
344
425
 
345
426
  const channels = Array.isArray(options.channels) ? options.channels : null;
346
427
 
@@ -427,6 +508,73 @@ function setProxyConfig(proxyPort, options = {}) {
427
508
  return { success: true, port: proxyPort, path: filePath };
428
509
  }
429
510
 
511
+ function setChannelConfig(channel = {}) {
512
+ const filePath = selectConfigPath();
513
+ backupConfig(filePath);
514
+
515
+ const current = readConfig(filePath);
516
+ const next = (current && typeof current === 'object') ? current : {};
517
+
518
+ if (!next.provider || typeof next.provider !== 'object') {
519
+ next.provider = {};
520
+ }
521
+
522
+ const removedProviderKeys = clearManagedProviders(next);
523
+ clearManagedModelRef(next, removedProviderKeys);
524
+
525
+ const baseProviderKey = sanitizeProviderKey(channel.providerKey || channel.name || 'ctx-channel');
526
+ let providerKey = baseProviderKey;
527
+ let suffix = 2;
528
+ while (next.provider[providerKey] && !isManagedChannelProvider(next.provider[providerKey])) {
529
+ providerKey = `${baseProviderKey}-${suffix}`;
530
+ suffix += 1;
531
+ }
532
+
533
+ const modelCandidates = collectChannelModelCandidates(channel);
534
+ const fallbackModel = String(channel.model || channel.speedTestModel || modelCandidates[0] || '').trim();
535
+ const modelsMap = buildModelsMap(modelCandidates, fallbackModel);
536
+ const modelIds = Object.keys(modelsMap);
537
+
538
+ if (modelIds.length === 0) {
539
+ throw new Error('OpenCode 渠道缺少可写入的模型,请至少配置默认模型或可用模型。');
540
+ }
541
+
542
+ next.provider[providerKey] = {
543
+ [MANAGED_PROVIDER_MARKER]: true,
544
+ npm: '@ai-sdk/openai-compatible',
545
+ name: channel.name || providerKey,
546
+ options: {
547
+ baseURL: String(channel.baseUrl || '').trim(),
548
+ apiKey: String(channel.apiKey || '').trim()
549
+ },
550
+ models: modelsMap
551
+ };
552
+
553
+ const topModel = normalizeOpenCodeModel(fallbackModel || modelIds[0], providerKey);
554
+ if (topModel) {
555
+ next.model = topModel;
556
+ } else {
557
+ clearManagedModelRef(next, [providerKey]);
558
+ }
559
+
560
+ writeConfig(filePath, next);
561
+ return { success: true, path: filePath, providerKey, model: next.model || null };
562
+ }
563
+
564
+ function clearManagedChannelConfig() {
565
+ const filePath = selectConfigPath();
566
+ if (!fs.existsSync(filePath)) {
567
+ return { success: true, path: filePath };
568
+ }
569
+
570
+ const current = readConfig(filePath);
571
+ const next = (current && typeof current === 'object') ? current : {};
572
+ const removedProviderKeys = clearManagedProviders(next);
573
+ clearManagedModelRef(next, removedProviderKeys);
574
+ writeConfig(filePath, next);
575
+ return { success: true, path: filePath };
576
+ }
577
+
430
578
  function isOldManagedModelRef(modelRef) {
431
579
  const s = String(modelRef || '');
432
580
  return s.startsWith(`${PROXY_PROVIDER_ID}/`) || s.startsWith(`${LEGACY_PROVIDER_ID}/`);
@@ -485,7 +633,12 @@ function getCurrentProxyPort() {
485
633
  module.exports = {
486
634
  configExists,
487
635
  hasBackup,
636
+ readConfig,
637
+ writeConfig,
638
+ selectConfigPath,
488
639
  setProxyConfig,
640
+ setChannelConfig,
641
+ clearManagedChannelConfig,
489
642
  restoreSettings,
490
643
  deleteBackup,
491
644
  isProxyConfig,
@@ -4,36 +4,12 @@ const {
4
4
  getDailyStatistics: getSharedDailyStatistics,
5
5
  getTodayStatistics: getSharedTodayStatistics
6
6
  } = require('./statistics-service');
7
+ const { normalizeUsageTokens, toNumber } = require('./proxy-log-helper');
7
8
 
8
9
  const TOOL_TYPE = 'opencode';
9
10
 
10
- function toNumber(value) {
11
- const num = Number(value);
12
- return Number.isFinite(num) ? num : 0;
13
- }
14
-
15
- function normalizeToolTokens(tokens = {}) {
16
- const input = toNumber(tokens.input);
17
- const output = toNumber(tokens.output);
18
- const reasoning = toNumber(tokens.reasoning);
19
- const cached = toNumber(tokens.cached);
20
- const cacheCreation = toNumber(tokens.cacheCreation);
21
- const cacheRead = toNumber(tokens.cacheRead || cached);
22
- const total = toNumber(tokens.total) || (input + output + reasoning);
23
-
24
- return {
25
- input,
26
- output,
27
- reasoning,
28
- cached,
29
- cacheCreation,
30
- cacheRead,
31
- total
32
- };
33
- }
34
-
35
11
  function toLegacyEntryShape(entry = {}, includeName = false) {
36
- const normalized = normalizeToolTokens(entry.tokens || {});
12
+ const normalized = normalizeUsageTokens(TOOL_TYPE, entry.tokens || {});
37
13
  const result = {
38
14
  requests: toNumber(entry.requests),
39
15
  tokens: {
@@ -126,7 +102,7 @@ function buildDailyStatistics(sharedDaily = {}, fallbackDate) {
126
102
  }
127
103
 
128
104
  function recordRequest(requestData = {}) {
129
- const normalizedTokens = normalizeToolTokens(requestData.tokens || {});
105
+ const normalizedTokens = normalizeUsageTokens(TOOL_TYPE, requestData.tokens || {});
130
106
  return recordSharedRequest({
131
107
  ...requestData,
132
108
  toolType: TOOL_TYPE,
@@ -10,7 +10,7 @@ const { listPlugins, getPlugin, updatePlugin: updatePluginRegistry } = require('
10
10
  const { installPlugin: installPluginCore, uninstallPlugin: uninstallPluginCore } = require('../../plugins/plugin-installer');
11
11
  const { initializePlugins, shutdownPlugins } = require('../../plugins/plugin-manager');
12
12
  const { INSTALLED_DIR, CONFIG_DIR } = require('../../plugins/constants');
13
- const { NATIVE_PATHS, HOME_DIR } = require('../../config/paths');
13
+ const { NATIVE_PATHS, PATHS } = require('../../config/paths');
14
14
 
15
15
  const CLAUDE_PLUGINS_DIR = path.join(path.dirname(NATIVE_PATHS.claude.settings), 'plugins');
16
16
  const CLAUDE_INSTALLED_FILE = path.join(CLAUDE_PLUGINS_DIR, 'installed_plugins.json');
@@ -106,9 +106,77 @@ function stripJsonComments(input = '') {
106
106
  class PluginsService {
107
107
  constructor(platform = 'claude') {
108
108
  this.platform = ['claude', 'opencode'].includes(platform) ? platform : 'claude';
109
- this.ccToolConfigDir = path.join(HOME_DIR, '.cc-tool');
109
+ this.ccToolConfigDir = path.dirname(PATHS.pluginRepos.claude);
110
110
  this.opencodePluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugins');
111
111
  this.opencodeLegacyPluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugin');
112
+ this.marketCachePath = this.platform === 'opencode'
113
+ ? PATHS.pluginMarketCache.opencode
114
+ : PATHS.pluginMarketCache.claude;
115
+ this._marketCache = null;
116
+ }
117
+
118
+ clearMarketCache({ removeFile = true } = {}) {
119
+ this._marketCache = null;
120
+ if (removeFile) {
121
+ try {
122
+ if (fs.existsSync(this.marketCachePath)) {
123
+ fs.unlinkSync(this.marketCachePath);
124
+ }
125
+ } catch (err) {
126
+ // ignore cache deletion errors
127
+ }
128
+ }
129
+ }
130
+
131
+ loadMarketCacheFromFile() {
132
+ try {
133
+ if (fs.existsSync(this.marketCachePath)) {
134
+ const data = JSON.parse(fs.readFileSync(this.marketCachePath, 'utf-8'));
135
+ if (Array.isArray(data.plugins)) {
136
+ return data.plugins;
137
+ }
138
+ }
139
+ } catch (err) {
140
+ // ignore cache read errors
141
+ }
142
+ return null;
143
+ }
144
+
145
+ saveMarketCacheToFile(plugins) {
146
+ try {
147
+ this._ensureDir(path.dirname(this.marketCachePath));
148
+ fs.writeFileSync(this.marketCachePath, JSON.stringify({ plugins }), 'utf-8');
149
+ } catch (err) {
150
+ // ignore cache write errors
151
+ }
152
+ }
153
+
154
+ prepareMarketPlugins(plugins = []) {
155
+ const preparedPlugins = Array.isArray(plugins)
156
+ ? plugins.map(plugin => ({ ...plugin }))
157
+ : [];
158
+ const seen = new Set();
159
+ const installedPlugins = this.listPlugins().plugins;
160
+ const installedNames = new Set(installedPlugins.map(p => p.name));
161
+
162
+ const deduped = [];
163
+ for (const plugin of preparedPlugins) {
164
+ const key = [
165
+ plugin.name || '',
166
+ plugin.repoOwner || '',
167
+ plugin.repoName || '',
168
+ plugin.directory || plugin.installSource || ''
169
+ ].join('::');
170
+ if (seen.has(key)) continue;
171
+ seen.add(key);
172
+ deduped.push({
173
+ ...plugin,
174
+ isInstalled: installedNames.has(plugin.name)
175
+ });
176
+ }
177
+
178
+ deduped.sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()));
179
+ return deduped;
112
180
  }
113
181
 
114
182
  _ensureDir(dirPath) {
@@ -777,11 +845,9 @@ class PluginsService {
777
845
  * @returns {string} Config file path
778
846
  */
779
847
  getReposConfigPath() {
780
- this._ensureDir(this.ccToolConfigDir);
781
- if (this._isOpenCode()) {
782
- return path.join(this.ccToolConfigDir, 'opencode-plugin-repos.json');
783
- }
784
- return path.join(this.ccToolConfigDir, 'plugin-repos.json');
848
+ const filePath = this._isOpenCode() ? PATHS.pluginRepos.opencode : PATHS.pluginRepos.claude;
849
+ this._ensureDir(path.dirname(filePath));
850
+ return filePath;
785
851
  }
786
852
 
787
853
  _getDefaultRepos() {
@@ -933,6 +999,7 @@ class PluginsService {
933
999
 
934
1000
  config.repos.push(newRepo);
935
1001
  this.saveReposConfig(config);
1002
+ this.clearMarketCache();
936
1003
 
937
1004
  return config.repos;
938
1005
  }
@@ -947,6 +1014,7 @@ class PluginsService {
947
1014
  const config = this.loadReposConfig();
948
1015
  config.repos = config.repos.filter(r => !(r.owner === owner && r.name === name));
949
1016
  this.saveReposConfig(config);
1017
+ this.clearMarketCache();
950
1018
  return config.repos;
951
1019
  }
952
1020
 
@@ -965,6 +1033,7 @@ class PluginsService {
965
1033
  }
966
1034
  repo.enabled = enabled;
967
1035
  this.saveReposConfig(config);
1036
+ this.clearMarketCache();
968
1037
  return config.repos;
969
1038
  }
970
1039
 
@@ -1153,9 +1222,30 @@ class PluginsService {
1153
1222
  * Get market plugins from configured repositories
1154
1223
  * @returns {Promise<Array>} List of available market plugins
1155
1224
  */
1156
- async getMarketPlugins() {
1225
+ async getMarketPlugins(forceRefresh = false) {
1226
+ if (forceRefresh) {
1227
+ this.clearMarketCache({ removeFile: false });
1228
+ }
1229
+
1230
+ const fileCache = this.loadMarketCacheFromFile();
1231
+
1232
+ if (!forceRefresh && Array.isArray(this._marketCache) && this._marketCache.length > 0) {
1233
+ if (Array.isArray(fileCache) && fileCache.length > this._marketCache.length) {
1234
+ this._marketCache = this.prepareMarketPlugins(fileCache);
1235
+ return this._marketCache;
1236
+ }
1237
+ this._marketCache = this.prepareMarketPlugins(this._marketCache);
1238
+ return this._marketCache;
1239
+ }
1240
+
1241
+ if (!forceRefresh && Array.isArray(fileCache) && fileCache.length > 0) {
1242
+ this._marketCache = this.prepareMarketPlugins(fileCache);
1243
+ return this._marketCache;
1244
+ }
1245
+
1157
1246
  const repos = this.getRepos().filter(r => r.enabled);
1158
1247
  const marketPlugins = [];
1248
+ let repoFailureCount = 0;
1159
1249
 
1160
1250
  for (const repo of repos) {
1161
1251
  try {
@@ -1248,19 +1338,29 @@ class PluginsService {
1248
1338
  }
1249
1339
  }
1250
1340
  } catch (err) {
1341
+ repoFailureCount++;
1251
1342
  console.error(`[PluginsService] Failed to fetch plugins from ${repo.owner}/${repo.name}:`, err.message);
1252
1343
  }
1253
1344
  }
1254
1345
 
1255
- // Mark installed plugins
1256
- const installedPlugins = this.listPlugins().plugins;
1257
- const installedNames = new Set(installedPlugins.map(p => p.name));
1346
+ const preparedPlugins = this.prepareMarketPlugins(marketPlugins);
1347
+ const preparedFileCache = Array.isArray(fileCache) && fileCache.length > 0
1348
+ ? this.prepareMarketPlugins(fileCache)
1349
+ : null;
1350
+ const shouldUseStaleFileCache = preparedFileCache && (
1351
+ (repos.length > 0 && repoFailureCount === repos.length) ||
1352
+ (repoFailureCount > 0 && preparedFileCache.length > preparedPlugins.length)
1353
+ );
1258
1354
 
1259
- marketPlugins.forEach(plugin => {
1260
- plugin.isInstalled = installedNames.has(plugin.name);
1261
- });
1355
+ if (shouldUseStaleFileCache) {
1356
+ this._marketCache = preparedFileCache;
1357
+ return this._marketCache;
1358
+ }
1359
+
1360
+ this._marketCache = preparedPlugins;
1361
+ this.saveMarketCacheToFile(preparedPlugins);
1262
1362
 
1263
- return marketPlugins;
1363
+ return preparedPlugins;
1264
1364
  }
1265
1365
  }
1266
1366
 
@@ -7,14 +7,13 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
- const { NATIVE_PATHS } = require('../../config/paths');
10
+ const { NATIVE_PATHS, PATHS } = require('../../config/paths');
11
11
  const { resolvePreferredHomeDir } = require('../../utils/home-dir');
12
12
 
13
13
  const HOME_DIR = resolvePreferredHomeDir(process.platform, process.env, os.homedir());
14
14
 
15
15
  // Prompts 配置文件路径
16
- const CC_TOOL_DIR = path.join(HOME_DIR, '.cc-tool');
17
- const PROMPTS_FILE = path.join(CC_TOOL_DIR, 'prompts.json');
16
+ const PROMPTS_FILE = PATHS.prompts;
18
17
 
19
18
  // 各平台提示词文件路径
20
19
  const CLAUDE_PROMPT_PATH = path.join(HOME_DIR, '.claude', 'CLAUDE.md');