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