coding-tool-x 3.6.7 → 3.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/web/assets/{Analytics-CKycBI0G.js → Analytics-BuBlsbcI.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-CACLQb8e.js → ConfigTemplates-DQczxuOX.js} +1 -1
  3. package/dist/web/assets/Home-BvAzzWnB.js +1 -0
  4. package/dist/web/assets/Home-CIua-b-o.css +1 -0
  5. package/dist/web/assets/{PluginManager-CQJl_uso.js → PluginManager-C8gAlaps.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DSkvQFEP.js → ProjectList-nqgi6_cR.js} +1 -1
  7. package/dist/web/assets/{SessionList-Czjs1GN1.js → SessionList-D8AxMYy6.js} +1 -1
  8. package/dist/web/assets/{SkillManager-Cg1xZ5Cv.js → SkillManager-BDbNbNkK.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-COjYeKvN.js → WorkspaceManager-DGGaGW2c.js} +1 -1
  10. package/dist/web/assets/index-Dsh4Q5Cg.css +1 -0
  11. package/dist/web/assets/index-y-eEayxR.js +2 -0
  12. package/dist/web/assets/{naive-ui-DM897mIP.js → naive-ui-kcWDeJwl.js} +1 -1
  13. package/dist/web/index.html +3 -3
  14. package/package.json +1 -1
  15. package/src/commands/stats.js +1 -1
  16. package/src/config/channel-preset-websites.js +2 -1
  17. package/src/config/paths.js +8 -0
  18. package/src/server/api/agents.js +16 -54
  19. package/src/server/api/claude-hooks.js +1 -1
  20. package/src/server/api/claude-statistics.js +57 -0
  21. package/src/server/api/commands.js +16 -54
  22. package/src/server/api/dashboard.js +2 -2
  23. package/src/server/api/gemini-channels.js +2 -0
  24. package/src/server/api/hooks.js +4 -1
  25. package/src/server/api/plugins.js +46 -69
  26. package/src/server/api/skills.js +21 -77
  27. package/src/server/api/validation-errors.js +34 -0
  28. package/src/server/gemini-proxy-server.js +205 -21
  29. package/src/server/index.js +1 -0
  30. package/src/server/services/agents-service.js +166 -75
  31. package/src/server/services/base/base-channel-service.js +15 -0
  32. package/src/server/services/base/response-usage-parser.js +44 -0
  33. package/src/server/services/channel-balance.js +86 -1
  34. package/src/server/services/claude-statistics-service.js +139 -0
  35. package/src/server/services/commands-service.js +203 -121
  36. package/src/server/services/config-artifact-paths.js +116 -0
  37. package/src/server/services/config-export-service.js +7 -6
  38. package/src/server/services/config-registry-service.js +2 -2
  39. package/src/server/services/config-sync-manager.js +45 -11
  40. package/src/server/services/config-templates-service.js +21 -19
  41. package/src/server/services/format-converter.js +42 -4
  42. package/src/server/services/gemini-channels.js +30 -1
  43. package/src/server/services/gemini-sessions.js +347 -61
  44. package/src/server/services/mcp-client.js +236 -49
  45. package/src/server/services/mcp-service.js +51 -64
  46. package/src/server/services/notification-hooks.js +659 -88
  47. package/src/server/services/opencode-channels.js +15 -0
  48. package/src/server/services/plugins-service.js +1137 -86
  49. package/src/server/services/repo-scanner-base.js +6 -29
  50. package/src/server/services/skill-service.js +198 -65
  51. package/dist/web/assets/Home-BeGkC4bb.css +0 -1
  52. package/dist/web/assets/Home-Bk6sP5Ed.js +0 -1
  53. package/dist/web/assets/index-0isNzXNY.css +0 -1
  54. package/dist/web/assets/index-fWgvlZQx.js +0 -2
@@ -0,0 +1,116 @@
1
+ const path = require('path');
2
+
3
+ function isWindowsAbsolutePath(input = '') {
4
+ return path.win32.isAbsolute(String(input || '')) || /^[a-zA-Z]:[\\/]/.test(String(input || ''));
5
+ }
6
+
7
+ function normalizeSafeRelativePath(input, label = 'path', options = {}) {
8
+ const {
9
+ allowEmpty = false,
10
+ allowHiddenSegments = false,
11
+ allowBackslash = true
12
+ } = options;
13
+
14
+ const rawInput = String(input ?? '').trim();
15
+ if (!rawInput) {
16
+ if (allowEmpty) return '';
17
+ throw new Error(`Invalid ${label}`);
18
+ }
19
+ if (rawInput.includes('\0')) {
20
+ throw new Error(`Invalid ${label}`);
21
+ }
22
+ if (path.isAbsolute(rawInput) || isWindowsAbsolutePath(rawInput) || rawInput.startsWith('//') || rawInput.startsWith('\\\\')) {
23
+ throw new Error(`Invalid ${label}`);
24
+ }
25
+ if (!allowBackslash && rawInput.includes('\\')) {
26
+ throw new Error(`Invalid ${label}`);
27
+ }
28
+
29
+ const normalizedInput = rawInput.replace(/\\/g, '/');
30
+ const normalized = path.posix.normalize(normalizedInput).replace(/^(\.\/)+/, '');
31
+ if (!normalized || normalized === '.') {
32
+ if (allowEmpty) return '';
33
+ throw new Error(`Invalid ${label}`);
34
+ }
35
+ if (
36
+ normalized === '..' ||
37
+ normalized.startsWith('../') ||
38
+ normalized.includes('/../') ||
39
+ path.posix.isAbsolute(normalized)
40
+ ) {
41
+ throw new Error(`Invalid ${label}`);
42
+ }
43
+
44
+ const segments = normalized.split('/').filter(Boolean);
45
+ if (!allowHiddenSegments && segments.some(segment => segment.startsWith('.'))) {
46
+ throw new Error(`Invalid ${label}`);
47
+ }
48
+
49
+ return normalized;
50
+ }
51
+
52
+ function normalizeSafeFileStem(input, label = 'name', options = {}) {
53
+ const {
54
+ allowDots = true,
55
+ allowHidden = false,
56
+ pattern = allowDots
57
+ ? /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
58
+ : /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/
59
+ } = options;
60
+ const rawInput = String(input ?? '').trim();
61
+ if (!rawInput || rawInput.includes('\0')) {
62
+ throw new Error(`Invalid ${label}`);
63
+ }
64
+ if (rawInput.includes('/') || rawInput.includes('\\') || rawInput.includes('..')) {
65
+ throw new Error(`Invalid ${label}`);
66
+ }
67
+ if (!allowHidden && rawInput.startsWith('.')) {
68
+ throw new Error(`Invalid ${label}`);
69
+ }
70
+ if (pattern && !pattern.test(rawInput)) {
71
+ throw new Error(`Invalid ${label}`);
72
+ }
73
+ return rawInput;
74
+ }
75
+
76
+ function resolveInsideRoot(rootDir, relativePath, label = 'path', options = {}) {
77
+ const { allowRoot = false } = options;
78
+ const resolvedRoot = path.resolve(rootDir);
79
+ const safeRelativePath = normalizeSafeRelativePath(relativePath, label, options);
80
+ const resolvedPath = path.resolve(resolvedRoot, safeRelativePath);
81
+ const isInside = resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${path.sep}`);
82
+ if (!isInside || (!allowRoot && resolvedPath === resolvedRoot)) {
83
+ throw new Error(`${label} escapes target directory`);
84
+ }
85
+ return resolvedPath;
86
+ }
87
+
88
+ function assertInsideAllowedRoots(targetPath, roots = [], label = 'path', options = {}) {
89
+ const { allowRoot = false } = options;
90
+ const resolvedTarget = path.resolve(String(targetPath || ''));
91
+ const allowed = roots.some(root => {
92
+ if (!root) return false;
93
+ const resolvedRoot = path.resolve(root);
94
+ if (!allowRoot && resolvedTarget === resolvedRoot) return false;
95
+ return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
96
+ });
97
+ if (!allowed) {
98
+ throw new Error(`${label} escapes allowed directories`);
99
+ }
100
+ return resolvedTarget;
101
+ }
102
+
103
+ function pathHasProtectedSegment(relativePath, protectedSegments = []) {
104
+ const normalized = normalizeSafeRelativePath(relativePath, 'path', { allowHiddenSegments: true });
105
+ const segments = normalized.split('/').filter(Boolean);
106
+ return protectedSegments.some(segment => segments.includes(segment));
107
+ }
108
+
109
+ module.exports = {
110
+ assertInsideAllowedRoots,
111
+ isWindowsAbsolutePath,
112
+ normalizeSafeFileStem,
113
+ normalizeSafeRelativePath,
114
+ pathHasProtectedSegment,
115
+ resolveInsideRoot
116
+ };
@@ -45,8 +45,8 @@ const CC_SECURITY_PATH = PATHS.security;
45
45
  const LEGACY_UI_CONFIG_PATH = PATHS.uiConfig;
46
46
  const LEGACY_NOTIFY_HOOK_PATH = PATHS.notifyHook;
47
47
  const GEMINI_SETTINGS_PATH = path.join(path.dirname(NATIVE_PATHS.gemini.env), 'settings.json');
48
- const AGENT_PLATFORMS = ['claude', 'codex', 'opencode'];
49
- const COMMAND_PLATFORMS = ['claude', 'opencode'];
48
+ const AGENT_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
49
+ const COMMAND_PLATFORMS = ['claude', 'gemini', 'opencode'];
50
50
  const SKILL_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
51
51
 
52
52
  function getOpenCodeConfigPaths() {
@@ -386,14 +386,15 @@ function resolveAgentRelativePath(agent, platform) {
386
386
  return platform === 'codex' ? `${baseName}.toml` : `${baseName}.md`;
387
387
  }
388
388
 
389
- function resolveCommandRelativePath(command) {
389
+ function resolveCommandRelativePath(command, platform = 'claude') {
390
390
  if (typeof command?.path === 'string' && command.path.trim()) {
391
391
  return command.path.trim();
392
392
  }
393
+ const extension = platform === 'gemini' ? '.toml' : '.md';
393
394
  if (command?.namespace) {
394
- return path.join(command.namespace, `${command.name}.md`);
395
+ return path.join(command.namespace, `${command.name}${extension}`);
395
396
  }
396
- return command?.name ? `${command.name}.md` : null;
397
+ return command?.name ? `${command.name}${extension}` : null;
397
398
  }
398
399
 
399
400
  function pickPrimaryChannel(channels = []) {
@@ -1399,7 +1400,7 @@ async function importConfigs(importData, options = {}) {
1399
1400
  const baseDir = commandsService.userCommandsDir;
1400
1401
 
1401
1402
  for (const command of platformCommands) {
1402
- const relativePath = resolveCommandRelativePath(command);
1403
+ const relativePath = resolveCommandRelativePath(command, platform);
1403
1404
  const content = command.fullContent || command.content || buildCommandContent(command);
1404
1405
 
1405
1406
  const status = relativePath ? writeTextFile(baseDir, relativePath, content, overwrite) : 'failed';
@@ -31,8 +31,8 @@ const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
31
31
 
32
32
  const PLATFORM_SUPPORT = {
33
33
  skills: { claude: true, codex: true, gemini: true, opencode: true },
34
- commands: { claude: true, codex: true, gemini: false, opencode: true },
35
- agents: { claude: true, codex: true, gemini: false, opencode: true },
34
+ commands: { claude: true, codex: true, gemini: true, opencode: true },
35
+ agents: { claude: true, codex: true, gemini: true, opencode: true },
36
36
  plugins: { claude: true, codex: false, gemini: false, opencode: true }
37
37
  };
38
38
 
@@ -4,7 +4,7 @@
4
4
  * Manages file synchronization between cc-tool central storage and CLI directories:
5
5
  * - Claude Code: ~/.claude/{skills,commands,agents,plugins}/
6
6
  * - Codex CLI: ~/.codex/skills/, ~/.codex/prompts/
7
- * - Gemini CLI: ~/.gemini/skills/
7
+ * - Gemini CLI: ~/.gemini/{skills,commands,agents}/
8
8
  * - OpenCode CLI: ~/.config/opencode/{skills,commands,agents,plugins}/
9
9
  *
10
10
  * Config types:
@@ -19,7 +19,7 @@ const path = require('path');
19
19
  const os = require('os');
20
20
  const toml = require('toml');
21
21
  const tomlStringify = require('@iarna/toml').stringify;
22
- const { convertSkillToCodex, convertCommandToCodex } = require('./format-converter');
22
+ const { convertSkillToCodex, convertCommandToCodex, convertCommandToGemini } = require('./format-converter');
23
23
  const { PATHS, NATIVE_PATHS, HOME_DIR, ensureStorageDirMigrated } = require('../../config/paths');
24
24
 
25
25
  // Paths
@@ -53,7 +53,9 @@ const CONFIG_TYPES = {
53
53
  codexTarget: 'prompts',
54
54
  codexSupported: true,
55
55
  convertForCodex: true,
56
- geminiSupported: false,
56
+ geminiTarget: 'commands',
57
+ geminiExtension: '.toml',
58
+ geminiSupported: true,
57
59
  opencodeTarget: 'commands',
58
60
  opencodeLegacyTarget: 'command',
59
61
  opencodeSupported: true
@@ -63,7 +65,8 @@ const CONFIG_TYPES = {
63
65
  extension: '.md',
64
66
  claudeTarget: 'agents',
65
67
  codexSupported: true,
66
- geminiSupported: false,
68
+ geminiTarget: 'agents',
69
+ geminiSupported: true,
67
70
  opencodeTarget: 'agents',
68
71
  opencodeLegacyTarget: 'agent',
69
72
  opencodeSupported: true
@@ -384,7 +387,7 @@ class ConfigSyncManager {
384
387
 
385
388
  /**
386
389
  * Sync a config item to Gemini CLI
387
- * Currently only skills are supported
390
+ * Supports skills, commands, and agents.
388
391
  * @param {string} type - Config type
389
392
  * @param {string} name - Item name
390
393
  * @returns {Object} Result with success status
@@ -411,10 +414,21 @@ class ConfigSyncManager {
411
414
  }
412
415
 
413
416
  try {
414
- const targetPath = path.join(this.geminiDir, config.geminiTarget, safeName);
417
+ const targetPath = this._getGeminiTargetPath(config, safeName);
415
418
  this._ensureDir(path.dirname(targetPath));
416
- this._copyDirRecursive(sourcePath, targetPath);
417
- console.log(`[ConfigSyncManager] Synced ${type}/${name} to Gemini (directory)`);
419
+ if (config.isDirectory) {
420
+ this._copyDirRecursive(sourcePath, targetPath);
421
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Gemini (directory)`);
422
+ } else if (type === 'commands') {
423
+ const content = fs.readFileSync(sourcePath, 'utf-8');
424
+ const result = convertCommandToGemini(content);
425
+ fs.writeFileSync(targetPath, result.content, 'utf-8');
426
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Gemini (command TOML)`);
427
+ return { success: true, target: targetPath, warnings: result.warnings || [] };
428
+ } else {
429
+ this._copyFile(sourcePath, targetPath);
430
+ console.log(`[ConfigSyncManager] Synced ${type}/${name} to Gemini (file)`);
431
+ }
418
432
  return { success: true, target: targetPath };
419
433
  } catch (err) {
420
434
  console.error(`[ConfigSyncManager] Sync to Gemini failed:`, err.message);
@@ -443,15 +457,21 @@ class ConfigSyncManager {
443
457
  return { success: true, skipped: true, reason: 'Not supported by Gemini' };
444
458
  }
445
459
 
446
- const targetPath = path.join(this.geminiDir, config.geminiTarget, safeName);
460
+ const targetPath = this._getGeminiTargetPath(config, safeName);
447
461
  if (!fs.existsSync(targetPath)) {
448
462
  console.log(`[ConfigSyncManager] Target not found (already removed): ${targetPath}`);
449
463
  return { success: true, message: 'Already removed' };
450
464
  }
451
465
 
452
466
  try {
453
- this._removeRecursive(targetPath);
454
- console.log(`[ConfigSyncManager] Removed ${type}/${name} from Gemini (directory)`);
467
+ if (config.isDirectory) {
468
+ this._removeRecursive(targetPath);
469
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Gemini (directory)`);
470
+ } else {
471
+ fs.unlinkSync(targetPath);
472
+ console.log(`[ConfigSyncManager] Removed ${type}/${name} from Gemini (file)`);
473
+ this._cleanupEmptyParents(path.dirname(targetPath), path.join(this.geminiDir, config.geminiTarget));
474
+ }
455
475
  return { success: true };
456
476
  } catch (err) {
457
477
  console.error(`[ConfigSyncManager] Remove from Gemini failed:`, err.message);
@@ -616,6 +636,9 @@ class ConfigSyncManager {
616
636
  const result = this.syncToGemini(type, name);
617
637
  if (result.success && !result.skipped) {
618
638
  results.synced.push({ type, name, platform: 'gemini' });
639
+ if (result.warnings && result.warnings.length > 0) {
640
+ results.warnings.push({ type, name, platform: 'gemini', warnings: result.warnings });
641
+ }
619
642
  } else if (!result.success) {
620
643
  results.errors.push({ type, name, platform: 'gemini', error: result.error });
621
644
  }
@@ -762,6 +785,17 @@ class ConfigSyncManager {
762
785
  return modernDir;
763
786
  }
764
787
 
788
+ _getGeminiTargetPath(config, safeName) {
789
+ const targetName = config.geminiExtension
790
+ ? safeName.replace(new RegExp(`${this._escapeRegExp(path.extname(safeName))}$`, 'i'), config.geminiExtension)
791
+ : safeName;
792
+ return path.join(this.geminiDir, config.geminiTarget, targetName);
793
+ }
794
+
795
+ _escapeRegExp(value) {
796
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
797
+ }
798
+
765
799
  /**
766
800
  * Recursively remove a file or directory
767
801
  */
@@ -12,7 +12,7 @@ const { AgentsService } = require('./agents-service');
12
12
  const { CommandsService } = require('./commands-service');
13
13
  const { SkillService } = require('./skill-service');
14
14
  const { PluginsService } = require('./plugins-service');
15
- const { convertCommandToCodex } = require('./format-converter');
15
+ const { convertCommandToCodex, convertCommandToGemini } = require('./format-converter');
16
16
  const mcpService = require('./mcp-service');
17
17
  const promptsService = require('./prompts-service');
18
18
  const pluginsService = new PluginsService();
@@ -499,6 +499,10 @@ function generateCommandContent(command) {
499
499
  return lines.join('\n') + '\n\n' + (command.body || '');
500
500
  }
501
501
 
502
+ function buildTemplateCommandFileName(commandName, format) {
503
+ return `${commandName}.${format === 'gemini' ? 'toml' : 'md'}`;
504
+ }
505
+
502
506
  /**
503
507
  * 转换为 OpenCode MCP 结构(local/remote)
504
508
  */
@@ -588,6 +592,9 @@ function applyTemplateToProject(targetDir, templateId, options = {}) {
588
592
  if (aiConfigTypes.includes('opencode')) {
589
593
  agentTargets.push({ baseDir: path.join(targetDir, '.opencode', 'agents'), prefix: '.opencode/agents' });
590
594
  }
595
+ if (aiConfigTypes.includes('gemini')) {
596
+ agentTargets.push({ baseDir: path.join(targetDir, '.gemini', 'agents'), prefix: '.gemini/agents' });
597
+ }
591
598
 
592
599
  for (const target of agentTargets) {
593
600
  ensureDir(target.baseDir);
@@ -606,9 +613,6 @@ function applyTemplateToProject(targetDir, templateId, options = {}) {
606
613
  if (aiConfigTypes.includes('codex')) {
607
614
  pushSkipped(results.skipped, 'agent', fileName, 'Codex agents 仅支持用户级配置,项目目录应用时已跳过');
608
615
  }
609
- if (aiConfigTypes.includes('gemini')) {
610
- pushSkipped(results.skipped, 'agent', fileName, 'Gemini 不支持 agents,已跳过');
611
- }
612
616
  if (written) {
613
617
  results.agents.applied++;
614
618
  }
@@ -626,6 +630,9 @@ function applyTemplateToProject(targetDir, templateId, options = {}) {
626
630
  if (aiConfigTypes.includes('opencode')) {
627
631
  commandTargets.push({ baseDir: path.join(targetDir, '.opencode', 'commands'), prefix: '.opencode/commands', format: 'claude' });
628
632
  }
633
+ if (aiConfigTypes.includes('gemini')) {
634
+ commandTargets.push({ baseDir: path.join(targetDir, '.gemini', 'commands'), prefix: '.gemini/commands', format: 'gemini' });
635
+ }
629
636
 
630
637
  for (const target of commandTargets) {
631
638
  ensureDir(target.baseDir);
@@ -638,23 +645,21 @@ function applyTemplateToProject(targetDir, templateId, options = {}) {
638
645
  let content = generateCommandContent(command);
639
646
  if (target.format === 'codex') {
640
647
  content = convertCommandToCodex(content).content;
648
+ } else if (target.format === 'gemini') {
649
+ content = convertCommandToGemini(content).content;
641
650
  }
642
651
  const targetCmdDir = command.namespace
643
652
  ? path.join(target.baseDir, command.namespace)
644
653
  : target.baseDir;
645
654
  ensureDir(targetCmdDir);
646
- const filePath = path.join(targetCmdDir, `${commandName}.md`);
655
+ const filePath = path.join(targetCmdDir, buildTemplateCommandFileName(commandName, target.format));
647
656
  fs.writeFileSync(filePath, content, 'utf-8');
648
657
  const relativePath = command.namespace
649
- ? `${target.prefix}/${command.namespace}/${commandName}.md`
650
- : `${target.prefix}/${commandName}.md`;
658
+ ? `${target.prefix}/${command.namespace}/${buildTemplateCommandFileName(commandName, target.format)}`
659
+ : `${target.prefix}/${buildTemplateCommandFileName(commandName, target.format)}`;
651
660
  results.commands.files.push(relativePath);
652
661
  written = true;
653
662
  }
654
-
655
- if (aiConfigTypes.includes('gemini')) {
656
- pushSkipped(results.skipped, 'command', commandName, 'Gemini 不支持 commands,已跳过');
657
- }
658
663
  if (written) {
659
664
  results.commands.applied++;
660
665
  }
@@ -795,6 +800,7 @@ function previewTemplateApplication(targetDir, templateId, options = {}) {
795
800
  const agentPrefixes = [];
796
801
  if (aiConfigTypes.includes('claude')) agentPrefixes.push('.claude/agents');
797
802
  if (aiConfigTypes.includes('opencode')) agentPrefixes.push('.opencode/agents');
803
+ if (aiConfigTypes.includes('gemini')) agentPrefixes.push('.gemini/agents');
798
804
 
799
805
  for (const agent of template.agents) {
800
806
  const fileName = resolveItemName(agent.fileName, agent.name, 'agent').toLowerCase().replace(/\s+/g, '-');
@@ -812,9 +818,6 @@ function previewTemplateApplication(targetDir, templateId, options = {}) {
812
818
  if (aiConfigTypes.includes('codex')) {
813
819
  pushSkipped(preview.skipped, 'agent', fileName, 'Codex agents 仅支持用户级配置,项目目录预览时已跳过');
814
820
  }
815
- if (aiConfigTypes.includes('gemini')) {
816
- pushSkipped(preview.skipped, 'agent', fileName, 'Gemini 不支持 agents,已跳过');
817
- }
818
821
  if (applicable) {
819
822
  preview.summary.agents++;
820
823
  }
@@ -826,14 +829,16 @@ function previewTemplateApplication(targetDir, templateId, options = {}) {
826
829
  if (aiConfigTypes.includes('claude')) commandPrefixes.push('.claude/commands');
827
830
  if (aiConfigTypes.includes('codex')) commandPrefixes.push('.codex/prompts');
828
831
  if (aiConfigTypes.includes('opencode')) commandPrefixes.push('.opencode/commands');
832
+ if (aiConfigTypes.includes('gemini')) commandPrefixes.push('.gemini/commands');
829
833
 
830
834
  for (const command of template.commands) {
831
835
  const commandName = resolveItemName(command.name, null, 'command');
832
836
  let applicable = false;
833
837
  for (const prefix of commandPrefixes) {
838
+ const extension = prefix === '.gemini/commands' ? 'toml' : 'md';
834
839
  const relativePath = command.namespace
835
- ? `${prefix}/${command.namespace}/${commandName}.md`
836
- : `${prefix}/${commandName}.md`;
840
+ ? `${prefix}/${command.namespace}/${commandName}.${extension}`
841
+ : `${prefix}/${commandName}.${extension}`;
837
842
  const fullPath = path.join(targetDir, relativePath);
838
843
  if (fs.existsSync(fullPath)) {
839
844
  preview.willOverwrite.push(relativePath);
@@ -842,9 +847,6 @@ function previewTemplateApplication(targetDir, templateId, options = {}) {
842
847
  }
843
848
  applicable = true;
844
849
  }
845
- if (aiConfigTypes.includes('gemini')) {
846
- pushSkipped(preview.skipped, 'command', commandName, 'Gemini 不支持 commands,已跳过');
847
- }
848
850
  if (applicable) {
849
851
  preview.summary.commands++;
850
852
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * 格式转换服务
3
3
  *
4
- * 支持 Claude Code ↔ Codex CLI 格式互转
4
+ * 支持 Claude Code ↔ Codex CLI/Gemini CLI 格式互转
5
5
  * - Skills: SKILL.md 格式转换
6
6
  * - Commands/Prompts: 命令/提示格式转换
7
7
  */
8
8
 
9
+ const tomlStringify = require('@iarna/toml').stringify;
10
+
9
11
  // Codex CLI 限制
10
12
  const CODEX_LIMITS = {
11
13
  skillName: 100,
@@ -268,8 +270,9 @@ function escapeYamlString(str) {
268
270
  * 检测 Skill 格式
269
271
  * @returns {'claude' | 'codex' | 'unknown'}
270
272
  */
271
- function detectSkillFormat(content) {
273
+ function detectSkillFormat(content, options = {}) {
272
274
  const { frontmatter } = parseFrontmatter(content);
275
+ const platform = options.platform || '';
273
276
 
274
277
  // Codex 特征: 有 metadata 对象
275
278
  if (frontmatter.metadata && typeof frontmatter.metadata === 'object') {
@@ -281,6 +284,11 @@ function detectSkillFormat(content) {
281
284
  return 'claude';
282
285
  }
283
286
 
287
+ // 在 Codex 平台上下文中,metadata 是可选的;name + description 已足够作为 Codex skill。
288
+ if (platform === 'codex' && frontmatter.name && frontmatter.description) {
289
+ return 'codex';
290
+ }
291
+
284
292
  // 两者都有 name 和 description,无法区分时默认 claude
285
293
  if (frontmatter.name && frontmatter.description) {
286
294
  return 'claude';
@@ -434,6 +442,35 @@ function convertCommandToCodex(claudeContent, options = {}) {
434
442
  };
435
443
  }
436
444
 
445
+ /**
446
+ * 转换 Command: Claude Code → Gemini CLI (Custom Command TOML)
447
+ */
448
+ function convertCommandToGemini(claudeContent, options = {}) {
449
+ const { frontmatter, body } = parseFrontmatter(claudeContent);
450
+ const warnings = [];
451
+
452
+ const commandConfig = {
453
+ prompt: body || ''
454
+ };
455
+
456
+ if (frontmatter.description) {
457
+ commandConfig.description = frontmatter.description;
458
+ }
459
+
460
+ const unsupportedFields = ['allowed-tools', 'model', 'context', 'agent', 'hooks'];
461
+ for (const field of unsupportedFields) {
462
+ if (frontmatter[field]) {
463
+ warnings.push(`${field} 字段在 Gemini commands 中不支持,已忽略`);
464
+ }
465
+ }
466
+
467
+ return {
468
+ content: tomlStringify(commandConfig),
469
+ warnings,
470
+ format: 'gemini'
471
+ };
472
+ }
473
+
437
474
  /**
438
475
  * 转换 Command: Codex CLI (Custom Prompt) → Claude Code
439
476
  */
@@ -528,9 +565,9 @@ function convertCommandsBatch(commands, targetFormat) {
528
565
  /**
529
566
  * 解析 Skill 内容(支持双格式)
530
567
  */
531
- function parseSkillContent(content) {
568
+ function parseSkillContent(content, options = {}) {
532
569
  const { frontmatter, body } = parseFrontmatter(content);
533
- const format = detectSkillFormat(content);
570
+ const format = detectSkillFormat(content, options);
534
571
 
535
572
  const result = {
536
573
  name: frontmatter.name || '',
@@ -606,6 +643,7 @@ module.exports = {
606
643
 
607
644
  // Commands 转换
608
645
  convertCommandToCodex,
646
+ convertCommandToGemini,
609
647
  convertCommandToClaude,
610
648
  convertCommandsBatch,
611
649
 
@@ -6,6 +6,21 @@ const { resolveChannelWebsiteUrl } = require('../../config/channel-preset-websit
6
6
  const { clearNativeOAuth } = require('./native-oauth-adapters');
7
7
  const { normalizeGatewaySourceType } = require('./base/proxy-utils');
8
8
 
9
+ const GEMINI_API_FORMATS = new Set(['gemini_api', 'vertex_ai_v1']);
10
+
11
+ function clearChannelBalanceCache(channel) {
12
+ try {
13
+ require('./channel-balance').clearChannelBalanceCache('gemini', channel);
14
+ } catch (_) {
15
+ // Balance cache invalidation is best-effort and should not block channel updates.
16
+ }
17
+ }
18
+
19
+ function normalizeGeminiApiFormat(value) {
20
+ const normalized = String(value || '').trim().toLowerCase();
21
+ return GEMINI_API_FORMATS.has(normalized) ? normalized : 'gemini_api';
22
+ }
23
+
9
24
  /**
10
25
  * Gemini 渠道管理服务(多渠道架构)
11
26
  *
@@ -118,6 +133,7 @@ function loadChannels() {
118
133
  balanceUserId: ch.balanceUserId || null,
119
134
  modelRedirects: ch.modelRedirects || [],
120
135
  speedTestModel: ch.speedTestModel || null,
136
+ apiFormat: normalizeGeminiApiFormat(ch.apiFormat),
121
137
  gatewaySourceType: normalizeGatewaySourceType(ch.gatewaySourceType, 'gemini')
122
138
  };
123
139
  normalized.websiteUrl = resolveChannelWebsiteUrl('gemini', normalized);
@@ -169,6 +185,7 @@ function initializeFromEnv() {
169
185
  maxConcurrency: null,
170
186
  balanceToken: '',
171
187
  balanceUserId: null,
188
+ apiFormat: 'gemini_api',
172
189
  gatewaySourceType: 'gemini',
173
190
  createdAt: Date.now(),
174
191
  updatedAt: Date.now()
@@ -230,6 +247,7 @@ function createChannel(name, baseUrl, apiKey, model = 'gemini-2.5-pro', extraCon
230
247
  balanceUserId: extraConfig.balanceUserId || null,
231
248
  modelRedirects: extraConfig.modelRedirects || [],
232
249
  speedTestModel: extraConfig.speedTestModel || null,
250
+ apiFormat: normalizeGeminiApiFormat(extraConfig.apiFormat),
233
251
  gatewaySourceType: normalizeGatewaySourceType(extraConfig.gatewaySourceType, 'gemini'),
234
252
  createdAt: Date.now(),
235
253
  updatedAt: Date.now()
@@ -275,6 +293,7 @@ function updateChannel(channelId, updates) {
275
293
  createdAt: oldChannel.createdAt, // 保持创建时间
276
294
  modelRedirects: updates.modelRedirects !== undefined ? updates.modelRedirects : (oldChannel.modelRedirects || []),
277
295
  speedTestModel: updates.speedTestModel !== undefined ? updates.speedTestModel : (oldChannel.speedTestModel || null),
296
+ apiFormat: updates.apiFormat !== undefined ? normalizeGeminiApiFormat(updates.apiFormat) : normalizeGeminiApiFormat(oldChannel.apiFormat),
278
297
  balanceToken: updates.balanceToken !== undefined ? updates.balanceToken : (oldChannel.balanceToken || ''),
279
298
  balanceUserId: updates.balanceUserId !== undefined ? updates.balanceUserId : (oldChannel.balanceUserId || null),
280
299
  gatewaySourceType: normalizeGatewaySourceType(merged.gatewaySourceType, 'gemini'),
@@ -300,6 +319,9 @@ function updateChannel(channelId, updates) {
300
319
  }
301
320
 
302
321
  saveChannels(data);
322
+ if (oldChannel.enabled === false && nextChannel.enabled !== false) {
323
+ clearChannelBalanceCache(nextChannel);
324
+ }
303
325
 
304
326
  // Only sync .env when proxy is OFF.
305
327
  // In dynamic switching mode, defer local config writes until proxy stop.
@@ -329,6 +351,7 @@ function applyChannelToSettings(channelId, channels = null) {
329
351
  throw new Error('Channel not found');
330
352
  }
331
353
 
354
+ const wasEnabled = channel.enabled !== false;
332
355
  // In single-channel mode, only this channel should be enabled
333
356
  data.channels.forEach(ch => {
334
357
  ch.enabled = ch.id === channelId;
@@ -337,6 +360,9 @@ function applyChannelToSettings(channelId, channels = null) {
337
360
  if (!channels) {
338
361
  saveChannels(data);
339
362
  }
363
+ if (!wasEnabled) {
364
+ clearChannelBalanceCache(channel);
365
+ }
340
366
 
341
367
  clearNativeOAuth('gemini');
342
368
 
@@ -502,5 +528,8 @@ module.exports = {
502
528
  isProxyConfig,
503
529
  getGeminiDir,
504
530
  applyChannelToSettings,
505
- disableAllChannels
531
+ disableAllChannels,
532
+ _test: {
533
+ normalizeGeminiApiFormat
534
+ }
506
535
  };