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.
- package/dist/web/assets/{Analytics-_Byi9M6y.js → Analytics-0PgPv5qO.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-DIwosdtG.js → ConfigTemplates-pBGoYbCP.js} +1 -1
- package/dist/web/assets/{Home-DdNMuQ9c.js → Home-BRN882om.js} +1 -1
- package/dist/web/assets/{PluginManager-iuY24cnW.js → PluginManager-am97Huts.js} +1 -1
- package/dist/web/assets/{ProjectList-DSkMulzL.js → ProjectList-CXS9KJN1.js} +1 -1
- package/dist/web/assets/{SessionList-B6pGquIr.js → SessionList-BZyrzH7J.js} +1 -1
- package/dist/web/assets/{SkillManager-CHtQX5r8.js → SkillManager-p1CI0tYa.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-gNPs-VaI.js → WorkspaceManager-CUPvLoba.js} +1 -1
- package/dist/web/assets/index-B4Wl3JfR.js +2 -0
- package/dist/web/assets/{index-pMqqe9ei.css → index-Bgt_oqoE.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/src/server/api/claude-hooks.js +1 -0
- package/src/server/api/codex-channels.js +26 -0
- package/src/server/api/oauth-credentials.js +23 -1
- package/src/server/api/opencode-proxy.js +0 -2
- package/src/server/api/plugins.js +161 -14
- package/src/server/api/skills.js +62 -7
- package/src/server/codex-proxy-server.js +10 -2
- package/src/server/gemini-proxy-server.js +10 -2
- package/src/server/opencode-proxy-server.js +10 -2
- package/src/server/proxy-server.js +10 -2
- package/src/server/services/codex-channels.js +64 -21
- package/src/server/services/codex-env-manager.js +44 -28
- package/src/server/services/native-oauth-adapters.js +94 -10
- package/src/server/services/oauth-credentials-service.js +44 -2
- package/src/server/services/opencode-channels.js +0 -2
- package/src/server/services/plugins-service.js +1060 -235
- package/src/server/services/proxy-runtime.js +129 -5
- package/src/server/services/server-shutdown.js +79 -0
- package/src/server/services/skill-service.js +142 -17
- 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.
|
|
438
|
-
return this.
|
|
735
|
+
if (repoInfo && repoInfo.directory) {
|
|
736
|
+
return this._installFromRepoDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
|
|
439
737
|
}
|
|
440
738
|
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
443
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
return await this._installFromGitHubDirectory(repoInfo);
|
|
763
|
+
if (repoInfo && repoInfo.directory) {
|
|
764
|
+
return await this._installFromRepoDirectory(repoInfo);
|
|
469
765
|
}
|
|
470
766
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
777
|
+
* Install plugin from repo directory
|
|
484
778
|
* @private
|
|
485
779
|
*/
|
|
486
|
-
async
|
|
487
|
-
const
|
|
488
|
-
const
|
|
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.
|
|
499
|
-
} catch
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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 =
|
|
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
|
-
*
|
|
937
|
+
* Parse GitHub/GitLab tree URL or local path
|
|
587
938
|
* @private
|
|
588
939
|
*/
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
873
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
|
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 =>
|
|
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
|
|
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 =>
|
|
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(
|
|
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
|
|
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 ${
|
|
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:
|
|
1844
|
+
results.push({ repo: repoRef, success: true });
|
|
1037
1845
|
} catch (err) {
|
|
1038
|
-
results.push({ repo:
|
|
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
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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 (!
|
|
1118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
2062
|
+
const repoLabel = repo.label || repo.repoUrl || repo.localPath || `${repo.owner || ''}/${repo.name || ''}`;
|
|
1228
2063
|
const pluginsBefore = marketPlugins.length;
|
|
1229
2064
|
try {
|
|
1230
|
-
const
|
|
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
|
-
|
|
1234
|
-
const
|
|
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
|
-
|
|
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
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
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
|
}
|