coding-tool-x 3.4.4 → 3.4.6

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 (32) hide show
  1. package/dist/web/assets/{Analytics-_Byi9M6y.js → Analytics-0PgPv5qO.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-DIwosdtG.js → ConfigTemplates-pBGoYbCP.js} +1 -1
  3. package/dist/web/assets/{Home-DdNMuQ9c.js → Home-BRN882om.js} +1 -1
  4. package/dist/web/assets/{PluginManager-iuY24cnW.js → PluginManager-am97Huts.js} +1 -1
  5. package/dist/web/assets/{ProjectList-DSkMulzL.js → ProjectList-CXS9KJN1.js} +1 -1
  6. package/dist/web/assets/{SessionList-B6pGquIr.js → SessionList-BZyrzH7J.js} +1 -1
  7. package/dist/web/assets/{SkillManager-CHtQX5r8.js → SkillManager-p1CI0tYa.js} +1 -1
  8. package/dist/web/assets/{WorkspaceManager-gNPs-VaI.js → WorkspaceManager-CUPvLoba.js} +1 -1
  9. package/dist/web/assets/index-B4Wl3JfR.js +2 -0
  10. package/dist/web/assets/{index-pMqqe9ei.css → index-Bgt_oqoE.css} +1 -1
  11. package/dist/web/index.html +2 -2
  12. package/package.json +2 -2
  13. package/src/server/api/claude-hooks.js +1 -0
  14. package/src/server/api/codex-channels.js +26 -0
  15. package/src/server/api/oauth-credentials.js +23 -1
  16. package/src/server/api/opencode-proxy.js +0 -2
  17. package/src/server/api/plugins.js +161 -14
  18. package/src/server/api/skills.js +62 -7
  19. package/src/server/codex-proxy-server.js +10 -2
  20. package/src/server/gemini-proxy-server.js +10 -2
  21. package/src/server/opencode-proxy-server.js +10 -2
  22. package/src/server/proxy-server.js +10 -2
  23. package/src/server/services/codex-channels.js +64 -21
  24. package/src/server/services/codex-env-manager.js +44 -28
  25. package/src/server/services/native-oauth-adapters.js +94 -10
  26. package/src/server/services/oauth-credentials-service.js +44 -2
  27. package/src/server/services/opencode-channels.js +0 -2
  28. package/src/server/services/plugins-service.js +1060 -235
  29. package/src/server/services/proxy-runtime.js +129 -5
  30. package/src/server/services/server-shutdown.js +79 -0
  31. package/src/server/services/skill-service.js +142 -17
  32. package/dist/web/assets/index-DGjGCo37.js +0 -2
@@ -5,17 +5,27 @@
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
31
  opencode: []
@@ -25,6 +35,126 @@ function cloneRepos(repos = []) {
25
35
  return repos.map(repo => ({ ...repo }));
26
36
  }
27
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
+
28
158
  function stripJsonComments(input = '') {
29
159
  let result = '';
30
160
  let inString = false;
@@ -81,6 +211,7 @@ function stripJsonComments(input = '') {
81
211
  class PluginsService {
82
212
  constructor(platform = 'claude') {
83
213
  this.platform = ['claude', 'opencode'].includes(platform) ? platform : 'claude';
214
+ this.configDir = PATHS.config || path.join((PATHS.base || process.env.HOME || os.homedir()), 'config');
84
215
  this.ccToolConfigDir = path.dirname(PATHS.pluginRepos.claude);
85
216
  this.opencodePluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugins');
86
217
  this.opencodeLegacyPluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugin');
@@ -90,6 +221,133 @@ class PluginsService {
90
221
  this._marketCache = null;
91
222
  }
92
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
+
93
351
  clearMarketCache({ removeFile = true } = {}) {
94
352
  this._marketCache = null;
95
353
  if (removeFile) {
@@ -138,8 +396,12 @@ class PluginsService {
138
396
  for (const plugin of preparedPlugins) {
139
397
  const key = [
140
398
  plugin.name || '',
399
+ plugin.repoId || '',
400
+ plugin.repoProvider || '',
141
401
  plugin.repoOwner || '',
142
402
  plugin.repoName || '',
403
+ plugin.repoProjectPath || '',
404
+ plugin.repoLocalPath || '',
143
405
  plugin.directory || plugin.installSource || ''
144
406
  ].join('::');
145
407
  if (seen.has(key)) continue;
@@ -229,6 +491,7 @@ class PluginsService {
229
491
 
230
492
  if (entry.isDirectory()) {
231
493
  const pkgPath = path.join(fullPath, 'package.json');
494
+ const repoSourceMeta = this.readRepoSourceMeta(fullPath) || {};
232
495
  let packageName = entry.name;
233
496
  let description = '';
234
497
  let version = '1.0.0';
@@ -251,7 +514,8 @@ class PluginsService {
251
514
  description,
252
515
  installed: true,
253
516
  enabled: true,
254
- pluginType: 'local'
517
+ pluginType: 'local',
518
+ ...repoSourceMeta
255
519
  });
256
520
  continue;
257
521
  }
@@ -325,7 +589,16 @@ class PluginsService {
325
589
  // Read plugin.json from installPath for description
326
590
  let description = '';
327
591
  let source = install.source || '';
328
- 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 || '';
329
602
 
330
603
  if (install.installPath && fs.existsSync(install.installPath)) {
331
604
  const manifestPath = path.join(install.installPath, 'plugin.json');
@@ -337,13 +610,28 @@ class PluginsService {
337
610
  // Ignore parse errors
338
611
  }
339
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 || '';
340
625
  }
341
626
 
342
627
  // Parse repoUrl from source if available
343
- if (source) {
628
+ if (!repoUrl && source) {
344
629
  const match = source.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
345
630
  if (match) {
346
631
  repoUrl = `https://github.com/${match[1]}/${match[2]}`;
632
+ repoProvider = repoProvider || 'github';
633
+ repoOwner = repoOwner || match[1];
634
+ repoName = repoName || match[2];
347
635
  }
348
636
  }
349
637
 
@@ -361,7 +649,17 @@ class PluginsService {
361
649
  enabled: enabledState,
362
650
  description,
363
651
  source,
364
- 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
365
663
  });
366
664
  }
367
665
  }
@@ -434,14 +732,13 @@ class PluginsService {
434
732
  */
435
733
  async installPlugin(source, repoInfo = null) {
436
734
  if (this._isOpenCode()) {
437
- if (repoInfo && repoInfo.owner && repoInfo.name && repoInfo.directory) {
438
- return this._installFromGitHubDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
735
+ if (repoInfo && repoInfo.directory) {
736
+ return this._installFromRepoDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
439
737
  }
440
738
 
441
- const treeMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
442
- if (treeMatch) {
443
- const [, owner, name, branch, directory] = treeMatch;
444
- 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() });
445
742
  }
446
743
 
447
744
  // OpenCode 原生支持 npm 包名,通过 opencode.json 的 plugin 数组管理
@@ -463,16 +760,13 @@ class PluginsService {
463
760
  };
464
761
  }
465
762
 
466
- // If repoInfo is provided, download from GitHub directly
467
- if (repoInfo && repoInfo.owner && repoInfo.name && repoInfo.directory) {
468
- return await this._installFromGitHubDirectory(repoInfo);
763
+ if (repoInfo && repoInfo.directory) {
764
+ return await this._installFromRepoDirectory(repoInfo);
469
765
  }
470
766
 
471
- // Parse tree URL format: https://github.com/owner/repo/tree/branch/path
472
- const treeMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
473
- if (treeMatch) {
474
- const [, owner, name, branch, directory] = treeMatch;
475
- return await this._installFromGitHubDirectory({ owner, name, branch, directory });
767
+ const parsedSource = this.parseRepoTreeSource(source);
768
+ if (parsedSource) {
769
+ return await this._installFromRepoDirectory(parsedSource);
476
770
  }
477
771
 
478
772
  // Fallback to original git clone method
@@ -480,25 +774,25 @@ class PluginsService {
480
774
  }
481
775
 
482
776
  /**
483
- * Install plugin from GitHub directory
777
+ * Install plugin from repo directory
484
778
  * @private
485
779
  */
486
- async _installFromGitHubDirectory(repoInfo, options = {}) {
487
- const { owner, name, branch, directory } = repoInfo;
488
- const https = require('https');
780
+ async _installFromRepoDirectory(repoInfo, options = {}) {
781
+ const normalizedRepo = this.normalizeRepoConfig(repoInfo);
782
+ const directory = normalizeRepoPath(repoInfo.directory || '');
489
783
  const pluginName = directory.split('/').pop();
490
784
  const installRoot = options.installRoot || INSTALLED_DIR;
491
785
 
492
786
  try {
493
- // Fetch plugin.json from the directory
494
- const manifestUrl = `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${directory}/plugin.json`;
495
787
  let manifest;
496
-
497
788
  try {
498
- manifest = await this._fetchJson(manifestUrl);
499
- } catch (e) {
500
- // No plugin.json, create a basic manifest
501
- 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
+ }
502
796
  }
503
797
 
504
798
  // Create plugin directory
@@ -507,14 +801,55 @@ class PluginsService {
507
801
  fs.mkdirSync(pluginDir, { recursive: true });
508
802
  }
509
803
 
510
- // Download all files from the directory
511
- const contentsUrl = `https://api.github.com/repos/${owner}/${name}/contents/${directory}?ref=${branch}`;
512
- 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);
838
+
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
+ }
513
845
 
514
- for (const item of contents) {
515
- if (item.type === 'file') {
516
- const fileContent = await this._fetchRawFile(item.download_url);
517
- fs.writeFileSync(path.join(pluginDir, item.name), fileContent);
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 });
518
853
  }
519
854
  }
520
855
 
@@ -527,7 +862,20 @@ class PluginsService {
527
862
  if (!this._isOpenCode()) {
528
863
  const installedPluginName = manifest.name || pluginName;
529
864
  const installTimestamp = new Date().toISOString();
530
- 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
+ };
531
879
 
532
880
  // Register in CTX legacy registry (for listPlugins fallback)
533
881
  const { addPlugin } = require('../../plugins/registry');
@@ -542,6 +890,8 @@ class PluginsService {
542
890
  console.warn('[PluginsService] Legacy registry addPlugin warning:', e.message);
543
891
  }
544
892
 
893
+ this.writeRepoSourceMeta(pluginDir, repoSourceMeta);
894
+
545
895
  // Also register in Claude's native installed_plugins.json
546
896
  try {
547
897
  this._ensureDir(CLAUDE_PLUGINS_DIR);
@@ -558,7 +908,8 @@ class PluginsService {
558
908
  installPath: pluginDir,
559
909
  installedAt: installTimestamp,
560
910
  scope: 'user',
561
- source: sourceUrl
911
+ source: sourceUrl,
912
+ ...repoSourceMeta
562
913
  }];
563
914
  fs.writeFileSync(CLAUDE_INSTALLED_FILE, JSON.stringify(nativeData, null, 2), 'utf8');
564
915
  } catch (e) {
@@ -583,26 +934,50 @@ class PluginsService {
583
934
  }
584
935
 
585
936
  /**
586
- * Fetch raw file content
937
+ * Parse GitHub/GitLab tree URL or local path
587
938
  * @private
588
939
  */
589
- async _fetchRawFile(url) {
590
- const https = require('https');
591
- return new Promise((resolve, reject) => {
592
- https.get(url, {
593
- headers: { 'User-Agent': 'coding-tool-x' }
594
- }, (res) => {
595
- let data = '';
596
- res.on('data', chunk => data += chunk);
597
- res.on('end', () => {
598
- if (res.statusCode === 200) {
599
- resolve(data);
600
- } else {
601
- reject(new Error(`HTTP ${res.statusCode}`));
602
- }
603
- });
604
- }).on('error', reject);
605
- });
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
+ };
606
981
  }
607
982
 
608
983
  /**
@@ -837,17 +1212,17 @@ class PluginsService {
837
1212
  const configPath = this.getReposConfigPath();
838
1213
  const defaultRepos = this._getDefaultRepos();
839
1214
  if (!fs.existsSync(configPath)) {
840
- return { repos: defaultRepos };
1215
+ return { repos: this.normalizeRepos(defaultRepos) };
841
1216
  }
842
1217
  try {
843
1218
  const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
844
1219
  if (parsed && Array.isArray(parsed.repos)) {
845
- return parsed;
1220
+ return { ...parsed, repos: this.normalizeRepos(parsed.repos) };
846
1221
  }
847
- return { repos: defaultRepos };
1222
+ return { repos: this.normalizeRepos(defaultRepos) };
848
1223
  } catch (err) {
849
1224
  console.error('Failed to load repos config:', err);
850
- return { repos: defaultRepos };
1225
+ return { repos: this.normalizeRepos(defaultRepos) };
851
1226
  }
852
1227
  }
853
1228
 
@@ -857,7 +1232,8 @@ class PluginsService {
857
1232
  */
858
1233
  saveReposConfig(config) {
859
1234
  const configPath = this.getReposConfigPath();
860
- 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');
861
1237
  }
862
1238
 
863
1239
  /**
@@ -869,10 +1245,16 @@ class PluginsService {
869
1245
  const repos = [];
870
1246
  const seenRepos = new Set();
871
1247
  const pushRepo = (repo) => {
872
- if (!repo || !repo.owner || !repo.name) return;
873
- 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;
874
1256
  if (seenRepos.has(key)) return;
875
- repos.push(repo);
1257
+ repos.push(normalizedRepo);
876
1258
  seenRepos.add(key);
877
1259
  };
878
1260
  const parseRepoUrl = (url) => {
@@ -907,9 +1289,10 @@ class PluginsService {
907
1289
  const parsed = parseRepoUrl(sourceUrl);
908
1290
  if (!parsed) continue;
909
1291
  pushRepo({
1292
+ provider: 'github',
910
1293
  owner: parsed.owner,
911
1294
  name: parsed.name,
912
- url: parsed.url,
1295
+ repoUrl: parsed.url,
913
1296
  branch: data?.source?.branch || data?.branch || 'main',
914
1297
  enabled: data?.enabled !== false,
915
1298
  source: 'claude-native',
@@ -932,51 +1315,22 @@ class PluginsService {
932
1315
  */
933
1316
  addRepo(repo) {
934
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);
935
1323
 
936
- // Parse URL if provided
937
- let owner = repo.owner;
938
- let name = repo.name;
939
- let url = repo.url;
940
-
941
- if (url && !owner && !name) {
942
- // Extract owner/name from URL
943
- const match = url.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
944
- if (match) {
945
- owner = match[1];
946
- name = match[2];
947
- }
948
- }
949
-
950
- if (!owner || !name) {
951
- throw new Error('Repository owner and name are required');
952
- }
953
-
954
- // Construct URL if not provided
955
- if (!url) {
956
- url = `https://github.com/${owner}/${name}`;
957
- }
958
-
959
- // Check if repo already exists
960
- const exists = config.repos.some(r => r.owner === owner && r.name === name);
961
- if (exists) {
962
- throw new Error(`Repository ${owner}/${name} already exists`);
1324
+ if (existingIndex >= 0) {
1325
+ config.repos[existingIndex] = normalizedRepo;
1326
+ } else {
1327
+ config.repos.push(normalizedRepo);
963
1328
  }
964
1329
 
965
- // Add new repo
966
- const newRepo = {
967
- owner,
968
- name,
969
- url,
970
- branch: repo.branch || 'main',
971
- enabled: repo.enabled !== false,
972
- addedAt: new Date().toISOString()
973
- };
974
-
975
- config.repos.push(newRepo);
976
1330
  this.saveReposConfig(config);
977
1331
  this.clearMarketCache();
978
1332
 
979
- return config.repos;
1333
+ return this.getRepos();
980
1334
  }
981
1335
 
982
1336
  /**
@@ -985,12 +1339,17 @@ class PluginsService {
985
1339
  * @param {string} name - Repository name
986
1340
  * @returns {Array} Updated repos list
987
1341
  */
988
- removeRepo(owner, name) {
1342
+ removeRepo(owner, name, repoId = '') {
989
1343
  const config = this.loadReposConfig();
990
- 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
+ });
991
1350
  this.saveReposConfig(config);
992
1351
  this.clearMarketCache();
993
- return config.repos;
1352
+ return this.getRepos();
994
1353
  }
995
1354
 
996
1355
  /**
@@ -1000,16 +1359,460 @@ class PluginsService {
1000
1359
  * @param {boolean} enabled - Enable or disable
1001
1360
  * @returns {Array} Updated repos list
1002
1361
  */
1003
- toggleRepo(owner, name, enabled) {
1362
+ toggleRepo(owner, name, enabled, repoId = '') {
1004
1363
  const config = this.loadReposConfig();
1005
- 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
+ });
1006
1368
  if (!repo) {
1007
- throw new Error(`Repository ${owner}/${name} not found`);
1369
+ throw new Error('Repository not found');
1008
1370
  }
1009
1371
  repo.enabled = enabled;
1010
1372
  this.saveReposConfig(config);
1011
1373
  this.clearMarketCache();
1012
- 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
+ });
1013
1816
  }
1014
1817
 
1015
1818
  /**
@@ -1026,16 +1829,21 @@ class PluginsService {
1026
1829
  const { execSync } = require('child_process');
1027
1830
 
1028
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
+ }
1029
1837
  try {
1030
- execSync(`claude plugin marketplace add ${repo.url}`, {
1838
+ execSync(`claude plugin marketplace add ${repoRef}`, {
1031
1839
  encoding: 'utf8',
1032
1840
  timeout: 30000,
1033
1841
  stdio: 'pipe',
1034
1842
  windowsHide: true
1035
1843
  });
1036
- results.push({ repo: repo.url, success: true });
1844
+ results.push({ repo: repoRef, success: true });
1037
1845
  } catch (err) {
1038
- results.push({ repo: repo.url, success: false, error: err.message });
1846
+ results.push({ repo: repoRef, success: false, error: err.message });
1039
1847
  }
1040
1848
  }
1041
1849
 
@@ -1050,32 +1858,6 @@ class PluginsService {
1050
1858
  return this.listPlugins();
1051
1859
  }
1052
1860
 
1053
- /**
1054
- * Fetch JSON from URL
1055
- * @private
1056
- */
1057
- async _fetchJson(url) {
1058
- const https = require('https');
1059
- return new Promise((resolve, reject) => {
1060
- https.get(url, {
1061
- headers: {
1062
- 'User-Agent': 'coding-tool-x',
1063
- 'Accept': 'application/vnd.github.v3+json'
1064
- }
1065
- }, (res) => {
1066
- let data = '';
1067
- res.on('data', chunk => data += chunk);
1068
- res.on('end', () => {
1069
- if (res.statusCode === 200) {
1070
- resolve(JSON.parse(data));
1071
- } else {
1072
- reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
1073
- }
1074
- });
1075
- }).on('error', reject);
1076
- });
1077
- }
1078
-
1079
1861
  /**
1080
1862
  * Get plugin README content
1081
1863
  * @param {Object} plugin - Plugin object with name, repoUrl, source, or repoInfo
@@ -1083,44 +1865,82 @@ class PluginsService {
1083
1865
  */
1084
1866
  async getPluginReadme(plugin) {
1085
1867
  try {
1086
- 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
+ };
1087
1878
 
1088
- // Case 1: Market plugin with repoInfo
1089
- if (plugin.repoOwner && plugin.repoName && plugin.directory) {
1090
- const branch = plugin.repoBranch || 'main';
1091
- readmeUrl = `https://raw.githubusercontent.com/${plugin.repoOwner}/${plugin.repoName}/${branch}/${plugin.directory}/README.md`;
1879
+ if (normalizedDirectory) {
1880
+ pushReadmeCandidates(normalizedDirectory);
1092
1881
  }
1093
- // Case 2: Installed plugin with source URL
1094
- else if (plugin.source) {
1095
- const treeMatch = plugin.source.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
1096
- if (treeMatch) {
1097
- const [, owner, name, branch, directory] = treeMatch;
1098
- readmeUrl = `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${directory}/README.md`;
1099
- } else {
1100
- // Try to parse as regular repo URL
1101
- const repoMatch = plugin.source.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
1102
- if (repoMatch) {
1103
- const [, owner, name] = repoMatch;
1104
- 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');
1105
1891
  }
1106
1892
  }
1107
1893
  }
1108
- // Case 3: Plugin with repoUrl
1109
- else if (plugin.repoUrl) {
1110
- const match = plugin.repoUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
1111
- if (match) {
1112
- const [, owner, name] = match;
1113
- 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
+ }
1114
1930
  }
1115
1931
  }
1116
1932
 
1117
- if (!readmeUrl) {
1118
- 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
+ }
1119
1941
  }
1120
1942
 
1121
- // Fetch README content
1122
- const content = await this._fetchRawFile(readmeUrl);
1123
- return content;
1943
+ return '';
1124
1944
  } catch (err) {
1125
1945
  console.error('[PluginsService] Failed to fetch README:', err.message);
1126
1946
  return '';
@@ -1136,29 +1956,48 @@ class PluginsService {
1136
1956
  };
1137
1957
  }
1138
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
+
1139
1987
  async _fetchOpenCodeMarketplacePlugins(repo, branch) {
1140
1988
  if (!this._isOpenCode()) return [];
1141
1989
 
1142
- let entries;
1143
- try {
1144
- const indexUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/contents/plugins?ref=${branch}`;
1145
- entries = await this._fetchJson(indexUrl);
1146
- } catch (err) {
1147
- return [];
1148
- }
1149
-
1150
- if (!Array.isArray(entries)) return [];
1151
-
1152
- const manifestFiles = entries.filter(
1153
- 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')
1154
1995
  );
1155
1996
  if (manifestFiles.length === 0) return [];
1156
1997
 
1157
1998
  const results = await Promise.allSettled(
1158
1999
  manifestFiles.map(async (file) => {
1159
- const fileUrl = file.download_url ||
1160
- `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${file.path}`;
1161
- const manifest = await this._fetchJson(fileUrl);
2000
+ const manifest = await this.fetchRepoJson(repo, file.path, file);
1162
2001
 
1163
2002
  const author = Array.isArray(manifest.authors)
1164
2003
  ? manifest.authors.map(item => item?.name).filter(Boolean).join(', ')
@@ -1170,22 +2009,18 @@ class PluginsService {
1170
2009
  const installSource = String(manifest.name || '').trim();
1171
2010
  const githubRepo = this._parseGitHubRepo(repoUrl);
1172
2011
 
1173
- return {
2012
+ return this.buildMarketPluginItem(repo, {
1174
2013
  name: manifest.name || file.name.replace(/\.plugin\.json$/, ''),
1175
2014
  displayName: manifest.displayName || '',
1176
2015
  description: manifest.description || '',
1177
2016
  author: author || repo.owner,
1178
2017
  version: manifest.version || manifest.opencode?.minimumVersion || '1.0.0',
1179
2018
  category: firstCategory ? String(firstCategory).toLowerCase() : 'general',
1180
- repoUrl,
1181
- repoOwner: '',
1182
- repoName: '',
1183
- repoBranch: githubRepo ? 'main' : branch,
1184
2019
  directory: file.path,
1185
- installSource,
2020
+ installSource: githubRepo ? '' : installSource,
1186
2021
  marketplaceFormat: 'opencode-plugin-json',
1187
- isInstalled: false
1188
- };
2022
+ repoUrl
2023
+ });
1189
2024
  })
1190
2025
  );
1191
2026
 
@@ -1224,42 +2059,43 @@ class PluginsService {
1224
2059
  let repoFailureCount = 0;
1225
2060
 
1226
2061
  for (const repo of repos) {
1227
- const repoLabel = repo.owner ? `${repo.owner}/${repo.name}` : repo.url;
2062
+ const repoLabel = repo.label || repo.repoUrl || repo.localPath || `${repo.owner || ''}/${repo.name || ''}`;
1228
2063
  const pluginsBefore = marketPlugins.length;
1229
2064
  try {
1230
- 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
+ };
1231
2076
 
1232
2077
  // Try to fetch marketplace.json first (official format)
1233
- try {
1234
- const marketplaceUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/.claude-plugin/marketplace.json`;
1235
- const marketplace = await this._fetchJson(marketplaceUrl);
1236
-
2078
+ if (fileMap.has('.claude-plugin/marketplace.json')) {
2079
+ const marketplace = await readJson('.claude-plugin/marketplace.json');
1237
2080
  if (marketplace && marketplace.plugins) {
1238
2081
  for (const plugin of marketplace.plugins) {
1239
- marketPlugins.push({
2082
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
1240
2083
  name: plugin.name,
1241
2084
  description: plugin.description || '',
1242
2085
  author: plugin.author?.name || marketplace.owner?.name || repo.owner,
1243
2086
  version: plugin.version || '1.0.0',
1244
2087
  category: plugin.category || 'general',
1245
- repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1246
- repoOwner: repo.owner,
1247
- repoName: repo.name,
1248
- repoBranch: branch,
1249
2088
  directory: plugin.source?.replace(/^\.\//, '') || plugin.name,
1250
- lspServers: plugin.lspServers || null,
1251
- isInstalled: false
1252
- });
2089
+ lspServers: plugin.lspServers || null
2090
+ }));
1253
2091
  }
1254
2092
  continue; // Skip legacy format check
1255
2093
  }
1256
- } catch (e) {
1257
- // marketplace.json not found, try legacy format
1258
2094
  }
1259
2095
 
1260
2096
  // OpenCode plugin marketplace format: plugins/*.plugin.json
1261
2097
  if (this._isOpenCode()) {
1262
- const openCodeMarketplacePlugins = await this._fetchOpenCodeMarketplacePlugins(repo, branch);
2098
+ const openCodeMarketplacePlugins = await this._fetchOpenCodeMarketplacePlugins(repo, repo.branch || 'main');
1263
2099
  if (openCodeMarketplacePlugins.length > 0) {
1264
2100
  marketPlugins.push(...openCodeMarketplacePlugins);
1265
2101
  continue;
@@ -1267,48 +2103,37 @@ class PluginsService {
1267
2103
  }
1268
2104
 
1269
2105
  // Legacy format: each directory is a plugin with plugin.json/package.json
1270
- const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/contents?ref=${branch}`;
1271
- const contents = await this._fetchJson(apiUrl);
1272
- 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
+ ));
1273
2111
 
1274
2112
  for (const dir of pluginDirs) {
1275
2113
  try {
1276
- const manifestUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${dir.name}/plugin.json`;
1277
- const manifest = await this._fetchJson(manifestUrl);
2114
+ const manifest = await readJson(`${dir}/plugin.json`);
1278
2115
 
1279
- marketPlugins.push({
1280
- name: manifest.name || dir.name,
2116
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
2117
+ name: manifest.name || dir,
1281
2118
  description: manifest.description || '',
1282
2119
  author: manifest.author || repo.owner,
1283
2120
  version: manifest.version || '1.0.0',
1284
- repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1285
- repoOwner: repo.owner,
1286
- repoName: repo.name,
1287
- repoBranch: branch,
1288
- directory: dir.name,
2121
+ directory: dir,
1289
2122
  commands: manifest.commands || [],
1290
- hooks: manifest.hooks || [],
1291
- isInstalled: false
1292
- });
2123
+ hooks: manifest.hooks || []
2124
+ }));
1293
2125
  } catch (e) {
1294
2126
  // OpenCode 仓库常见 package.json 格式
1295
2127
  if (this._isOpenCode()) {
1296
2128
  try {
1297
- const pkgUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${branch}/${dir.name}/package.json`;
1298
- const pkg = await this._fetchJson(pkgUrl);
1299
- const pluginName = pkg.name || dir.name;
1300
- marketPlugins.push({
1301
- name: pluginName,
2129
+ const pkg = await readJson(`${dir}/package.json`);
2130
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
2131
+ name: pkg.name || dir,
1302
2132
  description: pkg.description || '',
1303
2133
  author: pkg.author || repo.owner,
1304
2134
  version: pkg.version || '1.0.0',
1305
- repoUrl: `https://github.com/${repo.owner}/${repo.name}`,
1306
- repoOwner: repo.owner,
1307
- repoName: repo.name,
1308
- repoBranch: branch,
1309
- directory: dir.name,
1310
- isInstalled: false
1311
- });
2135
+ directory: dir
2136
+ }));
1312
2137
  } catch (pkgErr) {
1313
2138
  // neither plugin.json nor package.json
1314
2139
  }