coding-tool-x 3.6.6 → 3.6.8

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 (56) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/web/assets/{Analytics-CcGKjDkP.js → Analytics-C57ZRDq5.js} +1 -1
  3. package/dist/web/assets/{ConfigTemplates-DPOa1w5D.js → ConfigTemplates-B3qqqL05.js} +1 -1
  4. package/dist/web/assets/Home-CG5XrTNc.js +1 -0
  5. package/dist/web/assets/Home-CIua-b-o.css +1 -0
  6. package/dist/web/assets/{PluginManager-BVbHUdey.js → PluginManager-BhX0LUzr.js} +1 -1
  7. package/dist/web/assets/{ProjectList-BQrz2FKo.js → ProjectList-BQ01OZn9.js} +1 -1
  8. package/dist/web/assets/{SessionList-CRGer64T.js → SessionList-DPv2IOXG.js} +1 -1
  9. package/dist/web/assets/{SkillManager-BhJKtWYM.js → SkillManager-D5CfQm2O.js} +1 -1
  10. package/dist/web/assets/{WorkspaceManager-pc6tpF3m.js → WorkspaceManager-Ce8ggteK.js} +1 -1
  11. package/dist/web/assets/index-CpnFNWqC.js +2 -0
  12. package/dist/web/assets/index-DJ9VFidd.css +1 -0
  13. package/dist/web/assets/{naive-ui-DM897mIP.js → naive-ui-kcWDeJwl.js} +1 -1
  14. package/dist/web/index.html +3 -3
  15. package/package.json +1 -1
  16. package/src/commands/stats.js +1 -1
  17. package/src/config/channel-preset-websites.js +2 -1
  18. package/src/config/paths.js +8 -0
  19. package/src/server/api/agents.js +16 -54
  20. package/src/server/api/claude-hooks.js +1 -1
  21. package/src/server/api/claude-statistics.js +57 -0
  22. package/src/server/api/commands.js +16 -54
  23. package/src/server/api/dashboard.js +2 -2
  24. package/src/server/api/gemini-channels.js +2 -0
  25. package/src/server/api/hooks.js +4 -1
  26. package/src/server/api/plugins.js +40 -65
  27. package/src/server/api/skills.js +21 -77
  28. package/src/server/api/validation-errors.js +34 -0
  29. package/src/server/gemini-proxy-server.js +205 -21
  30. package/src/server/index.js +1 -0
  31. package/src/server/services/agents-service.js +166 -75
  32. package/src/server/services/base/base-channel-service.js +15 -0
  33. package/src/server/services/base/proxy-utils.js +6 -3
  34. package/src/server/services/base/response-usage-parser.js +44 -0
  35. package/src/server/services/channel-balance.js +86 -1
  36. package/src/server/services/claude-statistics-service.js +139 -0
  37. package/src/server/services/commands-service.js +203 -121
  38. package/src/server/services/config-artifact-paths.js +116 -0
  39. package/src/server/services/config-export-service.js +7 -6
  40. package/src/server/services/config-registry-service.js +2 -2
  41. package/src/server/services/config-sync-manager.js +45 -11
  42. package/src/server/services/config-templates-service.js +21 -19
  43. package/src/server/services/format-converter.js +42 -4
  44. package/src/server/services/gemini-channels.js +30 -1
  45. package/src/server/services/gemini-sessions.js +347 -61
  46. package/src/server/services/mcp-client.js +236 -49
  47. package/src/server/services/mcp-service.js +51 -64
  48. package/src/server/services/notification-hooks.js +659 -88
  49. package/src/server/services/opencode-channels.js +15 -0
  50. package/src/server/services/plugins-service.js +771 -63
  51. package/src/server/services/repo-scanner-base.js +6 -29
  52. package/src/server/services/skill-service.js +187 -63
  53. package/dist/web/assets/Home-BP1LxGtp.js +0 -1
  54. package/dist/web/assets/Home-BeGkC4bb.css +0 -1
  55. package/dist/web/assets/index-0isNzXNY.css +0 -1
  56. package/dist/web/assets/index-BsdIJwLf.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,18 @@ 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 CODEX_PLUGINS_DIR = path.join(path.dirname(NATIVE_PATHS.codex.config), 'plugins');
33
+ const CODEX_PLUGINS_CACHE_DIR = path.join(CODEX_PLUGINS_DIR, 'cache');
24
34
  const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
25
35
  const REPO_SOURCE_META_FILE = '.cc-tool-plugin-source.json';
26
36
  const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
@@ -28,8 +38,62 @@ const DEFAULT_GITHUB_HOST = 'https://github.com';
28
38
  const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
29
39
  const DEFAULT_REPOS_BY_PLATFORM = {
30
40
  claude: [],
41
+ codex: [],
42
+ gemini: [],
31
43
  opencode: []
32
44
  };
45
+ const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
46
+ const PLATFORM_CAPABILITIES = {
47
+ claude: {
48
+ platform: 'claude',
49
+ supportsPlugins: true,
50
+ repositories: true,
51
+ market: true,
52
+ install: true,
53
+ uninstall: true,
54
+ toggle: true,
55
+ config: true,
56
+ import: true,
57
+ syncRepos: true
58
+ },
59
+ codex: {
60
+ platform: 'codex',
61
+ supportsPlugins: true,
62
+ repositories: true,
63
+ market: true,
64
+ install: true,
65
+ uninstall: true,
66
+ toggle: true,
67
+ config: false,
68
+ import: false,
69
+ syncRepos: false
70
+ },
71
+ gemini: {
72
+ platform: 'gemini',
73
+ supportsPlugins: false,
74
+ repositories: false,
75
+ market: false,
76
+ install: false,
77
+ uninstall: false,
78
+ toggle: false,
79
+ config: false,
80
+ import: false,
81
+ syncRepos: false,
82
+ disabledReason: 'Gemini plugin management is not implemented yet'
83
+ },
84
+ opencode: {
85
+ platform: 'opencode',
86
+ supportsPlugins: true,
87
+ repositories: true,
88
+ market: true,
89
+ install: true,
90
+ uninstall: true,
91
+ toggle: true,
92
+ config: true,
93
+ import: false,
94
+ syncRepos: false
95
+ }
96
+ };
33
97
 
34
98
  function cloneRepos(repos = []) {
35
99
  return repos.map(repo => ({ ...repo }));
@@ -50,10 +114,63 @@ function normalizeRepoDirectory(directory = '') {
50
114
  return normalizeRepoPath(directory);
51
115
  }
52
116
 
117
+ function normalizePluginRepoDirectory(directory = '', label = 'plugin directory') {
118
+ return normalizeSafeRelativePath(directory || '', label, {
119
+ allowEmpty: true,
120
+ allowHiddenSegments: true
121
+ });
122
+ }
123
+
124
+ function normalizePluginPathName(name = '', label = 'plugin name') {
125
+ return normalizeSafeRelativePath(name, label, {
126
+ allowHiddenSegments: false
127
+ });
128
+ }
129
+
130
+ function normalizePluginCacheSegment(value = '', label = 'plugin segment', fallback = 'local') {
131
+ const rawValue = String(value || '').trim() || fallback;
132
+ return normalizeSafeFileStem(rawValue, label, {
133
+ allowDots: true,
134
+ pattern: /^[a-zA-Z0-9][a-zA-Z0-9._@+-]*$/
135
+ });
136
+ }
137
+
138
+ function resolvePluginConfigFile(configDir, name) {
139
+ const safeName = normalizePluginPathName(name, 'plugin config name');
140
+ return resolveInsideRoot(configDir, `${safeName}.json`, 'Plugin config path');
141
+ }
142
+
143
+ function joinRepoPath(...parts) {
144
+ return normalizeRepoPath(parts.filter(Boolean).join('/'));
145
+ }
146
+
53
147
  function stripGitSuffix(value = '') {
54
148
  return String(value || '').replace(/\.git$/i, '');
55
149
  }
56
150
 
151
+ function slugifyCodexKey(value = '', fallback = 'local') {
152
+ const slug = String(value || '')
153
+ .trim()
154
+ .replace(/\\/g, '/')
155
+ .split('/')
156
+ .filter(Boolean)
157
+ .pop()
158
+ ?.replace(/\.git$/i, '')
159
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
160
+ .replace(/^-+|-+$/g, '');
161
+ return slug || fallback;
162
+ }
163
+
164
+ function splitPluginMarketplaceKey(key = '') {
165
+ const value = String(key || '');
166
+ const atIndex = value.lastIndexOf('@');
167
+ if (atIndex <= 0) return { name: value, marketplace: '' };
168
+ return {
169
+ name: value.slice(0, atIndex),
170
+ marketplace: value.slice(atIndex + 1)
171
+ };
172
+ }
173
+
57
174
  function isWindowsAbsolutePath(input = '') {
58
175
  return /^[a-zA-Z]:[\\/]/.test(String(input || ''));
59
176
  }
@@ -210,17 +327,24 @@ function stripJsonComments(input = '') {
210
327
 
211
328
  class PluginsService {
212
329
  constructor(platform = 'claude') {
213
- this.platform = ['claude', 'opencode'].includes(platform) ? platform : 'claude';
330
+ this.platform = SUPPORTED_PLATFORMS.includes(platform) ? platform : 'claude';
214
331
  this.configDir = PATHS.config || path.join((PATHS.base || process.env.HOME || os.homedir()), 'config');
215
332
  this.ccToolConfigDir = path.dirname(PATHS.pluginRepos.claude);
216
333
  this.opencodePluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugins');
217
334
  this.opencodeLegacyPluginsDir = path.join(OPENCODE_CONFIG_DIR, 'plugin');
218
- this.marketCachePath = this.platform === 'opencode'
219
- ? PATHS.pluginMarketCache.opencode
220
- : PATHS.pluginMarketCache.claude;
335
+ this.codexPluginsCacheDir = CODEX_PLUGINS_CACHE_DIR;
336
+ this.marketCachePath = PATHS.pluginMarketCache[this.platform] || PATHS.pluginMarketCache.claude;
221
337
  this._marketCache = null;
222
338
  }
223
339
 
340
+ getCapabilities() {
341
+ return { ...(PLATFORM_CAPABILITIES[this.platform] || PLATFORM_CAPABILITIES.claude) };
342
+ }
343
+
344
+ _pluginsSupported() {
345
+ return this.getCapabilities().supportsPlugins !== false;
346
+ }
347
+
224
348
  normalizeRepoConfig(repo = {}) {
225
349
  const provider = SUPPORTED_REPO_PROVIDERS.includes(repo.provider)
226
350
  ? repo.provider
@@ -426,6 +550,14 @@ class PluginsService {
426
550
  return this.platform === 'opencode';
427
551
  }
428
552
 
553
+ _isCodex() {
554
+ return this.platform === 'codex';
555
+ }
556
+
557
+ _isGemini() {
558
+ return this.platform === 'gemini';
559
+ }
560
+
429
561
  _getOpenCodePluginsDir() {
430
562
  if (fs.existsSync(this.opencodeLegacyPluginsDir) && !fs.existsSync(this.opencodePluginsDir)) {
431
563
  return this.opencodeLegacyPluginsDir;
@@ -539,12 +671,184 @@ class PluginsService {
539
671
  return plugins;
540
672
  }
541
673
 
674
+ _readFirstMarkdownParagraph(filePath) {
675
+ if (!fs.existsSync(filePath)) return '';
676
+ try {
677
+ const content = fs.readFileSync(filePath, 'utf8');
678
+ const lines = content
679
+ .split(/\r?\n/)
680
+ .map(line => line.trim())
681
+ .filter(line => line && !line.startsWith('#') && !line.startsWith('<!--'));
682
+ return lines[0] || '';
683
+ } catch {
684
+ return '';
685
+ }
686
+ }
687
+
688
+ _listCodexCachedPlugins() {
689
+ const cacheDir = this.codexPluginsCacheDir;
690
+ if (!fs.existsSync(cacheDir)) return [];
691
+
692
+ const plugins = [];
693
+ try {
694
+ const marketplaces = fs.readdirSync(cacheDir, { withFileTypes: true });
695
+ for (const marketplaceEntry of marketplaces) {
696
+ if (!marketplaceEntry.isDirectory() || marketplaceEntry.name.startsWith('.')) continue;
697
+ const marketplaceDir = path.join(cacheDir, marketplaceEntry.name);
698
+ const pluginEntries = fs.readdirSync(marketplaceDir, { withFileTypes: true });
699
+
700
+ for (const pluginEntry of pluginEntries) {
701
+ if (!pluginEntry.isDirectory() || pluginEntry.name.startsWith('.')) continue;
702
+ const pluginRoot = path.join(marketplaceDir, pluginEntry.name);
703
+ const versionEntries = fs.readdirSync(pluginRoot, { withFileTypes: true })
704
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'));
705
+ const selectedVersion = versionEntries
706
+ .map(entry => ({
707
+ name: entry.name,
708
+ fullPath: path.join(pluginRoot, entry.name),
709
+ mtimeMs: fs.statSync(path.join(pluginRoot, entry.name)).mtimeMs
710
+ }))
711
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
712
+ if (!selectedVersion) continue;
713
+
714
+ const description = this._readFirstMarkdownParagraph(path.join(selectedVersion.fullPath, 'README.md'));
715
+ const manifest = this._readLocalManifest(selectedVersion.fullPath, [
716
+ '.codex-plugin/plugin.json',
717
+ 'plugin.json',
718
+ 'package.json'
719
+ ]) || {};
720
+ plugins.push({
721
+ name: manifest.name || pluginEntry.name,
722
+ marketplace: marketplaceEntry.name,
723
+ version: manifest.version || selectedVersion.name,
724
+ installPath: selectedVersion.fullPath,
725
+ directory: path.relative(CODEX_PLUGINS_DIR, selectedVersion.fullPath),
726
+ source: 'codex-cache',
727
+ installed: true,
728
+ enabled: this._isCodexPluginEnabled(manifest.name || pluginEntry.name, marketplaceEntry.name),
729
+ pluginType: 'cache',
730
+ description: manifest.description || description
731
+ });
732
+ }
733
+ }
734
+ } catch (err) {
735
+ console.warn('[PluginsService] Failed to scan Codex plugin cache:', err.message);
736
+ }
737
+
738
+ plugins.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
739
+ return plugins;
740
+ }
741
+
742
+ _readLocalManifest(rootDir, candidates = []) {
743
+ for (const candidate of candidates) {
744
+ const manifestPath = path.join(rootDir, candidate);
745
+ if (!fs.existsSync(manifestPath)) continue;
746
+ try {
747
+ return JSON.parse(stripJsonComments(fs.readFileSync(manifestPath, 'utf8')));
748
+ } catch {
749
+ // try next candidate
750
+ }
751
+ }
752
+ return null;
753
+ }
754
+
755
+ _readCodexConfig() {
756
+ const filePath = NATIVE_PATHS.codex.config;
757
+ if (!fs.existsSync(filePath)) return { filePath, config: {} };
758
+ try {
759
+ const raw = fs.readFileSync(filePath, 'utf8');
760
+ if (!raw.trim()) return { filePath, config: {} };
761
+ return { filePath, config: toml.parse(raw) };
762
+ } catch (err) {
763
+ console.error('[PluginsService] Failed to read Codex config:', err.message);
764
+ return { filePath, config: {} };
765
+ }
766
+ }
767
+
768
+ _writeCodexConfig(config) {
769
+ const filePath = NATIVE_PATHS.codex.config;
770
+ this._ensureDir(path.dirname(filePath));
771
+ const safeConfig = JSON.parse(JSON.stringify(config || {}));
772
+ fs.writeFileSync(filePath, tomlStringify(safeConfig), 'utf8');
773
+ }
774
+
775
+ _isCodexPluginEnabled(name, marketplace = '') {
776
+ const { config } = this._readCodexConfig();
777
+ const plugins = config.plugins || {};
778
+ if (marketplace) {
779
+ const exact = plugins[`${name}@${marketplace}`];
780
+ if (exact && typeof exact === 'object' && exact.enabled === false) return false;
781
+ }
782
+ for (const [key, value] of Object.entries(plugins)) {
783
+ const parsed = splitPluginMarketplaceKey(key);
784
+ if (parsed.name === name && value && typeof value === 'object' && value.enabled === false) {
785
+ return false;
786
+ }
787
+ }
788
+ return true;
789
+ }
790
+
791
+ _setCodexPluginEnabled(name, marketplace, enabled) {
792
+ const { config } = this._readCodexConfig();
793
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
794
+ nextConfig.plugins = { ...(nextConfig.plugins || {}) };
795
+ const pluginKey = marketplace ? `${name}@${marketplace}` : name;
796
+ nextConfig.plugins[pluginKey] = {
797
+ ...(nextConfig.plugins[pluginKey] || {}),
798
+ enabled: !!enabled
799
+ };
800
+ this._writeCodexConfig(nextConfig);
801
+ }
802
+
803
+ _registerCodexMarketplace(marketplace, source) {
804
+ const { config } = this._readCodexConfig();
805
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
806
+ nextConfig.marketplaces = { ...(nextConfig.marketplaces || {}) };
807
+ nextConfig.marketplaces[marketplace] = {
808
+ ...(nextConfig.marketplaces[marketplace] || {}),
809
+ source_type: isLikelyLocalPath(source) ? 'local' : 'git',
810
+ source,
811
+ last_updated: new Date().toISOString()
812
+ };
813
+ this._writeCodexConfig(nextConfig);
814
+ }
815
+
816
+ _unregisterCodexPlugin(name, marketplace = '') {
817
+ const { config } = this._readCodexConfig();
818
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
819
+ const plugins = { ...(nextConfig.plugins || {}) };
820
+ for (const key of Object.keys(plugins)) {
821
+ const parsed = splitPluginMarketplaceKey(key);
822
+ if (parsed.name === name && (!marketplace || parsed.marketplace === marketplace)) {
823
+ delete plugins[key];
824
+ }
825
+ }
826
+ nextConfig.plugins = plugins;
827
+ this._writeCodexConfig(nextConfig);
828
+ }
829
+
830
+ _resolveCodexMarketplaceName(repo, explicit = '') {
831
+ if (explicit) return slugifyCodexKey(explicit);
832
+ if (repo.marketplace) return slugifyCodexKey(repo.marketplace);
833
+ if (repo.provider === 'local') return slugifyCodexKey(repo.localPath, 'local');
834
+ if (repo.provider === 'gitlab') return slugifyCodexKey(repo.projectPath, 'gitlab');
835
+ return slugifyCodexKey(repo.name || repo.owner || 'github');
836
+ }
837
+
542
838
  /**
543
839
  * List all installed plugins with their status
544
840
  * Reads from Claude Code's native installed_plugins.json
545
841
  * @returns {Object} { plugins: Array }
546
842
  */
547
843
  listPlugins() {
844
+ if (!this._pluginsSupported()) {
845
+ return { plugins: [] };
846
+ }
847
+
848
+ if (this._isCodex()) {
849
+ return { plugins: this._listCodexCachedPlugins() };
850
+ }
851
+
548
852
  if (this._isOpenCode()) {
549
853
  const plugins = [];
550
854
  const seen = new Set();
@@ -690,6 +994,22 @@ class PluginsService {
690
994
  * @returns {Object|null} Plugin details or null
691
995
  */
692
996
  getPlugin(name) {
997
+ if (this._isCodex()) {
998
+ const plugin = this.listPlugins().plugins.find(p => p.name === name || `${p.name}@${p.marketplace}` === name);
999
+ if (!plugin) return null;
1000
+ const manifest = plugin.installPath
1001
+ ? this._readLocalManifest(plugin.installPath, ['.codex-plugin/plugin.json', 'plugin.json', 'package.json'])
1002
+ : null;
1003
+ return {
1004
+ name: plugin.name,
1005
+ ...plugin,
1006
+ author: manifest?.author || '',
1007
+ commands: manifest?.commands || [],
1008
+ hooks: manifest?.hooks || [],
1009
+ manifest
1010
+ };
1011
+ }
1012
+
693
1013
  if (this._isOpenCode()) {
694
1014
  const plugin = this.listPlugins().plugins.find(p => p.name === name || p.directory === name);
695
1015
  if (!plugin) return null;
@@ -731,6 +1051,34 @@ class PluginsService {
731
1051
  * @returns {Promise<Object>} Installation result
732
1052
  */
733
1053
  async installPlugin(source, repoInfo = null) {
1054
+ if (this._isCodex()) {
1055
+ if (repoInfo && repoInfo.directory) {
1056
+ return this._installFromRepoDirectory(repoInfo);
1057
+ }
1058
+
1059
+ const parsedSource = this.parseRepoTreeSource(source);
1060
+ if (parsedSource) {
1061
+ return this._installFromRepoDirectory(parsedSource);
1062
+ }
1063
+
1064
+ const parsedRepo = this._repoFromGitUrl(source, 'main');
1065
+ if (parsedRepo) {
1066
+ return this._installFromRepoDirectory({ ...parsedRepo, directory: '' });
1067
+ }
1068
+
1069
+ return {
1070
+ success: false,
1071
+ error: 'Codex plugin install expects repository metadata, a GitHub/GitLab tree URL, or a Git repository URL'
1072
+ };
1073
+ }
1074
+
1075
+ if (!this._pluginsSupported()) {
1076
+ return {
1077
+ success: false,
1078
+ error: `${this.platform} plugin management is not supported`
1079
+ };
1080
+ }
1081
+
734
1082
  if (this._isOpenCode()) {
735
1083
  if (repoInfo && repoInfo.directory) {
736
1084
  return this._installFromRepoDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
@@ -779,30 +1127,46 @@ class PluginsService {
779
1127
  */
780
1128
  async _installFromRepoDirectory(repoInfo, options = {}) {
781
1129
  const normalizedRepo = this.normalizeRepoConfig(repoInfo);
782
- const directory = normalizeRepoPath(repoInfo.directory || '');
783
- const pluginName = directory.split('/').pop();
784
- const installRoot = options.installRoot || INSTALLED_DIR;
1130
+ const directory = normalizePluginRepoDirectory(repoInfo.directory || normalizedRepo.directory || '');
1131
+ const pluginName = directory.split('/').filter(Boolean).pop() || slugifyCodexKey(normalizedRepo.name, 'plugin');
1132
+ const manifestCandidates = this._getManifestCandidates(directory);
785
1133
 
786
1134
  try {
787
1135
  let manifest;
788
- try {
789
- manifest = await this.fetchRepoJson(normalizedRepo, `${directory}/plugin.json`);
790
- } catch {
1136
+ let manifestPath = '';
1137
+ for (const candidate of manifestCandidates) {
791
1138
  try {
792
- manifest = await this.fetchRepoJson(normalizedRepo, `${directory}/package.json`);
1139
+ manifestPath = joinRepoPath(directory, candidate);
1140
+ manifest = await this.fetchRepoJson(normalizedRepo, manifestPath);
1141
+ break;
793
1142
  } catch {
794
- manifest = { name: pluginName, version: '1.0.0' };
1143
+ manifestPath = '';
795
1144
  }
796
1145
  }
1146
+ if (!manifest) {
1147
+ manifest = { name: pluginName, version: '1.0.0' };
1148
+ }
1149
+
1150
+ const installedPluginName = String(manifest.name || pluginName || 'plugin').trim() || 'plugin';
1151
+ const installRoot = this._resolvePluginInstallRoot(normalizedRepo, repoInfo, manifest, options);
797
1152
 
798
1153
  // Create plugin directory
799
- const pluginDir = path.join(installRoot, manifest.name || pluginName);
1154
+ const pluginDir = this._isCodex()
1155
+ ? installRoot
1156
+ : resolveInsideRoot(
1157
+ installRoot,
1158
+ normalizePluginPathName(installedPluginName, 'plugin name'),
1159
+ 'Plugin install path',
1160
+ { allowHiddenSegments: false }
1161
+ );
800
1162
  if (!fs.existsSync(pluginDir)) {
801
1163
  fs.mkdirSync(pluginDir, { recursive: true });
802
1164
  }
803
1165
 
804
1166
  if (normalizedRepo.provider === 'local') {
805
- const sourceDir = path.join(normalizedRepo.localPath, directory);
1167
+ const sourceDir = directory
1168
+ ? resolveInsideRoot(normalizedRepo.localPath, directory, 'Plugin directory', { allowHiddenSegments: true })
1169
+ : normalizedRepo.localPath;
806
1170
  if (!fs.existsSync(sourceDir)) {
807
1171
  throw new Error(`Plugin directory not found: ${directory}`);
808
1172
  }
@@ -843,7 +1207,10 @@ class PluginsService {
843
1207
  throw new Error('Empty archive');
844
1208
  }
845
1209
 
846
- const sourceDir = path.join(tempDir, extractedDir, directory);
1210
+ const extractedRoot = path.join(tempDir, extractedDir);
1211
+ const sourceDir = directory
1212
+ ? resolveInsideRoot(extractedRoot, directory, 'Plugin directory', { allowHiddenSegments: true })
1213
+ : extractedRoot;
847
1214
  if (!fs.existsSync(sourceDir)) {
848
1215
  throw new Error(`Plugin directory not found: ${directory}`);
849
1216
  }
@@ -854,13 +1221,33 @@ class PluginsService {
854
1221
  }
855
1222
 
856
1223
  // 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));
1224
+ const fallbackManifestPath = path.join(pluginDir, this._isCodex() ? '.codex-plugin/plugin.json' : 'plugin.json');
1225
+ if (!fs.existsSync(fallbackManifestPath)) {
1226
+ this._ensureDir(path.dirname(fallbackManifestPath));
1227
+ fs.writeFileSync(fallbackManifestPath, JSON.stringify(manifest, null, 2));
860
1228
  }
861
1229
 
862
- if (!this._isOpenCode()) {
863
- const installedPluginName = manifest.name || pluginName;
1230
+ if (this._isCodex()) {
1231
+ const marketplace = this._resolveCodexMarketplaceName(normalizedRepo, repoInfo.marketplace);
1232
+ const sourceUrl = this.buildRepoBrowserUrl(normalizedRepo, directory) || buildRepoUrl(normalizedRepo);
1233
+ this.writeRepoSourceMeta(pluginDir, {
1234
+ repoId: normalizedRepo.id,
1235
+ repoProvider: normalizedRepo.provider,
1236
+ repoOwner: normalizedRepo.owner || '',
1237
+ repoName: normalizedRepo.name || '',
1238
+ repoBranch: normalizedRepo.branch,
1239
+ repoDirectory: directory,
1240
+ repoHost: normalizedRepo.host || '',
1241
+ repoProjectPath: normalizedRepo.projectPath || '',
1242
+ repoLocalPath: normalizedRepo.localPath || '',
1243
+ repoUrl: normalizedRepo.repoUrl || buildRepoUrl(normalizedRepo),
1244
+ marketplace,
1245
+ manifestPath,
1246
+ source: sourceUrl
1247
+ });
1248
+ this._registerCodexMarketplace(marketplace, normalizedRepo.repoUrl || buildRepoUrl(normalizedRepo));
1249
+ this._setCodexPluginEnabled(installedPluginName, marketplace, true);
1250
+ } else if (!this._isOpenCode()) {
864
1251
  const installTimestamp = new Date().toISOString();
865
1252
  const sourceUrl = this.buildRepoBrowserUrl(normalizedRepo, directory) || buildRepoUrl(normalizedRepo);
866
1253
  const repoSourceMeta = {
@@ -933,6 +1320,43 @@ class PluginsService {
933
1320
  }
934
1321
  }
935
1322
 
1323
+ _getManifestCandidates() {
1324
+ if (this._isCodex()) {
1325
+ return ['.codex-plugin/plugin.json', 'plugin.json', 'package.json'];
1326
+ }
1327
+ if (this._isOpenCode()) {
1328
+ return ['package.json', 'plugin.json'];
1329
+ }
1330
+ return ['.claude-plugin/plugin.json', 'plugin.json', 'package.json'];
1331
+ }
1332
+
1333
+ _resolvePluginInstallRoot(normalizedRepo, repoInfo, manifest, options = {}) {
1334
+ if (!this._isCodex()) {
1335
+ return options.installRoot || INSTALLED_DIR;
1336
+ }
1337
+
1338
+ const marketplace = normalizePluginCacheSegment(
1339
+ this._resolveCodexMarketplaceName(normalizedRepo, repoInfo.marketplace),
1340
+ 'Codex marketplace',
1341
+ 'local'
1342
+ );
1343
+ const pluginName = normalizePluginCacheSegment(
1344
+ manifest.name || normalizeRepoPath(repoInfo.directory || '').split('/').pop() || 'plugin',
1345
+ 'Codex plugin name',
1346
+ 'plugin'
1347
+ );
1348
+ const version = normalizePluginCacheSegment(
1349
+ String(manifest.version || repoInfo.version || 'local').trim() || 'local',
1350
+ 'Codex plugin version',
1351
+ 'local'
1352
+ );
1353
+ return resolveInsideRoot(
1354
+ this.codexPluginsCacheDir,
1355
+ path.posix.join(marketplace, pluginName, version),
1356
+ 'Codex plugin cache path'
1357
+ );
1358
+ }
1359
+
936
1360
  /**
937
1361
  * Parse GitHub/GitLab tree URL or local path
938
1362
  * @private
@@ -986,13 +1410,56 @@ class PluginsService {
986
1410
  * @returns {Object} Uninstallation result
987
1411
  */
988
1412
  uninstallPlugin(name) {
1413
+ if (this._isCodex()) {
1414
+ const plugins = this.listPlugins().plugins;
1415
+ const target = plugins.find(plugin => plugin.name === name || `${plugin.name}@${plugin.marketplace}` === name);
1416
+ if (!target) {
1417
+ return {
1418
+ success: false,
1419
+ error: `Plugin "${name}" not found`
1420
+ };
1421
+ }
1422
+
1423
+ const pluginRoot = target.installPath
1424
+ ? assertInsideAllowedRoots(
1425
+ path.dirname(assertInsideAllowedRoots(target.installPath, [this.codexPluginsCacheDir], 'Codex plugin install path')),
1426
+ [this.codexPluginsCacheDir],
1427
+ 'Codex plugin root'
1428
+ )
1429
+ : resolveInsideRoot(
1430
+ this.codexPluginsCacheDir,
1431
+ path.posix.join(
1432
+ normalizePluginCacheSegment(target.marketplace || '', 'Codex marketplace', 'local'),
1433
+ normalizePluginCacheSegment(target.name || name, 'Codex plugin name', 'plugin')
1434
+ ),
1435
+ 'Codex plugin root'
1436
+ );
1437
+ if (pluginRoot && fs.existsSync(pluginRoot)) {
1438
+ fs.rmSync(pluginRoot, { recursive: true, force: true });
1439
+ }
1440
+ this._unregisterCodexPlugin(target.name, target.marketplace || '');
1441
+
1442
+ return {
1443
+ success: true,
1444
+ message: 'Plugin uninstalled successfully'
1445
+ };
1446
+ }
1447
+
1448
+ if (!this._pluginsSupported()) {
1449
+ return {
1450
+ success: false,
1451
+ error: `${this.platform} plugin management is not supported`
1452
+ };
1453
+ }
1454
+
989
1455
  if (this._isOpenCode()) {
1456
+ const safeName = normalizePluginPathName(name, 'plugin name');
990
1457
  const pluginsDir = this._getOpenCodePluginsDir();
991
1458
  let removed = false;
992
1459
 
993
1460
  // Remove from opencode config.plugin (npm plugins)
994
1461
  const configured = this._listOpenCodeConfiguredPlugins();
995
- const next = configured.filter(p => p !== name);
1462
+ const next = configured.filter(p => p !== safeName);
996
1463
  if (next.length !== configured.length) {
997
1464
  this._setOpenCodeConfiguredPlugins(next);
998
1465
  removed = true;
@@ -1000,7 +1467,7 @@ class PluginsService {
1000
1467
 
1001
1468
  // Remove local plugin directory/file
1002
1469
  if (fs.existsSync(pluginsDir)) {
1003
- const directPath = path.join(pluginsDir, name);
1470
+ const directPath = resolveInsideRoot(pluginsDir, safeName, 'OpenCode plugin path');
1004
1471
  if (fs.existsSync(directPath)) {
1005
1472
  fs.rmSync(directPath, { recursive: true, force: true });
1006
1473
  removed = true;
@@ -1008,8 +1475,9 @@ class PluginsService {
1008
1475
  const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
1009
1476
  for (const entry of entries) {
1010
1477
  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 });
1478
+ if (entry.name === safeName || baseName === safeName) {
1479
+ const entryPath = resolveInsideRoot(pluginsDir, entry.name, 'OpenCode plugin path');
1480
+ fs.rmSync(entryPath, { recursive: true, force: true });
1013
1481
  removed = true;
1014
1482
  break;
1015
1483
  }
@@ -1025,22 +1493,28 @@ class PluginsService {
1025
1493
 
1026
1494
  // Claude: Remove from native installed_plugins.json and delete install directories
1027
1495
  let removed = false;
1496
+ const safeName = normalizePluginPathName(name, 'plugin name');
1028
1497
  if (fs.existsSync(CLAUDE_INSTALLED_FILE)) {
1029
1498
  try {
1030
1499
  const data = JSON.parse(fs.readFileSync(CLAUDE_INSTALLED_FILE, 'utf8'));
1031
1500
  if (data.plugins) {
1032
1501
  const keysToDelete = [];
1033
- const baseName = name.split('/').pop(); // handle "plugins/pr-review-toolkit" → "pr-review-toolkit"
1502
+ const baseName = safeName.split('/').pop(); // handle "plugins/pr-review-toolkit" → "pr-review-toolkit"
1034
1503
  for (const [key, installations] of Object.entries(data.plugins)) {
1035
1504
  const [pluginName] = key.split('@');
1036
- if (pluginName === name || key === name || pluginName === baseName) {
1505
+ if (pluginName === safeName || key === safeName || pluginName === baseName) {
1037
1506
  keysToDelete.push(key);
1038
1507
  // Delete install directories
1039
1508
  if (Array.isArray(installations)) {
1040
1509
  for (const install of installations) {
1041
1510
  if (install.installPath && fs.existsSync(install.installPath)) {
1042
1511
  try {
1043
- fs.rmSync(install.installPath, { recursive: true, force: true });
1512
+ const installPath = assertInsideAllowedRoots(
1513
+ install.installPath,
1514
+ [INSTALLED_DIR, CLAUDE_PLUGINS_DIR],
1515
+ 'Claude plugin install path'
1516
+ );
1517
+ fs.rmSync(installPath, { recursive: true, force: true });
1044
1518
  } catch (e) {
1045
1519
  console.error('[PluginsService] Failed to delete install dir:', e.message);
1046
1520
  }
@@ -1090,6 +1564,24 @@ class PluginsService {
1090
1564
  * @returns {Object} Updated plugin info
1091
1565
  */
1092
1566
  togglePlugin(name, enabled) {
1567
+ if (this._isCodex()) {
1568
+ const plugins = this.listPlugins().plugins;
1569
+ const target = plugins.find(plugin => plugin.name === name || `${plugin.name}@${plugin.marketplace}` === name);
1570
+ if (!target) {
1571
+ throw new Error(`Plugin "${name}" not found`);
1572
+ }
1573
+ this._setCodexPluginEnabled(target.name, target.marketplace || '', enabled);
1574
+ return {
1575
+ name: target.name,
1576
+ enabled,
1577
+ success: true
1578
+ };
1579
+ }
1580
+
1581
+ if (!this._pluginsSupported()) {
1582
+ throw new Error(`${this.platform} plugin management is not supported`);
1583
+ }
1584
+
1093
1585
  if (this._isOpenCode()) {
1094
1586
  const configured = this._listOpenCodeConfiguredPlugins();
1095
1587
  const exists = configured.includes(name);
@@ -1159,10 +1651,18 @@ class PluginsService {
1159
1651
  * @returns {Object} Result
1160
1652
  */
1161
1653
  updatePluginConfig(name, config) {
1654
+ if (this._isCodex()) {
1655
+ throw new Error('Codex cached plugins do not support config updates');
1656
+ }
1657
+
1658
+ if (!this._pluginsSupported()) {
1659
+ throw new Error(`${this.platform} plugin management is not supported`);
1660
+ }
1661
+
1162
1662
  if (this._isOpenCode()) {
1163
1663
  const configDir = path.join(OPENCODE_CONFIG_DIR, 'plugins-config');
1164
- this._ensureDir(configDir);
1165
- const configFile = path.join(configDir, `${name}.json`);
1664
+ const configFile = resolvePluginConfigFile(configDir, name);
1665
+ this._ensureDir(path.dirname(configFile));
1166
1666
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf8');
1167
1667
  return {
1168
1668
  success: true,
@@ -1175,11 +1675,11 @@ class PluginsService {
1175
1675
  throw new Error(`Plugin "${name}" not found`);
1176
1676
  }
1177
1677
 
1178
- const configFile = path.join(CONFIG_DIR, `${name}.json`);
1678
+ const configFile = resolvePluginConfigFile(CONFIG_DIR, name);
1179
1679
 
1180
1680
  // Ensure config directory exists
1181
- if (!fs.existsSync(CONFIG_DIR)) {
1182
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
1681
+ if (!fs.existsSync(path.dirname(configFile))) {
1682
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
1183
1683
  }
1184
1684
 
1185
1685
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf8');
@@ -1195,7 +1695,7 @@ class PluginsService {
1195
1695
  * @returns {string} Config file path
1196
1696
  */
1197
1697
  getReposConfigPath() {
1198
- const filePath = this._isOpenCode() ? PATHS.pluginRepos.opencode : PATHS.pluginRepos.claude;
1698
+ const filePath = PATHS.pluginRepos[this.platform] || PATHS.pluginRepos.claude;
1199
1699
  this._ensureDir(path.dirname(filePath));
1200
1700
  return filePath;
1201
1701
  }
@@ -1242,6 +1742,10 @@ class PluginsService {
1242
1742
  * @returns {Array} Repos list
1243
1743
  */
1244
1744
  getRepos() {
1745
+ if (!this.getCapabilities().repositories) {
1746
+ return [];
1747
+ }
1748
+
1245
1749
  const repos = [];
1246
1750
  const seenRepos = new Set();
1247
1751
  const pushRepo = (repo) => {
@@ -1271,7 +1775,7 @@ class PluginsService {
1271
1775
  }
1272
1776
 
1273
1777
  // 2. Load Claude Code's native marketplace config (Claude only)
1274
- if (!this._isOpenCode() && fs.existsSync(CLAUDE_MARKETPLACES_FILE)) {
1778
+ if (this.platform === 'claude' && fs.existsSync(CLAUDE_MARKETPLACES_FILE)) {
1275
1779
  try {
1276
1780
  const marketplaces = JSON.parse(fs.readFileSync(CLAUDE_MARKETPLACES_FILE, 'utf8'));
1277
1781
  const entries = [];
@@ -1644,11 +2148,10 @@ class PluginsService {
1644
2148
  scanLocalRepoTree(currentDir, repoRoot, tree) {
1645
2149
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
1646
2150
  for (const entry of entries) {
1647
- if (entry.name.startsWith('.')) continue;
1648
2151
  const fullPath = path.join(currentDir, entry.name);
1649
2152
  const relativePath = normalizeRepoPath(path.relative(repoRoot, fullPath));
1650
2153
  if (entry.isDirectory()) {
1651
- if (entry.name === 'node_modules') continue;
2154
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
1652
2155
  tree.push({ path: relativePath, type: 'tree', name: entry.name });
1653
2156
  this.scanLocalRepoTree(fullPath, repoRoot, tree);
1654
2157
  } else {
@@ -1704,7 +2207,12 @@ class PluginsService {
1704
2207
 
1705
2208
  async fetchRepoFileContent(repo, filePath, file = null) {
1706
2209
  if (repo.provider === 'local') {
1707
- return fs.readFileSync(path.join(repo.localPath, filePath), 'utf-8');
2210
+ const safeFilePath = normalizePluginRepoDirectory(filePath, 'plugin repository file path');
2211
+ if (!safeFilePath) {
2212
+ throw new Error('Invalid plugin repository file path');
2213
+ }
2214
+ const localFilePath = resolveInsideRoot(repo.localPath, safeFilePath, 'Plugin repository file path', { allowHiddenSegments: true });
2215
+ return fs.readFileSync(localFilePath, 'utf-8');
1708
2216
  }
1709
2217
  if (repo.provider === 'gitlab') {
1710
2218
  return this.fetchGitLabFileContent(repo, filePath);
@@ -1820,7 +2328,7 @@ class PluginsService {
1820
2328
  * @returns {Promise<Object>} Sync results
1821
2329
  */
1822
2330
  async syncRepos() {
1823
- if (this._isOpenCode()) {
2331
+ if (!this.getCapabilities().syncRepos) {
1824
2332
  return { success: true, results: [] };
1825
2333
  }
1826
2334
 
@@ -1957,26 +2465,28 @@ class PluginsService {
1957
2465
  }
1958
2466
 
1959
2467
  buildMarketPluginItem(repo, data = {}) {
2468
+ const itemRepo = data.repo || repo;
1960
2469
  return {
1961
2470
  name: data.name,
1962
2471
  displayName: data.displayName || '',
1963
2472
  description: data.description || '',
1964
- author: data.author || repo.owner || repo.projectPath || 'unknown',
2473
+ author: data.author || itemRepo.owner || itemRepo.projectPath || 'unknown',
1965
2474
  version: data.version || '1.0.0',
1966
2475
  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,
2476
+ marketplace: data.marketplace || repo.marketplace || '',
2477
+ repoUrl: data.repoUrl || itemRepo.repoUrl || buildRepoUrl(itemRepo),
2478
+ repoProvider: itemRepo.provider,
2479
+ repoOwner: itemRepo.owner || '',
2480
+ repoName: itemRepo.name || '',
2481
+ repoBranch: itemRepo.branch || 'main',
2482
+ repoHost: itemRepo.host || '',
2483
+ repoProjectPath: itemRepo.projectPath || '',
2484
+ repoLocalPath: itemRepo.localPath || '',
2485
+ repoId: itemRepo.id,
1976
2486
  directory: normalizeRepoPath(data.directory || data.name || ''),
1977
2487
  installSource: data.installSource || '',
1978
2488
  marketplaceFormat: data.marketplaceFormat || '',
1979
- readmeUrl: this.buildRepoBrowserUrl(repo, data.directory || data.name || ''),
2489
+ readmeUrl: this.buildRepoBrowserUrl(itemRepo, data.directory || data.name || ''),
1980
2490
  lspServers: data.lspServers || null,
1981
2491
  commands: data.commands || [],
1982
2492
  hooks: data.hooks || [],
@@ -2017,7 +2527,7 @@ class PluginsService {
2017
2527
  version: manifest.version || manifest.opencode?.minimumVersion || '1.0.0',
2018
2528
  category: firstCategory ? String(firstCategory).toLowerCase() : 'general',
2019
2529
  directory: file.path,
2020
- installSource: githubRepo ? '' : installSource,
2530
+ installSource,
2021
2531
  marketplaceFormat: 'opencode-plugin-json',
2022
2532
  repoUrl
2023
2533
  });
@@ -2029,11 +2539,182 @@ class PluginsService {
2029
2539
  .map(item => item.value);
2030
2540
  }
2031
2541
 
2542
+ _repoFromMarketplaceSource(parentRepo, source) {
2543
+ if (!source) return { repo: parentRepo, directory: '', installSource: '' };
2544
+
2545
+ if (typeof source === 'string') {
2546
+ const raw = source.trim();
2547
+ if (!raw) return { repo: parentRepo, directory: '', installSource: '' };
2548
+ if (isLikelyLocalPath(raw) || raw.startsWith('.')) {
2549
+ return {
2550
+ repo: parentRepo,
2551
+ directory: normalizeRepoPath(raw.replace(/^\.\//, '')),
2552
+ installSource: ''
2553
+ };
2554
+ }
2555
+ const parsedTree = this.parseRepoTreeSource(raw);
2556
+ if (parsedTree) {
2557
+ return { repo: this.normalizeRepoConfig(parsedTree), directory: parsedTree.directory, installSource: raw };
2558
+ }
2559
+ const parsedRepo = this._repoFromGitUrl(raw, parentRepo.branch || 'main');
2560
+ if (parsedRepo) {
2561
+ return { repo: parsedRepo, directory: '', installSource: raw };
2562
+ }
2563
+ return { repo: parentRepo, directory: normalizeRepoPath(raw.replace(/^\.\//, '')), installSource: raw };
2564
+ }
2565
+
2566
+ if (typeof source !== 'object') {
2567
+ return { repo: parentRepo, directory: '', installSource: '' };
2568
+ }
2569
+
2570
+ const sourceType = source.source || source.type || '';
2571
+ const rawPath = source.path || source.directory || '';
2572
+ if (sourceType === 'local' || (!source.url && rawPath)) {
2573
+ return {
2574
+ repo: parentRepo,
2575
+ directory: normalizeRepoPath(String(rawPath).replace(/^\.\//, '')),
2576
+ installSource: ''
2577
+ };
2578
+ }
2579
+
2580
+ if (source.url) {
2581
+ const branch = source.ref || source.branch || source.sha || parentRepo.branch || 'main';
2582
+ const parsedRepo = this._repoFromGitUrl(source.url, branch);
2583
+ if (parsedRepo) {
2584
+ return {
2585
+ repo: parsedRepo,
2586
+ directory: normalizeRepoPath(rawPath),
2587
+ installSource: source.url
2588
+ };
2589
+ }
2590
+ }
2591
+
2592
+ return {
2593
+ repo: parentRepo,
2594
+ directory: normalizeRepoPath(rawPath),
2595
+ installSource: source.url || ''
2596
+ };
2597
+ }
2598
+
2599
+ _repoFromGitUrl(url = '', branch = 'main') {
2600
+ const value = String(url || '').trim();
2601
+ if (!value) return null;
2602
+ let parsed;
2603
+ try {
2604
+ parsed = new URL(value);
2605
+ } catch {
2606
+ return null;
2607
+ }
2608
+
2609
+ const parts = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '').split('/').filter(Boolean);
2610
+ if (parsed.hostname.includes('github') && parts.length >= 2) {
2611
+ return this.normalizeRepoConfig({
2612
+ provider: 'github',
2613
+ host: `${parsed.protocol}//${parsed.host}`,
2614
+ owner: parts[0],
2615
+ name: parts[1],
2616
+ branch,
2617
+ repoUrl: value
2618
+ });
2619
+ }
2620
+
2621
+ if (parsed.hostname.includes('gitlab') && parts.length >= 1) {
2622
+ return this.normalizeRepoConfig({
2623
+ provider: 'gitlab',
2624
+ host: `${parsed.protocol}//${parsed.host}`,
2625
+ projectPath: parts.join('/'),
2626
+ branch,
2627
+ repoUrl: value
2628
+ });
2629
+ }
2630
+
2631
+ return null;
2632
+ }
2633
+
2634
+ async _readManifestFromRepo(repo, directory, candidates) {
2635
+ for (const candidate of candidates) {
2636
+ try {
2637
+ return await this.fetchRepoJson(repo, joinRepoPath(directory, candidate));
2638
+ } catch {
2639
+ // try next candidate
2640
+ }
2641
+ }
2642
+ return null;
2643
+ }
2644
+
2645
+ async _fetchClaudeMarketplacePlugins(repo, marketplace, readJson) {
2646
+ if (!marketplace || !Array.isArray(marketplace.plugins)) return [];
2647
+ const results = [];
2648
+ for (const plugin of marketplace.plugins) {
2649
+ const sourceInfo = this._repoFromMarketplaceSource(repo, plugin.source);
2650
+ const canReadManifest = sourceInfo.repo.provider === 'local' || sourceInfo.repo.id === repo.id;
2651
+ const manifest = canReadManifest
2652
+ ? await this._readManifestFromRepo(sourceInfo.repo, sourceInfo.directory, [
2653
+ '.claude-plugin/plugin.json',
2654
+ 'plugin.json',
2655
+ 'package.json'
2656
+ ])
2657
+ : null;
2658
+ results.push(this.buildMarketPluginItem(repo, {
2659
+ repo: sourceInfo.repo,
2660
+ name: manifest?.name || plugin.name,
2661
+ description: manifest?.description || plugin.description || '',
2662
+ author: manifest?.author || plugin.author?.name || marketplace.owner?.name || repo.owner,
2663
+ version: manifest?.version || plugin.version || '1.0.0',
2664
+ category: plugin.category || 'general',
2665
+ directory: sourceInfo.directory || plugin.name,
2666
+ marketplace: marketplace.name || repo.marketplace || '',
2667
+ installSource: sourceInfo.directory ? '' : sourceInfo.installSource,
2668
+ marketplaceFormat: 'claude-marketplace',
2669
+ lspServers: plugin.lspServers || null,
2670
+ commands: manifest?.commands || [],
2671
+ hooks: manifest?.hooks || []
2672
+ }));
2673
+ }
2674
+ return results;
2675
+ }
2676
+
2677
+ async _fetchCodexMarketplacePlugins(repo, marketplace) {
2678
+ if (!marketplace || !Array.isArray(marketplace.plugins)) return [];
2679
+ const marketplaceName = this._resolveCodexMarketplaceName(repo, marketplace.name || marketplace.interface?.displayName || repo.marketplace);
2680
+ const results = [];
2681
+ for (const plugin of marketplace.plugins) {
2682
+ const sourceInfo = this._repoFromMarketplaceSource(repo, plugin.source);
2683
+ const canReadManifest = sourceInfo.repo.provider === 'local' || sourceInfo.repo.id === repo.id;
2684
+ const manifest = canReadManifest
2685
+ ? await this._readManifestFromRepo(sourceInfo.repo, sourceInfo.directory, [
2686
+ '.codex-plugin/plugin.json',
2687
+ 'plugin.json',
2688
+ 'package.json'
2689
+ ])
2690
+ : null;
2691
+ results.push(this.buildMarketPluginItem(repo, {
2692
+ repo: sourceInfo.repo,
2693
+ name: manifest?.name || plugin.name,
2694
+ description: manifest?.description || plugin.description || '',
2695
+ author: manifest?.author || plugin.author?.name || marketplace.owner?.name || repo.owner,
2696
+ version: manifest?.version || plugin.version || '1.0.0',
2697
+ category: plugin.category || 'general',
2698
+ directory: sourceInfo.directory || plugin.name,
2699
+ marketplace: marketplaceName,
2700
+ installSource: sourceInfo.directory ? '' : sourceInfo.installSource,
2701
+ marketplaceFormat: 'codex-marketplace',
2702
+ commands: manifest?.commands || [],
2703
+ hooks: manifest?.hooks || []
2704
+ }));
2705
+ }
2706
+ return results;
2707
+ }
2708
+
2032
2709
  /**
2033
2710
  * Get market plugins from configured repositories
2034
2711
  * @returns {Promise<Array>} List of available market plugins
2035
2712
  */
2036
2713
  async getMarketPlugins(forceRefresh = false) {
2714
+ if (!this.getCapabilities().market) {
2715
+ return [];
2716
+ }
2717
+
2037
2718
  if (forceRefresh) {
2038
2719
  this.clearMarketCache({ removeFile: false });
2039
2720
  }
@@ -2074,25 +2755,24 @@ class PluginsService {
2074
2755
  return this.fetchRepoJson(repo, normalizedPath, file);
2075
2756
  };
2076
2757
 
2077
- // Try to fetch marketplace.json first (official format)
2078
- if (fileMap.has('.claude-plugin/marketplace.json')) {
2758
+ // Claude official marketplace format
2759
+ if (this.platform === 'claude' && fileMap.has('.claude-plugin/marketplace.json')) {
2079
2760
  const marketplace = await readJson('.claude-plugin/marketplace.json');
2080
2761
  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
- }
2762
+ marketPlugins.push(...await this._fetchClaudeMarketplacePlugins(repo, marketplace, readJson));
2092
2763
  continue; // Skip legacy format check
2093
2764
  }
2094
2765
  }
2095
2766
 
2767
+ // Codex marketplace format generated by plugin-creator
2768
+ if (this._isCodex() && fileMap.has('.agents/plugins/marketplace.json')) {
2769
+ const marketplace = await readJson('.agents/plugins/marketplace.json');
2770
+ if (marketplace && marketplace.plugins) {
2771
+ marketPlugins.push(...await this._fetchCodexMarketplacePlugins(repo, marketplace));
2772
+ continue;
2773
+ }
2774
+ }
2775
+
2096
2776
  // OpenCode plugin marketplace format: plugins/*.plugin.json
2097
2777
  if (this._isOpenCode()) {
2098
2778
  const openCodeMarketplacePlugins = await this._fetchOpenCodeMarketplacePlugins(repo, repo.branch || 'main');
@@ -2102,6 +2782,34 @@ class PluginsService {
2102
2782
  }
2103
2783
  }
2104
2784
 
2785
+ const nestedManifestFiles = files.filter(file => {
2786
+ if (this._isCodex()) return file.path.endsWith('/.codex-plugin/plugin.json');
2787
+ if (this.platform === 'claude') return file.path.endsWith('/.claude-plugin/plugin.json');
2788
+ return false;
2789
+ });
2790
+ const nestedDirs = new Set();
2791
+ for (const file of nestedManifestFiles) {
2792
+ const marker = this._isCodex() ? '/.codex-plugin/plugin.json' : '/.claude-plugin/plugin.json';
2793
+ const dir = normalizeRepoPath(file.path.slice(0, -marker.length));
2794
+ if (!dir || nestedDirs.has(dir)) continue;
2795
+ nestedDirs.add(dir);
2796
+ const manifest = await readJson(file.path);
2797
+ marketPlugins.push(this.buildMarketPluginItem(repo, {
2798
+ name: manifest.name || dir.split('/').pop(),
2799
+ description: manifest.description || '',
2800
+ author: manifest.author || repo.owner,
2801
+ version: manifest.version || '1.0.0',
2802
+ directory: dir,
2803
+ marketplace: this._isCodex() ? this._resolveCodexMarketplaceName(repo) : '',
2804
+ marketplaceFormat: this._isCodex() ? 'codex-manifest' : 'claude-manifest',
2805
+ commands: manifest.commands || [],
2806
+ hooks: manifest.hooks || []
2807
+ }));
2808
+ }
2809
+ if (nestedDirs.size > 0) {
2810
+ continue;
2811
+ }
2812
+
2105
2813
  // Legacy format: each directory is a plugin with plugin.json/package.json
2106
2814
  const pluginDirs = Array.from(new Set(
2107
2815
  files