coding-tool-x 3.6.7 → 3.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/web/assets/{Analytics-CKycBI0G.js → Analytics-BuBlsbcI.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-CACLQb8e.js → ConfigTemplates-DQczxuOX.js} +1 -1
  3. package/dist/web/assets/Home-BvAzzWnB.js +1 -0
  4. package/dist/web/assets/Home-CIua-b-o.css +1 -0
  5. package/dist/web/assets/{PluginManager-CQJl_uso.js → PluginManager-C8gAlaps.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DSkvQFEP.js → ProjectList-nqgi6_cR.js} +1 -1
  7. package/dist/web/assets/{SessionList-Czjs1GN1.js → SessionList-D8AxMYy6.js} +1 -1
  8. package/dist/web/assets/{SkillManager-Cg1xZ5Cv.js → SkillManager-BDbNbNkK.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-COjYeKvN.js → WorkspaceManager-DGGaGW2c.js} +1 -1
  10. package/dist/web/assets/index-Dsh4Q5Cg.css +1 -0
  11. package/dist/web/assets/index-y-eEayxR.js +2 -0
  12. package/dist/web/assets/{naive-ui-DM897mIP.js → naive-ui-kcWDeJwl.js} +1 -1
  13. package/dist/web/index.html +3 -3
  14. package/package.json +1 -1
  15. package/src/commands/stats.js +1 -1
  16. package/src/config/channel-preset-websites.js +2 -1
  17. package/src/config/paths.js +8 -0
  18. package/src/server/api/agents.js +16 -54
  19. package/src/server/api/claude-hooks.js +1 -1
  20. package/src/server/api/claude-statistics.js +57 -0
  21. package/src/server/api/commands.js +16 -54
  22. package/src/server/api/dashboard.js +2 -2
  23. package/src/server/api/gemini-channels.js +2 -0
  24. package/src/server/api/hooks.js +4 -1
  25. package/src/server/api/plugins.js +46 -69
  26. package/src/server/api/skills.js +21 -77
  27. package/src/server/api/validation-errors.js +34 -0
  28. package/src/server/gemini-proxy-server.js +205 -21
  29. package/src/server/index.js +1 -0
  30. package/src/server/services/agents-service.js +166 -75
  31. package/src/server/services/base/base-channel-service.js +15 -0
  32. package/src/server/services/base/response-usage-parser.js +44 -0
  33. package/src/server/services/channel-balance.js +86 -1
  34. package/src/server/services/claude-statistics-service.js +139 -0
  35. package/src/server/services/commands-service.js +203 -121
  36. package/src/server/services/config-artifact-paths.js +116 -0
  37. package/src/server/services/config-export-service.js +7 -6
  38. package/src/server/services/config-registry-service.js +2 -2
  39. package/src/server/services/config-sync-manager.js +45 -11
  40. package/src/server/services/config-templates-service.js +21 -19
  41. package/src/server/services/format-converter.js +42 -4
  42. package/src/server/services/gemini-channels.js +30 -1
  43. package/src/server/services/gemini-sessions.js +347 -61
  44. package/src/server/services/mcp-client.js +236 -49
  45. package/src/server/services/mcp-service.js +51 -64
  46. package/src/server/services/notification-hooks.js +659 -88
  47. package/src/server/services/opencode-channels.js +15 -0
  48. package/src/server/services/plugins-service.js +1137 -86
  49. package/src/server/services/repo-scanner-base.js +6 -29
  50. package/src/server/services/skill-service.js +198 -65
  51. package/dist/web/assets/Home-BeGkC4bb.css +0 -1
  52. package/dist/web/assets/Home-Bk6sP5Ed.js +0 -1
  53. package/dist/web/assets/index-0isNzXNY.css +0 -1
  54. package/dist/web/assets/index-fWgvlZQx.js +0 -2
@@ -10,6 +10,8 @@ const http = require('http');
10
10
  const https = require('https');
11
11
  const path = require('path');
12
12
  const { execFileSync } = require('child_process');
13
+ const toml = require('toml');
14
+ const tomlStringify = require('@iarna/toml').stringify;
13
15
  const AdmZip = require('adm-zip');
14
16
  const { listPlugins, getPlugin, updatePlugin: updatePluginRegistry } = require('../../plugins/registry');
15
17
  const { installPlugin: installPluginCore, uninstallPlugin: uninstallPluginCore } = require('../../plugins/plugin-installer');
@@ -17,10 +19,20 @@ const { initializePlugins, shutdownPlugins } = require('../../plugins/plugin-man
17
19
  const { INSTALLED_DIR, CONFIG_DIR } = require('../../plugins/constants');
18
20
  const { NATIVE_PATHS, PATHS } = require('../../config/paths');
19
21
  const { maskToken } = require('./oauth-utils');
22
+ const {
23
+ assertInsideAllowedRoots,
24
+ normalizeSafeFileStem,
25
+ normalizeSafeRelativePath,
26
+ resolveInsideRoot
27
+ } = require('./config-artifact-paths');
20
28
 
21
29
  const CLAUDE_PLUGINS_DIR = path.join(path.dirname(NATIVE_PATHS.claude.settings), 'plugins');
22
30
  const CLAUDE_INSTALLED_FILE = path.join(CLAUDE_PLUGINS_DIR, 'installed_plugins.json');
23
31
  const CLAUDE_MARKETPLACES_FILE = path.join(CLAUDE_PLUGINS_DIR, 'known_marketplaces.json');
32
+ const CLAUDE_PLUGINS_CACHE_DIR = path.join(CLAUDE_PLUGINS_DIR, 'cache');
33
+ const CLAUDE_MARKETPLACES_DIR = path.join(CLAUDE_PLUGINS_DIR, 'marketplaces');
34
+ const CODEX_PLUGINS_DIR = path.join(path.dirname(NATIVE_PATHS.codex.config), 'plugins');
35
+ const CODEX_PLUGINS_CACHE_DIR = path.join(CODEX_PLUGINS_DIR, 'cache');
24
36
  const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
25
37
  const REPO_SOURCE_META_FILE = '.cc-tool-plugin-source.json';
26
38
  const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
@@ -28,8 +40,62 @@ const DEFAULT_GITHUB_HOST = 'https://github.com';
28
40
  const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
29
41
  const DEFAULT_REPOS_BY_PLATFORM = {
30
42
  claude: [],
43
+ codex: [],
44
+ gemini: [],
31
45
  opencode: []
32
46
  };
47
+ const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
48
+ const PLATFORM_CAPABILITIES = {
49
+ claude: {
50
+ platform: 'claude',
51
+ supportsPlugins: true,
52
+ repositories: true,
53
+ market: true,
54
+ install: true,
55
+ uninstall: true,
56
+ toggle: true,
57
+ config: true,
58
+ import: true,
59
+ syncRepos: true
60
+ },
61
+ codex: {
62
+ platform: 'codex',
63
+ supportsPlugins: true,
64
+ repositories: true,
65
+ market: true,
66
+ install: true,
67
+ uninstall: true,
68
+ toggle: true,
69
+ config: false,
70
+ import: false,
71
+ syncRepos: false
72
+ },
73
+ gemini: {
74
+ platform: 'gemini',
75
+ supportsPlugins: false,
76
+ repositories: false,
77
+ market: false,
78
+ install: false,
79
+ uninstall: false,
80
+ toggle: false,
81
+ config: false,
82
+ import: false,
83
+ syncRepos: false,
84
+ disabledReason: 'Gemini plugin management is not implemented yet'
85
+ },
86
+ opencode: {
87
+ platform: 'opencode',
88
+ supportsPlugins: true,
89
+ repositories: true,
90
+ market: true,
91
+ install: true,
92
+ uninstall: true,
93
+ toggle: true,
94
+ config: true,
95
+ import: false,
96
+ syncRepos: false
97
+ }
98
+ };
33
99
 
34
100
  function cloneRepos(repos = []) {
35
101
  return repos.map(repo => ({ ...repo }));
@@ -50,10 +116,82 @@ function normalizeRepoDirectory(directory = '') {
50
116
  return normalizeRepoPath(directory);
51
117
  }
52
118
 
119
+ function normalizePluginRepoDirectory(directory = '', label = 'plugin directory') {
120
+ return normalizeSafeRelativePath(directory || '', label, {
121
+ allowEmpty: true,
122
+ allowHiddenSegments: true
123
+ });
124
+ }
125
+
126
+ function normalizePluginPathName(name = '', label = 'plugin name') {
127
+ return normalizeSafeRelativePath(name, label, {
128
+ allowHiddenSegments: false
129
+ });
130
+ }
131
+
132
+ function normalizePluginCacheSegment(value = '', label = 'plugin segment', fallback = 'local') {
133
+ const rawValue = String(value || '').trim() || fallback;
134
+ return normalizeSafeFileStem(rawValue, label, {
135
+ allowDots: true,
136
+ pattern: /^[a-zA-Z0-9][a-zA-Z0-9._@+-]*$/
137
+ });
138
+ }
139
+
140
+ function normalizeClaudeMarketplaceSegment(value = '', fallback = 'ctx') {
141
+ return normalizePluginCacheSegment(value, 'Claude marketplace', fallback);
142
+ }
143
+
144
+ function resolvePluginConfigFile(configDir, name) {
145
+ const safeName = normalizePluginPathName(name, 'plugin config name');
146
+ return resolveInsideRoot(configDir, `${safeName}.json`, 'Plugin config path');
147
+ }
148
+
149
+ function joinRepoPath(...parts) {
150
+ return normalizeRepoPath(parts.filter(Boolean).join('/'));
151
+ }
152
+
53
153
  function stripGitSuffix(value = '') {
54
154
  return String(value || '').replace(/\.git$/i, '');
55
155
  }
56
156
 
157
+ function slugifyCodexKey(value = '', fallback = 'local') {
158
+ const slug = String(value || '')
159
+ .trim()
160
+ .replace(/\\/g, '/')
161
+ .split('/')
162
+ .filter(Boolean)
163
+ .pop()
164
+ ?.replace(/\.git$/i, '')
165
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
166
+ .replace(/^-+|-+$/g, '');
167
+ return slug || fallback;
168
+ }
169
+
170
+ function splitPluginMarketplaceKey(key = '') {
171
+ const value = String(key || '');
172
+ const atIndex = value.lastIndexOf('@');
173
+ if (atIndex <= 0) return { name: value, marketplace: '' };
174
+ return {
175
+ name: value.slice(0, atIndex),
176
+ marketplace: value.slice(atIndex + 1)
177
+ };
178
+ }
179
+
180
+ function hasRepoInstallInfo(repoInfo = null) {
181
+ return Boolean(
182
+ repoInfo &&
183
+ typeof repoInfo === 'object' &&
184
+ (
185
+ Object.prototype.hasOwnProperty.call(repoInfo, 'directory') ||
186
+ repoInfo.localPath ||
187
+ repoInfo.owner ||
188
+ repoInfo.projectPath ||
189
+ repoInfo.repoUrl ||
190
+ repoInfo.url
191
+ )
192
+ );
193
+ }
194
+
57
195
  function isWindowsAbsolutePath(input = '') {
58
196
  return /^[a-zA-Z]:[\\/]/.test(String(input || ''));
59
197
  }
@@ -210,17 +348,24 @@ function stripJsonComments(input = '') {
210
348
 
211
349
  class PluginsService {
212
350
  constructor(platform = 'claude') {
213
- this.platform = ['claude', 'opencode'].includes(platform) ? platform : 'claude';
351
+ this.platform = SUPPORTED_PLATFORMS.includes(platform) ? platform : 'claude';
214
352
  this.configDir = PATHS.config || path.join((PATHS.base || process.env.HOME || os.homedir()), 'config');
215
353
  this.ccToolConfigDir = path.dirname(PATHS.pluginRepos.claude);
216
354
  this.opencodePluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugins');
217
355
  this.opencodeLegacyPluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugin');
218
- this.marketCachePath = this.platform === 'opencode'
219
- ? PATHS.pluginMarketCache.opencode
220
- : PATHS.pluginMarketCache.claude;
356
+ this.codexPluginsCacheDir = CODEX_PLUGINS_CACHE_DIR;
357
+ this.marketCachePath = PATHS.pluginMarketCache[this.platform] || PATHS.pluginMarketCache.claude;
221
358
  this._marketCache = null;
222
359
  }
223
360
 
361
+ getCapabilities() {
362
+ return { ...(PLATFORM_CAPABILITIES[this.platform] || PLATFORM_CAPABILITIES.claude) };
363
+ }
364
+
365
+ _pluginsSupported() {
366
+ return this.getCapabilities().supportsPlugins !== false;
367
+ }
368
+
224
369
  normalizeRepoConfig(repo = {}) {
225
370
  const provider = SUPPORTED_REPO_PROVIDERS.includes(repo.provider)
226
371
  ? repo.provider
@@ -426,6 +571,14 @@ class PluginsService {
426
571
  return this.platform === 'opencode';
427
572
  }
428
573
 
574
+ _isCodex() {
575
+ return this.platform === 'codex';
576
+ }
577
+
578
+ _isGemini() {
579
+ return this.platform === 'gemini';
580
+ }
581
+
429
582
  _getOpenCodePluginsDir() {
430
583
  if (fs.existsSync(this.opencodeLegacyPluginsDir) && !fs.existsSync(this.opencodePluginsDir)) {
431
584
  return this.opencodeLegacyPluginsDir;
@@ -539,12 +692,397 @@ class PluginsService {
539
692
  return plugins;
540
693
  }
541
694
 
695
+ _readFirstMarkdownParagraph(filePath) {
696
+ if (!fs.existsSync(filePath)) return '';
697
+ try {
698
+ const content = fs.readFileSync(filePath, 'utf8');
699
+ const lines = content
700
+ .split(/\r?\n/)
701
+ .map(line => line.trim())
702
+ .filter(line => line && !line.startsWith('#') && !line.startsWith('<!--'));
703
+ return lines[0] || '';
704
+ } catch {
705
+ return '';
706
+ }
707
+ }
708
+
709
+ _listCodexCachedPlugins() {
710
+ const cacheDir = this.codexPluginsCacheDir;
711
+ if (!fs.existsSync(cacheDir)) return [];
712
+
713
+ const plugins = [];
714
+ try {
715
+ const marketplaces = fs.readdirSync(cacheDir, { withFileTypes: true });
716
+ for (const marketplaceEntry of marketplaces) {
717
+ if (!marketplaceEntry.isDirectory() || marketplaceEntry.name.startsWith('.')) continue;
718
+ const marketplaceDir = path.join(cacheDir, marketplaceEntry.name);
719
+ const pluginEntries = fs.readdirSync(marketplaceDir, { withFileTypes: true });
720
+
721
+ for (const pluginEntry of pluginEntries) {
722
+ if (!pluginEntry.isDirectory() || pluginEntry.name.startsWith('.')) continue;
723
+ const pluginRoot = path.join(marketplaceDir, pluginEntry.name);
724
+ const versionEntries = fs.readdirSync(pluginRoot, { withFileTypes: true })
725
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'));
726
+ const selectedVersion = versionEntries
727
+ .map(entry => ({
728
+ name: entry.name,
729
+ fullPath: path.join(pluginRoot, entry.name),
730
+ mtimeMs: fs.statSync(path.join(pluginRoot, entry.name)).mtimeMs
731
+ }))
732
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
733
+ if (!selectedVersion) continue;
734
+
735
+ const description = this._readFirstMarkdownParagraph(path.join(selectedVersion.fullPath, 'README.md'));
736
+ const manifest = this._readLocalManifest(selectedVersion.fullPath, [
737
+ '.codex-plugin/plugin.json',
738
+ 'plugin.json',
739
+ 'package.json'
740
+ ]) || {};
741
+ plugins.push({
742
+ name: manifest.name || pluginEntry.name,
743
+ marketplace: marketplaceEntry.name,
744
+ version: manifest.version || selectedVersion.name,
745
+ installPath: selectedVersion.fullPath,
746
+ directory: path.relative(CODEX_PLUGINS_DIR, selectedVersion.fullPath),
747
+ source: 'codex-cache',
748
+ installed: true,
749
+ enabled: this._isCodexPluginEnabled(manifest.name || pluginEntry.name, marketplaceEntry.name),
750
+ pluginType: 'cache',
751
+ description: manifest.description || description
752
+ });
753
+ }
754
+ }
755
+ } catch (err) {
756
+ console.warn('[PluginsService] Failed to scan Codex plugin cache:', err.message);
757
+ }
758
+
759
+ plugins.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
760
+ return plugins;
761
+ }
762
+
763
+ _readLocalManifest(rootDir, candidates = []) {
764
+ for (const candidate of candidates) {
765
+ const manifestPath = path.join(rootDir, candidate);
766
+ if (!fs.existsSync(manifestPath)) continue;
767
+ try {
768
+ return JSON.parse(stripJsonComments(fs.readFileSync(manifestPath, 'utf8')));
769
+ } catch {
770
+ // try next candidate
771
+ }
772
+ }
773
+ return null;
774
+ }
775
+
776
+ _readCodexConfig() {
777
+ const filePath = NATIVE_PATHS.codex.config;
778
+ if (!fs.existsSync(filePath)) return { filePath, config: {} };
779
+ try {
780
+ const raw = fs.readFileSync(filePath, 'utf8');
781
+ if (!raw.trim()) return { filePath, config: {} };
782
+ return { filePath, config: toml.parse(raw) };
783
+ } catch (err) {
784
+ console.error('[PluginsService] Failed to read Codex config:', err.message);
785
+ return { filePath, config: {} };
786
+ }
787
+ }
788
+
789
+ _writeCodexConfig(config) {
790
+ const filePath = NATIVE_PATHS.codex.config;
791
+ this._ensureDir(path.dirname(filePath));
792
+ const safeConfig = JSON.parse(JSON.stringify(config || {}));
793
+ fs.writeFileSync(filePath, tomlStringify(safeConfig), 'utf8');
794
+ }
795
+
796
+ _readClaudeSettings() {
797
+ const filePath = NATIVE_PATHS.claude.settings;
798
+ if (!fs.existsSync(filePath)) return { filePath, settings: {} };
799
+ try {
800
+ const raw = fs.readFileSync(filePath, 'utf8');
801
+ return { filePath, settings: raw.trim() ? JSON.parse(raw) : {} };
802
+ } catch (err) {
803
+ console.error('[PluginsService] Failed to read Claude settings:', err.message);
804
+ return { filePath, settings: {} };
805
+ }
806
+ }
807
+
808
+ _writeClaudeSettings(settings) {
809
+ const filePath = NATIVE_PATHS.claude.settings;
810
+ this._ensureDir(path.dirname(filePath));
811
+ fs.writeFileSync(filePath, JSON.stringify(settings || {}, null, 2), 'utf8');
812
+ }
813
+
814
+ _setClaudePluginEnabled(name, marketplace, enabled) {
815
+ const { settings } = this._readClaudeSettings();
816
+ const nextSettings = (settings && typeof settings === 'object') ? { ...settings } : {};
817
+ const pluginKey = marketplace ? `${name}@${marketplace}` : name;
818
+ nextSettings.enabledPlugins = { ...(nextSettings.enabledPlugins || {}) };
819
+ nextSettings.enabledPlugins[pluginKey] = !!enabled;
820
+ this._writeClaudeSettings(nextSettings);
821
+ }
822
+
823
+ _removeClaudePluginEnabled(name, marketplace = '') {
824
+ const { settings } = this._readClaudeSettings();
825
+ const nextSettings = (settings && typeof settings === 'object') ? { ...settings } : {};
826
+ const enabledPlugins = { ...(nextSettings.enabledPlugins || {}) };
827
+ for (const key of Object.keys(enabledPlugins)) {
828
+ const parsed = splitPluginMarketplaceKey(key);
829
+ if (parsed.name === name && (!marketplace || parsed.marketplace === marketplace)) {
830
+ delete enabledPlugins[key];
831
+ }
832
+ }
833
+ nextSettings.enabledPlugins = enabledPlugins;
834
+ this._writeClaudeSettings(nextSettings);
835
+ }
836
+
837
+ _isClaudePluginEnabled(name, marketplace = '') {
838
+ const { settings } = this._readClaudeSettings();
839
+ const enabledPlugins = settings.enabledPlugins || {};
840
+ const pluginKey = marketplace ? `${name}@${marketplace}` : name;
841
+ if (Object.prototype.hasOwnProperty.call(enabledPlugins, pluginKey)) {
842
+ return enabledPlugins[pluginKey] !== false;
843
+ }
844
+ for (const [key, value] of Object.entries(enabledPlugins)) {
845
+ const parsed = splitPluginMarketplaceKey(key);
846
+ if (parsed.name === name) {
847
+ return value !== false;
848
+ }
849
+ }
850
+ return true;
851
+ }
852
+
853
+ _resolveClaudeMarketplaceName(normalizedRepo, explicit = '') {
854
+ if (explicit) return normalizeClaudeMarketplaceSegment(explicit);
855
+ if (normalizedRepo.marketplace) return normalizeClaudeMarketplaceSegment(normalizedRepo.marketplace);
856
+ if (normalizedRepo.provider === 'local') return normalizeClaudeMarketplaceSegment(normalizedRepo.name || normalizedRepo.localPath, 'local');
857
+ if (normalizedRepo.provider === 'gitlab') return normalizeClaudeMarketplaceSegment(normalizedRepo.name || normalizedRepo.projectPath, 'gitlab');
858
+ return normalizeClaudeMarketplaceSegment(normalizedRepo.name || normalizedRepo.owner || 'ctx');
859
+ }
860
+
861
+ _resolveClaudePluginCachePath(marketplace, pluginName, version) {
862
+ return resolveInsideRoot(
863
+ CLAUDE_PLUGINS_CACHE_DIR,
864
+ path.posix.join(
865
+ normalizeClaudeMarketplaceSegment(marketplace),
866
+ normalizePluginCacheSegment(pluginName, 'Claude plugin name', 'plugin'),
867
+ normalizePluginCacheSegment(String(version || 'local'), 'Claude plugin version', 'local')
868
+ ),
869
+ 'Claude plugin cache path'
870
+ );
871
+ }
872
+
873
+ _resolveClaudeMarketplacePath(marketplace) {
874
+ return resolveInsideRoot(
875
+ CLAUDE_MARKETPLACES_DIR,
876
+ normalizeClaudeMarketplaceSegment(marketplace),
877
+ 'Claude marketplace path'
878
+ );
879
+ }
880
+
881
+ _registerClaudeMarketplace(marketplace, sourceDir, marketplaceInfo = {}) {
882
+ const safeMarketplace = normalizeClaudeMarketplaceSegment(marketplace);
883
+ const marketplaceDir = this._resolveClaudeMarketplacePath(safeMarketplace);
884
+ this._ensureDir(path.join(marketplaceDir, '.claude-plugin'));
885
+
886
+ const plugins = Array.isArray(marketplaceInfo.plugins) ? marketplaceInfo.plugins : [];
887
+ const manifestPath = path.join(marketplaceDir, '.claude-plugin', 'marketplace.json');
888
+ const marketplaceManifest = {
889
+ name: safeMarketplace,
890
+ owner: { name: 'CTX' },
891
+ plugins,
892
+ ...(marketplaceInfo.description ? { description: marketplaceInfo.description } : {})
893
+ };
894
+ fs.writeFileSync(manifestPath, JSON.stringify(marketplaceManifest, null, 2), 'utf8');
895
+
896
+ this._ensureDir(CLAUDE_PLUGINS_DIR);
897
+ let known = {};
898
+ if (fs.existsSync(CLAUDE_MARKETPLACES_FILE)) {
899
+ try {
900
+ known = JSON.parse(fs.readFileSync(CLAUDE_MARKETPLACES_FILE, 'utf8'));
901
+ } catch {
902
+ known = {};
903
+ }
904
+ }
905
+ known[safeMarketplace] = {
906
+ source: {
907
+ source: 'directory',
908
+ path: marketplaceDir
909
+ },
910
+ installLocation: marketplaceDir,
911
+ lastUpdated: new Date().toISOString()
912
+ };
913
+ fs.writeFileSync(CLAUDE_MARKETPLACES_FILE, JSON.stringify(known, null, 2), 'utf8');
914
+
915
+ const { settings } = this._readClaudeSettings();
916
+ const nextSettings = (settings && typeof settings === 'object') ? { ...settings } : {};
917
+ nextSettings.extraKnownMarketplaces = { ...(nextSettings.extraKnownMarketplaces || {}) };
918
+ nextSettings.extraKnownMarketplaces[safeMarketplace] = {
919
+ source: {
920
+ source: 'directory',
921
+ path: marketplaceDir
922
+ }
923
+ };
924
+ this._writeClaudeSettings(nextSettings);
925
+ return marketplaceDir;
926
+ }
927
+
928
+ _upsertClaudeMarketplacePlugin(marketplace, pluginDir, plugin) {
929
+ const marketplaceDir = this._resolveClaudeMarketplacePath(marketplace);
930
+ const marketplaceManifestPath = path.join(marketplaceDir, '.claude-plugin', 'marketplace.json');
931
+ let marketplaceManifest = {
932
+ name: normalizeClaudeMarketplaceSegment(marketplace),
933
+ owner: { name: 'CTX' },
934
+ plugins: []
935
+ };
936
+ if (fs.existsSync(marketplaceManifestPath)) {
937
+ try {
938
+ marketplaceManifest = JSON.parse(fs.readFileSync(marketplaceManifestPath, 'utf8'));
939
+ } catch {
940
+ // recreate malformed marketplace manifest below
941
+ }
942
+ }
943
+ if (!Array.isArray(marketplaceManifest.plugins)) {
944
+ marketplaceManifest.plugins = [];
945
+ }
946
+
947
+ const pluginName = String(plugin.name || '').trim();
948
+ const existingIndex = marketplaceManifest.plugins.findIndex(item => item?.name === pluginName);
949
+ const nextEntry = {
950
+ name: pluginName,
951
+ source: `./plugins/${pluginName}`,
952
+ description: plugin.description || ''
953
+ };
954
+ if (plugin.category) {
955
+ nextEntry.category = plugin.category;
956
+ }
957
+ if (existingIndex >= 0) {
958
+ marketplaceManifest.plugins[existingIndex] = {
959
+ ...marketplaceManifest.plugins[existingIndex],
960
+ ...nextEntry
961
+ };
962
+ } else {
963
+ marketplaceManifest.plugins.push(nextEntry);
964
+ }
965
+
966
+ const marketplacePluginDir = resolveInsideRoot(
967
+ marketplaceDir,
968
+ path.posix.join('plugins', normalizePluginCacheSegment(pluginName, 'Claude plugin name', 'plugin')),
969
+ 'Claude marketplace plugin path'
970
+ );
971
+ if (fs.existsSync(marketplacePluginDir)) {
972
+ fs.rmSync(marketplacePluginDir, { recursive: true, force: true });
973
+ }
974
+ this.copyDirRecursive(pluginDir, marketplacePluginDir, {
975
+ excludeNames: [REPO_SOURCE_META_FILE]
976
+ });
977
+ this._registerClaudeMarketplace(marketplace, marketplaceDir, marketplaceManifest);
978
+ }
979
+
980
+ _registerClaudeInstalledPlugin(name, marketplace, installPath, installData = {}) {
981
+ this._ensureDir(CLAUDE_PLUGINS_DIR);
982
+ let nativeData = { version: 2, plugins: {} };
983
+ if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
984
+ try {
985
+ nativeData = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
986
+ } catch {
987
+ nativeData = { version: 2, plugins: {} };
988
+ }
989
+ }
990
+ nativeData.version = nativeData.version || 2;
991
+ if (!nativeData.plugins || typeof nativeData.plugins !== 'object') {
992
+ nativeData.plugins = {};
993
+ }
994
+ const nativeKey = marketplace ? `${name}@${marketplace}` : name;
995
+ const installTimestamp = installData.installedAt || new Date().toISOString();
996
+ nativeData.plugins[nativeKey] = [{
997
+ scope: installData.scope || 'user',
998
+ installPath,
999
+ version: installData.version || '1.0.0',
1000
+ installedAt: installTimestamp,
1001
+ lastUpdated: installData.lastUpdated || installTimestamp,
1002
+ ...(installData.source ? { source: installData.source } : {}),
1003
+ ...(installData.repoSourceMeta || {})
1004
+ }];
1005
+ fs.writeFileSync(CLAUDE_INSTALLED_FILE, JSON.stringify(nativeData, null, 2), 'utf8');
1006
+ this._setClaudePluginEnabled(name, marketplace, true);
1007
+ }
1008
+
1009
+ _isCodexPluginEnabled(name, marketplace = '') {
1010
+ const { config } = this._readCodexConfig();
1011
+ const plugins = config.plugins || {};
1012
+ if (marketplace) {
1013
+ const exact = plugins[`${name}@${marketplace}`];
1014
+ if (exact && typeof exact === 'object' && exact.enabled === false) return false;
1015
+ }
1016
+ for (const [key, value] of Object.entries(plugins)) {
1017
+ const parsed = splitPluginMarketplaceKey(key);
1018
+ if (parsed.name === name && value && typeof value === 'object' && value.enabled === false) {
1019
+ return false;
1020
+ }
1021
+ }
1022
+ return true;
1023
+ }
1024
+
1025
+ _setCodexPluginEnabled(name, marketplace, enabled) {
1026
+ const { config } = this._readCodexConfig();
1027
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
1028
+ nextConfig.plugins = { ...(nextConfig.plugins || {}) };
1029
+ const pluginKey = marketplace ? `${name}@${marketplace}` : name;
1030
+ nextConfig.plugins[pluginKey] = {
1031
+ ...(nextConfig.plugins[pluginKey] || {}),
1032
+ enabled: !!enabled
1033
+ };
1034
+ this._writeCodexConfig(nextConfig);
1035
+ }
1036
+
1037
+ _registerCodexMarketplace(marketplace, source) {
1038
+ const { config } = this._readCodexConfig();
1039
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
1040
+ nextConfig.marketplaces = { ...(nextConfig.marketplaces || {}) };
1041
+ nextConfig.marketplaces[marketplace] = {
1042
+ ...(nextConfig.marketplaces[marketplace] || {}),
1043
+ source_type: isLikelyLocalPath(source) ? 'local' : 'git',
1044
+ source,
1045
+ last_updated: new Date().toISOString()
1046
+ };
1047
+ this._writeCodexConfig(nextConfig);
1048
+ }
1049
+
1050
+ _unregisterCodexPlugin(name, marketplace = '') {
1051
+ const { config } = this._readCodexConfig();
1052
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
1053
+ const plugins = { ...(nextConfig.plugins || {}) };
1054
+ for (const key of Object.keys(plugins)) {
1055
+ const parsed = splitPluginMarketplaceKey(key);
1056
+ if (parsed.name === name && (!marketplace || parsed.marketplace === marketplace)) {
1057
+ delete plugins[key];
1058
+ }
1059
+ }
1060
+ nextConfig.plugins = plugins;
1061
+ this._writeCodexConfig(nextConfig);
1062
+ }
1063
+
1064
+ _resolveCodexMarketplaceName(repo, explicit = '') {
1065
+ if (explicit) return slugifyCodexKey(explicit);
1066
+ if (repo.marketplace) return slugifyCodexKey(repo.marketplace);
1067
+ if (repo.provider === 'local') return slugifyCodexKey(repo.localPath, 'local');
1068
+ if (repo.provider === 'gitlab') return slugifyCodexKey(repo.projectPath, 'gitlab');
1069
+ return slugifyCodexKey(repo.name || repo.owner || 'github');
1070
+ }
1071
+
542
1072
  /**
543
1073
  * List all installed plugins with their status
544
1074
  * Reads from Claude Code's native installed_plugins.json
545
1075
  * @returns {Object} { plugins: Array }
546
1076
  */
547
1077
  listPlugins() {
1078
+ if (!this._pluginsSupported()) {
1079
+ return { plugins: [] };
1080
+ }
1081
+
1082
+ if (this._isCodex()) {
1083
+ return { plugins: this._listCodexCachedPlugins() };
1084
+ }
1085
+
548
1086
  if (this._isOpenCode()) {
549
1087
  const plugins = [];
550
1088
  const seen = new Set();
@@ -584,7 +1122,7 @@ class PluginsService {
584
1122
  for (const [key, installations] of Object.entries(data.plugins)) {
585
1123
  if (installations && installations.length > 0) {
586
1124
  const install = installations[0]; // Get first installation
587
- const [name, marketplace] = key.split('@');
1125
+ const { name, marketplace } = splitPluginMarketplaceKey(key);
588
1126
 
589
1127
  // Read plugin.json from installPath for description
590
1128
  let description = '';
@@ -637,7 +1175,8 @@ class PluginsService {
637
1175
 
638
1176
  // Read enabled state from CTX registry (defaults to true if not set)
639
1177
  const legacyInfo = getPlugin(name);
640
- const enabledState = legacyInfo ? legacyInfo.enabled !== false : true;
1178
+ const enabledState = this._isClaudePluginEnabled(name, marketplace) &&
1179
+ (legacyInfo ? legacyInfo.enabled !== false : true);
641
1180
 
642
1181
  plugins.push({
643
1182
  name,
@@ -690,6 +1229,22 @@ class PluginsService {
690
1229
  * @returns {Object|null} Plugin details or null
691
1230
  */
692
1231
  getPlugin(name) {
1232
+ if (this._isCodex()) {
1233
+ const plugin = this.listPlugins().plugins.find(p => p.name === name || `${p.name}@${p.marketplace}` === name);
1234
+ if (!plugin) return null;
1235
+ const manifest = plugin.installPath
1236
+ ? this._readLocalManifest(plugin.installPath, ['.codex-plugin/plugin.json', 'plugin.json', 'package.json'])
1237
+ : null;
1238
+ return {
1239
+ name: plugin.name,
1240
+ ...plugin,
1241
+ author: manifest?.author || '',
1242
+ commands: manifest?.commands || [],
1243
+ hooks: manifest?.hooks || [],
1244
+ manifest
1245
+ };
1246
+ }
1247
+
693
1248
  if (this._isOpenCode()) {
694
1249
  const plugin = this.listPlugins().plugins.find(p => p.name === name || p.directory === name);
695
1250
  if (!plugin) return null;
@@ -731,8 +1286,36 @@ class PluginsService {
731
1286
  * @returns {Promise<Object>} Installation result
732
1287
  */
733
1288
  async installPlugin(source, repoInfo = null) {
1289
+ if (this._isCodex()) {
1290
+ if (hasRepoInstallInfo(repoInfo)) {
1291
+ return this._installFromRepoDirectory(repoInfo);
1292
+ }
1293
+
1294
+ const parsedSource = this.parseRepoTreeSource(source);
1295
+ if (parsedSource) {
1296
+ return this._installFromRepoDirectory(parsedSource);
1297
+ }
1298
+
1299
+ const parsedRepo = this._repoFromGitUrl(source, 'main');
1300
+ if (parsedRepo) {
1301
+ return this._installFromRepoDirectory({ ...parsedRepo, directory: '' });
1302
+ }
1303
+
1304
+ return {
1305
+ success: false,
1306
+ error: 'Codex plugin install expects repository metadata, a GitHub/GitLab tree URL, or a Git repository URL'
1307
+ };
1308
+ }
1309
+
1310
+ if (!this._pluginsSupported()) {
1311
+ return {
1312
+ success: false,
1313
+ error: `${this.platform} plugin management is not supported`
1314
+ };
1315
+ }
1316
+
734
1317
  if (this._isOpenCode()) {
735
- if (repoInfo && repoInfo.directory) {
1318
+ if (hasRepoInstallInfo(repoInfo)) {
736
1319
  return this._installFromRepoDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
737
1320
  }
738
1321
 
@@ -741,6 +1324,11 @@ class PluginsService {
741
1324
  return this._installFromRepoDirectory(parsedSource, { installRoot: this._getOpenCodePluginsDir() });
742
1325
  }
743
1326
 
1327
+ const parsedRepo = this._repoFromGitUrl(source, 'main');
1328
+ if (parsedRepo) {
1329
+ return this._installFromRepoDirectory({ ...parsedRepo, directory: '' }, { installRoot: this._getOpenCodePluginsDir() });
1330
+ }
1331
+
744
1332
  // OpenCode 原生支持 npm 包名,通过 opencode.json 的 plugin 数组管理
745
1333
  if (!/^https?:\/\//.test(source)) {
746
1334
  const plugins = this._listOpenCodeConfiguredPlugins();
@@ -760,7 +1348,7 @@ class PluginsService {
760
1348
  };
761
1349
  }
762
1350
 
763
- if (repoInfo && repoInfo.directory) {
1351
+ if (hasRepoInstallInfo(repoInfo)) {
764
1352
  return await this._installFromRepoDirectory(repoInfo);
765
1353
  }
766
1354
 
@@ -769,6 +1357,11 @@ class PluginsService {
769
1357
  return await this._installFromRepoDirectory(parsedSource);
770
1358
  }
771
1359
 
1360
+ const parsedRepo = this._repoFromGitUrl(source, 'main');
1361
+ if (parsedRepo) {
1362
+ return await this._installFromRepoDirectory({ ...parsedRepo, directory: '' });
1363
+ }
1364
+
772
1365
  // Fallback to original git clone method
773
1366
  return await installPluginCore(source);
774
1367
  }
@@ -779,30 +1372,48 @@ class PluginsService {
779
1372
  */
780
1373
  async _installFromRepoDirectory(repoInfo, options = {}) {
781
1374
  const normalizedRepo = this.normalizeRepoConfig(repoInfo);
782
- const directory = normalizeRepoPath(repoInfo.directory || '');
783
- const pluginName = directory.split('/').pop();
784
- const installRoot = options.installRoot || INSTALLED_DIR;
1375
+ const directory = normalizePluginRepoDirectory(repoInfo.directory || normalizedRepo.directory || '');
1376
+ const pluginName = directory.split('/').filter(Boolean).pop() || slugifyCodexKey(normalizedRepo.name, 'plugin');
1377
+ const manifestCandidates = this._getManifestCandidates(directory);
785
1378
 
786
1379
  try {
787
1380
  let manifest;
788
- try {
789
- manifest = await this.fetchRepoJson(normalizedRepo, `${directory}/plugin.json`);
790
- } catch {
1381
+ let manifestPath = '';
1382
+ for (const candidate of manifestCandidates) {
791
1383
  try {
792
- manifest = await this.fetchRepoJson(normalizedRepo, `${directory}/package.json`);
1384
+ manifestPath = joinRepoPath(directory, candidate);
1385
+ manifest = await this.fetchRepoJson(normalizedRepo, manifestPath);
1386
+ break;
793
1387
  } catch {
794
- manifest = { name: pluginName, version: '1.0.0' };
1388
+ manifestPath = '';
795
1389
  }
796
1390
  }
1391
+ if (!manifest) {
1392
+ manifest = { name: pluginName, version: '1.0.0' };
1393
+ }
1394
+
1395
+ const installedPluginName = String(manifest.name || pluginName || 'plugin').trim() || 'plugin';
1396
+ const installRoot = this._resolvePluginInstallRoot(normalizedRepo, repoInfo, manifest, options);
797
1397
 
798
1398
  // Create plugin directory
799
- const pluginDir = path.join(installRoot, manifest.name || pluginName);
1399
+ const pluginDir = this._isCodex()
1400
+ ? installRoot
1401
+ : this.platform === 'claude'
1402
+ ? installRoot
1403
+ : resolveInsideRoot(
1404
+ installRoot,
1405
+ normalizePluginPathName(installedPluginName, 'plugin name'),
1406
+ 'Plugin install path',
1407
+ { allowHiddenSegments: false }
1408
+ );
800
1409
  if (!fs.existsSync(pluginDir)) {
801
1410
  fs.mkdirSync(pluginDir, { recursive: true });
802
1411
  }
803
1412
 
804
1413
  if (normalizedRepo.provider === 'local') {
805
- const sourceDir = path.join(normalizedRepo.localPath, directory);
1414
+ const sourceDir = directory
1415
+ ? resolveInsideRoot(normalizedRepo.localPath, directory, 'Plugin directory', { allowHiddenSegments: true })
1416
+ : normalizedRepo.localPath;
806
1417
  if (!fs.existsSync(sourceDir)) {
807
1418
  throw new Error(`Plugin directory not found: ${directory}`);
808
1419
  }
@@ -843,7 +1454,10 @@ class PluginsService {
843
1454
  throw new Error('Empty archive');
844
1455
  }
845
1456
 
846
- const sourceDir = path.join(tempDir, extractedDir, directory);
1457
+ const extractedRoot = path.join(tempDir, extractedDir);
1458
+ const sourceDir = directory
1459
+ ? resolveInsideRoot(extractedRoot, directory, 'Plugin directory', { allowHiddenSegments: true })
1460
+ : extractedRoot;
847
1461
  if (!fs.existsSync(sourceDir)) {
848
1462
  throw new Error(`Plugin directory not found: ${directory}`);
849
1463
  }
@@ -854,15 +1468,43 @@ class PluginsService {
854
1468
  }
855
1469
 
856
1470
  // Write plugin.json if not exists
857
- const manifestPath = path.join(pluginDir, 'plugin.json');
858
- if (!fs.existsSync(manifestPath)) {
859
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
1471
+ const fallbackManifestPath = path.join(pluginDir, this._isCodex() ? '.codex-plugin/plugin.json' : 'plugin.json');
1472
+ if (!fs.existsSync(fallbackManifestPath)) {
1473
+ this._ensureDir(path.dirname(fallbackManifestPath));
1474
+ fs.writeFileSync(fallbackManifestPath, JSON.stringify(manifest, null, 2));
1475
+ }
1476
+ if (this.platform === 'claude') {
1477
+ const claudeManifestPath = path.join(pluginDir, '.claude-plugin', 'plugin.json');
1478
+ if (!fs.existsSync(claudeManifestPath)) {
1479
+ this._ensureDir(path.dirname(claudeManifestPath));
1480
+ fs.writeFileSync(claudeManifestPath, JSON.stringify(manifest, null, 2));
1481
+ }
860
1482
  }
861
1483
 
862
- if (!this._isOpenCode()) {
863
- const installedPluginName = manifest.name || pluginName;
1484
+ if (this._isCodex()) {
1485
+ const marketplace = this._resolveCodexMarketplaceName(normalizedRepo, repoInfo.marketplace);
1486
+ const sourceUrl = this.buildRepoBrowserUrl(normalizedRepo, directory) || buildRepoUrl(normalizedRepo);
1487
+ this.writeRepoSourceMeta(pluginDir, {
1488
+ repoId: normalizedRepo.id,
1489
+ repoProvider: normalizedRepo.provider,
1490
+ repoOwner: normalizedRepo.owner || '',
1491
+ repoName: normalizedRepo.name || '',
1492
+ repoBranch: normalizedRepo.branch,
1493
+ repoDirectory: directory,
1494
+ repoHost: normalizedRepo.host || '',
1495
+ repoProjectPath: normalizedRepo.projectPath || '',
1496
+ repoLocalPath: normalizedRepo.localPath || '',
1497
+ repoUrl: normalizedRepo.repoUrl || buildRepoUrl(normalizedRepo),
1498
+ marketplace,
1499
+ manifestPath,
1500
+ source: sourceUrl
1501
+ });
1502
+ this._registerCodexMarketplace(marketplace, normalizedRepo.repoUrl || buildRepoUrl(normalizedRepo));
1503
+ this._setCodexPluginEnabled(installedPluginName, marketplace, true);
1504
+ } else if (this.platform === 'claude') {
864
1505
  const installTimestamp = new Date().toISOString();
865
1506
  const sourceUrl = this.buildRepoBrowserUrl(normalizedRepo, directory) || buildRepoUrl(normalizedRepo);
1507
+ const marketplace = this._resolveClaudeMarketplaceName(normalizedRepo, repoInfo.marketplace);
866
1508
  const repoSourceMeta = {
867
1509
  repoId: normalizedRepo.id,
868
1510
  repoProvider: normalizedRepo.provider,
@@ -874,6 +1516,7 @@ class PluginsService {
874
1516
  repoProjectPath: normalizedRepo.projectPath || '',
875
1517
  repoLocalPath: normalizedRepo.localPath || '',
876
1518
  repoUrl: normalizedRepo.repoUrl || buildRepoUrl(normalizedRepo),
1519
+ repoMarketplace: marketplace,
877
1520
  source: sourceUrl
878
1521
  };
879
1522
 
@@ -891,30 +1534,54 @@ class PluginsService {
891
1534
  }
892
1535
 
893
1536
  this.writeRepoSourceMeta(pluginDir, repoSourceMeta);
1537
+ this._upsertClaudeMarketplacePlugin(marketplace, pluginDir, {
1538
+ name: installedPluginName,
1539
+ description: manifest.description || '',
1540
+ category: manifest.category || ''
1541
+ });
894
1542
 
895
1543
  // Also register in Claude's native installed_plugins.json
896
1544
  try {
897
- this._ensureDir(CLAUDE_PLUGINS_DIR);
898
- let nativeData = { plugins: {} };
899
- if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
900
- try {
901
- nativeData = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
902
- if (!nativeData.plugins) nativeData.plugins = {};
903
- } catch (e) { /* ignore parse error */ }
904
- }
905
- const nativeKey = `${installedPluginName}@ctx`;
906
- nativeData.plugins[nativeKey] = [{
1545
+ this._registerClaudeInstalledPlugin(installedPluginName, marketplace, pluginDir, {
907
1546
  version: manifest.version || '1.0.0',
908
- installPath: pluginDir,
909
1547
  installedAt: installTimestamp,
910
1548
  scope: 'user',
911
1549
  source: sourceUrl,
912
- ...repoSourceMeta
913
- }];
914
- fs.writeFileSync(CLAUDE_INSTALLED_FILE, JSON.stringify(nativeData, null, 2), 'utf8');
1550
+ repoSourceMeta
1551
+ });
915
1552
  } catch (e) {
916
1553
  console.error('[PluginsService] Failed to update native installed_plugins.json:', e.message);
917
1554
  }
1555
+ } else if (!this._isOpenCode()) {
1556
+ const installTimestamp = new Date().toISOString();
1557
+ const sourceUrl = this.buildRepoBrowserUrl(normalizedRepo, directory) || buildRepoUrl(normalizedRepo);
1558
+ const repoSourceMeta = {
1559
+ repoId: normalizedRepo.id,
1560
+ repoProvider: normalizedRepo.provider,
1561
+ repoOwner: normalizedRepo.owner || '',
1562
+ repoName: normalizedRepo.name || '',
1563
+ repoBranch: normalizedRepo.branch,
1564
+ repoDirectory: directory,
1565
+ repoHost: normalizedRepo.host || '',
1566
+ repoProjectPath: normalizedRepo.projectPath || '',
1567
+ repoLocalPath: normalizedRepo.localPath || '',
1568
+ repoUrl: normalizedRepo.repoUrl || buildRepoUrl(normalizedRepo),
1569
+ source: sourceUrl
1570
+ };
1571
+
1572
+ const { addPlugin } = require('../../plugins/registry');
1573
+ try {
1574
+ addPlugin(installedPluginName, {
1575
+ version: manifest.version || '1.0.0',
1576
+ enabled: true,
1577
+ installedAt: installTimestamp,
1578
+ source: sourceUrl
1579
+ });
1580
+ } catch (e) {
1581
+ console.warn('[PluginsService] Legacy registry addPlugin warning:', e.message);
1582
+ }
1583
+
1584
+ this.writeRepoSourceMeta(pluginDir, repoSourceMeta);
918
1585
  }
919
1586
 
920
1587
  return {
@@ -933,6 +1600,49 @@ class PluginsService {
933
1600
  }
934
1601
  }
935
1602
 
1603
+ _getManifestCandidates() {
1604
+ if (this._isCodex()) {
1605
+ return ['.codex-plugin/plugin.json', 'plugin.json', 'package.json'];
1606
+ }
1607
+ if (this._isOpenCode()) {
1608
+ return ['package.json', 'plugin.json'];
1609
+ }
1610
+ return ['.claude-plugin/plugin.json', 'plugin.json', 'package.json'];
1611
+ }
1612
+
1613
+ _resolvePluginInstallRoot(normalizedRepo, repoInfo, manifest, options = {}) {
1614
+ if (!this._isCodex()) {
1615
+ if (this.platform === 'claude') {
1616
+ const marketplace = this._resolveClaudeMarketplaceName(normalizedRepo, repoInfo.marketplace);
1617
+ const pluginName = manifest.name || normalizeRepoPath(repoInfo.directory || '').split('/').pop() || 'plugin';
1618
+ const version = manifest.version || repoInfo.version || 'local';
1619
+ return this._resolveClaudePluginCachePath(marketplace, pluginName, version);
1620
+ }
1621
+ return options.installRoot || INSTALLED_DIR;
1622
+ }
1623
+
1624
+ const marketplace = normalizePluginCacheSegment(
1625
+ this._resolveCodexMarketplaceName(normalizedRepo, repoInfo.marketplace),
1626
+ 'Codex marketplace',
1627
+ 'local'
1628
+ );
1629
+ const pluginName = normalizePluginCacheSegment(
1630
+ manifest.name || normalizeRepoPath(repoInfo.directory || '').split('/').pop() || 'plugin',
1631
+ 'Codex plugin name',
1632
+ 'plugin'
1633
+ );
1634
+ const version = normalizePluginCacheSegment(
1635
+ String(manifest.version || repoInfo.version || 'local').trim() || 'local',
1636
+ 'Codex plugin version',
1637
+ 'local'
1638
+ );
1639
+ return resolveInsideRoot(
1640
+ this.codexPluginsCacheDir,
1641
+ path.posix.join(marketplace, pluginName, version),
1642
+ 'Codex plugin cache path'
1643
+ );
1644
+ }
1645
+
936
1646
  /**
937
1647
  * Parse GitHub/GitLab tree URL or local path
938
1648
  * @private
@@ -986,13 +1696,56 @@ class PluginsService {
986
1696
  * @returns {Object} Uninstallation result
987
1697
  */
988
1698
  uninstallPlugin(name) {
1699
+ if (this._isCodex()) {
1700
+ const plugins = this.listPlugins().plugins;
1701
+ const target = plugins.find(plugin => plugin.name === name || `${plugin.name}@${plugin.marketplace}` === name);
1702
+ if (!target) {
1703
+ return {
1704
+ success: false,
1705
+ error: `Plugin "${name}" not found`
1706
+ };
1707
+ }
1708
+
1709
+ const pluginRoot = target.installPath
1710
+ ? assertInsideAllowedRoots(
1711
+ path.dirname(assertInsideAllowedRoots(target.installPath, [this.codexPluginsCacheDir], 'Codex plugin install path')),
1712
+ [this.codexPluginsCacheDir],
1713
+ 'Codex plugin root'
1714
+ )
1715
+ : resolveInsideRoot(
1716
+ this.codexPluginsCacheDir,
1717
+ path.posix.join(
1718
+ normalizePluginCacheSegment(target.marketplace || '', 'Codex marketplace', 'local'),
1719
+ normalizePluginCacheSegment(target.name || name, 'Codex plugin name', 'plugin')
1720
+ ),
1721
+ 'Codex plugin root'
1722
+ );
1723
+ if (pluginRoot && fs.existsSync(pluginRoot)) {
1724
+ fs.rmSync(pluginRoot, { recursive: true, force: true });
1725
+ }
1726
+ this._unregisterCodexPlugin(target.name, target.marketplace || '');
1727
+
1728
+ return {
1729
+ success: true,
1730
+ message: 'Plugin uninstalled successfully'
1731
+ };
1732
+ }
1733
+
1734
+ if (!this._pluginsSupported()) {
1735
+ return {
1736
+ success: false,
1737
+ error: `${this.platform} plugin management is not supported`
1738
+ };
1739
+ }
1740
+
989
1741
  if (this._isOpenCode()) {
1742
+ const safeName = normalizePluginPathName(name, 'plugin name');
990
1743
  const pluginsDir = this._getOpenCodePluginsDir();
991
1744
  let removed = false;
992
1745
 
993
1746
  // Remove from opencode config.plugin (npm plugins)
994
1747
  const configured = this._listOpenCodeConfiguredPlugins();
995
- const next = configured.filter(p => p !== name);
1748
+ const next = configured.filter(p => p !== safeName);
996
1749
  if (next.length !== configured.length) {
997
1750
  this._setOpenCodeConfiguredPlugins(next);
998
1751
  removed = true;
@@ -1000,7 +1753,7 @@ class PluginsService {
1000
1753
 
1001
1754
  // Remove local plugin directory/file
1002
1755
  if (fs.existsSync(pluginsDir)) {
1003
- const directPath = path.join(pluginsDir, name);
1756
+ const directPath = resolveInsideRoot(pluginsDir, safeName, 'OpenCode plugin path');
1004
1757
  if (fs.existsSync(directPath)) {
1005
1758
  fs.rmSync(directPath, { recursive: true, force: true });
1006
1759
  removed = true;
@@ -1008,8 +1761,9 @@ class PluginsService {
1008
1761
  const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
1009
1762
  for (const entry of entries) {
1010
1763
  const baseName = entry.name.replace(path.extname(entry.name), '');
1011
- if (entry.name === name || baseName === name) {
1012
- fs.rmSync(path.join(pluginsDir, entry.name), { recursive: true, force: true });
1764
+ if (entry.name === safeName || baseName === safeName) {
1765
+ const entryPath = resolveInsideRoot(pluginsDir, entry.name, 'OpenCode plugin path');
1766
+ fs.rmSync(entryPath, { recursive: true, force: true });
1013
1767
  removed = true;
1014
1768
  break;
1015
1769
  }
@@ -1025,22 +1779,28 @@ class PluginsService {
1025
1779
 
1026
1780
  // Claude: Remove from native installed_plugins.json and delete install directories
1027
1781
  let removed = false;
1782
+ const safeName = normalizePluginPathName(name, 'plugin name');
1028
1783
  if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
1029
1784
  try {
1030
1785
  const data = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
1031
1786
  if (data.plugins) {
1032
1787
  const keysToDelete = [];
1033
- const baseName = name.split('/').pop(); // handle "plugins/pr-review-toolkit" → "pr-review-toolkit"
1788
+ const baseName = safeName.split('/').pop(); // handle "plugins/pr-review-toolkit" → "pr-review-toolkit"
1034
1789
  for (const [key, installations] of Object.entries(data.plugins)) {
1035
- const [pluginName] = key.split('@');
1036
- if (pluginName === name || key === name || pluginName === baseName) {
1790
+ const { name: pluginName } = splitPluginMarketplaceKey(key);
1791
+ if (pluginName === safeName || key === safeName || pluginName === baseName) {
1037
1792
  keysToDelete.push(key);
1038
1793
  // Delete install directories
1039
1794
  if (Array.isArray(installations)) {
1040
1795
  for (const install of installations) {
1041
1796
  if (install.installPath && fs.existsSync(install.installPath)) {
1042
1797
  try {
1043
- fs.rmSync(install.installPath, { recursive: true, force: true });
1798
+ const installPath = assertInsideAllowedRoots(
1799
+ install.installPath,
1800
+ [INSTALLED_DIR, CLAUDE_PLUGINS_DIR],
1801
+ 'Claude plugin install path'
1802
+ );
1803
+ fs.rmSync(installPath, { recursive: true, force: true });
1044
1804
  } catch (e) {
1045
1805
  console.error('[PluginsService] Failed to delete install dir:', e.message);
1046
1806
  }
@@ -1051,6 +1811,8 @@ class PluginsService {
1051
1811
  }
1052
1812
  if (keysToDelete.length > 0) {
1053
1813
  for (const key of keysToDelete) {
1814
+ const parsed = splitPluginMarketplaceKey(key);
1815
+ this._removeClaudePluginEnabled(parsed.name || safeName, parsed.marketplace || '');
1054
1816
  delete data.plugins[key];
1055
1817
  }
1056
1818
  fs.writeFileSync(CLAUDE_INSTALLED_FILE, JSON.stringify(data, null, 2), 'utf8');
@@ -1090,6 +1852,24 @@ class PluginsService {
1090
1852
  * @returns {Object} Updated plugin info
1091
1853
  */
1092
1854
  togglePlugin(name, enabled) {
1855
+ if (this._isCodex()) {
1856
+ const plugins = this.listPlugins().plugins;
1857
+ const target = plugins.find(plugin => plugin.name === name || `${plugin.name}@${plugin.marketplace}` === name);
1858
+ if (!target) {
1859
+ throw new Error(`Plugin "${name}" not found`);
1860
+ }
1861
+ this._setCodexPluginEnabled(target.name, target.marketplace || '', enabled);
1862
+ return {
1863
+ name: target.name,
1864
+ enabled,
1865
+ success: true
1866
+ };
1867
+ }
1868
+
1869
+ if (!this._pluginsSupported()) {
1870
+ throw new Error(`${this.platform} plugin management is not supported`);
1871
+ }
1872
+
1093
1873
  if (this._isOpenCode()) {
1094
1874
  const configured = this._listOpenCodeConfiguredPlugins();
1095
1875
  const exists = configured.includes(name);
@@ -1115,7 +1895,7 @@ class PluginsService {
1115
1895
  const data = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
1116
1896
  if (data.plugins) {
1117
1897
  for (const key of Object.keys(data.plugins)) {
1118
- const [pluginName] = key.split('@');
1898
+ const { name: pluginName } = splitPluginMarketplaceKey(key);
1119
1899
  if (pluginName === name || key === name || pluginName === baseName) {
1120
1900
  pluginExists = true;
1121
1901
  break;
@@ -1134,6 +1914,7 @@ class PluginsService {
1134
1914
  }
1135
1915
 
1136
1916
  // Store enabled state in CTX registry (upsert)
1917
+ let matchedMarketplace = '';
1137
1918
  try {
1138
1919
  const { addPlugin } = require('../../plugins/registry');
1139
1920
  if (legacyPlugin) {
@@ -1144,6 +1925,21 @@ class PluginsService {
1144
1925
  } catch (e) {
1145
1926
  console.warn('[PluginsService] Failed to update plugin registry:', e.message);
1146
1927
  }
1928
+ if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
1929
+ try {
1930
+ const data = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
1931
+ for (const key of Object.keys(data.plugins || {})) {
1932
+ const parsed = splitPluginMarketplaceKey(key);
1933
+ if (parsed.name === name || key === name || parsed.name === baseName) {
1934
+ matchedMarketplace = parsed.marketplace || '';
1935
+ break;
1936
+ }
1937
+ }
1938
+ } catch {
1939
+ // ignore
1940
+ }
1941
+ }
1942
+ this._setClaudePluginEnabled(baseName || name, matchedMarketplace, enabled);
1147
1943
 
1148
1944
  return {
1149
1945
  name,
@@ -1159,10 +1955,18 @@ class PluginsService {
1159
1955
  * @returns {Object} Result
1160
1956
  */
1161
1957
  updatePluginConfig(name, config) {
1958
+ if (this._isCodex()) {
1959
+ throw new Error('Codex cached plugins do not support config updates');
1960
+ }
1961
+
1962
+ if (!this._pluginsSupported()) {
1963
+ throw new Error(`${this.platform} plugin management is not supported`);
1964
+ }
1965
+
1162
1966
  if (this._isOpenCode()) {
1163
1967
  const configDir = path.join(OPENCODE_CONFIG_DIR, 'plugins-config');
1164
- this._ensureDir(configDir);
1165
- const configFile = path.join(configDir, `${name}.json`);
1968
+ const configFile = resolvePluginConfigFile(configDir, name);
1969
+ this._ensureDir(path.dirname(configFile));
1166
1970
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf8');
1167
1971
  return {
1168
1972
  success: true,
@@ -1175,11 +1979,11 @@ class PluginsService {
1175
1979
  throw new Error(`Plugin "${name}" not found`);
1176
1980
  }
1177
1981
 
1178
- const configFile = path.join(CONFIG_DIR, `${name}.json`);
1982
+ const configFile = resolvePluginConfigFile(CONFIG_DIR, name);
1179
1983
 
1180
1984
  // Ensure config directory exists
1181
- if (!fs.existsSync(CONFIG_DIR)) {
1182
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
1985
+ if (!fs.existsSync(path.dirname(configFile))) {
1986
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
1183
1987
  }
1184
1988
 
1185
1989
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf8');
@@ -1195,7 +1999,7 @@ class PluginsService {
1195
1999
  * @returns {string} Config file path
1196
2000
  */
1197
2001
  getReposConfigPath() {
1198
- const filePath = this._isOpenCode() ? PATHS.pluginRepos.opencode : PATHS.pluginRepos.claude;
2002
+ const filePath = PATHS.pluginRepos[this.platform] || PATHS.pluginRepos.claude;
1199
2003
  this._ensureDir(path.dirname(filePath));
1200
2004
  return filePath;
1201
2005
  }
@@ -1242,6 +2046,10 @@ class PluginsService {
1242
2046
  * @returns {Array} Repos list
1243
2047
  */
1244
2048
  getRepos() {
2049
+ if (!this.getCapabilities().repositories) {
2050
+ return [];
2051
+ }
2052
+
1245
2053
  const repos = [];
1246
2054
  const seenRepos = new Set();
1247
2055
  const pushRepo = (repo) => {
@@ -1271,7 +2079,7 @@ class PluginsService {
1271
2079
  }
1272
2080
 
1273
2081
  // 2. Load Claude Code's native marketplace config (Claude only)
1274
- if (!this._isOpenCode() && fs.existsSync(CLAUDE_MARKETPLACES_FILE)) {
2082
+ if (this.platform === 'claude' && fs.existsSync(CLAUDE_MARKETPLACES_FILE)) {
1275
2083
  try {
1276
2084
  const marketplaces = JSON.parse(fs.readFileSync(CLAUDE_MARKETPLACES_FILE, 'utf8'));
1277
2085
  const entries = [];
@@ -1644,11 +2452,10 @@ class PluginsService {
1644
2452
  scanLocalRepoTree(currentDir, repoRoot, tree) {
1645
2453
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
1646
2454
  for (const entry of entries) {
1647
- if (entry.name.startsWith('.')) continue;
1648
2455
  const fullPath = path.join(currentDir, entry.name);
1649
2456
  const relativePath = normalizeRepoPath(path.relative(repoRoot, fullPath));
1650
2457
  if (entry.isDirectory()) {
1651
- if (entry.name === 'node_modules') continue;
2458
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
1652
2459
  tree.push({ path: relativePath, type: 'tree', name: entry.name });
1653
2460
  this.scanLocalRepoTree(fullPath, repoRoot, tree);
1654
2461
  } else {
@@ -1704,7 +2511,12 @@ class PluginsService {
1704
2511
 
1705
2512
  async fetchRepoFileContent(repo, filePath, file = null) {
1706
2513
  if (repo.provider === 'local') {
1707
- return fs.readFileSync(path.join(repo.localPath, filePath), 'utf-8');
2514
+ const safeFilePath = normalizePluginRepoDirectory(filePath, 'plugin repository file path');
2515
+ if (!safeFilePath) {
2516
+ throw new Error('Invalid plugin repository file path');
2517
+ }
2518
+ const localFilePath = resolveInsideRoot(repo.localPath, safeFilePath, 'Plugin repository file path', { allowHiddenSegments: true });
2519
+ return fs.readFileSync(localFilePath, 'utf-8');
1708
2520
  }
1709
2521
  if (repo.provider === 'gitlab') {
1710
2522
  return this.fetchGitLabFileContent(repo, filePath);
@@ -1752,16 +2564,17 @@ class PluginsService {
1752
2564
  }
1753
2565
  }
1754
2566
 
1755
- copyDirRecursive(sourceDir, destDir) {
2567
+ copyDirRecursive(sourceDir, destDir, options = {}) {
1756
2568
  const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
1757
2569
  for (const entry of entries) {
2570
+ if (options.excludeNames?.includes(entry.name)) continue;
1758
2571
  const sourcePath = path.join(sourceDir, entry.name);
1759
2572
  const destPath = path.join(destDir, entry.name);
1760
2573
  if (entry.isDirectory()) {
1761
2574
  if (!fs.existsSync(destPath)) {
1762
2575
  fs.mkdirSync(destPath, { recursive: true });
1763
2576
  }
1764
- this.copyDirRecursive(sourcePath, destPath);
2577
+ this.copyDirRecursive(sourcePath, destPath, options);
1765
2578
  } else {
1766
2579
  fs.copyFileSync(sourcePath, destPath);
1767
2580
  }
@@ -1820,7 +2633,7 @@ class PluginsService {
1820
2633
  * @returns {Promise<Object>} Sync results
1821
2634
  */
1822
2635
  async syncRepos() {
1823
- if (this._isOpenCode()) {
2636
+ if (!this.getCapabilities().syncRepos) {
1824
2637
  return { success: true, results: [] };
1825
2638
  }
1826
2639
 
@@ -1957,26 +2770,31 @@ class PluginsService {
1957
2770
  }
1958
2771
 
1959
2772
  buildMarketPluginItem(repo, data = {}) {
2773
+ const itemRepo = data.repo || repo;
2774
+ const directory = Object.prototype.hasOwnProperty.call(data, 'directory')
2775
+ ? data.directory
2776
+ : data.name;
1960
2777
  return {
1961
2778
  name: data.name,
1962
2779
  displayName: data.displayName || '',
1963
2780
  description: data.description || '',
1964
- author: data.author || repo.owner || repo.projectPath || 'unknown',
2781
+ author: data.author || itemRepo.owner || itemRepo.projectPath || 'unknown',
1965
2782
  version: data.version || '1.0.0',
1966
2783
  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 || ''),
2784
+ marketplace: data.marketplace || repo.marketplace || '',
2785
+ repoUrl: data.repoUrl || itemRepo.repoUrl || buildRepoUrl(itemRepo),
2786
+ repoProvider: itemRepo.provider,
2787
+ repoOwner: itemRepo.owner || '',
2788
+ repoName: itemRepo.name || '',
2789
+ repoBranch: itemRepo.branch || 'main',
2790
+ repoHost: itemRepo.host || '',
2791
+ repoProjectPath: itemRepo.projectPath || '',
2792
+ repoLocalPath: itemRepo.localPath || '',
2793
+ repoId: itemRepo.id,
2794
+ directory: normalizeRepoPath(directory || ''),
1977
2795
  installSource: data.installSource || '',
1978
2796
  marketplaceFormat: data.marketplaceFormat || '',
1979
- readmeUrl: this.buildRepoBrowserUrl(repo, data.directory || data.name || ''),
2797
+ readmeUrl: this.buildRepoBrowserUrl(itemRepo, directory || ''),
1980
2798
  lspServers: data.lspServers || null,
1981
2799
  commands: data.commands || [],
1982
2800
  hooks: data.hooks || [],
@@ -2017,7 +2835,7 @@ class PluginsService {
2017
2835
  version: manifest.version || manifest.opencode?.minimumVersion || '1.0.0',
2018
2836
  category: firstCategory ? String(firstCategory).toLowerCase() : 'general',
2019
2837
  directory: file.path,
2020
- installSource: githubRepo ? '' : installSource,
2838
+ installSource,
2021
2839
  marketplaceFormat: 'opencode-plugin-json',
2022
2840
  repoUrl
2023
2841
  });
@@ -2029,11 +2847,209 @@ class PluginsService {
2029
2847
  .map(item => item.value);
2030
2848
  }
2031
2849
 
2850
+ _repoFromMarketplaceSource(parentRepo, source) {
2851
+ if (!source) return { repo: parentRepo, directory: '', installSource: '' };
2852
+
2853
+ if (typeof source === 'string') {
2854
+ const raw = source.trim();
2855
+ if (!raw) return { repo: parentRepo, directory: '', installSource: '' };
2856
+ if (isLikelyLocalPath(raw) || raw.startsWith('.')) {
2857
+ return {
2858
+ repo: parentRepo,
2859
+ directory: normalizeRepoPath(raw.replace(/^\.\//, '')),
2860
+ installSource: ''
2861
+ };
2862
+ }
2863
+ const parsedTree = this.parseRepoTreeSource(raw);
2864
+ if (parsedTree) {
2865
+ return { repo: this.normalizeRepoConfig(parsedTree), directory: parsedTree.directory, installSource: raw };
2866
+ }
2867
+ const parsedRepo = this._repoFromGitUrl(raw, parentRepo.branch || 'main');
2868
+ if (parsedRepo) {
2869
+ return { repo: parsedRepo, directory: '', installSource: raw };
2870
+ }
2871
+ return { repo: parentRepo, directory: normalizeRepoPath(raw.replace(/^\.\//, '')), installSource: raw };
2872
+ }
2873
+
2874
+ if (typeof source !== 'object') {
2875
+ return { repo: parentRepo, directory: '', installSource: '' };
2876
+ }
2877
+
2878
+ const sourceType = source.source || source.type || '';
2879
+ const rawPath = source.path || source.directory || '';
2880
+ if (sourceType === 'local' || (!source.url && rawPath)) {
2881
+ return {
2882
+ repo: parentRepo,
2883
+ directory: normalizeRepoPath(String(rawPath).replace(/^\.\//, '')),
2884
+ installSource: ''
2885
+ };
2886
+ }
2887
+
2888
+ if (source.url) {
2889
+ const branch = source.ref || source.branch || source.sha || parentRepo.branch || 'main';
2890
+ const parsedRepo = this._repoFromGitUrl(source.url, branch);
2891
+ if (parsedRepo) {
2892
+ return {
2893
+ repo: parsedRepo,
2894
+ directory: normalizeRepoPath(rawPath),
2895
+ installSource: source.url
2896
+ };
2897
+ }
2898
+ }
2899
+
2900
+ return {
2901
+ repo: parentRepo,
2902
+ directory: normalizeRepoPath(rawPath),
2903
+ installSource: source.url || ''
2904
+ };
2905
+ }
2906
+
2907
+ _repoFromGitUrl(url = '', branch = 'main') {
2908
+ const value = String(url || '').trim();
2909
+ if (!value) return null;
2910
+
2911
+ const sshMatch = value.match(/^git@([^:]+):(.+?)(?:\.git)?$/i);
2912
+ if (sshMatch) {
2913
+ const host = `https://${sshMatch[1]}`;
2914
+ const repoPath = sshMatch[2].replace(/\.git$/i, '').replace(/^\/+|\/+$/g, '');
2915
+ const parts = repoPath.split('/').filter(Boolean);
2916
+ if (sshMatch[1].includes('github') && parts.length >= 2) {
2917
+ return this.normalizeRepoConfig({
2918
+ provider: 'github',
2919
+ host,
2920
+ owner: parts[0],
2921
+ name: parts[1],
2922
+ branch,
2923
+ repoUrl: value
2924
+ });
2925
+ }
2926
+ if (parts.length > 0) {
2927
+ return this.normalizeRepoConfig({
2928
+ provider: 'gitlab',
2929
+ host,
2930
+ projectPath: repoPath,
2931
+ branch,
2932
+ repoUrl: value
2933
+ });
2934
+ }
2935
+ }
2936
+
2937
+ let parsed;
2938
+ try {
2939
+ parsed = new URL(value);
2940
+ } catch {
2941
+ return null;
2942
+ }
2943
+
2944
+ const parts = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '').split('/').filter(Boolean);
2945
+ if (parsed.hostname.includes('github') && parts.length >= 2) {
2946
+ return this.normalizeRepoConfig({
2947
+ provider: 'github',
2948
+ host: `${parsed.protocol}//${parsed.host}`,
2949
+ owner: parts[0],
2950
+ name: parts[1],
2951
+ branch,
2952
+ repoUrl: value
2953
+ });
2954
+ }
2955
+
2956
+ if (parsed.hostname.includes('gitlab') && parts.length >= 1) {
2957
+ return this.normalizeRepoConfig({
2958
+ provider: 'gitlab',
2959
+ host: `${parsed.protocol}//${parsed.host}`,
2960
+ projectPath: parts.join('/'),
2961
+ branch,
2962
+ repoUrl: value
2963
+ });
2964
+ }
2965
+
2966
+ return null;
2967
+ }
2968
+
2969
+ async _readManifestFromRepo(repo, directory, candidates) {
2970
+ for (const candidate of candidates) {
2971
+ try {
2972
+ return await this.fetchRepoJson(repo, joinRepoPath(directory, candidate));
2973
+ } catch {
2974
+ // try next candidate
2975
+ }
2976
+ }
2977
+ return null;
2978
+ }
2979
+
2980
+ async _fetchClaudeMarketplacePlugins(repo, marketplace, readJson) {
2981
+ if (!marketplace || !Array.isArray(marketplace.plugins)) return [];
2982
+ const results = [];
2983
+ for (const plugin of marketplace.plugins) {
2984
+ const sourceInfo = this._repoFromMarketplaceSource(repo, plugin.source);
2985
+ const canReadManifest = sourceInfo.repo.provider === 'local' || sourceInfo.repo.id === repo.id;
2986
+ const manifest = canReadManifest
2987
+ ? await this._readManifestFromRepo(sourceInfo.repo, sourceInfo.directory, [
2988
+ '.claude-plugin/plugin.json',
2989
+ 'plugin.json',
2990
+ 'package.json'
2991
+ ])
2992
+ : null;
2993
+ results.push(this.buildMarketPluginItem(repo, {
2994
+ repo: sourceInfo.repo,
2995
+ name: manifest?.name || plugin.name,
2996
+ description: manifest?.description || plugin.description || '',
2997
+ author: manifest?.author || plugin.author?.name || marketplace.owner?.name || repo.owner,
2998
+ version: manifest?.version || plugin.version || '1.0.0',
2999
+ category: plugin.category || 'general',
3000
+ directory: sourceInfo.directory || plugin.name,
3001
+ marketplace: marketplace.name || repo.marketplace || '',
3002
+ installSource: sourceInfo.directory ? '' : sourceInfo.installSource,
3003
+ marketplaceFormat: 'claude-marketplace',
3004
+ lspServers: plugin.lspServers || null,
3005
+ commands: manifest?.commands || [],
3006
+ hooks: manifest?.hooks || []
3007
+ }));
3008
+ }
3009
+ return results;
3010
+ }
3011
+
3012
+ async _fetchCodexMarketplacePlugins(repo, marketplace) {
3013
+ if (!marketplace || !Array.isArray(marketplace.plugins)) return [];
3014
+ const marketplaceName = this._resolveCodexMarketplaceName(repo, marketplace.name || marketplace.interface?.displayName || repo.marketplace);
3015
+ const results = [];
3016
+ for (const plugin of marketplace.plugins) {
3017
+ const sourceInfo = this._repoFromMarketplaceSource(repo, plugin.source);
3018
+ const canReadManifest = sourceInfo.repo.provider === 'local' || sourceInfo.repo.id === repo.id;
3019
+ const manifest = canReadManifest
3020
+ ? await this._readManifestFromRepo(sourceInfo.repo, sourceInfo.directory, [
3021
+ '.codex-plugin/plugin.json',
3022
+ 'plugin.json',
3023
+ 'package.json'
3024
+ ])
3025
+ : null;
3026
+ results.push(this.buildMarketPluginItem(repo, {
3027
+ repo: sourceInfo.repo,
3028
+ name: manifest?.name || plugin.name,
3029
+ description: manifest?.description || plugin.description || '',
3030
+ author: manifest?.author || plugin.author?.name || marketplace.owner?.name || repo.owner,
3031
+ version: manifest?.version || plugin.version || '1.0.0',
3032
+ category: plugin.category || 'general',
3033
+ directory: sourceInfo.directory || plugin.name,
3034
+ marketplace: marketplaceName,
3035
+ installSource: sourceInfo.directory ? '' : sourceInfo.installSource,
3036
+ marketplaceFormat: 'codex-marketplace',
3037
+ commands: manifest?.commands || [],
3038
+ hooks: manifest?.hooks || []
3039
+ }));
3040
+ }
3041
+ return results;
3042
+ }
3043
+
2032
3044
  /**
2033
3045
  * Get market plugins from configured repositories
2034
3046
  * @returns {Promise<Array>} List of available market plugins
2035
3047
  */
2036
3048
  async getMarketPlugins(forceRefresh = false) {
3049
+ if (!this.getCapabilities().market) {
3050
+ return [];
3051
+ }
3052
+
2037
3053
  if (forceRefresh) {
2038
3054
  this.clearMarketCache({ removeFile: false });
2039
3055
  }
@@ -2074,25 +3090,24 @@ class PluginsService {
2074
3090
  return this.fetchRepoJson(repo, normalizedPath, file);
2075
3091
  };
2076
3092
 
2077
- // Try to fetch marketplace.json first (official format)
2078
- if (fileMap.has('.claude-plugin/marketplace.json')) {
3093
+ // Claude official marketplace format
3094
+ if (this.platform === 'claude' && fileMap.has('.claude-plugin/marketplace.json')) {
2079
3095
  const marketplace = await readJson('.claude-plugin/marketplace.json');
2080
3096
  if (marketplace && marketplace.plugins) {
2081
- for (const plugin of marketplace.plugins) {
2082
- marketPlugins.push(this.buildMarketPluginItem(repo, {
2083
- name: plugin.name,
2084
- description: plugin.description || '',
2085
- author: plugin.author?.name || marketplace.owner?.name || repo.owner,
2086
- version: plugin.version || '1.0.0',
2087
- category: plugin.category || 'general',
2088
- directory: plugin.source?.replace(/^\.\//, '') || plugin.name,
2089
- lspServers: plugin.lspServers || null
2090
- }));
2091
- }
3097
+ marketPlugins.push(...await this._fetchClaudeMarketplacePlugins(repo, marketplace, readJson));
2092
3098
  continue; // Skip legacy format check
2093
3099
  }
2094
3100
  }
2095
3101
 
3102
+ // Codex marketplace format generated by plugin-creator
3103
+ if (this._isCodex() && fileMap.has('.agents/plugins/marketplace.json')) {
3104
+ const marketplace = await readJson('.agents/plugins/marketplace.json');
3105
+ if (marketplace && marketplace.plugins) {
3106
+ marketPlugins.push(...await this._fetchCodexMarketplacePlugins(repo, marketplace));
3107
+ continue;
3108
+ }
3109
+ }
3110
+
2096
3111
  // OpenCode plugin marketplace format: plugins/*.plugin.json
2097
3112
  if (this._isOpenCode()) {
2098
3113
  const openCodeMarketplacePlugins = await this._fetchOpenCodeMarketplacePlugins(repo, repo.branch || 'main');
@@ -2102,6 +3117,42 @@ class PluginsService {
2102
3117
  }
2103
3118
  }
2104
3119
 
3120
+ const nestedManifestFiles = files.filter(file => {
3121
+ if (this._isCodex()) {
3122
+ return file.path === '.codex-plugin/plugin.json' || file.path.endsWith('/.codex-plugin/plugin.json');
3123
+ }
3124
+ if (this.platform === 'claude') {
3125
+ return file.path === '.claude-plugin/plugin.json' || file.path.endsWith('/.claude-plugin/plugin.json');
3126
+ }
3127
+ return false;
3128
+ });
3129
+ const nestedDirs = new Set();
3130
+ for (const file of nestedManifestFiles) {
3131
+ const marker = this._isCodex() ? '/.codex-plugin/plugin.json' : '/.claude-plugin/plugin.json';
3132
+ const rootMarker = marker.slice(1);
3133
+ const dir = file.path === rootMarker
3134
+ ? ''
3135
+ : normalizeRepoPath(file.path.slice(0, -marker.length));
3136
+ const dirKey = dir || '.';
3137
+ if (nestedDirs.has(dirKey)) continue;
3138
+ nestedDirs.add(dirKey);
3139
+ const manifest = await readJson(file.path);
3140
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
3141
+ name: manifest.name || dir.split('/').pop(),
3142
+ description: manifest.description || '',
3143
+ author: manifest.author || repo.owner,
3144
+ version: manifest.version || '1.0.0',
3145
+ directory: dir,
3146
+ marketplace: this._isCodex() ? this._resolveCodexMarketplaceName(repo) : '',
3147
+ marketplaceFormat: this._isCodex() ? 'codex-manifest' : 'claude-manifest',
3148
+ commands: manifest.commands || [],
3149
+ hooks: manifest.hooks || []
3150
+ }));
3151
+ }
3152
+ if (nestedDirs.size > 0) {
3153
+ continue;
3154
+ }
3155
+
2105
3156
  // Legacy format: each directory is a plugin with plugin.json/package.json
2106
3157
  const pluginDirs = Array.from(new Set(
2107
3158
  files