coding-tool-x 3.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.
Files changed (185) hide show
  1. package/CHANGELOG.md +599 -0
  2. package/LICENSE +21 -0
  3. package/README.md +439 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
  6. package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
  7. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  8. package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
  9. package/dist/web/assets/Home-38JTUlYt.js +1 -0
  10. package/dist/web/assets/Home-CjupSEWE.css +1 -0
  11. package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
  12. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  13. package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
  14. package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
  15. package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
  16. package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
  17. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  18. package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
  19. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  20. package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
  21. package/dist/web/assets/icons-DRrXwWZi.js +1 -0
  22. package/dist/web/assets/index-CetESrXw.css +1 -0
  23. package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
  24. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  25. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  26. package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
  27. package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
  28. package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
  29. package/dist/web/favicon.ico +0 -0
  30. package/dist/web/index.html +20 -0
  31. package/dist/web/logo.png +0 -0
  32. package/docs/bannel.png +0 -0
  33. package/docs/home.png +0 -0
  34. package/docs/logo.png +0 -0
  35. package/docs/model-redirection.md +251 -0
  36. package/docs/multi-channel-load-balancing.md +249 -0
  37. package/package.json +80 -0
  38. package/src/commands/channels.js +551 -0
  39. package/src/commands/cli-type.js +101 -0
  40. package/src/commands/daemon.js +365 -0
  41. package/src/commands/doctor.js +333 -0
  42. package/src/commands/export-config.js +205 -0
  43. package/src/commands/list.js +222 -0
  44. package/src/commands/logs.js +261 -0
  45. package/src/commands/plugin.js +585 -0
  46. package/src/commands/port-config.js +135 -0
  47. package/src/commands/proxy-control.js +264 -0
  48. package/src/commands/proxy.js +152 -0
  49. package/src/commands/resume.js +137 -0
  50. package/src/commands/search.js +190 -0
  51. package/src/commands/security.js +37 -0
  52. package/src/commands/stats.js +398 -0
  53. package/src/commands/switch.js +48 -0
  54. package/src/commands/toggle-proxy.js +247 -0
  55. package/src/commands/ui.js +99 -0
  56. package/src/commands/update.js +97 -0
  57. package/src/commands/workspace.js +454 -0
  58. package/src/config/default.js +69 -0
  59. package/src/config/loader.js +149 -0
  60. package/src/config/model-metadata.js +167 -0
  61. package/src/config/model-metadata.json +125 -0
  62. package/src/config/model-pricing.js +35 -0
  63. package/src/config/paths.js +190 -0
  64. package/src/index.js +680 -0
  65. package/src/plugins/constants.js +15 -0
  66. package/src/plugins/event-bus.js +54 -0
  67. package/src/plugins/manifest-validator.js +129 -0
  68. package/src/plugins/plugin-api.js +128 -0
  69. package/src/plugins/plugin-installer.js +601 -0
  70. package/src/plugins/plugin-loader.js +229 -0
  71. package/src/plugins/plugin-manager.js +170 -0
  72. package/src/plugins/registry.js +152 -0
  73. package/src/plugins/schema/plugin-manifest.json +115 -0
  74. package/src/reset-config.js +94 -0
  75. package/src/server/api/agents.js +826 -0
  76. package/src/server/api/aliases.js +36 -0
  77. package/src/server/api/channels.js +368 -0
  78. package/src/server/api/claude-hooks.js +480 -0
  79. package/src/server/api/codex-channels.js +417 -0
  80. package/src/server/api/codex-projects.js +104 -0
  81. package/src/server/api/codex-proxy.js +195 -0
  82. package/src/server/api/codex-sessions.js +483 -0
  83. package/src/server/api/codex-statistics.js +57 -0
  84. package/src/server/api/commands.js +482 -0
  85. package/src/server/api/config-export.js +212 -0
  86. package/src/server/api/config-registry.js +357 -0
  87. package/src/server/api/config-sync.js +155 -0
  88. package/src/server/api/config-templates.js +248 -0
  89. package/src/server/api/config.js +521 -0
  90. package/src/server/api/convert.js +260 -0
  91. package/src/server/api/dashboard.js +142 -0
  92. package/src/server/api/env.js +144 -0
  93. package/src/server/api/favorites.js +77 -0
  94. package/src/server/api/gemini-channels.js +366 -0
  95. package/src/server/api/gemini-projects.js +91 -0
  96. package/src/server/api/gemini-proxy.js +173 -0
  97. package/src/server/api/gemini-sessions.js +376 -0
  98. package/src/server/api/gemini-statistics.js +57 -0
  99. package/src/server/api/health-check.js +31 -0
  100. package/src/server/api/mcp.js +399 -0
  101. package/src/server/api/opencode-channels.js +419 -0
  102. package/src/server/api/opencode-projects.js +99 -0
  103. package/src/server/api/opencode-proxy.js +207 -0
  104. package/src/server/api/opencode-sessions.js +327 -0
  105. package/src/server/api/opencode-statistics.js +57 -0
  106. package/src/server/api/plugins.js +463 -0
  107. package/src/server/api/pm2-autostart.js +269 -0
  108. package/src/server/api/projects.js +124 -0
  109. package/src/server/api/prompts.js +279 -0
  110. package/src/server/api/proxy.js +306 -0
  111. package/src/server/api/security.js +53 -0
  112. package/src/server/api/sessions.js +514 -0
  113. package/src/server/api/settings.js +142 -0
  114. package/src/server/api/skills.js +570 -0
  115. package/src/server/api/statistics.js +238 -0
  116. package/src/server/api/ui-config.js +64 -0
  117. package/src/server/api/workspaces.js +456 -0
  118. package/src/server/codex-proxy-server.js +681 -0
  119. package/src/server/dev-server.js +26 -0
  120. package/src/server/gemini-proxy-server.js +610 -0
  121. package/src/server/index.js +422 -0
  122. package/src/server/opencode-proxy-server.js +4771 -0
  123. package/src/server/proxy-server.js +669 -0
  124. package/src/server/services/agents-service.js +1137 -0
  125. package/src/server/services/alias.js +71 -0
  126. package/src/server/services/channel-health.js +234 -0
  127. package/src/server/services/channel-scheduler.js +240 -0
  128. package/src/server/services/channels.js +447 -0
  129. package/src/server/services/codex-channels.js +705 -0
  130. package/src/server/services/codex-config.js +90 -0
  131. package/src/server/services/codex-parser.js +322 -0
  132. package/src/server/services/codex-sessions.js +936 -0
  133. package/src/server/services/codex-settings-manager.js +619 -0
  134. package/src/server/services/codex-speed-test-template.json +24 -0
  135. package/src/server/services/codex-statistics-service.js +161 -0
  136. package/src/server/services/commands-service.js +574 -0
  137. package/src/server/services/config-export-service.js +1165 -0
  138. package/src/server/services/config-registry-service.js +828 -0
  139. package/src/server/services/config-sync-manager.js +941 -0
  140. package/src/server/services/config-sync-service.js +504 -0
  141. package/src/server/services/config-templates-service.js +913 -0
  142. package/src/server/services/enhanced-cache.js +196 -0
  143. package/src/server/services/env-checker.js +409 -0
  144. package/src/server/services/env-manager.js +436 -0
  145. package/src/server/services/favorites.js +165 -0
  146. package/src/server/services/format-converter.js +620 -0
  147. package/src/server/services/gemini-channels.js +459 -0
  148. package/src/server/services/gemini-config.js +73 -0
  149. package/src/server/services/gemini-sessions.js +689 -0
  150. package/src/server/services/gemini-settings-manager.js +263 -0
  151. package/src/server/services/gemini-statistics-service.js +157 -0
  152. package/src/server/services/health-check.js +85 -0
  153. package/src/server/services/mcp-client.js +790 -0
  154. package/src/server/services/mcp-service.js +1732 -0
  155. package/src/server/services/model-detector.js +1245 -0
  156. package/src/server/services/network-access.js +80 -0
  157. package/src/server/services/opencode-channels.js +366 -0
  158. package/src/server/services/opencode-gateway-adapters.js +1168 -0
  159. package/src/server/services/opencode-gateway-converter.js +639 -0
  160. package/src/server/services/opencode-sessions.js +931 -0
  161. package/src/server/services/opencode-settings-manager.js +478 -0
  162. package/src/server/services/opencode-statistics-service.js +161 -0
  163. package/src/server/services/plugins-service.js +1268 -0
  164. package/src/server/services/prompts-service.js +534 -0
  165. package/src/server/services/proxy-runtime.js +79 -0
  166. package/src/server/services/repo-scanner-base.js +708 -0
  167. package/src/server/services/request-logger.js +130 -0
  168. package/src/server/services/response-decoder.js +21 -0
  169. package/src/server/services/security-config.js +131 -0
  170. package/src/server/services/session-cache.js +127 -0
  171. package/src/server/services/session-converter.js +577 -0
  172. package/src/server/services/sessions.js +900 -0
  173. package/src/server/services/settings-manager.js +163 -0
  174. package/src/server/services/skill-service.js +1482 -0
  175. package/src/server/services/speed-test.js +1146 -0
  176. package/src/server/services/statistics-service.js +1043 -0
  177. package/src/server/services/ui-config.js +132 -0
  178. package/src/server/services/workspace-service.js +830 -0
  179. package/src/server/utils/pricing.js +73 -0
  180. package/src/server/websocket-server.js +513 -0
  181. package/src/ui/menu.js +139 -0
  182. package/src/ui/prompts.js +100 -0
  183. package/src/utils/format.js +43 -0
  184. package/src/utils/port-helper.js +108 -0
  185. package/src/utils/session.js +240 -0
@@ -0,0 +1,1268 @@
1
+ /**
2
+ * Plugins Service
3
+ *
4
+ * Wraps the plugin system for API access
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+ const { listPlugins, getPlugin, updatePlugin: updatePluginRegistry } = require('../../plugins/registry');
11
+ const { installPlugin: installPluginCore, uninstallPlugin: uninstallPluginCore } = require('../../plugins/plugin-installer');
12
+ const { initializePlugins, shutdownPlugins } = require('../../plugins/plugin-manager');
13
+ const { INSTALLED_DIR, CONFIG_DIR } = require('../../plugins/constants');
14
+ const { NATIVE_PATHS } = require('../../config/paths');
15
+
16
+ const CLAUDE_PLUGINS_DIR = path.join(os.homedir(), '.claude', 'plugins');
17
+ const CLAUDE_INSTALLED_FILE = path.join(CLAUDE_PLUGINS_DIR, 'installed_plugins.json');
18
+ const CLAUDE_MARKETPLACES_FILE = path.join(CLAUDE_PLUGINS_DIR, 'known_marketplaces.json');
19
+ const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
20
+ const DEFAULT_REPOS_BY_PLATFORM = {
21
+ claude: [],
22
+ opencode: [
23
+ {
24
+ owner: 'Tommertom',
25
+ name: 'opencode-plugin-marketplace',
26
+ url: 'https://github.com/Tommertom/opencode-plugin-marketplace',
27
+ branch: 'main',
28
+ enabled: true,
29
+ source: 'opencode-default'
30
+ },
31
+ {
32
+ owner: 'avifenesh',
33
+ name: 'awesome-slash',
34
+ url: 'https://github.com/avifenesh/awesome-slash',
35
+ branch: 'main',
36
+ enabled: true,
37
+ source: 'opencode-default'
38
+ },
39
+ {
40
+ owner: 'NeoLabHQ',
41
+ name: 'context-engineering-kit',
42
+ url: 'https://github.com/NeoLabHQ/context-engineering-kit',
43
+ branch: 'master',
44
+ enabled: true,
45
+ source: 'opencode-default'
46
+ }
47
+ ]
48
+ };
49
+
50
+ function cloneRepos(repos = []) {
51
+ return repos.map(repo => ({ ...repo }));
52
+ }
53
+
54
+ function stripJsonComments(input = '') {
55
+ let result = '';
56
+ let inString = false;
57
+ let stringChar = '';
58
+ let i = 0;
59
+
60
+ while (i < input.length) {
61
+ const ch = input[i];
62
+ const next = input[i + 1];
63
+
64
+ if (inString) {
65
+ result += ch;
66
+ if (ch === '\\') {
67
+ if (next) {
68
+ result += next;
69
+ i += 2;
70
+ continue;
71
+ }
72
+ } else if (ch === stringChar) {
73
+ inString = false;
74
+ }
75
+ i += 1;
76
+ continue;
77
+ }
78
+
79
+ if (ch === '"' || ch === '\'') {
80
+ inString = true;
81
+ stringChar = ch;
82
+ result += ch;
83
+ i += 1;
84
+ continue;
85
+ }
86
+
87
+ if (ch === '/' && next === '/') {
88
+ i += 2;
89
+ while (i < input.length && input[i] !== '\n') i += 1;
90
+ continue;
91
+ }
92
+
93
+ if (ch === '/' && next === '*') {
94
+ i += 2;
95
+ while (i < input.length - 1 && !(input[i] === '*' && input[i + 1] === '/')) i += 1;
96
+ i += 2;
97
+ continue;
98
+ }
99
+
100
+ result += ch;
101
+ i += 1;
102
+ }
103
+
104
+ return result;
105
+ }
106
+
107
+ class PluginsService {
108
+ constructor(platform = 'claude') {
109
+ this.platform = ['claude', 'opencode'].includes(platform) ? platform : 'claude';
110
+ this.ccToolConfigDir = path.join(os.homedir(), '.cc-tool');
111
+ this.opencodePluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugins');
112
+ this.opencodeLegacyPluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugin');
113
+ }
114
+
115
+ _ensureDir(dirPath) {
116
+ if (!fs.existsSync(dirPath)) {
117
+ fs.mkdirSync(dirPath, { recursive: true });
118
+ }
119
+ }
120
+
121
+ _isOpenCode() {
122
+ return this.platform === 'opencode';
123
+ }
124
+
125
+ _getOpenCodePluginsDir() {
126
+ if (fs.existsSync(this.opencodeLegacyPluginsDir) && !fs.existsSync(this.opencodePluginsDir)) {
127
+ return this.opencodeLegacyPluginsDir;
128
+ }
129
+ return this.opencodePluginsDir;
130
+ }
131
+
132
+ _getOpenCodeConfigPath() {
133
+ const jsonc = path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc');
134
+ const json = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
135
+ const config = path.join(OPENCODE_CONFIG_DIR, 'config.json');
136
+ if (fs.existsSync(jsonc)) return jsonc;
137
+ if (fs.existsSync(json)) return json;
138
+ if (fs.existsSync(config)) return config;
139
+ return json;
140
+ }
141
+
142
+ _readOpenCodeConfig() {
143
+ const filePath = this._getOpenCodeConfigPath();
144
+ if (!fs.existsSync(filePath)) return { filePath, config: {} };
145
+
146
+ try {
147
+ const raw = fs.readFileSync(filePath, 'utf8');
148
+ if (!raw.trim()) return { filePath, config: {} };
149
+ if (filePath.endsWith('.jsonc')) {
150
+ return { filePath, config: JSON.parse(stripJsonComments(raw)) };
151
+ }
152
+ return { filePath, config: JSON.parse(raw) };
153
+ } catch (err) {
154
+ console.error('[PluginsService] Failed to read opencode config:', err.message);
155
+ return { filePath, config: {} };
156
+ }
157
+ }
158
+
159
+ _writeOpenCodeConfig(filePath, config) {
160
+ this._ensureDir(path.dirname(filePath));
161
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
162
+ }
163
+
164
+ _listOpenCodeConfiguredPlugins() {
165
+ const { config } = this._readOpenCodeConfig();
166
+ if (!Array.isArray(config.plugin)) return [];
167
+ return config.plugin.filter(Boolean);
168
+ }
169
+
170
+ _setOpenCodeConfiguredPlugins(plugins) {
171
+ const { filePath, config } = this._readOpenCodeConfig();
172
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
173
+ nextConfig.plugin = Array.from(new Set((plugins || []).filter(Boolean)));
174
+ this._writeOpenCodeConfig(filePath, nextConfig);
175
+ }
176
+
177
+ _listOpenCodeLocalPlugins() {
178
+ const pluginsDir = this._getOpenCodePluginsDir();
179
+ if (!fs.existsSync(pluginsDir)) return [];
180
+
181
+ const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
182
+ const plugins = [];
183
+
184
+ for (const entry of entries) {
185
+ if (entry.name.startsWith('.')) continue;
186
+ const fullPath = path.join(pluginsDir, entry.name);
187
+
188
+ if (entry.isDirectory()) {
189
+ const pkgPath = path.join(fullPath, 'package.json');
190
+ let packageName = entry.name;
191
+ let description = '';
192
+ let version = '1.0.0';
193
+ if (fs.existsSync(pkgPath)) {
194
+ try {
195
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
196
+ packageName = pkg.name || packageName;
197
+ description = pkg.description || '';
198
+ version = pkg.version || version;
199
+ } catch (err) {
200
+ // ignore invalid package.json
201
+ }
202
+ }
203
+ plugins.push({
204
+ name: packageName,
205
+ directory: entry.name,
206
+ installPath: fullPath,
207
+ source: 'opencode-local',
208
+ version,
209
+ description,
210
+ installed: true,
211
+ enabled: true,
212
+ pluginType: 'local'
213
+ });
214
+ continue;
215
+ }
216
+
217
+ const ext = path.extname(entry.name).toLowerCase();
218
+ if (['.js', '.mjs', '.cjs', '.ts'].includes(ext)) {
219
+ plugins.push({
220
+ name: entry.name.replace(ext, ''),
221
+ directory: entry.name,
222
+ installPath: fullPath,
223
+ source: 'opencode-local',
224
+ version: '1.0.0',
225
+ description: '',
226
+ installed: true,
227
+ enabled: true,
228
+ pluginType: 'local'
229
+ });
230
+ }
231
+ }
232
+
233
+ return plugins;
234
+ }
235
+
236
+ /**
237
+ * List all installed plugins with their status
238
+ * Reads from Claude Code's native installed_plugins.json
239
+ * @returns {Object} { plugins: Array }
240
+ */
241
+ listPlugins() {
242
+ if (this._isOpenCode()) {
243
+ const plugins = [];
244
+ const seen = new Set();
245
+
246
+ for (const pkg of this._listOpenCodeConfiguredPlugins()) {
247
+ if (seen.has(pkg)) continue;
248
+ seen.add(pkg);
249
+ plugins.push({
250
+ name: pkg,
251
+ directory: pkg,
252
+ source: 'opencode-config',
253
+ version: 'latest',
254
+ description: '',
255
+ installed: true,
256
+ enabled: true,
257
+ pluginType: 'npm'
258
+ });
259
+ }
260
+
261
+ for (const plugin of this._listOpenCodeLocalPlugins()) {
262
+ if (!seen.has(plugin.name)) {
263
+ seen.add(plugin.name);
264
+ plugins.push(plugin);
265
+ }
266
+ }
267
+
268
+ return { plugins };
269
+ }
270
+
271
+ const plugins = [];
272
+
273
+ // Read Claude Code's installed_plugins.json
274
+ if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
275
+ try {
276
+ const data = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
277
+ if (data.plugins) {
278
+ for (const [key, installations] of Object.entries(data.plugins)) {
279
+ if (installations && installations.length > 0) {
280
+ const install = installations[0]; // Get first installation
281
+ const [name, marketplace] = key.split('@');
282
+
283
+ // Read plugin.json from installPath for description
284
+ let description = '';
285
+ let source = install.source || '';
286
+ let repoUrl = '';
287
+
288
+ if (install.installPath && fs.existsSync(install.installPath)) {
289
+ const manifestPath = path.join(install.installPath, 'plugin.json');
290
+ if (fs.existsSync(manifestPath)) {
291
+ try {
292
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
293
+ description = manifest.description || '';
294
+ } catch (err) {
295
+ // Ignore parse errors
296
+ }
297
+ }
298
+ }
299
+
300
+ // Parse repoUrl from source if available
301
+ if (source) {
302
+ const match = source.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
303
+ if (match) {
304
+ repoUrl = `https://github.com/${match[1]}/${match[2]}`;
305
+ }
306
+ }
307
+
308
+ // Read enabled state from CTX registry (defaults to true if not set)
309
+ const legacyInfo = getPlugin(name);
310
+ const enabledState = legacyInfo ? legacyInfo.enabled !== false : true;
311
+
312
+ plugins.push({
313
+ name,
314
+ marketplace,
315
+ version: install.version || '1.0.0',
316
+ installPath: install.installPath,
317
+ installedAt: install.installedAt,
318
+ scope: install.scope,
319
+ enabled: enabledState,
320
+ description,
321
+ source,
322
+ repoUrl
323
+ });
324
+ }
325
+ }
326
+ }
327
+ } catch (err) {
328
+ console.error('[PluginsService] Failed to read installed_plugins.json:', err.message);
329
+ }
330
+ }
331
+
332
+ // Also check legacy registry
333
+ try {
334
+ const legacyPlugins = listPlugins();
335
+ for (const plugin of legacyPlugins) {
336
+ if (!plugins.find(p => p.name === plugin.name)) {
337
+ plugins.push(plugin);
338
+ }
339
+ }
340
+ } catch (err) {
341
+ // Ignore legacy registry errors
342
+ }
343
+
344
+ return { plugins };
345
+ }
346
+
347
+ /**
348
+ * Get single plugin details
349
+ * @param {string} name - Plugin name
350
+ * @returns {Object|null} Plugin details or null
351
+ */
352
+ getPlugin(name) {
353
+ if (this._isOpenCode()) {
354
+ const plugin = this.listPlugins().plugins.find(p => p.name === name || p.directory === name);
355
+ if (!plugin) return null;
356
+ return plugin;
357
+ }
358
+
359
+ const plugin = getPlugin(name);
360
+ if (!plugin) {
361
+ return null;
362
+ }
363
+
364
+ const pluginDir = path.join(INSTALLED_DIR, name);
365
+ const manifestPath = path.join(pluginDir, 'plugin.json');
366
+
367
+ let manifest = null;
368
+ if (fs.existsSync(manifestPath)) {
369
+ try {
370
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
371
+ } catch (err) {
372
+ // Ignore parse errors
373
+ }
374
+ }
375
+
376
+ return {
377
+ name,
378
+ ...plugin,
379
+ description: manifest?.description || '',
380
+ author: manifest?.author || '',
381
+ commands: manifest?.commands || [],
382
+ hooks: manifest?.hooks || [],
383
+ manifest
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Install plugin from Git URL or repo directory
389
+ * @param {string} source - Git repository URL or tree URL
390
+ * @param {Object} repoInfo - Optional repo info { owner, name, branch, directory }
391
+ * @returns {Promise<Object>} Installation result
392
+ */
393
+ async installPlugin(source, repoInfo = null) {
394
+ if (this._isOpenCode()) {
395
+ if (repoInfo && repoInfo.owner && repoInfo.name && repoInfo.directory) {
396
+ return this._installFromGitHubDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
397
+ }
398
+
399
+ const treeMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
400
+ if (treeMatch) {
401
+ const [, owner, name, branch, directory] = treeMatch;
402
+ return this._installFromGitHubDirectory({ owner, name, branch, directory }, { installRoot: this._getOpenCodePluginsDir() });
403
+ }
404
+
405
+ // OpenCode 原生支持 npm 包名,通过 opencode.json 的 plugin 数组管理
406
+ if (!/^https?:\/\//.test(source)) {
407
+ const plugins = this._listOpenCodeConfiguredPlugins();
408
+ if (!plugins.includes(source)) {
409
+ plugins.push(source);
410
+ this._setOpenCodeConfiguredPlugins(plugins);
411
+ }
412
+ return {
413
+ success: true,
414
+ plugin: { name: source, version: 'latest', description: '' }
415
+ };
416
+ }
417
+
418
+ return {
419
+ success: false,
420
+ error: 'OpenCode plugin install expects npm package name or GitHub tree URL'
421
+ };
422
+ }
423
+
424
+ // If repoInfo is provided, download from GitHub directly
425
+ if (repoInfo && repoInfo.owner && repoInfo.name && repoInfo.directory) {
426
+ return await this._installFromGitHubDirectory(repoInfo);
427
+ }
428
+
429
+ // Parse tree URL format: https://github.com/owner/repo/tree/branch/path
430
+ const treeMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
431
+ if (treeMatch) {
432
+ const [, owner, name, branch, directory] = treeMatch;
433
+ return await this._installFromGitHubDirectory({ owner, name, branch, directory });
434
+ }
435
+
436
+ // Fallback to original git clone method
437
+ return await installPluginCore(source);
438
+ }
439
+
440
+ /**
441
+ * Install plugin from GitHub directory
442
+ * @private
443
+ */
444
+ async _installFromGitHubDirectory(repoInfo, options = {}) {
445
+ const { owner, name, branch, directory } = repoInfo;
446
+ const https = require('https');
447
+ const pluginName = directory.split('/').pop();
448
+ const installRoot = options.installRoot || INSTALLED_DIR;
449
+
450
+ try {
451
+ // Fetch plugin.json from the directory
452
+ const manifestUrl = `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${directory}/plugin.json`;
453
+ let manifest;
454
+
455
+ try {
456
+ manifest = await this._fetchJson(manifestUrl);
457
+ } catch (e) {
458
+ // No plugin.json, create a basic manifest
459
+ manifest = { name: pluginName, version: '1.0.0' };
460
+ }
461
+
462
+ // Create plugin directory
463
+ const pluginDir = path.join(installRoot, manifest.name || pluginName);
464
+ if (!fs.existsSync(pluginDir)) {
465
+ fs.mkdirSync(pluginDir, { recursive: true });
466
+ }
467
+
468
+ // Download all files from the directory
469
+ const contentsUrl = `https://api.github.com/repos/${owner}/${name}/contents/${directory}?ref=${branch}`;
470
+ const contents = await this._fetchJson(contentsUrl);
471
+
472
+ for (const item of contents) {
473
+ if (item.type === 'file') {
474
+ const fileContent = await this._fetchRawFile(item.download_url);
475
+ fs.writeFileSync(path.join(pluginDir, item.name), fileContent);
476
+ }
477
+ }
478
+
479
+ // Write plugin.json if not exists
480
+ const manifestPath = path.join(pluginDir, 'plugin.json');
481
+ if (!fs.existsSync(manifestPath)) {
482
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
483
+ }
484
+
485
+ if (!this._isOpenCode()) {
486
+ const installedPluginName = manifest.name || pluginName;
487
+ const installTimestamp = new Date().toISOString();
488
+ const sourceUrl = `https://github.com/${owner}/${name}/tree/${branch}/${directory}`;
489
+
490
+ // Register in CTX legacy registry (for listPlugins fallback)
491
+ const { addPlugin } = require('../../plugins/registry');
492
+ try {
493
+ addPlugin(installedPluginName, {
494
+ version: manifest.version || '1.0.0',
495
+ enabled: true,
496
+ installedAt: installTimestamp,
497
+ source: sourceUrl
498
+ });
499
+ } catch (e) {
500
+ console.warn('[PluginsService] Legacy registry addPlugin warning:', e.message);
501
+ }
502
+
503
+ // Also register in Claude's native installed_plugins.json
504
+ try {
505
+ this._ensureDir(CLAUDE_PLUGINS_DIR);
506
+ let nativeData = { plugins: {} };
507
+ if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
508
+ try {
509
+ nativeData = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
510
+ if (!nativeData.plugins) nativeData.plugins = {};
511
+ } catch (e) { /* ignore parse error */ }
512
+ }
513
+ const nativeKey = `${installedPluginName}@ctx`;
514
+ nativeData.plugins[nativeKey] = [{
515
+ version: manifest.version || '1.0.0',
516
+ installPath: pluginDir,
517
+ installedAt: installTimestamp,
518
+ scope: 'user',
519
+ source: sourceUrl
520
+ }];
521
+ fs.writeFileSync(CLAUDE_INSTALLED_FILE, JSON.stringify(nativeData, null, 2), 'utf8');
522
+ } catch (e) {
523
+ console.error('[PluginsService] Failed to update native installed_plugins.json:', e.message);
524
+ }
525
+ }
526
+
527
+ return {
528
+ success: true,
529
+ plugin: {
530
+ name: manifest.name || pluginName,
531
+ version: manifest.version || '1.0.0',
532
+ description: manifest.description || ''
533
+ }
534
+ };
535
+ } catch (err) {
536
+ return {
537
+ success: false,
538
+ error: `Failed to install plugin: ${err.message}`
539
+ };
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Fetch raw file content
545
+ * @private
546
+ */
547
+ async _fetchRawFile(url) {
548
+ const https = require('https');
549
+ return new Promise((resolve, reject) => {
550
+ https.get(url, {
551
+ headers: { 'User-Agent': 'coding-tool-x' }
552
+ }, (res) => {
553
+ let data = '';
554
+ res.on('data', chunk => data += chunk);
555
+ res.on('end', () => {
556
+ if (res.statusCode === 200) {
557
+ resolve(data);
558
+ } else {
559
+ reject(new Error(`HTTP ${res.statusCode}`));
560
+ }
561
+ });
562
+ }).on('error', reject);
563
+ });
564
+ }
565
+
566
+ /**
567
+ * Uninstall plugin
568
+ * @param {string} name - Plugin name
569
+ * @returns {Object} Uninstallation result
570
+ */
571
+ uninstallPlugin(name) {
572
+ if (this._isOpenCode()) {
573
+ const pluginsDir = this._getOpenCodePluginsDir();
574
+ let removed = false;
575
+
576
+ // Remove from opencode config.plugin (npm plugins)
577
+ const configured = this._listOpenCodeConfiguredPlugins();
578
+ const next = configured.filter(p => p !== name);
579
+ if (next.length !== configured.length) {
580
+ this._setOpenCodeConfiguredPlugins(next);
581
+ removed = true;
582
+ }
583
+
584
+ // Remove local plugin directory/file
585
+ if (fs.existsSync(pluginsDir)) {
586
+ const directPath = path.join(pluginsDir, name);
587
+ if (fs.existsSync(directPath)) {
588
+ fs.rmSync(directPath, { recursive: true, force: true });
589
+ removed = true;
590
+ } else {
591
+ const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
592
+ for (const entry of entries) {
593
+ const baseName = entry.name.replace(path.extname(entry.name), '');
594
+ if (entry.name === name || baseName === name) {
595
+ fs.rmSync(path.join(pluginsDir, entry.name), { recursive: true, force: true });
596
+ removed = true;
597
+ break;
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ return {
604
+ success: true,
605
+ message: removed ? 'Plugin removed successfully' : 'Plugin not found'
606
+ };
607
+ }
608
+
609
+ // Claude: Remove from native installed_plugins.json and delete install directories
610
+ let removed = false;
611
+ if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
612
+ try {
613
+ const data = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
614
+ if (data.plugins) {
615
+ const keysToDelete = [];
616
+ const baseName = name.split('/').pop(); // handle "plugins/pr-review-toolkit" → "pr-review-toolkit"
617
+ for (const [key, installations] of Object.entries(data.plugins)) {
618
+ const [pluginName] = key.split('@');
619
+ if (pluginName === name || key === name || pluginName === baseName) {
620
+ keysToDelete.push(key);
621
+ // Delete install directories
622
+ if (Array.isArray(installations)) {
623
+ for (const install of installations) {
624
+ if (install.installPath && fs.existsSync(install.installPath)) {
625
+ try {
626
+ fs.rmSync(install.installPath, { recursive: true, force: true });
627
+ } catch (e) {
628
+ console.error('[PluginsService] Failed to delete install dir:', e.message);
629
+ }
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
635
+ if (keysToDelete.length > 0) {
636
+ for (const key of keysToDelete) {
637
+ delete data.plugins[key];
638
+ }
639
+ fs.writeFileSync(CLAUDE_INSTALLED_FILE, JSON.stringify(data, null, 2), 'utf8');
640
+ removed = true;
641
+ }
642
+ }
643
+ } catch (err) {
644
+ console.error('[PluginsService] Failed to update installed_plugins.json:', err.message);
645
+ }
646
+ }
647
+
648
+ // Also try legacy registry removal
649
+ try {
650
+ const legacyResult = uninstallPluginCore(name);
651
+ if (legacyResult.success) removed = true;
652
+ } catch (err) {
653
+ // Ignore legacy registry errors
654
+ }
655
+
656
+ if (!removed) {
657
+ return {
658
+ success: false,
659
+ error: `Plugin "${name}" not found`
660
+ };
661
+ }
662
+
663
+ return {
664
+ success: true,
665
+ message: 'Plugin uninstalled successfully'
666
+ };
667
+ }
668
+
669
+ /**
670
+ * Toggle plugin enabled/disabled
671
+ * @param {string} name - Plugin name
672
+ * @param {boolean} enabled - Enable or disable
673
+ * @returns {Object} Updated plugin info
674
+ */
675
+ togglePlugin(name, enabled) {
676
+ if (this._isOpenCode()) {
677
+ const configured = this._listOpenCodeConfiguredPlugins();
678
+ const exists = configured.includes(name);
679
+ if (enabled && !exists) {
680
+ configured.push(name);
681
+ this._setOpenCodeConfiguredPlugins(configured);
682
+ } else if (!enabled && exists) {
683
+ this._setOpenCodeConfiguredPlugins(configured.filter(p => p !== name));
684
+ }
685
+
686
+ return {
687
+ name,
688
+ enabled
689
+ };
690
+ }
691
+
692
+ // Claude: store enabled state in CTX registry
693
+ // First check if plugin exists in native installed_plugins.json
694
+ let pluginExists = false;
695
+ const baseName = name.split('/').pop();
696
+ if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
697
+ try {
698
+ const data = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
699
+ if (data.plugins) {
700
+ for (const key of Object.keys(data.plugins)) {
701
+ const [pluginName] = key.split('@');
702
+ if (pluginName === name || key === name || pluginName === baseName) {
703
+ pluginExists = true;
704
+ break;
705
+ }
706
+ }
707
+ }
708
+ } catch (e) { /* ignore */ }
709
+ }
710
+
711
+ // Also check legacy registry
712
+ const legacyPlugin = getPlugin(name);
713
+ if (legacyPlugin) pluginExists = true;
714
+
715
+ if (!pluginExists) {
716
+ throw new Error(`Plugin "${name}" not found`);
717
+ }
718
+
719
+ // Store enabled state in CTX registry (upsert)
720
+ try {
721
+ const { addPlugin } = require('../../plugins/registry');
722
+ if (legacyPlugin) {
723
+ updatePluginRegistry(name, { enabled });
724
+ } else {
725
+ addPlugin(name, { version: '1.0.0', enabled, source: 'claude-native' });
726
+ }
727
+ } catch (e) {
728
+ console.warn('[PluginsService] Failed to update plugin registry:', e.message);
729
+ }
730
+
731
+ return {
732
+ name,
733
+ enabled,
734
+ success: true
735
+ };
736
+ }
737
+
738
+ /**
739
+ * Update plugin config
740
+ * @param {string} name - Plugin name
741
+ * @param {Object} config - Configuration object
742
+ * @returns {Object} Result
743
+ */
744
+ updatePluginConfig(name, config) {
745
+ if (this._isOpenCode()) {
746
+ const configDir = path.join(OPENCODE_CONFIG_DIR, 'plugins-config');
747
+ this._ensureDir(configDir);
748
+ const configFile = path.join(configDir, `${name}.json`);
749
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf8');
750
+ return {
751
+ success: true,
752
+ message: `Configuration updated for plugin "${name}"`
753
+ };
754
+ }
755
+
756
+ const plugin = getPlugin(name);
757
+ if (!plugin) {
758
+ throw new Error(`Plugin "${name}" not found`);
759
+ }
760
+
761
+ const configFile = path.join(CONFIG_DIR, `${name}.json`);
762
+
763
+ // Ensure config directory exists
764
+ if (!fs.existsSync(CONFIG_DIR)) {
765
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
766
+ }
767
+
768
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf8');
769
+
770
+ return {
771
+ success: true,
772
+ message: `Configuration updated for plugin "${name}"`
773
+ };
774
+ }
775
+
776
+ /**
777
+ * Get plugin repositories config file path
778
+ * @returns {string} Config file path
779
+ */
780
+ getReposConfigPath() {
781
+ this._ensureDir(this.ccToolConfigDir);
782
+ if (this._isOpenCode()) {
783
+ return path.join(this.ccToolConfigDir, 'opencode-plugin-repos.json');
784
+ }
785
+ return path.join(this.ccToolConfigDir, 'plugin-repos.json');
786
+ }
787
+
788
+ _getDefaultRepos() {
789
+ return cloneRepos(DEFAULT_REPOS_BY_PLATFORM[this.platform] || DEFAULT_REPOS_BY_PLATFORM.claude);
790
+ }
791
+
792
+ /**
793
+ * Load repos from config file
794
+ * @returns {Object} Config object with repos array
795
+ */
796
+ loadReposConfig() {
797
+ const configPath = this.getReposConfigPath();
798
+ const defaultRepos = this._getDefaultRepos();
799
+ if (!fs.existsSync(configPath)) {
800
+ return { repos: defaultRepos };
801
+ }
802
+ try {
803
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
804
+ if (parsed && Array.isArray(parsed.repos)) {
805
+ return parsed;
806
+ }
807
+ return { repos: defaultRepos };
808
+ } catch (err) {
809
+ console.error('Failed to load repos config:', err);
810
+ return { repos: defaultRepos };
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Save repos to config file
816
+ * @param {Object} config - Config object with repos array
817
+ */
818
+ saveReposConfig(config) {
819
+ const configPath = this.getReposConfigPath();
820
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
821
+ }
822
+
823
+ /**
824
+ * Get plugin repositories
825
+ * Reads from both our config and Claude Code's native marketplace config
826
+ * @returns {Array} Repos list
827
+ */
828
+ getRepos() {
829
+ const repos = [];
830
+ const seenRepos = new Set();
831
+ const pushRepo = (repo) => {
832
+ if (!repo || !repo.owner || !repo.name) return;
833
+ const key = `${repo.owner}/${repo.name}`;
834
+ if (seenRepos.has(key)) return;
835
+ repos.push(repo);
836
+ seenRepos.add(key);
837
+ };
838
+ const parseRepoUrl = (url) => {
839
+ if (!url || typeof url !== 'string') return null;
840
+ const match = url.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
841
+ if (!match) return null;
842
+ return { owner: match[1], name: match[2], url };
843
+ };
844
+
845
+ // 1. Load our own config
846
+ const config = this.loadReposConfig();
847
+ for (const repo of config.repos || []) {
848
+ pushRepo(repo);
849
+ }
850
+
851
+ // 2. Load Claude Code's native marketplace config (Claude only)
852
+ if (!this._isOpenCode() && fs.existsSync(CLAUDE_MARKETPLACES_FILE)) {
853
+ try {
854
+ const marketplaces = JSON.parse(fs.readFileSync(CLAUDE_MARKETPLACES_FILE, 'utf8'));
855
+ const entries = [];
856
+ if (Array.isArray(marketplaces)) {
857
+ entries.push(...marketplaces.map(item => ({ key: '', data: item })));
858
+ } else if (marketplaces && typeof marketplaces === 'object') {
859
+ entries.push(...Object.entries(marketplaces).map(([key, data]) => ({ key, data })));
860
+ if (Array.isArray(marketplaces.marketplaces)) {
861
+ entries.push(...marketplaces.marketplaces.map(item => ({ key: item?.name || '', data: item })));
862
+ }
863
+ }
864
+
865
+ for (const { key, data } of entries) {
866
+ const sourceUrl = data?.source?.url || data?.url || data?.repoUrl || data?.repository;
867
+ const parsed = parseRepoUrl(sourceUrl);
868
+ if (!parsed) continue;
869
+ pushRepo({
870
+ owner: parsed.owner,
871
+ name: parsed.name,
872
+ url: parsed.url,
873
+ branch: data?.source?.branch || data?.branch || 'main',
874
+ enabled: data?.enabled !== false,
875
+ source: 'claude-native',
876
+ marketplace: key || data?.name || '',
877
+ lastUpdated: data?.lastUpdated
878
+ });
879
+ }
880
+ } catch (err) {
881
+ console.error('[PluginsService] Failed to read known_marketplaces.json:', err.message);
882
+ }
883
+ }
884
+
885
+ return repos;
886
+ }
887
+
888
+ /**
889
+ * Add repository
890
+ * @param {Object} repo - Repository info { url, owner, name, branch, enabled }
891
+ * @returns {Array} Updated repos list
892
+ */
893
+ addRepo(repo) {
894
+ const config = this.loadReposConfig();
895
+
896
+ // Parse URL if provided
897
+ let owner = repo.owner;
898
+ let name = repo.name;
899
+ let url = repo.url;
900
+
901
+ if (url && !owner && !name) {
902
+ // Extract owner/name from URL
903
+ const match = url.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
904
+ if (match) {
905
+ owner = match[1];
906
+ name = match[2];
907
+ }
908
+ }
909
+
910
+ if (!owner || !name) {
911
+ throw new Error('Repository owner and name are required');
912
+ }
913
+
914
+ // Construct URL if not provided
915
+ if (!url) {
916
+ url = `https://github.com/${owner}/${name}`;
917
+ }
918
+
919
+ // Check if repo already exists
920
+ const exists = config.repos.some(r => r.owner === owner && r.name === name);
921
+ if (exists) {
922
+ throw new Error(`Repository ${owner}/${name} already exists`);
923
+ }
924
+
925
+ // Add new repo
926
+ const newRepo = {
927
+ owner,
928
+ name,
929
+ url,
930
+ branch: repo.branch || 'main',
931
+ enabled: repo.enabled !== false,
932
+ addedAt: new Date().toISOString()
933
+ };
934
+
935
+ config.repos.push(newRepo);
936
+ this.saveReposConfig(config);
937
+
938
+ return config.repos;
939
+ }
940
+
941
+ /**
942
+ * Remove repository
943
+ * @param {string} owner - Repository owner
944
+ * @param {string} name - Repository name
945
+ * @returns {Array} Updated repos list
946
+ */
947
+ removeRepo(owner, name) {
948
+ const config = this.loadReposConfig();
949
+ config.repos = config.repos.filter(r => !(r.owner === owner && r.name === name));
950
+ this.saveReposConfig(config);
951
+ return config.repos;
952
+ }
953
+
954
+ /**
955
+ * Toggle repository enabled status
956
+ * @param {string} owner - Repository owner
957
+ * @param {string} name - Repository name
958
+ * @param {boolean} enabled - Enable or disable
959
+ * @returns {Array} Updated repos list
960
+ */
961
+ toggleRepo(owner, name, enabled) {
962
+ const config = this.loadReposConfig();
963
+ const repo = config.repos.find(r => r.owner === owner && r.name === name);
964
+ if (!repo) {
965
+ throw new Error(`Repository ${owner}/${name} not found`);
966
+ }
967
+ repo.enabled = enabled;
968
+ this.saveReposConfig(config);
969
+ return config.repos;
970
+ }
971
+
972
+ /**
973
+ * Sync repositories to Claude Code marketplace
974
+ * @returns {Promise<Object>} Sync results
975
+ */
976
+ async syncRepos() {
977
+ if (this._isOpenCode()) {
978
+ return { success: true, results: [] };
979
+ }
980
+
981
+ const repos = this.getRepos();
982
+ const results = [];
983
+ const { execSync } = require('child_process');
984
+
985
+ for (const repo of repos.filter(r => r.enabled)) {
986
+ try {
987
+ execSync(`claude plugin marketplace add ${repo.url}`, {
988
+ encoding: 'utf8',
989
+ timeout: 30000,
990
+ stdio: 'pipe'
991
+ });
992
+ results.push({ repo: repo.url, success: true });
993
+ } catch (err) {
994
+ results.push({ repo: repo.url, success: false, error: err.message });
995
+ }
996
+ }
997
+
998
+ return { success: true, results };
999
+ }
1000
+
1001
+ /**
1002
+ * Sync plugins from Claude Code
1003
+ * @returns {Promise<Object>} Updated plugins list
1004
+ */
1005
+ async syncPlugins() {
1006
+ return this.listPlugins();
1007
+ }
1008
+
1009
+ /**
1010
+ * Fetch JSON from URL
1011
+ * @private
1012
+ */
1013
+ async _fetchJson(url) {
1014
+ const https = require('https');
1015
+ return new Promise((resolve, reject) => {
1016
+ https.get(url, {
1017
+ headers: {
1018
+ 'User-Agent': 'coding-tool-x',
1019
+ 'Accept': 'application/vnd.github.v3+json'
1020
+ }
1021
+ }, (res) => {
1022
+ let data = '';
1023
+ res.on('data', chunk => data += chunk);
1024
+ res.on('end', () => {
1025
+ if (res.statusCode === 200) {
1026
+ resolve(JSON.parse(data));
1027
+ } else {
1028
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
1029
+ }
1030
+ });
1031
+ }).on('error', reject);
1032
+ });
1033
+ }
1034
+
1035
+ /**
1036
+ * Get plugin README content
1037
+ * @param {Object} plugin - Plugin object with name, repoUrl, source, or repoInfo
1038
+ * @returns {Promise<string>} README content or empty string
1039
+ */
1040
+ async getPluginReadme(plugin) {
1041
+ try {
1042
+ let readmeUrl = null;
1043
+
1044
+ // Case 1: Market plugin with repoInfo
1045
+ if (plugin.repoOwner && plugin.repoName && plugin.directory) {
1046
+ const branch = plugin.repoBranch || 'main';
1047
+ readmeUrl = `https://raw.githubusercontent.com/${plugin.repoOwner}/${plugin.repoName}/${branch}/${plugin.directory}/README.md`;
1048
+ }
1049
+ // Case 2: Installed plugin with source URL
1050
+ else if (plugin.source) {
1051
+ const treeMatch = plugin.source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
1052
+ if (treeMatch) {
1053
+ const [, owner, name, branch, directory] = treeMatch;
1054
+ readmeUrl = `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${directory}/README.md`;
1055
+ } else {
1056
+ // Try to parse as regular repo URL
1057
+ const repoMatch = plugin.source.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
1058
+ if (repoMatch) {
1059
+ const [, owner, name] = repoMatch;
1060
+ readmeUrl = `https://raw.githubusercontent.com/${owner}/${name}/main/README.md`;
1061
+ }
1062
+ }
1063
+ }
1064
+ // Case 3: Plugin with repoUrl
1065
+ else if (plugin.repoUrl) {
1066
+ const match = plugin.repoUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
1067
+ if (match) {
1068
+ const [, owner, name] = match;
1069
+ readmeUrl = `https://raw.githubusercontent.com/${owner}/${name}/main/README.md`;
1070
+ }
1071
+ }
1072
+
1073
+ if (!readmeUrl) {
1074
+ return '';
1075
+ }
1076
+
1077
+ // Fetch README content
1078
+ const content = await this._fetchRawFile(readmeUrl);
1079
+ return content;
1080
+ } catch (err) {
1081
+ console.error('[PluginsService] Failed to fetch README:', err.message);
1082
+ return '';
1083
+ }
1084
+ }
1085
+
1086
+ _parseGitHubRepo(url = '') {
1087
+ const match = url.match(/github\.com\/([^\/]+)\/([^\/#?]+)/i);
1088
+ if (!match) return null;
1089
+ return {
1090
+ owner: match[1],
1091
+ name: match[2].replace(/\.git$/, '')
1092
+ };
1093
+ }
1094
+
1095
+ async _fetchOpenCodeMarketplacePlugins(repo, branch) {
1096
+ if (!this._isOpenCode()) return [];
1097
+
1098
+ let entries;
1099
+ try {
1100
+ const indexUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/contents/plugins?ref=${branch}`;
1101
+ entries = await this._fetchJson(indexUrl);
1102
+ } catch (err) {
1103
+ return [];
1104
+ }
1105
+
1106
+ if (!Array.isArray(entries)) return [];
1107
+
1108
+ const manifestFiles = entries.filter(
1109
+ item => item.type === 'file' && item.name.endsWith('.plugin.json')
1110
+ );
1111
+ if (manifestFiles.length === 0) return [];
1112
+
1113
+ const results = await Promise.allSettled(
1114
+ manifestFiles.map(async (file) => {
1115
+ const fileUrl = file.download_url ||
1116
+ `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${file.path}`;
1117
+ const manifest = await this._fetchJson(fileUrl);
1118
+
1119
+ const author = Array.isArray(manifest.authors)
1120
+ ? manifest.authors.map(item => item?.name).filter(Boolean).join(', ')
1121
+ : '';
1122
+ const firstCategory = Array.isArray(manifest.categories) ? manifest.categories[0] : '';
1123
+ const repoUrl = manifest.links?.repository || `https://github.com/${repo.owner}/${repo.name}`;
1124
+ // OpenCode supports npm package plugins via opencode.json "plugin" array.
1125
+ // Use package name as install source so UI install button is enabled.
1126
+ const installSource = String(manifest.name || '').trim();
1127
+ const githubRepo = this._parseGitHubRepo(repoUrl);
1128
+
1129
+ return {
1130
+ name: manifest.name || file.name.replace(/\.plugin\.json$/, ''),
1131
+ displayName: manifest.displayName || '',
1132
+ description: manifest.description || '',
1133
+ author: author || repo.owner,
1134
+ version: manifest.version || manifest.opencode?.minimumVersion || '1.0.0',
1135
+ category: firstCategory ? String(firstCategory).toLowerCase() : 'general',
1136
+ repoUrl,
1137
+ repoOwner: '',
1138
+ repoName: '',
1139
+ repoBranch: githubRepo ? 'main' : branch,
1140
+ directory: file.path,
1141
+ installSource,
1142
+ marketplaceFormat: 'opencode-plugin-json',
1143
+ isInstalled: false
1144
+ };
1145
+ })
1146
+ );
1147
+
1148
+ return results
1149
+ .filter(item => item.status === 'fulfilled' && item.value)
1150
+ .map(item => item.value);
1151
+ }
1152
+
1153
+ /**
1154
+ * Get market plugins from configured repositories
1155
+ * @returns {Promise<Array>} List of available market plugins
1156
+ */
1157
+ async getMarketPlugins() {
1158
+ const repos = this.getRepos().filter(r => r.enabled);
1159
+ const marketPlugins = [];
1160
+
1161
+ for (const repo of repos) {
1162
+ try {
1163
+ const branch = repo.branch || 'main';
1164
+
1165
+ // Try to fetch marketplace.json first (official format)
1166
+ try {
1167
+ const marketplaceUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/.claude-plugin/marketplace.json`;
1168
+ const marketplace = await this._fetchJson(marketplaceUrl);
1169
+
1170
+ if (marketplace && marketplace.plugins) {
1171
+ for (const plugin of marketplace.plugins) {
1172
+ marketPlugins.push({
1173
+ name: plugin.name,
1174
+ description: plugin.description || '',
1175
+ author: plugin.author?.name || marketplace.owner?.name || repo.owner,
1176
+ version: plugin.version || '1.0.0',
1177
+ category: plugin.category || 'general',
1178
+ repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1179
+ repoOwner: repo.owner,
1180
+ repoName: repo.name,
1181
+ repoBranch: branch,
1182
+ directory: plugin.source?.replace(/^\.\//, '') || plugin.name,
1183
+ lspServers: plugin.lspServers || null,
1184
+ isInstalled: false
1185
+ });
1186
+ }
1187
+ continue; // Skip legacy format check
1188
+ }
1189
+ } catch (e) {
1190
+ // marketplace.json not found, try legacy format
1191
+ }
1192
+
1193
+ // OpenCode plugin marketplace format: plugins/*.plugin.json
1194
+ if (this._isOpenCode()) {
1195
+ const openCodeMarketplacePlugins = await this._fetchOpenCodeMarketplacePlugins(repo, branch);
1196
+ if (openCodeMarketplacePlugins.length > 0) {
1197
+ marketPlugins.push(...openCodeMarketplacePlugins);
1198
+ continue;
1199
+ }
1200
+ }
1201
+
1202
+ // Legacy format: each directory is a plugin with plugin.json/package.json
1203
+ const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/contents?ref=${branch}`;
1204
+ const contents = await this._fetchJson(apiUrl);
1205
+ const pluginDirs = contents.filter(item => item.type === 'dir' && !item.name.startsWith('.'));
1206
+
1207
+ for (const dir of pluginDirs) {
1208
+ try {
1209
+ const manifestUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${dir.name}/plugin.json`;
1210
+ const manifest = await this._fetchJson(manifestUrl);
1211
+
1212
+ marketPlugins.push({
1213
+ name: manifest.name || dir.name,
1214
+ description: manifest.description || '',
1215
+ author: manifest.author || repo.owner,
1216
+ version: manifest.version || '1.0.0',
1217
+ repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1218
+ repoOwner: repo.owner,
1219
+ repoName: repo.name,
1220
+ repoBranch: branch,
1221
+ directory: dir.name,
1222
+ commands: manifest.commands || [],
1223
+ hooks: manifest.hooks || [],
1224
+ isInstalled: false
1225
+ });
1226
+ } catch (e) {
1227
+ // OpenCode 仓库常见 package.json 格式
1228
+ if (this._isOpenCode()) {
1229
+ try {
1230
+ const pkgUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${dir.name}/package.json`;
1231
+ const pkg = await this._fetchJson(pkgUrl);
1232
+ const pluginName = pkg.name || dir.name;
1233
+ marketPlugins.push({
1234
+ name: pluginName,
1235
+ description: pkg.description || '',
1236
+ author: pkg.author || repo.owner,
1237
+ version: pkg.version || '1.0.0',
1238
+ repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1239
+ repoOwner: repo.owner,
1240
+ repoName: repo.name,
1241
+ repoBranch: branch,
1242
+ directory: dir.name,
1243
+ isInstalled: false
1244
+ });
1245
+ } catch (pkgErr) {
1246
+ // neither plugin.json nor package.json
1247
+ }
1248
+ }
1249
+ }
1250
+ }
1251
+ } catch (err) {
1252
+ console.error(`[PluginsService] Failed to fetch plugins from ${repo.owner}/${repo.name}:`, err.message);
1253
+ }
1254
+ }
1255
+
1256
+ // Mark installed plugins
1257
+ const installedPlugins = this.listPlugins().plugins;
1258
+ const installedNames = new Set(installedPlugins.map(p => p.name));
1259
+
1260
+ marketPlugins.forEach(plugin => {
1261
+ plugin.isInstalled = installedNames.has(plugin.name);
1262
+ });
1263
+
1264
+ return marketPlugins;
1265
+ }
1266
+ }
1267
+
1268
+ module.exports = { PluginsService };