coding-tool-x 3.2.0

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 (185) hide show
  1. package/CHANGELOG.md +599 -0
  2. package/LICENSE +21 -0
  3. package/README.md +439 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
  6. package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
  7. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  8. package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
  9. package/dist/web/assets/Home-38JTUlYt.js +1 -0
  10. package/dist/web/assets/Home-CjupSEWE.css +1 -0
  11. package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
  12. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  13. package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
  14. package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
  15. package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
  16. package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
  17. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  18. package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
  19. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  20. package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
  21. package/dist/web/assets/icons-DRrXwWZi.js +1 -0
  22. package/dist/web/assets/index-CetESrXw.css +1 -0
  23. package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
  24. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  25. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  26. package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
  27. package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
  28. package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
  29. package/dist/web/favicon.ico +0 -0
  30. package/dist/web/index.html +20 -0
  31. package/dist/web/logo.png +0 -0
  32. package/docs/bannel.png +0 -0
  33. package/docs/home.png +0 -0
  34. package/docs/logo.png +0 -0
  35. package/docs/model-redirection.md +251 -0
  36. package/docs/multi-channel-load-balancing.md +249 -0
  37. package/package.json +80 -0
  38. package/src/commands/channels.js +551 -0
  39. package/src/commands/cli-type.js +101 -0
  40. package/src/commands/daemon.js +365 -0
  41. package/src/commands/doctor.js +333 -0
  42. package/src/commands/export-config.js +205 -0
  43. package/src/commands/list.js +222 -0
  44. package/src/commands/logs.js +261 -0
  45. package/src/commands/plugin.js +585 -0
  46. package/src/commands/port-config.js +135 -0
  47. package/src/commands/proxy-control.js +264 -0
  48. package/src/commands/proxy.js +152 -0
  49. package/src/commands/resume.js +137 -0
  50. package/src/commands/search.js +190 -0
  51. package/src/commands/security.js +37 -0
  52. package/src/commands/stats.js +398 -0
  53. package/src/commands/switch.js +48 -0
  54. package/src/commands/toggle-proxy.js +247 -0
  55. package/src/commands/ui.js +99 -0
  56. package/src/commands/update.js +97 -0
  57. package/src/commands/workspace.js +454 -0
  58. package/src/config/default.js +69 -0
  59. package/src/config/loader.js +149 -0
  60. package/src/config/model-metadata.js +167 -0
  61. package/src/config/model-metadata.json +125 -0
  62. package/src/config/model-pricing.js +35 -0
  63. package/src/config/paths.js +190 -0
  64. package/src/index.js +680 -0
  65. package/src/plugins/constants.js +15 -0
  66. package/src/plugins/event-bus.js +54 -0
  67. package/src/plugins/manifest-validator.js +129 -0
  68. package/src/plugins/plugin-api.js +128 -0
  69. package/src/plugins/plugin-installer.js +601 -0
  70. package/src/plugins/plugin-loader.js +229 -0
  71. package/src/plugins/plugin-manager.js +170 -0
  72. package/src/plugins/registry.js +152 -0
  73. package/src/plugins/schema/plugin-manifest.json +115 -0
  74. package/src/reset-config.js +94 -0
  75. package/src/server/api/agents.js +826 -0
  76. package/src/server/api/aliases.js +36 -0
  77. package/src/server/api/channels.js +368 -0
  78. package/src/server/api/claude-hooks.js +480 -0
  79. package/src/server/api/codex-channels.js +417 -0
  80. package/src/server/api/codex-projects.js +104 -0
  81. package/src/server/api/codex-proxy.js +195 -0
  82. package/src/server/api/codex-sessions.js +483 -0
  83. package/src/server/api/codex-statistics.js +57 -0
  84. package/src/server/api/commands.js +482 -0
  85. package/src/server/api/config-export.js +212 -0
  86. package/src/server/api/config-registry.js +357 -0
  87. package/src/server/api/config-sync.js +155 -0
  88. package/src/server/api/config-templates.js +248 -0
  89. package/src/server/api/config.js +521 -0
  90. package/src/server/api/convert.js +260 -0
  91. package/src/server/api/dashboard.js +142 -0
  92. package/src/server/api/env.js +144 -0
  93. package/src/server/api/favorites.js +77 -0
  94. package/src/server/api/gemini-channels.js +366 -0
  95. package/src/server/api/gemini-projects.js +91 -0
  96. package/src/server/api/gemini-proxy.js +173 -0
  97. package/src/server/api/gemini-sessions.js +376 -0
  98. package/src/server/api/gemini-statistics.js +57 -0
  99. package/src/server/api/health-check.js +31 -0
  100. package/src/server/api/mcp.js +399 -0
  101. package/src/server/api/opencode-channels.js +419 -0
  102. package/src/server/api/opencode-projects.js +99 -0
  103. package/src/server/api/opencode-proxy.js +207 -0
  104. package/src/server/api/opencode-sessions.js +327 -0
  105. package/src/server/api/opencode-statistics.js +57 -0
  106. package/src/server/api/plugins.js +463 -0
  107. package/src/server/api/pm2-autostart.js +269 -0
  108. package/src/server/api/projects.js +124 -0
  109. package/src/server/api/prompts.js +279 -0
  110. package/src/server/api/proxy.js +306 -0
  111. package/src/server/api/security.js +53 -0
  112. package/src/server/api/sessions.js +514 -0
  113. package/src/server/api/settings.js +142 -0
  114. package/src/server/api/skills.js +570 -0
  115. package/src/server/api/statistics.js +238 -0
  116. package/src/server/api/ui-config.js +64 -0
  117. package/src/server/api/workspaces.js +456 -0
  118. package/src/server/codex-proxy-server.js +681 -0
  119. package/src/server/dev-server.js +26 -0
  120. package/src/server/gemini-proxy-server.js +610 -0
  121. package/src/server/index.js +422 -0
  122. package/src/server/opencode-proxy-server.js +4771 -0
  123. package/src/server/proxy-server.js +669 -0
  124. package/src/server/services/agents-service.js +1137 -0
  125. package/src/server/services/alias.js +71 -0
  126. package/src/server/services/channel-health.js +234 -0
  127. package/src/server/services/channel-scheduler.js +240 -0
  128. package/src/server/services/channels.js +447 -0
  129. package/src/server/services/codex-channels.js +705 -0
  130. package/src/server/services/codex-config.js +90 -0
  131. package/src/server/services/codex-parser.js +322 -0
  132. package/src/server/services/codex-sessions.js +936 -0
  133. package/src/server/services/codex-settings-manager.js +619 -0
  134. package/src/server/services/codex-speed-test-template.json +24 -0
  135. package/src/server/services/codex-statistics-service.js +161 -0
  136. package/src/server/services/commands-service.js +574 -0
  137. package/src/server/services/config-export-service.js +1165 -0
  138. package/src/server/services/config-registry-service.js +828 -0
  139. package/src/server/services/config-sync-manager.js +941 -0
  140. package/src/server/services/config-sync-service.js +504 -0
  141. package/src/server/services/config-templates-service.js +913 -0
  142. package/src/server/services/enhanced-cache.js +196 -0
  143. package/src/server/services/env-checker.js +409 -0
  144. package/src/server/services/env-manager.js +436 -0
  145. package/src/server/services/favorites.js +165 -0
  146. package/src/server/services/format-converter.js +620 -0
  147. package/src/server/services/gemini-channels.js +459 -0
  148. package/src/server/services/gemini-config.js +73 -0
  149. package/src/server/services/gemini-sessions.js +689 -0
  150. package/src/server/services/gemini-settings-manager.js +263 -0
  151. package/src/server/services/gemini-statistics-service.js +157 -0
  152. package/src/server/services/health-check.js +85 -0
  153. package/src/server/services/mcp-client.js +790 -0
  154. package/src/server/services/mcp-service.js +1732 -0
  155. package/src/server/services/model-detector.js +1245 -0
  156. package/src/server/services/network-access.js +80 -0
  157. package/src/server/services/opencode-channels.js +366 -0
  158. package/src/server/services/opencode-gateway-adapters.js +1168 -0
  159. package/src/server/services/opencode-gateway-converter.js +639 -0
  160. package/src/server/services/opencode-sessions.js +931 -0
  161. package/src/server/services/opencode-settings-manager.js +478 -0
  162. package/src/server/services/opencode-statistics-service.js +161 -0
  163. package/src/server/services/plugins-service.js +1268 -0
  164. package/src/server/services/prompts-service.js +534 -0
  165. package/src/server/services/proxy-runtime.js +79 -0
  166. package/src/server/services/repo-scanner-base.js +708 -0
  167. package/src/server/services/request-logger.js +130 -0
  168. package/src/server/services/response-decoder.js +21 -0
  169. package/src/server/services/security-config.js +131 -0
  170. package/src/server/services/session-cache.js +127 -0
  171. package/src/server/services/session-converter.js +577 -0
  172. package/src/server/services/sessions.js +900 -0
  173. package/src/server/services/settings-manager.js +163 -0
  174. package/src/server/services/skill-service.js +1482 -0
  175. package/src/server/services/speed-test.js +1146 -0
  176. package/src/server/services/statistics-service.js +1043 -0
  177. package/src/server/services/ui-config.js +132 -0
  178. package/src/server/services/workspace-service.js +830 -0
  179. package/src/server/utils/pricing.js +73 -0
  180. package/src/server/websocket-server.js +513 -0
  181. package/src/ui/menu.js +139 -0
  182. package/src/ui/prompts.js +100 -0
  183. package/src/utils/format.js +43 -0
  184. package/src/utils/port-helper.js +108 -0
  185. package/src/utils/session.js +240 -0
@@ -0,0 +1,941 @@
1
+ /**
2
+ * Config Sync Manager
3
+ *
4
+ * Manages file synchronization between cc-tool central storage and CLI directories:
5
+ * - Claude Code: ~/.claude/{skills,commands,agents,plugins}/
6
+ * - Codex CLI: ~/.codex/skills/, ~/.codex/prompts/
7
+ * - Gemini CLI: ~/.gemini/skills/
8
+ * - OpenCode CLI: ~/.config/opencode/{skills,commands,agents,plugins}/
9
+ *
10
+ * Config types:
11
+ * - skills: directory-based (each skill is a dir with SKILL.md)
12
+ * - commands: file-based (.md), may be nested in subdirectories
13
+ * - agents: file-based (.md), flat directory
14
+ * - plugins: directory-based
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const toml = require('toml');
21
+ const tomlStringify = require('@iarna/toml').stringify;
22
+ const { convertSkillToCodex, convertCommandToCodex } = require('./format-converter');
23
+ const { PATHS, NATIVE_PATHS, ensureStorageDirMigrated } = require('../../config/paths');
24
+
25
+ // Paths
26
+ const HOME = os.homedir();
27
+ const CC_TOOL_CONFIGS = path.join(PATHS.base, 'configs');
28
+ const CLAUDE_CODE_DIR = path.join(HOME, '.claude');
29
+ const CODEX_DIR = path.join(HOME, '.codex');
30
+ const GEMINI_DIR = path.join(HOME, '.gemini');
31
+ const OPENCODE_DIR = NATIVE_PATHS.opencode.config;
32
+ const CODEX_CONFIG_PATH = NATIVE_PATHS.codex.config;
33
+
34
+ // Config type definitions
35
+ const CONFIG_TYPES = {
36
+ skills: {
37
+ isDirectory: true,
38
+ markerFile: 'SKILL.md',
39
+ claudeTarget: 'skills',
40
+ codexTarget: 'skills',
41
+ codexSupported: true,
42
+ convertForCodex: true,
43
+ geminiTarget: 'skills',
44
+ geminiSupported: true,
45
+ opencodeTarget: 'skills',
46
+ opencodeLegacyTarget: 'skill',
47
+ opencodeSupported: true
48
+ },
49
+ commands: {
50
+ isDirectory: false,
51
+ extension: '.md',
52
+ claudeTarget: 'commands',
53
+ codexTarget: 'prompts',
54
+ codexSupported: true,
55
+ convertForCodex: true,
56
+ geminiSupported: false,
57
+ opencodeTarget: 'commands',
58
+ opencodeLegacyTarget: 'command',
59
+ opencodeSupported: true
60
+ },
61
+ agents: {
62
+ isDirectory: false,
63
+ extension: '.md',
64
+ claudeTarget: 'agents',
65
+ codexSupported: true,
66
+ geminiSupported: false,
67
+ opencodeTarget: 'agents',
68
+ opencodeLegacyTarget: 'agent',
69
+ opencodeSupported: true
70
+ },
71
+ plugins: {
72
+ isDirectory: true,
73
+ claudeTarget: 'plugins',
74
+ codexSupported: false,
75
+ geminiSupported: false,
76
+ opencodeTarget: 'plugins',
77
+ opencodeLegacyTarget: 'plugin',
78
+ opencodeSupported: true
79
+ }
80
+ };
81
+
82
+ class ConfigSyncManager {
83
+ constructor() {
84
+ ensureStorageDirMigrated();
85
+ this.ccToolConfigs = CC_TOOL_CONFIGS;
86
+ this.claudeDir = CLAUDE_CODE_DIR;
87
+ this.codexDir = CODEX_DIR;
88
+ this.geminiDir = GEMINI_DIR;
89
+ this.opencodeDir = OPENCODE_DIR;
90
+ this.configTypes = CONFIG_TYPES;
91
+ }
92
+
93
+ /**
94
+ * Sync a config item to Claude Code
95
+ * @param {string} type - Config type (skills, commands, agents, plugins)
96
+ * @param {string} name - Item name (directory name for skills, file path for others)
97
+ * @returns {Object} Result with success status
98
+ */
99
+ syncToClaude(type, name) {
100
+ const config = this.configTypes[type];
101
+ if (!config) {
102
+ console.log(`[ConfigSyncManager] Unknown config type: ${type}`);
103
+ return { success: false, error: `Unknown config type: ${type}` };
104
+ }
105
+
106
+ const safeName = this._normalizeSafeRelativeName(name);
107
+ if (!safeName) {
108
+ return { success: false, error: 'Invalid config item name' };
109
+ }
110
+ const sourcePath = path.join(this.ccToolConfigs, type, safeName);
111
+ const targetPath = path.join(this.claudeDir, config.claudeTarget, safeName);
112
+
113
+ // Check if source exists
114
+ if (!fs.existsSync(sourcePath)) {
115
+ console.log(`[ConfigSyncManager] Source not found: ${sourcePath}`);
116
+ return { success: false, error: 'Source not found' };
117
+ }
118
+
119
+ try {
120
+ if (config.isDirectory) {
121
+ // Copy entire directory recursively
122
+ this._ensureDir(path.dirname(targetPath));
123
+ this._copyDirRecursive(sourcePath, targetPath);
124
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Claude Code (directory)`);
125
+ } else {
126
+ // Copy single file, preserving subdirectory structure
127
+ this._ensureDir(path.dirname(targetPath));
128
+ this._copyFile(sourcePath, targetPath);
129
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Claude Code (file)`);
130
+ }
131
+
132
+ return { success: true, target: targetPath };
133
+ } catch (err) {
134
+ console.error(`[ConfigSyncManager] Sync to Claude failed:`, err.message);
135
+ return { success: false, error: err.message };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Remove a config item from Claude Code
141
+ * @param {string} type - Config type
142
+ * @param {string} name - Item name
143
+ * @returns {Object} Result with success status
144
+ */
145
+ removeFromClaude(type, name) {
146
+ const config = this.configTypes[type];
147
+ if (!config) {
148
+ return { success: false, error: `Unknown config type: ${type}` };
149
+ }
150
+
151
+ const safeName = this._normalizeSafeRelativeName(name);
152
+ if (!safeName) {
153
+ return { success: false, error: 'Invalid config item name' };
154
+ }
155
+ const targetPath = path.join(this.claudeDir, config.claudeTarget, safeName);
156
+
157
+ if (!fs.existsSync(targetPath)) {
158
+ console.log(`[ConfigSyncManager] Target not found (already removed): ${targetPath}`);
159
+ return { success: true, message: 'Already removed' };
160
+ }
161
+
162
+ try {
163
+ if (config.isDirectory) {
164
+ // Remove entire directory
165
+ this._removeRecursive(targetPath);
166
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Claude Code (directory)`);
167
+ } else {
168
+ // Remove file
169
+ fs.unlinkSync(targetPath);
170
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Claude Code (file)`);
171
+
172
+ // Clean up empty parent directories for file-based configs
173
+ this._cleanupEmptyParents(path.dirname(targetPath), path.join(this.claudeDir, config.claudeTarget));
174
+ }
175
+
176
+ return { success: true };
177
+ } catch (err) {
178
+ console.error(`[ConfigSyncManager] Remove from Claude failed:`, err.message);
179
+ return { success: false, error: err.message };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Sync a config item to Codex CLI
185
+ * Supports skills, commands, agents
186
+ * @param {string} type - Config type
187
+ * @param {string} name - Item name
188
+ * @returns {Object} Result with success status and any warnings
189
+ */
190
+ syncToCodex(type, name) {
191
+ const config = this.configTypes[type];
192
+ if (!config) {
193
+ return { success: false, error: `Unknown config type: ${type}` };
194
+ }
195
+
196
+ if (!config.codexSupported) {
197
+ console.log(`[ConfigSyncManager] ${type} not supported by Codex, skipping`);
198
+ return { success: true, skipped: true, reason: 'Not supported by Codex' };
199
+ }
200
+
201
+ const safeName = this._normalizeSafeRelativeName(name);
202
+ if (!safeName) {
203
+ return { success: false, error: 'Invalid config item name' };
204
+ }
205
+ const sourcePath = path.join(this.ccToolConfigs, type, safeName);
206
+
207
+ if (!fs.existsSync(sourcePath)) {
208
+ console.log(`[ConfigSyncManager] Source not found: ${sourcePath}`);
209
+ return { success: false, error: 'Source not found' };
210
+ }
211
+
212
+ try {
213
+ const warnings = [];
214
+
215
+ if (type === 'agents') {
216
+ const sourceContent = fs.readFileSync(sourcePath, 'utf-8');
217
+ const { frontmatter } = this._parseFrontmatter(sourceContent);
218
+ const fileName = path.basename(safeName, path.extname(safeName));
219
+
220
+ const codexConfig = this._readCodexConfigToml();
221
+ codexConfig.features = this._isPlainObject(codexConfig.features) ? codexConfig.features : {};
222
+ codexConfig.features.multi_agent = true;
223
+ codexConfig.agents = this._isPlainObject(codexConfig.agents) ? codexConfig.agents : {};
224
+
225
+ const existing = codexConfig.agents[fileName];
226
+ if (Object.prototype.hasOwnProperty.call(codexConfig.agents, fileName) &&
227
+ !this._isPlainObject(existing)) {
228
+ return { success: false, error: `Agent name "${fileName}" conflicts with global [agents] key` };
229
+ }
230
+
231
+ const entry = this._isPlainObject(existing) ? { ...existing } : {};
232
+ entry.description = (frontmatter.description || fileName).trim();
233
+
234
+ const model = typeof frontmatter.model === 'string' ? frontmatter.model.trim() : '';
235
+ const existingConfigFile = this._normalizeCodexPath(entry.config_file);
236
+ const isExistingManagedConfig = this._isManagedCodexAgentConfigPath(existingConfigFile);
237
+ if (model) {
238
+ const managedConfigPath = isExistingManagedConfig
239
+ ? existingConfigFile
240
+ : this._getCodexManagedAgentConfigPath(fileName);
241
+ const parsedConfigFile = this._readCodexAgentConfigFile(managedConfigPath);
242
+ const configData = this._isPlainObject(parsedConfigFile?.data) ? parsedConfigFile.data : {};
243
+ configData.model = model;
244
+ this._writeCodexAgentConfigFile(managedConfigPath, configData);
245
+ entry.config_file = managedConfigPath;
246
+ } else if (isExistingManagedConfig) {
247
+ const resolvedConfigPath = this._resolveCodexPath(existingConfigFile);
248
+ if (resolvedConfigPath && fs.existsSync(resolvedConfigPath)) {
249
+ fs.unlinkSync(resolvedConfigPath);
250
+ }
251
+ delete entry.config_file;
252
+ }
253
+
254
+ codexConfig.agents[fileName] = entry;
255
+
256
+ this._writeCodexConfigToml(codexConfig);
257
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Codex (config.toml agents table)`);
258
+ return { success: true, target: CODEX_CONFIG_PATH, warnings };
259
+ }
260
+
261
+ if (type === 'skills') {
262
+ // Skills: copy directory, convert SKILL.md content
263
+ const targetPath = path.join(this.codexDir, config.codexTarget, safeName);
264
+ this._ensureDir(targetPath);
265
+
266
+ // Copy all files, converting SKILL.md
267
+ this._copyDirWithConversion(sourcePath, targetPath, (filePath, content) => {
268
+ if (path.basename(filePath) === 'SKILL.md') {
269
+ const result = convertSkillToCodex(content);
270
+ if (result.warnings && result.warnings.length > 0) {
271
+ warnings.push(...result.warnings);
272
+ }
273
+ return result.content;
274
+ }
275
+ return content;
276
+ });
277
+
278
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Codex (skill directory)`);
279
+ return { success: true, target: targetPath, warnings };
280
+
281
+ } else if (type === 'commands') {
282
+ // Commands: convert and write to prompts directory
283
+ const content = fs.readFileSync(sourcePath, 'utf-8');
284
+ const result = convertCommandToCodex(content);
285
+
286
+ if (result.warnings && result.warnings.length > 0) {
287
+ warnings.push(...result.warnings);
288
+ }
289
+
290
+ // Target path in codex prompts (same relative path structure)
291
+ const targetPath = path.join(this.codexDir, config.codexTarget, safeName);
292
+ this._ensureDir(path.dirname(targetPath));
293
+ fs.writeFileSync(targetPath, result.content, 'utf-8');
294
+
295
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Codex (prompt)`);
296
+ return { success: true, target: targetPath, warnings };
297
+ }
298
+
299
+ return { success: false, error: 'Unexpected type' };
300
+ } catch (err) {
301
+ console.error(`[ConfigSyncManager] Sync to Codex failed:`, err.message);
302
+ return { success: false, error: err.message };
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Remove a config item from Codex CLI
308
+ * @param {string} type - Config type
309
+ * @param {string} name - Item name
310
+ * @returns {Object} Result with success status
311
+ */
312
+ removeFromCodex(type, name) {
313
+ const config = this.configTypes[type];
314
+ if (!config) {
315
+ return { success: false, error: `Unknown config type: ${type}` };
316
+ }
317
+
318
+ const safeName = this._normalizeSafeRelativeName(name);
319
+ if (!safeName) {
320
+ return { success: false, error: 'Invalid config item name' };
321
+ }
322
+
323
+ if (!config.codexSupported) {
324
+ return { success: true, skipped: true, reason: 'Not supported by Codex' };
325
+ }
326
+
327
+ if (type === 'agents') {
328
+ try {
329
+ const configData = this._readCodexConfigToml();
330
+ const agentsTable = this._isPlainObject(configData.agents) ? configData.agents : {};
331
+ const fileName = path.basename(safeName, path.extname(safeName));
332
+
333
+ const existing = agentsTable[fileName];
334
+ if (!this._isPlainObject(existing)) {
335
+ return { success: true, message: 'Already removed' };
336
+ }
337
+
338
+ const existingConfigFile = this._normalizeCodexPath(existing.config_file);
339
+ if (existingConfigFile && this._isManagedCodexAgentConfigPath(existingConfigFile)) {
340
+ const resolvedPath = this._resolveCodexPath(existingConfigFile);
341
+ if (resolvedPath && fs.existsSync(resolvedPath)) {
342
+ fs.unlinkSync(resolvedPath);
343
+ }
344
+ }
345
+
346
+ delete agentsTable[fileName];
347
+ configData.agents = agentsTable;
348
+ this._writeCodexConfigToml(configData);
349
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Codex (config.toml agents table)`);
350
+ return { success: true };
351
+ } catch (err) {
352
+ console.error(`[ConfigSyncManager] Remove from Codex failed:`, err.message);
353
+ return { success: false, error: err.message };
354
+ }
355
+ }
356
+
357
+ const targetPath = path.join(this.codexDir, config.codexTarget, safeName);
358
+
359
+ if (!fs.existsSync(targetPath)) {
360
+ console.log(`[ConfigSyncManager] Target not found (already removed): ${targetPath}`);
361
+ return { success: true, message: 'Already removed' };
362
+ }
363
+
364
+ try {
365
+ if (type === 'skills') {
366
+ // Remove entire directory
367
+ this._removeRecursive(targetPath);
368
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Codex (skill directory)`);
369
+ } else {
370
+ // Remove file
371
+ fs.unlinkSync(targetPath);
372
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Codex (prompt)`);
373
+
374
+ // Clean up empty parent directories
375
+ this._cleanupEmptyParents(path.dirname(targetPath), path.join(this.codexDir, config.codexTarget));
376
+ }
377
+
378
+ return { success: true };
379
+ } catch (err) {
380
+ console.error(`[ConfigSyncManager] Remove from Codex failed:`, err.message);
381
+ return { success: false, error: err.message };
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Sync a config item to Gemini CLI
387
+ * Currently only skills are supported
388
+ * @param {string} type - Config type
389
+ * @param {string} name - Item name
390
+ * @returns {Object} Result with success status
391
+ */
392
+ syncToGemini(type, name) {
393
+ const config = this.configTypes[type];
394
+ if (!config) {
395
+ return { success: false, error: `Unknown config type: ${type}` };
396
+ }
397
+
398
+ if (!config.geminiSupported) {
399
+ console.log(`[ConfigSyncManager] ${type} not supported by Gemini, skipping`);
400
+ return { success: true, skipped: true, reason: 'Not supported by Gemini' };
401
+ }
402
+
403
+ const safeName = this._normalizeSafeRelativeName(name);
404
+ if (!safeName) {
405
+ return { success: false, error: 'Invalid config item name' };
406
+ }
407
+ const sourcePath = path.join(this.ccToolConfigs, type, safeName);
408
+ if (!fs.existsSync(sourcePath)) {
409
+ console.log(`[ConfigSyncManager] Source not found: ${sourcePath}`);
410
+ return { success: false, error: 'Source not found' };
411
+ }
412
+
413
+ try {
414
+ const targetPath = path.join(this.geminiDir, config.geminiTarget, safeName);
415
+ this._ensureDir(path.dirname(targetPath));
416
+ this._copyDirRecursive(sourcePath, targetPath);
417
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Gemini (directory)`);
418
+ return { success: true, target: targetPath };
419
+ } catch (err) {
420
+ console.error(`[ConfigSyncManager] Sync to Gemini failed:`, err.message);
421
+ return { success: false, error: err.message };
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Remove a config item from Gemini CLI
427
+ * @param {string} type - Config type
428
+ * @param {string} name - Item name
429
+ * @returns {Object} Result with success status
430
+ */
431
+ removeFromGemini(type, name) {
432
+ const config = this.configTypes[type];
433
+ if (!config) {
434
+ return { success: false, error: `Unknown config type: ${type}` };
435
+ }
436
+
437
+ const safeName = this._normalizeSafeRelativeName(name);
438
+ if (!safeName) {
439
+ return { success: false, error: 'Invalid config item name' };
440
+ }
441
+
442
+ if (!config.geminiSupported) {
443
+ return { success: true, skipped: true, reason: 'Not supported by Gemini' };
444
+ }
445
+
446
+ const targetPath = path.join(this.geminiDir, config.geminiTarget, safeName);
447
+ if (!fs.existsSync(targetPath)) {
448
+ console.log(`[ConfigSyncManager] Target not found (already removed): ${targetPath}`);
449
+ return { success: true, message: 'Already removed' };
450
+ }
451
+
452
+ try {
453
+ this._removeRecursive(targetPath);
454
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Gemini (directory)`);
455
+ return { success: true };
456
+ } catch (err) {
457
+ console.error(`[ConfigSyncManager] Remove from Gemini failed:`, err.message);
458
+ return { success: false, error: err.message };
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Sync a config item to OpenCode CLI
464
+ * Supports skills, commands, agents, plugins
465
+ * @param {string} type - Config type
466
+ * @param {string} name - Item name
467
+ * @returns {Object} Result with success status
468
+ */
469
+ syncToOpenCode(type, name) {
470
+ const config = this.configTypes[type];
471
+ if (!config) {
472
+ return { success: false, error: `Unknown config type: ${type}` };
473
+ }
474
+
475
+ if (!config.opencodeSupported) {
476
+ console.log(`[ConfigSyncManager] ${type} not supported by OpenCode, skipping`);
477
+ return { success: true, skipped: true, reason: 'Not supported by OpenCode' };
478
+ }
479
+
480
+ const safeName = this._normalizeSafeRelativeName(name);
481
+ if (!safeName) {
482
+ return { success: false, error: 'Invalid config item name' };
483
+ }
484
+ const sourcePath = path.join(this.ccToolConfigs, type, safeName);
485
+ if (!fs.existsSync(sourcePath)) {
486
+ console.log(`[ConfigSyncManager] Source not found: ${sourcePath}`);
487
+ return { success: false, error: 'Source not found' };
488
+ }
489
+
490
+ try {
491
+ const targetBaseDir = this._getOpenCodeTypeBaseDir(config);
492
+ const targetPath = path.join(targetBaseDir, safeName);
493
+
494
+ if (config.isDirectory) {
495
+ this._ensureDir(path.dirname(targetPath));
496
+ this._copyDirRecursive(sourcePath, targetPath);
497
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to OpenCode (directory)`);
498
+ } else {
499
+ this._ensureDir(path.dirname(targetPath));
500
+ this._copyFile(sourcePath, targetPath);
501
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to OpenCode (file)`);
502
+ }
503
+
504
+ return { success: true, target: targetPath };
505
+ } catch (err) {
506
+ console.error(`[ConfigSyncManager] Sync to OpenCode failed:`, err.message);
507
+ return { success: false, error: err.message };
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Remove a config item from OpenCode CLI
513
+ * @param {string} type - Config type
514
+ * @param {string} name - Item name
515
+ * @returns {Object} Result with success status
516
+ */
517
+ removeFromOpenCode(type, name) {
518
+ const config = this.configTypes[type];
519
+ if (!config) {
520
+ return { success: false, error: `Unknown config type: ${type}` };
521
+ }
522
+
523
+ const safeName = this._normalizeSafeRelativeName(name);
524
+ if (!safeName) {
525
+ return { success: false, error: 'Invalid config item name' };
526
+ }
527
+
528
+ if (!config.opencodeSupported) {
529
+ return { success: true, skipped: true, reason: 'Not supported by OpenCode' };
530
+ }
531
+
532
+ const targetBaseDir = this._getOpenCodeTypeBaseDir(config);
533
+ const targetPath = path.join(targetBaseDir, safeName);
534
+
535
+ if (!fs.existsSync(targetPath)) {
536
+ console.log(`[ConfigSyncManager] Target not found (already removed): ${targetPath}`);
537
+ return { success: true, message: 'Already removed' };
538
+ }
539
+
540
+ try {
541
+ if (config.isDirectory) {
542
+ this._removeRecursive(targetPath);
543
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from OpenCode (directory)`);
544
+ } else {
545
+ fs.unlinkSync(targetPath);
546
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from OpenCode (file)`);
547
+ this._cleanupEmptyParents(path.dirname(targetPath), targetBaseDir);
548
+ }
549
+
550
+ return { success: true };
551
+ } catch (err) {
552
+ console.error(`[ConfigSyncManager] Remove from OpenCode failed:`, err.message);
553
+ return { success: false, error: err.message };
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Batch sync based on registry data
559
+ * @param {string} type - Config type
560
+ * @param {Object} registryItems - Registry items { name: { enabled, platforms: { claude, codex, gemini, opencode } } }
561
+ * @returns {Object} Results summary
562
+ */
563
+ syncAll(type, registryItems) {
564
+ const results = {
565
+ synced: [],
566
+ removed: [],
567
+ errors: [],
568
+ warnings: []
569
+ };
570
+
571
+ if (!registryItems || typeof registryItems !== 'object') {
572
+ return results;
573
+ }
574
+
575
+ for (const [name, item] of Object.entries(registryItems)) {
576
+ if (!item || typeof item !== 'object') continue;
577
+
578
+ const { enabled, platforms } = item;
579
+
580
+ if (enabled && platforms) {
581
+ // Sync to enabled platforms
582
+ if (platforms.claude) {
583
+ const result = this.syncToClaude(type, name);
584
+ if (result.success && !result.skipped) {
585
+ results.synced.push({ type, name, platform: 'claude' });
586
+ } else if (!result.success) {
587
+ results.errors.push({ type, name, platform: 'claude', error: result.error });
588
+ }
589
+ } else {
590
+ // Platform disabled, remove
591
+ const result = this.removeFromClaude(type, name);
592
+ if (result.success && !result.message) {
593
+ results.removed.push({ type, name, platform: 'claude' });
594
+ }
595
+ }
596
+
597
+ if (platforms.codex) {
598
+ const result = this.syncToCodex(type, name);
599
+ if (result.success && !result.skipped) {
600
+ results.synced.push({ type, name, platform: 'codex' });
601
+ if (result.warnings && result.warnings.length > 0) {
602
+ results.warnings.push({ type, name, platform: 'codex', warnings: result.warnings });
603
+ }
604
+ } else if (!result.success) {
605
+ results.errors.push({ type, name, platform: 'codex', error: result.error });
606
+ }
607
+ } else {
608
+ // Platform disabled, remove
609
+ const result = this.removeFromCodex(type, name);
610
+ if (result.success && !result.message && !result.skipped) {
611
+ results.removed.push({ type, name, platform: 'codex' });
612
+ }
613
+ }
614
+
615
+ if (platforms.gemini) {
616
+ const result = this.syncToGemini(type, name);
617
+ if (result.success && !result.skipped) {
618
+ results.synced.push({ type, name, platform: 'gemini' });
619
+ } else if (!result.success) {
620
+ results.errors.push({ type, name, platform: 'gemini', error: result.error });
621
+ }
622
+ } else {
623
+ const result = this.removeFromGemini(type, name);
624
+ if (result.success && !result.message && !result.skipped) {
625
+ results.removed.push({ type, name, platform: 'gemini' });
626
+ }
627
+ }
628
+
629
+ if (platforms.opencode) {
630
+ const result = this.syncToOpenCode(type, name);
631
+ if (result.success && !result.skipped) {
632
+ results.synced.push({ type, name, platform: 'opencode' });
633
+ } else if (!result.success) {
634
+ results.errors.push({ type, name, platform: 'opencode', error: result.error });
635
+ }
636
+ } else {
637
+ const result = this.removeFromOpenCode(type, name);
638
+ if (result.success && !result.message && !result.skipped) {
639
+ results.removed.push({ type, name, platform: 'opencode' });
640
+ }
641
+ }
642
+ } else {
643
+ // Item disabled, remove from all platforms
644
+ const claudeResult = this.removeFromClaude(type, name);
645
+ if (claudeResult.success && !claudeResult.message) {
646
+ results.removed.push({ type, name, platform: 'claude' });
647
+ }
648
+
649
+ const codexResult = this.removeFromCodex(type, name);
650
+ if (codexResult.success && !codexResult.message && !codexResult.skipped) {
651
+ results.removed.push({ type, name, platform: 'codex' });
652
+ }
653
+
654
+ const geminiResult = this.removeFromGemini(type, name);
655
+ if (geminiResult.success && !geminiResult.message && !geminiResult.skipped) {
656
+ results.removed.push({ type, name, platform: 'gemini' });
657
+ }
658
+
659
+ const opencodeResult = this.removeFromOpenCode(type, name);
660
+ if (opencodeResult.success && !opencodeResult.message && !opencodeResult.skipped) {
661
+ results.removed.push({ type, name, platform: 'opencode' });
662
+ }
663
+ }
664
+ }
665
+
666
+ console.log(`[ConfigSyncManager] syncAll(${type}): synced=${results.synced.length}, removed=${results.removed.length}, errors=${results.errors.length}`);
667
+ return results;
668
+ }
669
+
670
+ // ==================== Helper Methods ====================
671
+
672
+ /**
673
+ * Ensure a directory exists
674
+ */
675
+ _ensureDir(dir) {
676
+ if (!fs.existsSync(dir)) {
677
+ fs.mkdirSync(dir, { recursive: true });
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Recursively copy a directory
683
+ */
684
+ _copyDirRecursive(src, dest) {
685
+ this._ensureDir(dest);
686
+
687
+ const entries = fs.readdirSync(src, { withFileTypes: true });
688
+
689
+ for (const entry of entries) {
690
+ const srcPath = path.join(src, entry.name);
691
+ const destPath = path.join(dest, entry.name);
692
+
693
+ if (entry.isDirectory()) {
694
+ this._copyDirRecursive(srcPath, destPath);
695
+ } else {
696
+ fs.copyFileSync(srcPath, destPath);
697
+ }
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Copy a directory with content transformation
703
+ * @param {string} src - Source directory
704
+ * @param {string} dest - Destination directory
705
+ * @param {Function} transform - Function(filePath, content) => transformedContent
706
+ */
707
+ _copyDirWithConversion(src, dest, transform) {
708
+ this._ensureDir(dest);
709
+
710
+ const entries = fs.readdirSync(src, { withFileTypes: true });
711
+
712
+ for (const entry of entries) {
713
+ const srcPath = path.join(src, entry.name);
714
+ const destPath = path.join(dest, entry.name);
715
+
716
+ if (entry.isDirectory()) {
717
+ this._copyDirWithConversion(srcPath, destPath, transform);
718
+ } else {
719
+ // Check if it's a text file that should be transformed
720
+ const ext = path.extname(entry.name).toLowerCase();
721
+ const textExtensions = ['.md', '.txt', '.json', '.js', '.ts', '.py', '.sh', '.yaml', '.yml'];
722
+
723
+ if (textExtensions.includes(ext)) {
724
+ const content = fs.readFileSync(srcPath, 'utf-8');
725
+ const transformed = transform(srcPath, content);
726
+ fs.writeFileSync(destPath, transformed, 'utf-8');
727
+ } else {
728
+ // Binary file, copy as-is
729
+ fs.copyFileSync(srcPath, destPath);
730
+ }
731
+ }
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Copy a single file
737
+ */
738
+ _copyFile(src, dest) {
739
+ fs.copyFileSync(src, dest);
740
+ }
741
+
742
+ /**
743
+ * Resolve OpenCode base target directory for a config type.
744
+ * OpenCode supports both plural (new) and singular (legacy) folder names.
745
+ */
746
+ _getOpenCodeTypeBaseDir(config) {
747
+ const modernDir = path.join(this.opencodeDir, config.opencodeTarget);
748
+ // 技能目录强制使用 modern/plural 形式,避免 legacy 目录带来的跨平台历史污染
749
+ if (config === this.configTypes.skills) {
750
+ return modernDir;
751
+ }
752
+
753
+ if (!config.opencodeLegacyTarget) {
754
+ return modernDir;
755
+ }
756
+
757
+ const legacyDir = path.join(this.opencodeDir, config.opencodeLegacyTarget);
758
+ if (fs.existsSync(legacyDir) && !fs.existsSync(modernDir)) {
759
+ return legacyDir;
760
+ }
761
+
762
+ return modernDir;
763
+ }
764
+
765
+ /**
766
+ * Recursively remove a file or directory
767
+ */
768
+ _removeRecursive(target) {
769
+ if (!fs.existsSync(target)) {
770
+ return;
771
+ }
772
+
773
+ fs.rmSync(target, { recursive: true, force: true });
774
+ }
775
+
776
+ /**
777
+ * Clean up empty parent directories up to the base directory
778
+ */
779
+ _cleanupEmptyParents(dir, baseDir) {
780
+ // Normalize paths for comparison
781
+ const normalizedDir = path.resolve(dir);
782
+ const normalizedBase = path.resolve(baseDir);
783
+
784
+ // Don't go above base directory
785
+ if (!normalizedDir.startsWith(normalizedBase) || normalizedDir === normalizedBase) {
786
+ return;
787
+ }
788
+
789
+ try {
790
+ const entries = fs.readdirSync(dir);
791
+ if (entries.length === 0) {
792
+ fs.rmdirSync(dir);
793
+ console.log(`[ConfigSyncManager] Removed empty directory: ${dir}`);
794
+ // Recurse to parent
795
+ this._cleanupEmptyParents(path.dirname(dir), baseDir);
796
+ }
797
+ } catch (err) {
798
+ // Ignore errors (directory might not exist or permission issues)
799
+ }
800
+ }
801
+
802
+ _normalizeSafeRelativeName(name) {
803
+ const raw = String(name || '').replace(/\\/g, '/').trim();
804
+ if (!raw || raw.includes('\0')) {
805
+ return null;
806
+ }
807
+
808
+ const normalized = path.posix.normalize(raw).replace(/^(\.\/)+/, '');
809
+ if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
810
+ return null;
811
+ }
812
+
813
+ if (path.isAbsolute(raw) || raw.startsWith('/')) {
814
+ return null;
815
+ }
816
+
817
+ return normalized;
818
+ }
819
+
820
+ _parseFrontmatter(content) {
821
+ const result = {
822
+ frontmatter: {},
823
+ body: content
824
+ };
825
+
826
+ const normalized = content.trim().replace(/^\uFEFF/, '');
827
+ const match = normalized.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
828
+ if (!match) {
829
+ return result;
830
+ }
831
+
832
+ const frontmatterText = match[1];
833
+ result.body = match[2].trim();
834
+
835
+ for (const line of frontmatterText.split('\n')) {
836
+ const colonIndex = line.indexOf(':');
837
+ if (colonIndex === -1) continue;
838
+ const key = line.slice(0, colonIndex).trim();
839
+ let value = line.slice(colonIndex + 1).trim();
840
+ if ((value.startsWith('"') && value.endsWith('"')) ||
841
+ (value.startsWith("'") && value.endsWith("'"))) {
842
+ value = value.slice(1, -1);
843
+ }
844
+ result.frontmatter[key] = value;
845
+ }
846
+
847
+ return result;
848
+ }
849
+
850
+ _isPlainObject(value) {
851
+ return !!value && typeof value === 'object' && !Array.isArray(value);
852
+ }
853
+
854
+ _normalizeCodexPath(configPath) {
855
+ return typeof configPath === 'string' ? configPath.trim() : '';
856
+ }
857
+
858
+ _resolveCodexPath(configPath) {
859
+ const normalized = this._normalizeCodexPath(configPath);
860
+ if (!normalized) return '';
861
+
862
+ if (normalized.startsWith('~/')) {
863
+ return path.join(HOME, normalized.slice(2));
864
+ }
865
+
866
+ if (path.isAbsolute(normalized)) {
867
+ return normalized;
868
+ }
869
+
870
+ return path.resolve(path.dirname(CODEX_CONFIG_PATH), normalized);
871
+ }
872
+
873
+ _isManagedCodexAgentConfigPath(configPath) {
874
+ const resolved = this._resolveCodexPath(configPath);
875
+ if (!resolved) return false;
876
+
877
+ const managedRoot = path.resolve(this._getCodexManagedAgentConfigDir()) + path.sep;
878
+ return resolved.startsWith(managedRoot) || resolved === path.resolve(this._getCodexManagedAgentConfigDir());
879
+ }
880
+
881
+ _getCodexManagedAgentConfigDir() {
882
+ return path.join(this.codexDir, 'agents');
883
+ }
884
+
885
+ _getCodexManagedAgentConfigPath(fileName) {
886
+ return path.join(this._getCodexManagedAgentConfigDir(), `${fileName}.toml`);
887
+ }
888
+
889
+ _writeCodexAgentConfigFile(configPath, data) {
890
+ const resolved = this._resolveCodexPath(configPath);
891
+ this._ensureDir(path.dirname(resolved));
892
+ const tempPath = `${resolved}.tmp-${process.pid}-${Date.now()}`;
893
+ fs.writeFileSync(tempPath, tomlStringify(data), 'utf-8');
894
+ fs.renameSync(tempPath, resolved);
895
+ }
896
+
897
+ _readCodexAgentConfigFile(configPath) {
898
+ const resolved = this._resolveCodexPath(configPath);
899
+ if (!resolved || !fs.existsSync(resolved)) {
900
+ return null;
901
+ }
902
+
903
+ try {
904
+ const content = fs.readFileSync(resolved, 'utf-8');
905
+ return {
906
+ content,
907
+ data: toml.parse(content)
908
+ };
909
+ } catch (err) {
910
+ return {
911
+ content: fs.readFileSync(resolved, 'utf-8'),
912
+ data: null
913
+ };
914
+ }
915
+ }
916
+
917
+ _readCodexConfigToml() {
918
+ if (!fs.existsSync(CODEX_CONFIG_PATH)) {
919
+ return {};
920
+ }
921
+ const content = fs.readFileSync(CODEX_CONFIG_PATH, 'utf-8');
922
+ return toml.parse(content);
923
+ }
924
+
925
+ _writeCodexConfigToml(config) {
926
+ this._ensureDir(path.dirname(CODEX_CONFIG_PATH));
927
+ const tempPath = `${CODEX_CONFIG_PATH}.tmp-${process.pid}-${Date.now()}`;
928
+ fs.writeFileSync(tempPath, tomlStringify(config), 'utf-8');
929
+ fs.renameSync(tempPath, CODEX_CONFIG_PATH);
930
+ }
931
+ }
932
+
933
+ module.exports = {
934
+ ConfigSyncManager,
935
+ CONFIG_TYPES,
936
+ CC_TOOL_CONFIGS,
937
+ CLAUDE_CODE_DIR,
938
+ CODEX_DIR,
939
+ GEMINI_DIR,
940
+ OPENCODE_DIR
941
+ };