coding-tool-x 3.4.3 → 3.4.5

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 (63) hide show
  1. package/dist/web/assets/{Analytics-CbGxotgz.js → Analytics-DFWyPf5C.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-oP6nrFEb.js → ConfigTemplates-BFE7hmKd.js} +1 -1
  3. package/dist/web/assets/{Home-DMntmEvh.js → Home-DZUuCrxk.js} +1 -1
  4. package/dist/web/assets/{PluginManager-BUC_c7nH.js → PluginManager-WyGY2BQN.js} +1 -1
  5. package/dist/web/assets/{ProjectList-CW8J49n7.js → ProjectList-CBc0QawN.js} +1 -1
  6. package/dist/web/assets/{ProjectList-oJIyIRkP.css → ProjectList-DL4JK6ci.css} +1 -1
  7. package/dist/web/assets/{SessionList-7lYnF92v.js → SessionList-CdPR7QLq.js} +1 -1
  8. package/dist/web/assets/{SkillManager-Cs08216i.js → SkillManager-B5-DxQOS.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-CY-oGtyB.js → WorkspaceManager-C7yqFjpi.js} +1 -1
  10. package/dist/web/assets/index-BDsmoSfO.js +2 -0
  11. package/dist/web/assets/{index-5qy5NMIP.css → index-C1pzEgmj.css} +1 -1
  12. package/dist/web/index.html +2 -2
  13. package/package.json +2 -2
  14. package/src/commands/channels.js +13 -13
  15. package/src/commands/cli-type.js +5 -5
  16. package/src/commands/daemon.js +31 -31
  17. package/src/commands/doctor.js +14 -14
  18. package/src/commands/export-config.js +23 -23
  19. package/src/commands/list.js +4 -4
  20. package/src/commands/logs.js +19 -19
  21. package/src/commands/plugin.js +62 -62
  22. package/src/commands/port-config.js +4 -4
  23. package/src/commands/proxy-control.js +35 -35
  24. package/src/commands/proxy.js +28 -28
  25. package/src/commands/resume.js +4 -4
  26. package/src/commands/search.js +9 -9
  27. package/src/commands/security.js +5 -5
  28. package/src/commands/stats.js +18 -18
  29. package/src/commands/switch.js +1 -1
  30. package/src/commands/toggle-proxy.js +18 -18
  31. package/src/commands/ui.js +11 -11
  32. package/src/commands/update.js +9 -9
  33. package/src/commands/workspace.js +11 -11
  34. package/src/index.js +24 -24
  35. package/src/plugins/plugin-installer.js +1 -1
  36. package/src/reset-config.js +9 -9
  37. package/src/server/api/channels.js +1 -1
  38. package/src/server/api/claude-hooks.js +3 -2
  39. package/src/server/api/plugins.js +165 -14
  40. package/src/server/api/pm2-autostart.js +2 -2
  41. package/src/server/api/proxy.js +6 -6
  42. package/src/server/api/skills.js +66 -7
  43. package/src/server/codex-proxy-server.js +10 -2
  44. package/src/server/dev-server.js +2 -2
  45. package/src/server/gemini-proxy-server.js +10 -2
  46. package/src/server/index.js +37 -37
  47. package/src/server/opencode-proxy-server.js +10 -2
  48. package/src/server/proxy-server.js +14 -6
  49. package/src/server/services/codex-channels.js +64 -21
  50. package/src/server/services/codex-env-manager.js +44 -28
  51. package/src/server/services/config-export-service.js +1 -1
  52. package/src/server/services/mcp-service.js +2 -1
  53. package/src/server/services/model-detector.js +2 -2
  54. package/src/server/services/native-keychain.js +1 -0
  55. package/src/server/services/plugins-service.js +1066 -261
  56. package/src/server/services/proxy-runtime.js +129 -5
  57. package/src/server/services/server-shutdown.js +79 -0
  58. package/src/server/services/settings-manager.js +3 -3
  59. package/src/server/services/skill-service.js +146 -29
  60. package/src/server/websocket-server.js +8 -8
  61. package/src/ui/menu.js +2 -2
  62. package/src/ui/prompts.js +5 -5
  63. package/dist/web/assets/index-ClCqKpvX.js +0 -2
@@ -5,51 +5,156 @@
5
5
  */
6
6
 
7
7
  const fs = require('fs');
8
+ const os = require('os');
9
+ const http = require('http');
10
+ const https = require('https');
8
11
  const path = require('path');
12
+ const { execFileSync } = require('child_process');
13
+ const AdmZip = require('adm-zip');
9
14
  const { listPlugins, getPlugin, updatePlugin: updatePluginRegistry } = require('../../plugins/registry');
10
15
  const { installPlugin: installPluginCore, uninstallPlugin: uninstallPluginCore } = require('../../plugins/plugin-installer');
11
16
  const { initializePlugins, shutdownPlugins } = require('../../plugins/plugin-manager');
12
17
  const { INSTALLED_DIR, CONFIG_DIR } = require('../../plugins/constants');
13
18
  const { NATIVE_PATHS, PATHS } = require('../../config/paths');
19
+ const { maskToken } = require('./oauth-utils');
14
20
 
15
21
  const CLAUDE_PLUGINS_DIR = path.join(path.dirname(NATIVE_PATHS.claude.settings), 'plugins');
16
22
  const CLAUDE_INSTALLED_FILE = path.join(CLAUDE_PLUGINS_DIR, 'installed_plugins.json');
17
23
  const CLAUDE_MARKETPLACES_FILE = path.join(CLAUDE_PLUGINS_DIR, 'known_marketplaces.json');
18
24
  const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
25
+ const REPO_SOURCE_META_FILE = '.cc-tool-plugin-source.json';
26
+ const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
27
+ const DEFAULT_GITHUB_HOST = 'https://github.com';
28
+ const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
19
29
  const DEFAULT_REPOS_BY_PLATFORM = {
20
30
  claude: [],
21
- opencode: [
22
- {
23
- owner: 'Tommertom',
24
- name: 'opencode-plugin-marketplace',
25
- url: 'https://github.com/Tommertom/opencode-plugin-marketplace',
26
- branch: 'main',
27
- enabled: true,
28
- source: 'opencode-default'
29
- },
30
- {
31
- owner: 'avifenesh',
32
- name: 'awesome-slash',
33
- url: 'https://github.com/avifenesh/awesome-slash',
34
- branch: 'main',
35
- enabled: true,
36
- source: 'opencode-default'
37
- },
38
- {
39
- owner: 'NeoLabHQ',
40
- name: 'context-engineering-kit',
41
- url: 'https://github.com/NeoLabHQ/context-engineering-kit',
42
- branch: 'master',
43
- enabled: true,
44
- source: 'opencode-default'
45
- }
46
- ]
31
+ opencode: []
47
32
  };
48
33
 
49
34
  function cloneRepos(repos = []) {
50
35
  return repos.map(repo => ({ ...repo }));
51
36
  }
52
37
 
38
+ function normalizeRepoToken(token = '') {
39
+ return String(token || '').trim();
40
+ }
41
+
42
+ function normalizeRepoPath(input = '') {
43
+ return String(input || '')
44
+ .replace(/\\/g, '/')
45
+ .replace(/^\/+/, '')
46
+ .replace(/\/+$/, '');
47
+ }
48
+
49
+ function normalizeRepoDirectory(directory = '') {
50
+ return normalizeRepoPath(directory);
51
+ }
52
+
53
+ function stripGitSuffix(value = '') {
54
+ return String(value || '').replace(/\.git$/i, '');
55
+ }
56
+
57
+ function isWindowsAbsolutePath(input = '') {
58
+ return /^[a-zA-Z]:[\\/]/.test(String(input || ''));
59
+ }
60
+
61
+ function expandHomePath(input = '') {
62
+ const normalized = String(input || '').trim();
63
+ if (!normalized) return '';
64
+ if (normalized.startsWith('~/')) {
65
+ return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir(), normalized.slice(2));
66
+ }
67
+ if (normalized === '~') {
68
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
69
+ }
70
+ if (normalized.startsWith('file://')) {
71
+ try {
72
+ return decodeURIComponent(new URL(normalized).pathname);
73
+ } catch {
74
+ return normalized;
75
+ }
76
+ }
77
+ return normalized;
78
+ }
79
+
80
+ function resolveLocalRepoPath(input = '') {
81
+ const expanded = expandHomePath(input);
82
+ if (!expanded) return '';
83
+ return path.resolve(expanded);
84
+ }
85
+
86
+ function normalizeRepoHost(host, provider = 'github') {
87
+ const fallback = provider === 'gitlab' ? DEFAULT_GITLAB_HOST : DEFAULT_GITHUB_HOST;
88
+ let normalized = String(host || '').trim();
89
+ if (!normalized) {
90
+ normalized = fallback;
91
+ }
92
+ if (!/^https?:\/\//i.test(normalized)) {
93
+ normalized = `https://${normalized}`;
94
+ }
95
+ try {
96
+ const parsed = new URL(normalized);
97
+ return `${parsed.protocol}//${parsed.host}`;
98
+ } catch {
99
+ return fallback;
100
+ }
101
+ }
102
+
103
+ function extractHostname(host = '') {
104
+ const normalized = String(host || '').trim();
105
+ if (!normalized) return '';
106
+ try {
107
+ return new URL(normalized).hostname || '';
108
+ } catch {
109
+ return normalized.replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
110
+ }
111
+ }
112
+
113
+ function buildRepoUrl(repo) {
114
+ if (repo.provider === 'local') {
115
+ return repo.localPath || '';
116
+ }
117
+ if (repo.provider === 'gitlab') {
118
+ return `${repo.host}/${repo.projectPath}`;
119
+ }
120
+ return `${repo.host}/${repo.owner}/${repo.name}`;
121
+ }
122
+
123
+ function buildRepoLabel(repo) {
124
+ if (repo.provider === 'local') {
125
+ return repo.localPath || '';
126
+ }
127
+ if (repo.provider === 'gitlab') {
128
+ return repo.projectPath || '';
129
+ }
130
+ return [repo.owner, repo.name].filter(Boolean).join('/');
131
+ }
132
+
133
+ function buildRepoId(repo) {
134
+ const branch = String(repo.branch || 'main').trim() || 'main';
135
+ const directory = normalizeRepoDirectory(repo.directory);
136
+ if (repo.provider === 'local') {
137
+ return `local:${repo.localPath}::${directory}`;
138
+ }
139
+ if (repo.provider === 'gitlab') {
140
+ return `gitlab:${repo.host}::${repo.projectPath}::${branch}::${directory}`;
141
+ }
142
+ return `github:${repo.host}::${repo.owner}/${repo.name}::${branch}::${directory}`;
143
+ }
144
+
145
+ function isLikelyLocalPath(input = '') {
146
+ const normalized = String(input || '').trim();
147
+ if (!normalized) return false;
148
+ return (
149
+ normalized.startsWith('/') ||
150
+ normalized.startsWith('~/') ||
151
+ normalized.startsWith('./') ||
152
+ normalized.startsWith('../') ||
153
+ normalized.startsWith('file://') ||
154
+ isWindowsAbsolutePath(normalized)
155
+ );
156
+ }
157
+
53
158
  function stripJsonComments(input = '') {
54
159
  let result = '';
55
160
  let inString = false;
@@ -106,6 +211,7 @@ function stripJsonComments(input = '') {
106
211
  class PluginsService {
107
212
  constructor(platform = 'claude') {
108
213
  this.platform = ['claude', 'opencode'].includes(platform) ? platform : 'claude';
214
+ this.configDir = PATHS.config || path.join((PATHS.base || process.env.HOME || os.homedir()), 'config');
109
215
  this.ccToolConfigDir = path.dirname(PATHS.pluginRepos.claude);
110
216
  this.opencodePluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugins');
111
217
  this.opencodeLegacyPluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugin');
@@ -115,6 +221,133 @@ class PluginsService {
115
221
  this._marketCache = null;
116
222
  }
117
223
 
224
+ normalizeRepoConfig(repo = {}) {
225
+ const provider = SUPPORTED_REPO_PROVIDERS.includes(repo.provider)
226
+ ? repo.provider
227
+ : (repo.localPath || isLikelyLocalPath(repo.url || '') ? 'local' : (repo.projectPath ? 'gitlab' : 'github'));
228
+ const rawRepoUrl = String(repo.repoUrl || repo.url || '').trim();
229
+ let parsedOwner = String(repo.owner || '').trim();
230
+ let parsedName = stripGitSuffix(repo.name || '');
231
+ let parsedProjectPath = normalizeRepoPath(repo.projectPath || '');
232
+
233
+ if (rawRepoUrl && !parsedProjectPath) {
234
+ try {
235
+ const parsedUrl = new URL(rawRepoUrl);
236
+ const pathParts = parsedUrl.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '').split('/').filter(Boolean);
237
+ if (provider === 'gitlab' && pathParts.length > 0) {
238
+ parsedProjectPath = pathParts.join('/');
239
+ } else if (pathParts.length >= 2) {
240
+ parsedOwner = parsedOwner || pathParts[0];
241
+ parsedName = parsedName || stripGitSuffix(pathParts[1]);
242
+ }
243
+ } catch {
244
+ // ignore invalid url, validation below will surface missing fields
245
+ }
246
+ }
247
+
248
+ const normalized = {
249
+ provider,
250
+ branch: String(repo.branch || 'main').trim() || 'main',
251
+ directory: normalizeRepoDirectory(repo.directory),
252
+ enabled: repo.enabled !== false
253
+ };
254
+
255
+ if (provider === 'local') {
256
+ normalized.localPath = resolveLocalRepoPath(repo.localPath || repo.path || repo.url || '');
257
+ if (!normalized.localPath) {
258
+ throw new Error('Missing local repository path');
259
+ }
260
+ normalized.name = path.basename(normalized.localPath) || 'local-repo';
261
+ } else if (provider === 'gitlab') {
262
+ normalized.host = normalizeRepoHost(repo.host, 'gitlab');
263
+ normalized.projectPath = normalizeRepoPath(parsedProjectPath || [parsedOwner, parsedName].filter(Boolean).join('/'));
264
+ if (!normalized.projectPath) {
265
+ throw new Error('Missing GitLab project path');
266
+ }
267
+ normalized.name = stripGitSuffix(normalized.projectPath.split('/').pop() || '');
268
+ normalized.owner = normalized.projectPath.split('/')[0] || '';
269
+ } else {
270
+ normalized.host = normalizeRepoHost(repo.host, 'github');
271
+ normalized.owner = parsedOwner;
272
+ normalized.name = parsedName;
273
+ if (!normalized.owner || !normalized.name) {
274
+ throw new Error('Repository owner and name are required');
275
+ }
276
+ }
277
+
278
+ normalized.repoUrl = repo.repoUrl || repo.url || buildRepoUrl(normalized);
279
+ normalized.url = normalized.repoUrl;
280
+ normalized.label = buildRepoLabel(normalized);
281
+ normalized.id = buildRepoId(normalized);
282
+
283
+ if (provider !== 'local') {
284
+ const token = normalizeRepoToken(repo.token);
285
+ if (token) {
286
+ normalized.token = token;
287
+ }
288
+ }
289
+
290
+ if (repo.source) normalized.source = repo.source;
291
+ if (repo.marketplace) normalized.marketplace = repo.marketplace;
292
+ if (repo.lastUpdated) normalized.lastUpdated = repo.lastUpdated;
293
+ if (repo.addedAt) normalized.addedAt = repo.addedAt;
294
+
295
+ return normalized;
296
+ }
297
+
298
+ normalizeRepos(repos = []) {
299
+ return repos.map(repo => this.normalizeRepoConfig(repo));
300
+ }
301
+
302
+ toClientRepo(repo = {}) {
303
+ const normalizedRepo = this.normalizeRepoConfig(repo);
304
+ const token = normalizeRepoToken(normalizedRepo.token);
305
+ const clientRepo = {
306
+ ...normalizedRepo,
307
+ hasToken: Boolean(token),
308
+ tokenPreview: token ? maskToken(token) : ''
309
+ };
310
+ delete clientRepo.token;
311
+ return clientRepo;
312
+ }
313
+
314
+ getReposForClient(repos = null) {
315
+ const sourceRepos = Array.isArray(repos) ? repos : this.getRepos();
316
+ return sourceRepos.map(repo => this.toClientRepo(repo));
317
+ }
318
+
319
+ findStoredRepo(repo = {}) {
320
+ const repoId = String(repo.id || repo.repoId || '').trim();
321
+ const repos = this.loadReposConfig().repos;
322
+
323
+ if (repoId) {
324
+ return repos.find(candidate => candidate.id === repoId) || null;
325
+ }
326
+
327
+ try {
328
+ const normalizedRepo = this.normalizeRepoConfig(repo);
329
+ return repos.find(candidate => candidate.id === normalizedRepo.id) || null;
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+
335
+ resolveRepoToken(repo = null) {
336
+ if (!repo || typeof repo !== 'object') return null;
337
+
338
+ const directToken = normalizeRepoToken(repo.token);
339
+ if (directToken) {
340
+ return directToken;
341
+ }
342
+
343
+ const storedRepo = this.findStoredRepo(repo);
344
+ if (!storedRepo) {
345
+ return null;
346
+ }
347
+
348
+ return normalizeRepoToken(storedRepo.token) || null;
349
+ }
350
+
118
351
  clearMarketCache({ removeFile = true } = {}) {
119
352
  this._marketCache = null;
120
353
  if (removeFile) {
@@ -163,8 +396,12 @@ class PluginsService {
163
396
  for (const plugin of preparedPlugins) {
164
397
  const key = [
165
398
  plugin.name || '',
399
+ plugin.repoId || '',
400
+ plugin.repoProvider || '',
166
401
  plugin.repoOwner || '',
167
402
  plugin.repoName || '',
403
+ plugin.repoProjectPath || '',
404
+ plugin.repoLocalPath || '',
168
405
  plugin.directory || plugin.installSource || ''
169
406
  ].join('::');
170
407
  if (seen.has(key)) continue;
@@ -254,6 +491,7 @@ class PluginsService {
254
491
 
255
492
  if (entry.isDirectory()) {
256
493
  const pkgPath = path.join(fullPath, 'package.json');
494
+ const repoSourceMeta = this.readRepoSourceMeta(fullPath) || {};
257
495
  let packageName = entry.name;
258
496
  let description = '';
259
497
  let version = '1.0.0';
@@ -276,7 +514,8 @@ class PluginsService {
276
514
  description,
277
515
  installed: true,
278
516
  enabled: true,
279
- pluginType: 'local'
517
+ pluginType: 'local',
518
+ ...repoSourceMeta
280
519
  });
281
520
  continue;
282
521
  }
@@ -350,7 +589,16 @@ class PluginsService {
350
589
  // Read plugin.json from installPath for description
351
590
  let description = '';
352
591
  let source = install.source || '';
353
- let repoUrl = '';
592
+ let repoUrl = install.repoUrl || '';
593
+ let repoProvider = install.repoProvider || '';
594
+ let repoOwner = install.repoOwner || '';
595
+ let repoName = install.repoName || '';
596
+ let repoBranch = install.repoBranch || 'main';
597
+ let repoDirectory = install.repoDirectory || '';
598
+ let repoHost = install.repoHost || '';
599
+ let repoProjectPath = install.repoProjectPath || '';
600
+ let repoLocalPath = install.repoLocalPath || '';
601
+ let repoId = install.repoId || '';
354
602
 
355
603
  if (install.installPath && fs.existsSync(install.installPath)) {
356
604
  const manifestPath = path.join(install.installPath, 'plugin.json');
@@ -362,13 +610,28 @@ class PluginsService {
362
610
  // Ignore parse errors
363
611
  }
364
612
  }
613
+
614
+ const repoSourceMeta = this.readRepoSourceMeta(install.installPath) || {};
615
+ repoUrl = repoUrl || repoSourceMeta.repoUrl || '';
616
+ repoProvider = repoProvider || repoSourceMeta.repoProvider || '';
617
+ repoOwner = repoOwner || repoSourceMeta.repoOwner || '';
618
+ repoName = repoName || repoSourceMeta.repoName || '';
619
+ repoBranch = repoBranch || repoSourceMeta.repoBranch || 'main';
620
+ repoDirectory = repoDirectory || repoSourceMeta.repoDirectory || '';
621
+ repoHost = repoHost || repoSourceMeta.repoHost || '';
622
+ repoProjectPath = repoProjectPath || repoSourceMeta.repoProjectPath || '';
623
+ repoLocalPath = repoLocalPath || repoSourceMeta.repoLocalPath || '';
624
+ repoId = repoId || repoSourceMeta.repoId || '';
365
625
  }
366
626
 
367
627
  // Parse repoUrl from source if available
368
- if (source) {
628
+ if (!repoUrl && source) {
369
629
  const match = source.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
370
630
  if (match) {
371
631
  repoUrl = `https://github.com/${match[1]}/${match[2]}`;
632
+ repoProvider = repoProvider || 'github';
633
+ repoOwner = repoOwner || match[1];
634
+ repoName = repoName || match[2];
372
635
  }
373
636
  }
374
637
 
@@ -386,7 +649,17 @@ class PluginsService {
386
649
  enabled: enabledState,
387
650
  description,
388
651
  source,
389
- repoUrl
652
+ repoUrl,
653
+ repoProvider,
654
+ repoOwner,
655
+ repoName,
656
+ repoBranch,
657
+ directory: repoDirectory || install.installPath || '',
658
+ repoDirectory,
659
+ repoHost,
660
+ repoProjectPath,
661
+ repoLocalPath,
662
+ repoId
390
663
  });
391
664
  }
392
665
  }
@@ -459,14 +732,13 @@ class PluginsService {
459
732
  */
460
733
  async installPlugin(source, repoInfo = null) {
461
734
  if (this._isOpenCode()) {
462
- if (repoInfo && repoInfo.owner && repoInfo.name && repoInfo.directory) {
463
- return this._installFromGitHubDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
735
+ if (repoInfo && repoInfo.directory) {
736
+ return this._installFromRepoDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
464
737
  }
465
738
 
466
- const treeMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
467
- if (treeMatch) {
468
- const [, owner, name, branch, directory] = treeMatch;
469
- return this._installFromGitHubDirectory({ owner, name, branch, directory }, { installRoot: this._getOpenCodePluginsDir() });
739
+ const parsedSource = this.parseRepoTreeSource(source);
740
+ if (parsedSource) {
741
+ return this._installFromRepoDirectory(parsedSource, { installRoot: this._getOpenCodePluginsDir() });
470
742
  }
471
743
 
472
744
  // OpenCode 原生支持 npm 包名,通过 opencode.json 的 plugin 数组管理
@@ -488,16 +760,13 @@ class PluginsService {
488
760
  };
489
761
  }
490
762
 
491
- // If repoInfo is provided, download from GitHub directly
492
- if (repoInfo && repoInfo.owner && repoInfo.name && repoInfo.directory) {
493
- return await this._installFromGitHubDirectory(repoInfo);
763
+ if (repoInfo && repoInfo.directory) {
764
+ return await this._installFromRepoDirectory(repoInfo);
494
765
  }
495
766
 
496
- // Parse tree URL format: https://github.com/owner/repo/tree/branch/path
497
- const treeMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
498
- if (treeMatch) {
499
- const [, owner, name, branch, directory] = treeMatch;
500
- return await this._installFromGitHubDirectory({ owner, name, branch, directory });
767
+ const parsedSource = this.parseRepoTreeSource(source);
768
+ if (parsedSource) {
769
+ return await this._installFromRepoDirectory(parsedSource);
501
770
  }
502
771
 
503
772
  // Fallback to original git clone method
@@ -505,25 +774,25 @@ class PluginsService {
505
774
  }
506
775
 
507
776
  /**
508
- * Install plugin from GitHub directory
777
+ * Install plugin from repo directory
509
778
  * @private
510
779
  */
511
- async _installFromGitHubDirectory(repoInfo, options = {}) {
512
- const { owner, name, branch, directory } = repoInfo;
513
- const https = require('https');
780
+ async _installFromRepoDirectory(repoInfo, options = {}) {
781
+ const normalizedRepo = this.normalizeRepoConfig(repoInfo);
782
+ const directory = normalizeRepoPath(repoInfo.directory || '');
514
783
  const pluginName = directory.split('/').pop();
515
784
  const installRoot = options.installRoot || INSTALLED_DIR;
516
785
 
517
786
  try {
518
- // Fetch plugin.json from the directory
519
- const manifestUrl = `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${directory}/plugin.json`;
520
787
  let manifest;
521
-
522
788
  try {
523
- manifest = await this._fetchJson(manifestUrl);
524
- } catch (e) {
525
- // No plugin.json, create a basic manifest
526
- manifest = { name: pluginName, version: '1.0.0' };
789
+ manifest = await this.fetchRepoJson(normalizedRepo, `${directory}/plugin.json`);
790
+ } catch {
791
+ try {
792
+ manifest = await this.fetchRepoJson(normalizedRepo, `${directory}/package.json`);
793
+ } catch {
794
+ manifest = { name: pluginName, version: '1.0.0' };
795
+ }
527
796
  }
528
797
 
529
798
  // Create plugin directory
@@ -532,14 +801,55 @@ class PluginsService {
532
801
  fs.mkdirSync(pluginDir, { recursive: true });
533
802
  }
534
803
 
535
- // Download all files from the directory
536
- const contentsUrl = `https://api.github.com/repos/${owner}/${name}/contents/${directory}?ref=${branch}`;
537
- const contents = await this._fetchJson(contentsUrl);
804
+ if (normalizedRepo.provider === 'local') {
805
+ const sourceDir = path.join(normalizedRepo.localPath, directory);
806
+ if (!fs.existsSync(sourceDir)) {
807
+ throw new Error(`Plugin directory not found: ${directory}`);
808
+ }
809
+ this.copyDirRecursive(sourceDir, pluginDir);
810
+ } else {
811
+ const tempDir = path.join(os.tmpdir(), `plugin-${Date.now()}`);
812
+ const zipPath = path.join(tempDir, 'repo.zip');
813
+ fs.mkdirSync(tempDir, { recursive: true });
814
+
815
+ try {
816
+ let zipUrl = '';
817
+ let zipHeaders = {};
818
+
819
+ if (normalizedRepo.provider === 'gitlab') {
820
+ const projectId = encodeURIComponent(normalizedRepo.projectPath);
821
+ zipUrl = `${normalizedRepo.host}/api/v4/projects/${projectId}/repository/archive.zip?sha=${encodeURIComponent(normalizedRepo.branch)}`;
822
+ const token = this.getGitLabToken(normalizedRepo);
823
+ if (token) {
824
+ zipHeaders['PRIVATE-TOKEN'] = token;
825
+ }
826
+ } else {
827
+ zipUrl = `https://api.github.com/repos/${normalizedRepo.owner}/${normalizedRepo.name}/zipball/${encodeURIComponent(normalizedRepo.branch)}`;
828
+ const token = this.getGitHubToken(normalizedRepo);
829
+ zipHeaders.Accept = 'application/vnd.github+json';
830
+ if (token) {
831
+ zipHeaders.Authorization = `token ${token}`;
832
+ }
833
+ }
834
+
835
+ await this.downloadFile(zipUrl, zipPath, zipHeaders);
836
+ const zip = new AdmZip(zipPath);
837
+ zip.extractAllTo(tempDir, true);
538
838
 
539
- for (const item of contents) {
540
- if (item.type === 'file') {
541
- const fileContent = await this._fetchRawFile(item.download_url);
542
- fs.writeFileSync(path.join(pluginDir, item.name), fileContent);
839
+ const extractedDir = fs.readdirSync(tempDir).find(item =>
840
+ fs.statSync(path.join(tempDir, item)).isDirectory()
841
+ );
842
+ if (!extractedDir) {
843
+ throw new Error('Empty archive');
844
+ }
845
+
846
+ const sourceDir = path.join(tempDir, extractedDir, directory);
847
+ if (!fs.existsSync(sourceDir)) {
848
+ throw new Error(`Plugin directory not found: ${directory}`);
849
+ }
850
+ this.copyDirRecursive(sourceDir, pluginDir);
851
+ } finally {
852
+ fs.rmSync(tempDir, { recursive: true, force: true });
543
853
  }
544
854
  }
545
855
 
@@ -552,7 +862,20 @@ class PluginsService {
552
862
  if (!this._isOpenCode()) {
553
863
  const installedPluginName = manifest.name || pluginName;
554
864
  const installTimestamp = new Date().toISOString();
555
- const sourceUrl = `https://github.com/${owner}/${name}/tree/${branch}/${directory}`;
865
+ const sourceUrl = this.buildRepoBrowserUrl(normalizedRepo, directory) || buildRepoUrl(normalizedRepo);
866
+ const repoSourceMeta = {
867
+ repoId: normalizedRepo.id,
868
+ repoProvider: normalizedRepo.provider,
869
+ repoOwner: normalizedRepo.owner || '',
870
+ repoName: normalizedRepo.name || '',
871
+ repoBranch: normalizedRepo.branch,
872
+ repoDirectory: directory,
873
+ repoHost: normalizedRepo.host || '',
874
+ repoProjectPath: normalizedRepo.projectPath || '',
875
+ repoLocalPath: normalizedRepo.localPath || '',
876
+ repoUrl: normalizedRepo.repoUrl || buildRepoUrl(normalizedRepo),
877
+ source: sourceUrl
878
+ };
556
879
 
557
880
  // Register in CTX legacy registry (for listPlugins fallback)
558
881
  const { addPlugin } = require('../../plugins/registry');
@@ -567,6 +890,8 @@ class PluginsService {
567
890
  console.warn('[PluginsService] Legacy registry addPlugin warning:', e.message);
568
891
  }
569
892
 
893
+ this.writeRepoSourceMeta(pluginDir, repoSourceMeta);
894
+
570
895
  // Also register in Claude's native installed_plugins.json
571
896
  try {
572
897
  this._ensureDir(CLAUDE_PLUGINS_DIR);
@@ -583,7 +908,8 @@ class PluginsService {
583
908
  installPath: pluginDir,
584
909
  installedAt: installTimestamp,
585
910
  scope: 'user',
586
- source: sourceUrl
911
+ source: sourceUrl,
912
+ ...repoSourceMeta
587
913
  }];
588
914
  fs.writeFileSync(CLAUDE_INSTALLED_FILE, JSON.stringify(nativeData, null, 2), 'utf8');
589
915
  } catch (e) {
@@ -608,26 +934,50 @@ class PluginsService {
608
934
  }
609
935
 
610
936
  /**
611
- * Fetch raw file content
937
+ * Parse GitHub/GitLab tree URL or local path
612
938
  * @private
613
939
  */
614
- async _fetchRawFile(url) {
615
- const https = require('https');
616
- return new Promise((resolve, reject) => {
617
- https.get(url, {
618
- headers: { 'User-Agent': 'coding-tool-x' }
619
- }, (res) => {
620
- let data = '';
621
- res.on('data', chunk => data += chunk);
622
- res.on('end', () => {
623
- if (res.statusCode === 200) {
624
- resolve(data);
625
- } else {
626
- reject(new Error(`HTTP ${res.statusCode}`));
627
- }
628
- });
629
- }).on('error', reject);
630
- });
940
+ parseRepoTreeSource(source = '') {
941
+ const value = String(source || '').trim();
942
+ if (!value) return null;
943
+
944
+ if (isLikelyLocalPath(value)) {
945
+ return {
946
+ provider: 'local',
947
+ localPath: value,
948
+ directory: ''
949
+ };
950
+ }
951
+
952
+ let parsed;
953
+ try {
954
+ parsed = new URL(value);
955
+ } catch {
956
+ return null;
957
+ }
958
+
959
+ const parts = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '').split('/').filter(Boolean);
960
+ if (parsed.hostname.includes('github')) {
961
+ if (parts.length < 4 || parts[2] !== 'tree') return null;
962
+ return {
963
+ provider: 'github',
964
+ host: `${parsed.protocol}//${parsed.host}`,
965
+ owner: parts[0],
966
+ name: parts[1],
967
+ branch: parts[3],
968
+ directory: parts.slice(4).join('/')
969
+ };
970
+ }
971
+
972
+ const treeIndex = parts.findIndex((part, index) => part === '-' && parts[index + 1] === 'tree');
973
+ if (treeIndex < 0 || !parts[treeIndex + 2]) return null;
974
+ return {
975
+ provider: 'gitlab',
976
+ host: `${parsed.protocol}//${parsed.host}`,
977
+ projectPath: parts.slice(0, treeIndex).join('/'),
978
+ branch: parts[treeIndex + 2],
979
+ directory: parts.slice(treeIndex + 3).join('/')
980
+ };
631
981
  }
632
982
 
633
983
  /**
@@ -862,17 +1212,17 @@ class PluginsService {
862
1212
  const configPath = this.getReposConfigPath();
863
1213
  const defaultRepos = this._getDefaultRepos();
864
1214
  if (!fs.existsSync(configPath)) {
865
- return { repos: defaultRepos };
1215
+ return { repos: this.normalizeRepos(defaultRepos) };
866
1216
  }
867
1217
  try {
868
1218
  const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
869
1219
  if (parsed && Array.isArray(parsed.repos)) {
870
- return parsed;
1220
+ return { ...parsed, repos: this.normalizeRepos(parsed.repos) };
871
1221
  }
872
- return { repos: defaultRepos };
1222
+ return { repos: this.normalizeRepos(defaultRepos) };
873
1223
  } catch (err) {
874
1224
  console.error('Failed to load repos config:', err);
875
- return { repos: defaultRepos };
1225
+ return { repos: this.normalizeRepos(defaultRepos) };
876
1226
  }
877
1227
  }
878
1228
 
@@ -882,7 +1232,8 @@ class PluginsService {
882
1232
  */
883
1233
  saveReposConfig(config) {
884
1234
  const configPath = this.getReposConfigPath();
885
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
1235
+ const normalizedRepos = this.normalizeRepos(config?.repos || []);
1236
+ fs.writeFileSync(configPath, JSON.stringify({ ...(config || {}), repos: normalizedRepos }, null, 2), 'utf8');
886
1237
  }
887
1238
 
888
1239
  /**
@@ -894,10 +1245,16 @@ class PluginsService {
894
1245
  const repos = [];
895
1246
  const seenRepos = new Set();
896
1247
  const pushRepo = (repo) => {
897
- if (!repo || !repo.owner || !repo.name) return;
898
- const key = `${repo.owner}/${repo.name}`;
1248
+ if (!repo) return;
1249
+ let normalizedRepo;
1250
+ try {
1251
+ normalizedRepo = this.normalizeRepoConfig(repo);
1252
+ } catch {
1253
+ return;
1254
+ }
1255
+ const key = normalizedRepo.id;
899
1256
  if (seenRepos.has(key)) return;
900
- repos.push(repo);
1257
+ repos.push(normalizedRepo);
901
1258
  seenRepos.add(key);
902
1259
  };
903
1260
  const parseRepoUrl = (url) => {
@@ -932,9 +1289,10 @@ class PluginsService {
932
1289
  const parsed = parseRepoUrl(sourceUrl);
933
1290
  if (!parsed) continue;
934
1291
  pushRepo({
1292
+ provider: 'github',
935
1293
  owner: parsed.owner,
936
1294
  name: parsed.name,
937
- url: parsed.url,
1295
+ repoUrl: parsed.url,
938
1296
  branch: data?.source?.branch || data?.branch || 'main',
939
1297
  enabled: data?.enabled !== false,
940
1298
  source: 'claude-native',
@@ -957,51 +1315,22 @@ class PluginsService {
957
1315
  */
958
1316
  addRepo(repo) {
959
1317
  const config = this.loadReposConfig();
1318
+ const normalizedRepo = this.normalizeRepoConfig({
1319
+ ...repo,
1320
+ addedAt: repo.addedAt || new Date().toISOString()
1321
+ });
1322
+ const existingIndex = config.repos.findIndex(r => r.id === normalizedRepo.id);
960
1323
 
961
- // Parse URL if provided
962
- let owner = repo.owner;
963
- let name = repo.name;
964
- let url = repo.url;
965
-
966
- if (url && !owner && !name) {
967
- // Extract owner/name from URL
968
- const match = url.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
969
- if (match) {
970
- owner = match[1];
971
- name = match[2];
972
- }
973
- }
974
-
975
- if (!owner || !name) {
976
- throw new Error('Repository owner and name are required');
977
- }
978
-
979
- // Construct URL if not provided
980
- if (!url) {
981
- url = `https://github.com/${owner}/${name}`;
982
- }
983
-
984
- // Check if repo already exists
985
- const exists = config.repos.some(r => r.owner === owner && r.name === name);
986
- if (exists) {
987
- throw new Error(`Repository ${owner}/${name} already exists`);
1324
+ if (existingIndex >= 0) {
1325
+ config.repos[existingIndex] = normalizedRepo;
1326
+ } else {
1327
+ config.repos.push(normalizedRepo);
988
1328
  }
989
1329
 
990
- // Add new repo
991
- const newRepo = {
992
- owner,
993
- name,
994
- url,
995
- branch: repo.branch || 'main',
996
- enabled: repo.enabled !== false,
997
- addedAt: new Date().toISOString()
998
- };
999
-
1000
- config.repos.push(newRepo);
1001
1330
  this.saveReposConfig(config);
1002
1331
  this.clearMarketCache();
1003
1332
 
1004
- return config.repos;
1333
+ return this.getRepos();
1005
1334
  }
1006
1335
 
1007
1336
  /**
@@ -1010,12 +1339,17 @@ class PluginsService {
1010
1339
  * @param {string} name - Repository name
1011
1340
  * @returns {Array} Updated repos list
1012
1341
  */
1013
- removeRepo(owner, name) {
1342
+ removeRepo(owner, name, repoId = '') {
1014
1343
  const config = this.loadReposConfig();
1015
- config.repos = config.repos.filter(r => !(r.owner === owner && r.name === name));
1344
+ config.repos = config.repos.filter(r => {
1345
+ if (repoId) {
1346
+ return r.id !== repoId;
1347
+ }
1348
+ return !(r.owner === owner && r.name === name);
1349
+ });
1016
1350
  this.saveReposConfig(config);
1017
1351
  this.clearMarketCache();
1018
- return config.repos;
1352
+ return this.getRepos();
1019
1353
  }
1020
1354
 
1021
1355
  /**
@@ -1025,16 +1359,460 @@ class PluginsService {
1025
1359
  * @param {boolean} enabled - Enable or disable
1026
1360
  * @returns {Array} Updated repos list
1027
1361
  */
1028
- toggleRepo(owner, name, enabled) {
1362
+ toggleRepo(owner, name, enabled, repoId = '') {
1029
1363
  const config = this.loadReposConfig();
1030
- const repo = config.repos.find(r => r.owner === owner && r.name === name);
1364
+ const repo = config.repos.find(r => {
1365
+ if (repoId) return r.id === repoId;
1366
+ return r.owner === owner && r.name === name;
1367
+ });
1031
1368
  if (!repo) {
1032
- throw new Error(`Repository ${owner}/${name} not found`);
1369
+ throw new Error('Repository not found');
1033
1370
  }
1034
1371
  repo.enabled = enabled;
1035
1372
  this.saveReposConfig(config);
1036
1373
  this.clearMarketCache();
1037
- return config.repos;
1374
+ return this.getRepos();
1375
+ }
1376
+
1377
+ updateRepoAuth(owner, name, token = '', clearToken = false, repoId = '') {
1378
+ const config = this.loadReposConfig();
1379
+ const repo = config.repos.find(r => {
1380
+ if (repoId) return r.id === repoId;
1381
+ return r.owner === owner && r.name === name;
1382
+ });
1383
+
1384
+ if (!repo) {
1385
+ throw new Error('Repository not found');
1386
+ }
1387
+
1388
+ if (repo.provider === 'local') {
1389
+ throw new Error('Local repository does not support token auth');
1390
+ }
1391
+
1392
+ if (clearToken) {
1393
+ delete repo.token;
1394
+ } else {
1395
+ const normalizedToken = normalizeRepoToken(token);
1396
+ if (!normalizedToken) {
1397
+ throw new Error('Missing token');
1398
+ }
1399
+ repo.token = normalizedToken;
1400
+ }
1401
+
1402
+ this.saveReposConfig(config);
1403
+ this.clearMarketCache();
1404
+ return this.getRepos();
1405
+ }
1406
+
1407
+ getTokenFromConfigFile(fileName) {
1408
+ try {
1409
+ const configPath = path.join(this.configDir, fileName);
1410
+ if (fs.existsSync(configPath)) {
1411
+ return fs.readFileSync(configPath, 'utf-8').trim() || null;
1412
+ }
1413
+ } catch {
1414
+ // ignore
1415
+ }
1416
+ return null;
1417
+ }
1418
+
1419
+ getTokenFromCommand(command, args = []) {
1420
+ try {
1421
+ const output = execFileSync(command, args, {
1422
+ encoding: 'utf-8',
1423
+ timeout: 3000,
1424
+ stdio: ['ignore', 'pipe', 'ignore'],
1425
+ windowsHide: true
1426
+ }).trim();
1427
+ return output || null;
1428
+ } catch {
1429
+ return null;
1430
+ }
1431
+ }
1432
+
1433
+ getTokenFromGitCredential(host) {
1434
+ const hostname = extractHostname(host);
1435
+ if (!hostname) return null;
1436
+
1437
+ try {
1438
+ const output = execFileSync('git', ['credential', 'fill'], {
1439
+ input: `protocol=https\nhost=${hostname}\n\n`,
1440
+ encoding: 'utf-8',
1441
+ timeout: 3000,
1442
+ stdio: ['pipe', 'pipe', 'ignore'],
1443
+ windowsHide: true
1444
+ });
1445
+ const passwordLine = output
1446
+ .split(/\r?\n/)
1447
+ .find(line => line.startsWith('password='));
1448
+ if (!passwordLine) return null;
1449
+ return passwordLine.slice('password='.length).trim() || null;
1450
+ } catch {
1451
+ return null;
1452
+ }
1453
+ }
1454
+
1455
+ getGitHubToken(repoOrHost = DEFAULT_GITHUB_HOST) {
1456
+ if (repoOrHost && typeof repoOrHost === 'object') {
1457
+ const repoToken = this.resolveRepoToken(repoOrHost);
1458
+ if (repoToken) {
1459
+ return repoToken;
1460
+ }
1461
+ }
1462
+
1463
+ const host = typeof repoOrHost === 'string'
1464
+ ? repoOrHost
1465
+ : (repoOrHost?.host || DEFAULT_GITHUB_HOST);
1466
+
1467
+ if (process.env.GITHUB_TOKEN) {
1468
+ return process.env.GITHUB_TOKEN;
1469
+ }
1470
+
1471
+ const configToken = this.getTokenFromConfigFile('github-token.txt');
1472
+ if (configToken) {
1473
+ return configToken;
1474
+ }
1475
+
1476
+ const hostname = extractHostname(host);
1477
+ if (hostname) {
1478
+ const ghHostToken = this.getTokenFromCommand('gh', ['auth', 'token', '--hostname', hostname]);
1479
+ if (ghHostToken) {
1480
+ return ghHostToken;
1481
+ }
1482
+ }
1483
+
1484
+ const ghToken = this.getTokenFromCommand('gh', ['auth', 'token']);
1485
+ if (ghToken) {
1486
+ return ghToken;
1487
+ }
1488
+
1489
+ return this.getTokenFromGitCredential(host);
1490
+ }
1491
+
1492
+ getGitLabToken(repoOrHost = DEFAULT_GITLAB_HOST) {
1493
+ if (repoOrHost && typeof repoOrHost === 'object') {
1494
+ const repoToken = this.resolveRepoToken(repoOrHost);
1495
+ if (repoToken) {
1496
+ return repoToken;
1497
+ }
1498
+ }
1499
+
1500
+ const host = typeof repoOrHost === 'string'
1501
+ ? repoOrHost
1502
+ : (repoOrHost?.host || DEFAULT_GITLAB_HOST);
1503
+
1504
+ if (process.env.GITLAB_TOKEN) {
1505
+ return process.env.GITLAB_TOKEN;
1506
+ }
1507
+ if (process.env.GITLAB_PRIVATE_TOKEN) {
1508
+ return process.env.GITLAB_PRIVATE_TOKEN;
1509
+ }
1510
+
1511
+ const configToken = this.getTokenFromConfigFile('gitlab-token.txt');
1512
+ if (configToken) {
1513
+ return configToken;
1514
+ }
1515
+
1516
+ const hostname = extractHostname(host);
1517
+ if (hostname) {
1518
+ const glabHostToken = this.getTokenFromCommand('glab', ['auth', 'token', '--hostname', hostname]);
1519
+ if (glabHostToken) {
1520
+ return glabHostToken;
1521
+ }
1522
+ }
1523
+
1524
+ const glabToken = this.getTokenFromCommand('glab', ['auth', 'token']);
1525
+ if (glabToken) {
1526
+ return glabToken;
1527
+ }
1528
+
1529
+ return this.getTokenFromGitCredential(host);
1530
+ }
1531
+
1532
+ async fetchGitHubApi(url, repo = null) {
1533
+ const token = this.getGitHubToken(repo || url);
1534
+ const headers = {
1535
+ 'User-Agent': 'coding-tool-x',
1536
+ 'Accept': 'application/vnd.github.v3+json'
1537
+ };
1538
+ if (token) {
1539
+ headers.Authorization = `token ${token}`;
1540
+ }
1541
+
1542
+ return new Promise((resolve, reject) => {
1543
+ const req = https.get(url, {
1544
+ headers,
1545
+ timeout: 15000
1546
+ }, (res) => {
1547
+ let data = '';
1548
+ res.on('data', chunk => data += chunk);
1549
+ res.on('end', () => {
1550
+ if (res.statusCode === 200) {
1551
+ try {
1552
+ resolve(JSON.parse(data));
1553
+ } catch {
1554
+ reject(new Error('Invalid JSON response'));
1555
+ }
1556
+ } else {
1557
+ reject(new Error(`GitHub API error: ${res.statusCode}`));
1558
+ }
1559
+ });
1560
+ });
1561
+
1562
+ req.on('error', reject);
1563
+ req.on('timeout', () => {
1564
+ req.destroy();
1565
+ reject(new Error('Request timeout'));
1566
+ });
1567
+ });
1568
+ }
1569
+
1570
+ async fetchGitLabApi(url, { raw = false, repo = null } = {}) {
1571
+ const token = this.getGitLabToken(repo || url);
1572
+ const headers = {
1573
+ 'User-Agent': 'coding-tool-x'
1574
+ };
1575
+ if (!raw) {
1576
+ headers.Accept = 'application/json';
1577
+ }
1578
+ if (token) {
1579
+ headers['PRIVATE-TOKEN'] = token;
1580
+ }
1581
+
1582
+ return new Promise((resolve, reject) => {
1583
+ const transport = url.startsWith('http:') ? http : https;
1584
+ const req = transport.get(url, {
1585
+ headers,
1586
+ timeout: 15000
1587
+ }, (res) => {
1588
+ let data = '';
1589
+ res.on('data', chunk => data += chunk);
1590
+ res.on('end', () => {
1591
+ if (res.statusCode === 200) {
1592
+ if (raw) {
1593
+ resolve(data);
1594
+ return;
1595
+ }
1596
+ try {
1597
+ resolve({
1598
+ data: JSON.parse(data),
1599
+ headers: res.headers
1600
+ });
1601
+ } catch {
1602
+ reject(new Error('Invalid JSON response'));
1603
+ }
1604
+ } else {
1605
+ reject(new Error(`GitLab API error: ${res.statusCode}`));
1606
+ }
1607
+ });
1608
+ });
1609
+
1610
+ req.on('error', reject);
1611
+ req.on('timeout', () => {
1612
+ req.destroy();
1613
+ reject(new Error('Request timeout'));
1614
+ });
1615
+ });
1616
+ }
1617
+
1618
+ async fetchGitHubRepoTree(repo) {
1619
+ const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
1620
+ const tree = await this.fetchGitHubApi(treeUrl, repo);
1621
+ return tree?.tree || [];
1622
+ }
1623
+
1624
+ async fetchGitLabTree(repo) {
1625
+ const tree = [];
1626
+ const projectId = encodeURIComponent(repo.projectPath);
1627
+ let page = 1;
1628
+
1629
+ while (page) {
1630
+ const url = `${repo.host}/api/v4/projects/${projectId}/repository/tree?ref=${encodeURIComponent(repo.branch)}&recursive=true&per_page=100&page=${page}`;
1631
+ const response = await this.fetchGitLabApi(url, { repo });
1632
+ tree.push(...(response.data || []).map(item => ({
1633
+ ...item,
1634
+ path: normalizeRepoPath(item.path),
1635
+ type: item.type === 'tree' ? 'tree' : 'blob'
1636
+ })));
1637
+ const nextPage = Number(response.headers['x-next-page'] || 0);
1638
+ page = Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 0;
1639
+ }
1640
+
1641
+ return tree;
1642
+ }
1643
+
1644
+ scanLocalRepoTree(currentDir, repoRoot, tree) {
1645
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
1646
+ for (const entry of entries) {
1647
+ if (entry.name.startsWith('.')) continue;
1648
+ const fullPath = path.join(currentDir, entry.name);
1649
+ const relativePath = normalizeRepoPath(path.relative(repoRoot, fullPath));
1650
+ if (entry.isDirectory()) {
1651
+ if (entry.name === 'node_modules') continue;
1652
+ tree.push({ path: relativePath, type: 'tree', name: entry.name });
1653
+ this.scanLocalRepoTree(fullPath, repoRoot, tree);
1654
+ } else {
1655
+ tree.push({ path: relativePath, type: 'blob', name: entry.name });
1656
+ }
1657
+ }
1658
+ }
1659
+
1660
+ async fetchLocalRepoTree(repo) {
1661
+ const tree = [];
1662
+ if (!fs.existsSync(repo.localPath)) {
1663
+ throw new Error(`Local repo path not found: ${repo.localPath}`);
1664
+ }
1665
+ this.scanLocalRepoTree(repo.localPath, repo.localPath, tree);
1666
+ return tree;
1667
+ }
1668
+
1669
+ async fetchRepoTree(repo) {
1670
+ if (repo.provider === 'local') {
1671
+ return this.fetchLocalRepoTree(repo);
1672
+ }
1673
+ if (repo.provider === 'gitlab') {
1674
+ return this.fetchGitLabTree(repo);
1675
+ }
1676
+ return this.fetchGitHubRepoTree(repo);
1677
+ }
1678
+
1679
+ async fetchGitHubFileContent(repo, filePath, file = null) {
1680
+ if (file?.sha) {
1681
+ const url = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/blobs/${file.sha}`;
1682
+ const data = await this.fetchGitHubApi(url, repo);
1683
+ if (!data || typeof data.content !== 'string') {
1684
+ throw new Error('Invalid GitHub blob response');
1685
+ }
1686
+ return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf-8');
1687
+ }
1688
+
1689
+ const normalizedPath = normalizeRepoPath(filePath);
1690
+ const url = `https://api.github.com/repos/${repo.owner}/${repo.name}/contents/${normalizedPath}?ref=${encodeURIComponent(repo.branch)}`;
1691
+ const data = await this.fetchGitHubApi(url, repo);
1692
+ if (typeof data.content !== 'string') {
1693
+ throw new Error('Invalid GitHub contents response');
1694
+ }
1695
+ return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf-8');
1696
+ }
1697
+
1698
+ async fetchGitLabFileContent(repo, filePath) {
1699
+ const projectId = encodeURIComponent(repo.projectPath);
1700
+ const normalizedFilePath = encodeURIComponent(normalizeRepoPath(filePath));
1701
+ const url = `${repo.host}/api/v4/projects/${projectId}/repository/files/${normalizedFilePath}/raw?ref=${encodeURIComponent(repo.branch)}`;
1702
+ return this.fetchGitLabApi(url, { raw: true, repo });
1703
+ }
1704
+
1705
+ async fetchRepoFileContent(repo, filePath, file = null) {
1706
+ if (repo.provider === 'local') {
1707
+ return fs.readFileSync(path.join(repo.localPath, filePath), 'utf-8');
1708
+ }
1709
+ if (repo.provider === 'gitlab') {
1710
+ return this.fetchGitLabFileContent(repo, filePath);
1711
+ }
1712
+ return this.fetchGitHubFileContent(repo, filePath, file);
1713
+ }
1714
+
1715
+ async fetchRepoJson(repo, filePath, file = null) {
1716
+ const content = await this.fetchRepoFileContent(repo, filePath, file);
1717
+ return JSON.parse(stripJsonComments(content));
1718
+ }
1719
+
1720
+ buildRepoBrowserUrl(repo, filePath = '') {
1721
+ const normalizedPath = normalizeRepoPath(filePath);
1722
+ if (repo.provider === 'local') {
1723
+ return null;
1724
+ }
1725
+ if (repo.provider === 'gitlab') {
1726
+ const suffix = normalizedPath ? `/-/tree/${repo.branch}/${normalizedPath}` : `/-/tree/${repo.branch}`;
1727
+ return `${repo.host}/${repo.projectPath}${suffix}`;
1728
+ }
1729
+ const suffix = normalizedPath ? `tree/${repo.branch}/${normalizedPath}` : `tree/${repo.branch}`;
1730
+ return `${repo.host}/${repo.owner}/${repo.name}/${suffix}`;
1731
+ }
1732
+
1733
+ writeRepoSourceMeta(pluginDir, metadata = {}) {
1734
+ try {
1735
+ fs.writeFileSync(
1736
+ path.join(pluginDir, REPO_SOURCE_META_FILE),
1737
+ JSON.stringify(metadata, null, 2),
1738
+ 'utf8'
1739
+ );
1740
+ } catch {
1741
+ // ignore
1742
+ }
1743
+ }
1744
+
1745
+ readRepoSourceMeta(pluginDir) {
1746
+ try {
1747
+ const metaPath = path.join(pluginDir, REPO_SOURCE_META_FILE);
1748
+ if (!fs.existsSync(metaPath)) return null;
1749
+ return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
1750
+ } catch {
1751
+ return null;
1752
+ }
1753
+ }
1754
+
1755
+ copyDirRecursive(sourceDir, destDir) {
1756
+ const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
1757
+ for (const entry of entries) {
1758
+ const sourcePath = path.join(sourceDir, entry.name);
1759
+ const destPath = path.join(destDir, entry.name);
1760
+ if (entry.isDirectory()) {
1761
+ if (!fs.existsSync(destPath)) {
1762
+ fs.mkdirSync(destPath, { recursive: true });
1763
+ }
1764
+ this.copyDirRecursive(sourcePath, destPath);
1765
+ } else {
1766
+ fs.copyFileSync(sourcePath, destPath);
1767
+ }
1768
+ }
1769
+ }
1770
+
1771
+ downloadFile(url, destination, headers = {}) {
1772
+ return new Promise((resolve, reject) => {
1773
+ const transport = url.startsWith('http:') ? http : https;
1774
+ const file = fs.createWriteStream(destination);
1775
+
1776
+ const req = transport.get(url, {
1777
+ headers: {
1778
+ 'User-Agent': 'coding-tool-x',
1779
+ ...headers
1780
+ },
1781
+ timeout: 30000
1782
+ }, (res) => {
1783
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
1784
+ file.close(() => {
1785
+ fs.unlink(destination, () => {
1786
+ this.downloadFile(res.headers.location, destination, headers).then(resolve).catch(reject);
1787
+ });
1788
+ });
1789
+ return;
1790
+ }
1791
+
1792
+ if (res.statusCode !== 200) {
1793
+ file.close(() => {
1794
+ fs.unlink(destination, () => reject(new Error(`HTTP ${res.statusCode}`)));
1795
+ });
1796
+ return;
1797
+ }
1798
+
1799
+ res.pipe(file);
1800
+ file.on('finish', () => file.close(resolve));
1801
+ });
1802
+
1803
+ req.on('error', (err) => {
1804
+ file.close(() => {
1805
+ fs.unlink(destination, () => reject(err));
1806
+ });
1807
+ });
1808
+
1809
+ req.on('timeout', () => {
1810
+ req.destroy();
1811
+ file.close(() => {
1812
+ fs.unlink(destination, () => reject(new Error('Request timeout')));
1813
+ });
1814
+ });
1815
+ });
1038
1816
  }
1039
1817
 
1040
1818
  /**
@@ -1051,16 +1829,21 @@ class PluginsService {
1051
1829
  const { execSync } = require('child_process');
1052
1830
 
1053
1831
  for (const repo of repos.filter(r => r.enabled)) {
1832
+ const repoRef = repo.repoUrl || repo.url || buildRepoUrl(repo);
1833
+ if (!repoRef || repo.provider === 'local') {
1834
+ results.push({ repo: repoRef || repo.id, success: false, error: 'Local repository sync is not supported by Claude marketplace' });
1835
+ continue;
1836
+ }
1054
1837
  try {
1055
- execSync(`claude plugin marketplace add ${repo.url}`, {
1838
+ execSync(`claude plugin marketplace add ${repoRef}`, {
1056
1839
  encoding: 'utf8',
1057
1840
  timeout: 30000,
1058
1841
  stdio: 'pipe',
1059
1842
  windowsHide: true
1060
1843
  });
1061
- results.push({ repo: repo.url, success: true });
1844
+ results.push({ repo: repoRef, success: true });
1062
1845
  } catch (err) {
1063
- results.push({ repo: repo.url, success: false, error: err.message });
1846
+ results.push({ repo: repoRef, success: false, error: err.message });
1064
1847
  }
1065
1848
  }
1066
1849
 
@@ -1075,32 +1858,6 @@ class PluginsService {
1075
1858
  return this.listPlugins();
1076
1859
  }
1077
1860
 
1078
- /**
1079
- * Fetch JSON from URL
1080
- * @private
1081
- */
1082
- async _fetchJson(url) {
1083
- const https = require('https');
1084
- return new Promise((resolve, reject) => {
1085
- https.get(url, {
1086
- headers: {
1087
- 'User-Agent': 'coding-tool-x',
1088
- 'Accept': 'application/vnd.github.v3+json'
1089
- }
1090
- }, (res) => {
1091
- let data = '';
1092
- res.on('data', chunk => data += chunk);
1093
- res.on('end', () => {
1094
- if (res.statusCode === 200) {
1095
- resolve(JSON.parse(data));
1096
- } else {
1097
- reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
1098
- }
1099
- });
1100
- }).on('error', reject);
1101
- });
1102
- }
1103
-
1104
1861
  /**
1105
1862
  * Get plugin README content
1106
1863
  * @param {Object} plugin - Plugin object with name, repoUrl, source, or repoInfo
@@ -1108,44 +1865,82 @@ class PluginsService {
1108
1865
  */
1109
1866
  async getPluginReadme(plugin) {
1110
1867
  try {
1111
- let readmeUrl = null;
1868
+ const normalizedDirectory = normalizeRepoPath(plugin.directory || '');
1869
+ const readmeCandidates = [];
1870
+ const pushReadmeCandidates = (directory = '') => {
1871
+ const base = normalizeRepoPath(directory);
1872
+ if (base) {
1873
+ readmeCandidates.push(`${base}/README.md`, `${base}/readme.md`);
1874
+ } else {
1875
+ readmeCandidates.push('README.md', 'readme.md');
1876
+ }
1877
+ };
1112
1878
 
1113
- // Case 1: Market plugin with repoInfo
1114
- if (plugin.repoOwner && plugin.repoName && plugin.directory) {
1115
- const branch = plugin.repoBranch || 'main';
1116
- readmeUrl = `https://raw.githubusercontent.com/${plugin.repoOwner}/${plugin.repoName}/${branch}/${plugin.directory}/README.md`;
1879
+ if (normalizedDirectory) {
1880
+ pushReadmeCandidates(normalizedDirectory);
1117
1881
  }
1118
- // Case 2: Installed plugin with source URL
1119
- else if (plugin.source) {
1120
- const treeMatch = plugin.source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
1121
- if (treeMatch) {
1122
- const [, owner, name, branch, directory] = treeMatch;
1123
- readmeUrl = `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${directory}/README.md`;
1124
- } else {
1125
- // Try to parse as regular repo URL
1126
- const repoMatch = plugin.source.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
1127
- if (repoMatch) {
1128
- const [, owner, name] = repoMatch;
1129
- readmeUrl = `https://raw.githubusercontent.com/${owner}/${name}/main/README.md`;
1882
+ pushReadmeCandidates('');
1883
+
1884
+ if (plugin.installPath && fs.existsSync(plugin.installPath)) {
1885
+ const localCandidates = normalizedDirectory
1886
+ ? [path.join(plugin.installPath, 'README.md'), path.join(plugin.installPath, 'readme.md')]
1887
+ : readmeCandidates.map(candidate => path.join(plugin.installPath, candidate));
1888
+ for (const candidatePath of localCandidates) {
1889
+ if (fs.existsSync(candidatePath)) {
1890
+ return fs.readFileSync(candidatePath, 'utf8');
1130
1891
  }
1131
1892
  }
1132
1893
  }
1133
- // Case 3: Plugin with repoUrl
1134
- else if (plugin.repoUrl) {
1135
- const match = plugin.repoUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
1136
- if (match) {
1137
- const [, owner, name] = match;
1138
- readmeUrl = `https://raw.githubusercontent.com/${owner}/${name}/main/README.md`;
1894
+
1895
+ let repo = null;
1896
+ if (plugin.repoProvider || plugin.repoLocalPath || plugin.repoProjectPath || plugin.repoOwner) {
1897
+ try {
1898
+ repo = this.normalizeRepoConfig({
1899
+ id: plugin.repoId,
1900
+ provider: plugin.repoProvider,
1901
+ host: plugin.repoHost,
1902
+ owner: plugin.repoOwner,
1903
+ name: plugin.repoName,
1904
+ branch: plugin.repoBranch || 'main',
1905
+ projectPath: plugin.repoProjectPath,
1906
+ localPath: plugin.repoLocalPath,
1907
+ repoUrl: plugin.repoUrl
1908
+ });
1909
+ } catch {
1910
+ repo = null;
1911
+ }
1912
+ } else if (plugin.source) {
1913
+ repo = this.parseRepoTreeSource(plugin.source);
1914
+ }
1915
+
1916
+ if (!repo && plugin.repoUrl) {
1917
+ const parsedTreeSource = this.parseRepoTreeSource(plugin.repoUrl);
1918
+ if (parsedTreeSource) {
1919
+ repo = parsedTreeSource;
1920
+ } else if (plugin.repoUrl.includes('github.com')) {
1921
+ const match = plugin.repoUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
1922
+ if (match) {
1923
+ repo = this.normalizeRepoConfig({
1924
+ provider: 'github',
1925
+ owner: match[1],
1926
+ name: match[2],
1927
+ branch: plugin.repoBranch || 'main'
1928
+ });
1929
+ }
1139
1930
  }
1140
1931
  }
1141
1932
 
1142
- if (!readmeUrl) {
1143
- return '';
1933
+ if (!repo) return '';
1934
+
1935
+ for (const candidate of readmeCandidates) {
1936
+ try {
1937
+ return await this.fetchRepoFileContent(repo, candidate);
1938
+ } catch {
1939
+ // try next candidate
1940
+ }
1144
1941
  }
1145
1942
 
1146
- // Fetch README content
1147
- const content = await this._fetchRawFile(readmeUrl);
1148
- return content;
1943
+ return '';
1149
1944
  } catch (err) {
1150
1945
  console.error('[PluginsService] Failed to fetch README:', err.message);
1151
1946
  return '';
@@ -1161,29 +1956,48 @@ class PluginsService {
1161
1956
  };
1162
1957
  }
1163
1958
 
1959
+ buildMarketPluginItem(repo, data = {}) {
1960
+ return {
1961
+ name: data.name,
1962
+ displayName: data.displayName || '',
1963
+ description: data.description || '',
1964
+ author: data.author || repo.owner || repo.projectPath || 'unknown',
1965
+ version: data.version || '1.0.0',
1966
+ category: data.category || 'general',
1967
+ repoUrl: data.repoUrl || repo.repoUrl || buildRepoUrl(repo),
1968
+ repoProvider: repo.provider,
1969
+ repoOwner: repo.owner || '',
1970
+ repoName: repo.name || '',
1971
+ repoBranch: repo.branch || 'main',
1972
+ repoHost: repo.host || '',
1973
+ repoProjectPath: repo.projectPath || '',
1974
+ repoLocalPath: repo.localPath || '',
1975
+ repoId: repo.id,
1976
+ directory: normalizeRepoPath(data.directory || data.name || ''),
1977
+ installSource: data.installSource || '',
1978
+ marketplaceFormat: data.marketplaceFormat || '',
1979
+ readmeUrl: this.buildRepoBrowserUrl(repo, data.directory || data.name || ''),
1980
+ lspServers: data.lspServers || null,
1981
+ commands: data.commands || [],
1982
+ hooks: data.hooks || [],
1983
+ isInstalled: false
1984
+ };
1985
+ }
1986
+
1164
1987
  async _fetchOpenCodeMarketplacePlugins(repo, branch) {
1165
1988
  if (!this._isOpenCode()) return [];
1166
1989
 
1167
- let entries;
1168
- try {
1169
- const indexUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/contents/plugins?ref=${branch}`;
1170
- entries = await this._fetchJson(indexUrl);
1171
- } catch (err) {
1172
- return [];
1173
- }
1174
-
1175
- if (!Array.isArray(entries)) return [];
1176
-
1177
- const manifestFiles = entries.filter(
1178
- item => item.type === 'file' && item.name.endsWith('.plugin.json')
1990
+ const tree = await this.fetchRepoTree(repo);
1991
+ const manifestFiles = tree.filter(item =>
1992
+ item.type === 'blob' &&
1993
+ item.path.startsWith('plugins/') &&
1994
+ item.path.endsWith('.plugin.json')
1179
1995
  );
1180
1996
  if (manifestFiles.length === 0) return [];
1181
1997
 
1182
1998
  const results = await Promise.allSettled(
1183
1999
  manifestFiles.map(async (file) => {
1184
- const fileUrl = file.download_url ||
1185
- `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${file.path}`;
1186
- const manifest = await this._fetchJson(fileUrl);
2000
+ const manifest = await this.fetchRepoJson(repo, file.path, file);
1187
2001
 
1188
2002
  const author = Array.isArray(manifest.authors)
1189
2003
  ? manifest.authors.map(item => item?.name).filter(Boolean).join(', ')
@@ -1195,22 +2009,18 @@ class PluginsService {
1195
2009
  const installSource = String(manifest.name || '').trim();
1196
2010
  const githubRepo = this._parseGitHubRepo(repoUrl);
1197
2011
 
1198
- return {
2012
+ return this.buildMarketPluginItem(repo, {
1199
2013
  name: manifest.name || file.name.replace(/\.plugin\.json$/, ''),
1200
2014
  displayName: manifest.displayName || '',
1201
2015
  description: manifest.description || '',
1202
2016
  author: author || repo.owner,
1203
2017
  version: manifest.version || manifest.opencode?.minimumVersion || '1.0.0',
1204
2018
  category: firstCategory ? String(firstCategory).toLowerCase() : 'general',
1205
- repoUrl,
1206
- repoOwner: '',
1207
- repoName: '',
1208
- repoBranch: githubRepo ? 'main' : branch,
1209
2019
  directory: file.path,
1210
- installSource,
2020
+ installSource: githubRepo ? '' : installSource,
1211
2021
  marketplaceFormat: 'opencode-plugin-json',
1212
- isInstalled: false
1213
- };
2022
+ repoUrl
2023
+ });
1214
2024
  })
1215
2025
  );
1216
2026
 
@@ -1249,40 +2059,43 @@ class PluginsService {
1249
2059
  let repoFailureCount = 0;
1250
2060
 
1251
2061
  for (const repo of repos) {
2062
+ const repoLabel = repo.label || repo.repoUrl || repo.localPath || `${repo.owner || ''}/${repo.name || ''}`;
2063
+ const pluginsBefore = marketPlugins.length;
1252
2064
  try {
1253
- const branch = repo.branch || 'main';
2065
+ const tree = await this.fetchRepoTree(repo);
2066
+ const files = tree.filter(item => item.type === 'blob');
2067
+ const fileMap = new Map(files.map(file => [normalizeRepoPath(file.path), file]));
2068
+ const readJson = async (filePath) => {
2069
+ const normalizedPath = normalizeRepoPath(filePath);
2070
+ const file = fileMap.get(normalizedPath);
2071
+ if (!file) {
2072
+ throw new Error(`File not found: ${normalizedPath}`);
2073
+ }
2074
+ return this.fetchRepoJson(repo, normalizedPath, file);
2075
+ };
1254
2076
 
1255
2077
  // Try to fetch marketplace.json first (official format)
1256
- try {
1257
- const marketplaceUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/.claude-plugin/marketplace.json`;
1258
- const marketplace = await this._fetchJson(marketplaceUrl);
1259
-
2078
+ if (fileMap.has('.claude-plugin/marketplace.json')) {
2079
+ const marketplace = await readJson('.claude-plugin/marketplace.json');
1260
2080
  if (marketplace && marketplace.plugins) {
1261
2081
  for (const plugin of marketplace.plugins) {
1262
- marketPlugins.push({
2082
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
1263
2083
  name: plugin.name,
1264
2084
  description: plugin.description || '',
1265
2085
  author: plugin.author?.name || marketplace.owner?.name || repo.owner,
1266
2086
  version: plugin.version || '1.0.0',
1267
2087
  category: plugin.category || 'general',
1268
- repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1269
- repoOwner: repo.owner,
1270
- repoName: repo.name,
1271
- repoBranch: branch,
1272
2088
  directory: plugin.source?.replace(/^\.\//, '') || plugin.name,
1273
- lspServers: plugin.lspServers || null,
1274
- isInstalled: false
1275
- });
2089
+ lspServers: plugin.lspServers || null
2090
+ }));
1276
2091
  }
1277
2092
  continue; // Skip legacy format check
1278
2093
  }
1279
- } catch (e) {
1280
- // marketplace.json not found, try legacy format
1281
2094
  }
1282
2095
 
1283
2096
  // OpenCode plugin marketplace format: plugins/*.plugin.json
1284
2097
  if (this._isOpenCode()) {
1285
- const openCodeMarketplacePlugins = await this._fetchOpenCodeMarketplacePlugins(repo, branch);
2098
+ const openCodeMarketplacePlugins = await this._fetchOpenCodeMarketplacePlugins(repo, repo.branch || 'main');
1286
2099
  if (openCodeMarketplacePlugins.length > 0) {
1287
2100
  marketPlugins.push(...openCodeMarketplacePlugins);
1288
2101
  continue;
@@ -1290,48 +2103,37 @@ class PluginsService {
1290
2103
  }
1291
2104
 
1292
2105
  // Legacy format: each directory is a plugin with plugin.json/package.json
1293
- const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/contents?ref=${branch}`;
1294
- const contents = await this._fetchJson(apiUrl);
1295
- const pluginDirs = contents.filter(item => item.type === 'dir' && !item.name.startsWith('.'));
2106
+ const pluginDirs = Array.from(new Set(
2107
+ files
2108
+ .map(item => item.path.split('/')[0])
2109
+ .filter(dir => dir && !dir.startsWith('.') && dir !== 'node_modules')
2110
+ ));
1296
2111
 
1297
2112
  for (const dir of pluginDirs) {
1298
2113
  try {
1299
- const manifestUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${dir.name}/plugin.json`;
1300
- const manifest = await this._fetchJson(manifestUrl);
2114
+ const manifest = await readJson(`${dir}/plugin.json`);
1301
2115
 
1302
- marketPlugins.push({
1303
- name: manifest.name || dir.name,
2116
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
2117
+ name: manifest.name || dir,
1304
2118
  description: manifest.description || '',
1305
2119
  author: manifest.author || repo.owner,
1306
2120
  version: manifest.version || '1.0.0',
1307
- repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1308
- repoOwner: repo.owner,
1309
- repoName: repo.name,
1310
- repoBranch: branch,
1311
- directory: dir.name,
2121
+ directory: dir,
1312
2122
  commands: manifest.commands || [],
1313
- hooks: manifest.hooks || [],
1314
- isInstalled: false
1315
- });
2123
+ hooks: manifest.hooks || []
2124
+ }));
1316
2125
  } catch (e) {
1317
2126
  // OpenCode 仓库常见 package.json 格式
1318
2127
  if (this._isOpenCode()) {
1319
2128
  try {
1320
- const pkgUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${dir.name}/package.json`;
1321
- const pkg = await this._fetchJson(pkgUrl);
1322
- const pluginName = pkg.name || dir.name;
1323
- marketPlugins.push({
1324
- name: pluginName,
2129
+ const pkg = await readJson(`${dir}/package.json`);
2130
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
2131
+ name: pkg.name || dir,
1325
2132
  description: pkg.description || '',
1326
2133
  author: pkg.author || repo.owner,
1327
2134
  version: pkg.version || '1.0.0',
1328
- repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1329
- repoOwner: repo.owner,
1330
- repoName: repo.name,
1331
- repoBranch: branch,
1332
- directory: dir.name,
1333
- isInstalled: false
1334
- });
2135
+ directory: dir
2136
+ }));
1335
2137
  } catch (pkgErr) {
1336
2138
  // neither plugin.json nor package.json
1337
2139
  }
@@ -1340,8 +2142,11 @@ class PluginsService {
1340
2142
  }
1341
2143
  } catch (err) {
1342
2144
  repoFailureCount++;
1343
- console.error(`[PluginsService] Failed to fetch plugins from ${repo.owner}/${repo.name}:`, err.message);
2145
+ console.error(`[PluginsService] Failed to fetch plugins from ${repoLabel}:`, err.message);
2146
+ continue;
1344
2147
  }
2148
+ const added = marketPlugins.length - pluginsBefore;
2149
+ console.log(`[PluginsService] ${repoLabel}: ${added} plugins loaded`);
1345
2150
  }
1346
2151
 
1347
2152
  const preparedPlugins = this.prepareMarketPlugins(marketPlugins);