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
@@ -1,24 +1,32 @@
1
1
  /**
2
2
  * Commands 服务
3
3
  *
4
- * 管理 Claude/OpenCode 自定义命令的 CRUD 操作
4
+ * 管理 Claude/Codex/Gemini/OpenCode 自定义命令的 CRUD 操作
5
5
  * 支持从 GitHub 仓库扫描和安装命令
6
6
  */
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const toml = require('toml');
11
+ const tomlStringify = require('@iarna/toml').stringify;
10
12
  const { RepoScannerBase } = require('./repo-scanner-base');
11
13
  const { NATIVE_PATHS } = require('../../config/paths');
12
14
  const {
13
- parseCommandContent,
14
15
  parseFrontmatter
15
16
  } = require('./format-converter');
17
+ const {
18
+ normalizeSafeFileStem,
19
+ normalizeSafeRelativePath,
20
+ resolveInsideRoot
21
+ } = require('./config-artifact-paths');
16
22
 
17
23
  // 默认仓库源
18
24
  const DEFAULT_REPOS = [];
19
- const SUPPORTED_PLATFORMS = ['claude', 'opencode'];
25
+ const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
20
26
  const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
21
27
  const CLAUDE_COMMANDS_DIR = path.join(path.dirname(NATIVE_PATHS.claude.settings), 'commands');
28
+ const CODEX_COMMANDS_DIR = path.join(path.dirname(NATIVE_PATHS.codex.config), 'commands');
29
+ const GEMINI_COMMANDS_DIR = path.join(NATIVE_PATHS.gemini.dir, 'commands');
22
30
 
23
31
  const PLATFORM_CONFIG = {
24
32
  claude: {
@@ -26,6 +34,11 @@ const PLATFORM_CONFIG = {
26
34
  projectCommandsDir: (projectPath) => path.join(projectPath, '.claude', 'commands'),
27
35
  repoType: 'commands'
28
36
  },
37
+ codex: {
38
+ userCommandsDir: CODEX_COMMANDS_DIR,
39
+ projectCommandsDir: (projectPath) => path.join(projectPath, '.codex', 'commands'),
40
+ repoType: 'codex-commands'
41
+ },
29
42
  opencode: {
30
43
  userCommandsDir: path.join(OPENCODE_CONFIG_DIR, 'commands'),
31
44
  legacyUserCommandsDir: path.join(OPENCODE_CONFIG_DIR, 'command'),
@@ -38,6 +51,12 @@ const PLATFORM_CONFIG = {
38
51
  return modern;
39
52
  },
40
53
  repoType: 'opencode-commands'
54
+ },
55
+ gemini: {
56
+ userCommandsDir: GEMINI_COMMANDS_DIR,
57
+ projectCommandsDir: (projectPath) => path.join(projectPath, '.gemini', 'commands'),
58
+ repoType: 'gemini-commands',
59
+ fileExtension: '.toml'
41
60
  }
42
61
  };
43
62
 
@@ -86,11 +105,75 @@ function generateCommandFrontmatter(data) {
86
105
  return lines.join('\n');
87
106
  }
88
107
 
108
+ function getCommandFileExtension(platform) {
109
+ return PLATFORM_CONFIG[platform]?.fileExtension || '.md';
110
+ }
111
+
112
+ function getCommandTargetName(name, platform) {
113
+ return `${name}${getCommandFileExtension(platform)}`;
114
+ }
115
+
116
+ function normalizeCommandName(name) {
117
+ if (!name || !String(name).trim()) {
118
+ throw new Error('命令名称不能为空');
119
+ }
120
+
121
+ try {
122
+ return normalizeSafeFileStem(name, 'command name', {
123
+ allowDots: false,
124
+ pattern: /^[a-zA-Z0-9_-]+$/
125
+ });
126
+ } catch {
127
+ throw new Error('命令名只能包含字母、数字、横杠和下划线');
128
+ }
129
+ }
130
+
131
+ function normalizeCommandNamespace(namespace) {
132
+ return normalizeSafeRelativePath(namespace || '', 'command namespace', {
133
+ allowEmpty: true,
134
+ allowHiddenSegments: false
135
+ });
136
+ }
137
+
138
+ function buildCommandRelativePath(name, namespace, platform) {
139
+ const safeName = normalizeCommandName(name);
140
+ const safeNamespace = normalizeCommandNamespace(namespace);
141
+ const fileName = getCommandTargetName(safeName, platform);
142
+ return safeNamespace ? path.posix.join(safeNamespace, fileName) : fileName;
143
+ }
144
+
145
+ function parseGeminiCommandToml(content) {
146
+ try {
147
+ const parsed = toml.parse(content);
148
+ return {
149
+ description: parsed.description || '',
150
+ body: parsed.prompt || ''
151
+ };
152
+ } catch (err) {
153
+ return {
154
+ description: '',
155
+ body: content,
156
+ parseError: err.message
157
+ };
158
+ }
159
+ }
160
+
161
+ function generateGeminiCommandToml({ description, body }) {
162
+ const data = {
163
+ prompt: body || ''
164
+ };
165
+ if (description) {
166
+ data.description = description;
167
+ }
168
+ return tomlStringify(data);
169
+ }
170
+
89
171
  /**
90
172
  * 递归扫描目录获取命令文件
91
173
  */
92
- function scanCommandsDir(dir, basePath, scope) {
174
+ function scanCommandsDir(dir, basePath, scope, platform = 'claude') {
93
175
  const commands = [];
176
+ const fileExtension = getCommandFileExtension(platform);
94
177
 
95
178
  if (!fs.existsSync(dir)) {
96
179
  return commands;
@@ -104,17 +187,19 @@ function scanCommandsDir(dir, basePath, scope) {
104
187
 
105
188
  if (entry.isDirectory() && !entry.name.startsWith('.')) {
106
189
  // 递归扫描子目录
107
- const subCommands = scanCommandsDir(fullPath, basePath, scope);
190
+ const subCommands = scanCommandsDir(fullPath, basePath, scope, platform);
108
191
  commands.push(...subCommands);
109
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
192
+ } else if (entry.isFile() && entry.name.endsWith(fileExtension)) {
110
193
  // 解析命令文件
111
194
  try {
112
195
  const content = fs.readFileSync(fullPath, 'utf-8');
113
- const { frontmatter, body } = parseFrontmatter(content);
196
+ const parsed = platform === 'gemini'
197
+ ? { frontmatter: {}, body: '', gemini: parseGeminiCommandToml(content) }
198
+ : { ...parseFrontmatter(content), gemini: null };
114
199
 
115
200
  // 计算相对路径和命令名
116
201
  const relativePath = path.relative(basePath, fullPath);
117
- const commandName = entry.name.replace(/\.md$/, '');
202
+ const commandName = entry.name.slice(0, -fileExtension.length);
118
203
  const namespace = path.dirname(relativePath);
119
204
 
120
205
  commands.push({
@@ -123,13 +208,14 @@ function scanCommandsDir(dir, basePath, scope) {
123
208
  scope,
124
209
  path: relativePath,
125
210
  fullPath,
126
- description: frontmatter.description || '',
127
- allowedTools: frontmatter['allowed-tools'] || '',
128
- argumentHint: frontmatter['argument-hint'] || '',
129
- agent: frontmatter.agent || '',
130
- model: frontmatter.model || '',
131
- subtask: frontmatter.subtask || '',
132
- body,
211
+ description: parsed.gemini?.description || parsed.frontmatter.description || '',
212
+ allowedTools: parsed.frontmatter['allowed-tools'] || '',
213
+ argumentHint: parsed.frontmatter['argument-hint'] || '',
214
+ agent: parsed.frontmatter.agent || '',
215
+ model: parsed.frontmatter.model || '',
216
+ subtask: parsed.frontmatter.subtask || '',
217
+ body: parsed.gemini?.body ?? parsed.body,
218
+ parseError: parsed.gemini?.parseError || '',
133
219
  fullContent: content,
134
220
  updatedAt: fs.statSync(fullPath).mtime.getTime()
135
221
  });
@@ -150,13 +236,15 @@ function scanCommandsDir(dir, basePath, scope) {
150
236
  */
151
237
  class CommandsRepoScanner extends RepoScannerBase {
152
238
  constructor(platform, installDir) {
239
+ const normalizedPlatform = normalizePlatform(platform);
153
240
  super({
154
- type: PLATFORM_CONFIG[platform]?.repoType || 'commands',
241
+ type: PLATFORM_CONFIG[normalizedPlatform]?.repoType || 'commands',
155
242
  installDir,
156
243
  markerFile: null, // 直接扫描 .md 文件
157
- fileExtension: '.md',
244
+ fileExtension: getCommandFileExtension(normalizedPlatform),
158
245
  defaultRepos: DEFAULT_REPOS
159
246
  });
247
+ this.platform = normalizedPlatform;
160
248
  }
161
249
 
162
250
  /**
@@ -166,12 +254,15 @@ class CommandsRepoScanner extends RepoScannerBase {
166
254
  try {
167
255
  // 计算相对路径
168
256
  const relativePath = baseDir ? file.path.slice(baseDir.length + 1) : file.path;
169
- const fileName = path.basename(file.path, '.md');
257
+ const fileExtension = getCommandFileExtension(this.platform);
258
+ const fileName = path.basename(file.path, fileExtension);
170
259
  const namespace = path.dirname(relativePath);
171
260
 
172
261
  // 获取文件内容
173
262
  const content = await this.fetchRawContent(repo, file.path);
174
- const { frontmatter, body } = this.parseFrontmatter(content);
263
+ const parsed = this.platform === 'gemini'
264
+ ? { frontmatter: {}, body: '', gemini: parseGeminiCommandToml(content) }
265
+ : { ...this.parseFrontmatter(content), gemini: null };
175
266
 
176
267
  return {
177
268
  key: `${repo.owner}/${repo.name}:${relativePath}`,
@@ -180,13 +271,14 @@ class CommandsRepoScanner extends RepoScannerBase {
180
271
  scope: 'remote',
181
272
  path: relativePath,
182
273
  repoPath: file.path,
183
- description: frontmatter.description || '',
184
- allowedTools: frontmatter['allowed-tools'] || '',
185
- argumentHint: frontmatter['argument-hint'] || '',
186
- agent: frontmatter.agent || '',
187
- model: frontmatter.model || '',
188
- subtask: frontmatter.subtask || '',
189
- body,
274
+ description: parsed.gemini?.description || parsed.frontmatter.description || '',
275
+ allowedTools: parsed.frontmatter['allowed-tools'] || '',
276
+ argumentHint: parsed.frontmatter['argument-hint'] || '',
277
+ agent: parsed.frontmatter.agent || '',
278
+ model: parsed.frontmatter.model || '',
279
+ subtask: parsed.frontmatter.subtask || '',
280
+ body: parsed.gemini?.body ?? parsed.body,
281
+ parseError: parsed.gemini?.parseError || '',
190
282
  fullContent: content,
191
283
  installed: this.isInstalled(relativePath),
192
284
  readmeUrl: `https://github.com/${repo.owner}/${repo.name}/blob/${repo.branch}/${file.path}`,
@@ -205,8 +297,13 @@ class CommandsRepoScanner extends RepoScannerBase {
205
297
  * 检查命令是否已安装
206
298
  */
207
299
  isInstalled(relativePath) {
208
- const fullPath = path.join(this.installDir, relativePath);
209
- return fs.existsSync(fullPath);
300
+ try {
301
+ const safeRelativePath = normalizeSafeRelativePath(relativePath, 'command path');
302
+ const fullPath = resolveInsideRoot(this.installDir, safeRelativePath, 'Command path');
303
+ return fs.existsSync(fullPath);
304
+ } catch {
305
+ return false;
306
+ }
210
307
  }
211
308
 
212
309
  /**
@@ -257,6 +354,44 @@ class CommandsService {
257
354
  return this.projectCommandsDir(projectPath);
258
355
  }
259
356
 
357
+ _getBaseDir(scope, projectPath = null) {
358
+ return scope === 'user'
359
+ ? this.userCommandsDir
360
+ : this.getProjectCommandsDir(projectPath);
361
+ }
362
+
363
+ _resolveCommandPath(baseDir, name, namespace = null) {
364
+ if (!baseDir) {
365
+ throw new Error('项目路径不能为空');
366
+ }
367
+ const relativePath = buildCommandRelativePath(name, namespace, this.platform);
368
+ const fullPath = resolveInsideRoot(baseDir, relativePath, 'Command path');
369
+ return { relativePath, fullPath };
370
+ }
371
+
372
+ _generateCommandContent({ description, allowedTools, argumentHint, agent, model, subtask, body }) {
373
+ if (this.platform === 'gemini') {
374
+ return generateGeminiCommandToml({ description, body });
375
+ }
376
+
377
+ const frontmatterData = {};
378
+ if (description) frontmatterData.description = description;
379
+ if (this.platform !== 'opencode') {
380
+ if (allowedTools) frontmatterData['allowed-tools'] = allowedTools;
381
+ if (argumentHint) frontmatterData['argument-hint'] = argumentHint;
382
+ }
383
+ if (agent) frontmatterData.agent = agent;
384
+ if (model) frontmatterData.model = model;
385
+ if (typeof subtask === 'boolean') frontmatterData.subtask = subtask;
386
+
387
+ let content = '';
388
+ if (Object.keys(frontmatterData).length > 0) {
389
+ content = generateCommandFrontmatter(frontmatterData) + '\n\n';
390
+ }
391
+ content += body || '';
392
+ return content;
393
+ }
394
+
260
395
  /**
261
396
  * 获取所有命令列表
262
397
  * @param {string} projectPath - 项目路径(可选,用于获取项目级命令)
@@ -265,13 +400,13 @@ class CommandsService {
265
400
  const commands = [];
266
401
 
267
402
  // 获取用户级命令
268
- const userCommands = scanCommandsDir(this.userCommandsDir, this.userCommandsDir, 'user');
403
+ const userCommands = scanCommandsDir(this.userCommandsDir, this.userCommandsDir, 'user', this.platform);
269
404
  commands.push(...userCommands);
270
405
 
271
406
  // 获取项目级命令(如果提供了项目路径)
272
407
  if (projectPath) {
273
408
  const projectCommandsDir = this.getProjectCommandsDir(projectPath);
274
- const projectCommands = scanCommandsDir(projectCommandsDir, projectCommandsDir, 'project');
409
+ const projectCommands = scanCommandsDir(projectCommandsDir, projectCommandsDir, 'project', this.platform);
275
410
  commands.push(...projectCommands);
276
411
  }
277
412
 
@@ -336,36 +471,34 @@ class CommandsService {
336
471
  * 获取单个命令详情
337
472
  */
338
473
  getCommand(name, scope, projectPath = null, namespace = null) {
339
- const baseDir = scope === 'user'
340
- ? this.userCommandsDir
341
- : this.getProjectCommandsDir(projectPath);
342
-
343
- const relativePath = namespace
344
- ? path.join(namespace, `${name}.md`)
345
- : `${name}.md`;
346
-
347
- const fullPath = path.join(baseDir, relativePath);
474
+ const safeName = normalizeCommandName(name);
475
+ const safeNamespace = normalizeCommandNamespace(namespace);
476
+ const baseDir = this._getBaseDir(scope, projectPath);
477
+ const { relativePath, fullPath } = this._resolveCommandPath(baseDir, safeName, safeNamespace);
348
478
 
349
479
  if (!fs.existsSync(fullPath)) {
350
480
  return null;
351
481
  }
352
482
 
353
483
  const content = fs.readFileSync(fullPath, 'utf-8');
354
- const { frontmatter, body } = parseFrontmatter(content);
484
+ const parsed = this.platform === 'gemini'
485
+ ? { frontmatter: {}, body: '', gemini: parseGeminiCommandToml(content) }
486
+ : { ...parseFrontmatter(content), gemini: null };
355
487
 
356
488
  return {
357
- name,
358
- namespace,
489
+ name: safeName,
490
+ namespace: safeNamespace || null,
359
491
  scope,
360
492
  path: relativePath,
361
493
  fullPath,
362
- description: frontmatter.description || '',
363
- allowedTools: frontmatter['allowed-tools'] || '',
364
- argumentHint: frontmatter['argument-hint'] || '',
365
- agent: frontmatter.agent || '',
366
- model: frontmatter.model || '',
367
- subtask: frontmatter.subtask || '',
368
- body,
494
+ description: parsed.gemini?.description || parsed.frontmatter.description || '',
495
+ allowedTools: parsed.frontmatter['allowed-tools'] || '',
496
+ argumentHint: parsed.frontmatter['argument-hint'] || '',
497
+ agent: parsed.frontmatter.agent || '',
498
+ model: parsed.frontmatter.model || '',
499
+ subtask: parsed.frontmatter.subtask || '',
500
+ body: parsed.gemini?.body ?? parsed.body,
501
+ parseError: parsed.gemini?.parseError || '',
369
502
  fullContent: content,
370
503
  updatedAt: fs.statSync(fullPath).mtime.getTime()
371
504
  };
@@ -375,104 +508,53 @@ class CommandsService {
375
508
  * 创建命令
376
509
  */
377
510
  createCommand({ name, scope, projectPath, namespace, description, allowedTools, argumentHint, agent, model, subtask, body }) {
378
- if (!name || !name.trim()) {
379
- throw new Error('命令名称不能为空');
380
- }
381
-
382
- // 验证命令名:只允许字母、数字、横杠、下划线
383
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
384
- throw new Error('命令名只能包含字母、数字、横杠和下划线');
385
- }
386
-
387
- const baseDir = scope === 'user'
388
- ? this.userCommandsDir
389
- : this.getProjectCommandsDir(projectPath);
390
-
391
- const targetDir = namespace ? path.join(baseDir, namespace) : baseDir;
511
+ const safeName = normalizeCommandName(name);
512
+ const safeNamespace = normalizeCommandNamespace(namespace);
513
+ const baseDir = this._getBaseDir(scope, projectPath);
514
+ const { fullPath: filePath } = this._resolveCommandPath(baseDir, safeName, safeNamespace);
515
+ const targetDir = path.dirname(filePath);
392
516
  ensureDir(targetDir);
393
517
 
394
- const filePath = path.join(targetDir, `${name}.md`);
395
-
396
518
  // 检查是否已存在
397
519
  if (fs.existsSync(filePath)) {
398
- throw new Error(`命令 "${name}" 已存在`);
520
+ throw new Error(`命令 "${safeName}" 已存在`);
399
521
  }
400
522
 
401
- // 生成文件内容
402
- const frontmatterData = {};
403
- if (description) frontmatterData.description = description;
404
- if (this.platform !== 'opencode') {
405
- if (allowedTools) frontmatterData['allowed-tools'] = allowedTools;
406
- if (argumentHint) frontmatterData['argument-hint'] = argumentHint;
407
- }
408
- if (agent) frontmatterData.agent = agent;
409
- if (model) frontmatterData.model = model;
410
- if (typeof subtask === 'boolean') frontmatterData.subtask = subtask;
411
-
412
- let content = '';
413
- if (Object.keys(frontmatterData).length > 0) {
414
- content = generateCommandFrontmatter(frontmatterData) + '\n\n';
415
- }
416
- content += body || '';
523
+ const content = this._generateCommandContent({ description, allowedTools, argumentHint, agent, model, subtask, body });
417
524
 
418
525
  fs.writeFileSync(filePath, content, 'utf-8');
419
526
 
420
- return this.getCommand(name, scope, projectPath, namespace);
527
+ return this.getCommand(safeName, scope, projectPath, safeNamespace);
421
528
  }
422
529
 
423
530
  /**
424
531
  * 更新命令
425
532
  */
426
533
  updateCommand({ name, scope, projectPath, namespace, description, allowedTools, argumentHint, agent, model, subtask, body }) {
427
- const baseDir = scope === 'user'
428
- ? this.userCommandsDir
429
- : this.getProjectCommandsDir(projectPath);
430
-
431
- const relativePath = namespace
432
- ? path.join(namespace, `${name}.md`)
433
- : `${name}.md`;
434
-
435
- const filePath = path.join(baseDir, relativePath);
534
+ const safeName = normalizeCommandName(name);
535
+ const safeNamespace = normalizeCommandNamespace(namespace);
536
+ const baseDir = this._getBaseDir(scope, projectPath);
537
+ const { fullPath: filePath } = this._resolveCommandPath(baseDir, safeName, safeNamespace);
436
538
 
437
539
  if (!fs.existsSync(filePath)) {
438
- throw new Error(`命令 "${name}" 不存在`);
540
+ throw new Error(`命令 "${safeName}" 不存在`);
439
541
  }
440
542
 
441
- // 生成文件内容
442
- const frontmatterData = {};
443
- if (description) frontmatterData.description = description;
444
- if (this.platform !== 'opencode') {
445
- if (allowedTools) frontmatterData['allowed-tools'] = allowedTools;
446
- if (argumentHint) frontmatterData['argument-hint'] = argumentHint;
447
- }
448
- if (agent) frontmatterData.agent = agent;
449
- if (model) frontmatterData.model = model;
450
- if (typeof subtask === 'boolean') frontmatterData.subtask = subtask;
451
-
452
- let content = '';
453
- if (Object.keys(frontmatterData).length > 0) {
454
- content = generateCommandFrontmatter(frontmatterData) + '\n\n';
455
- }
456
- content += body || '';
543
+ const content = this._generateCommandContent({ description, allowedTools, argumentHint, agent, model, subtask, body });
457
544
 
458
545
  fs.writeFileSync(filePath, content, 'utf-8');
459
546
 
460
- return this.getCommand(name, scope, projectPath, namespace);
547
+ return this.getCommand(safeName, scope, projectPath, safeNamespace);
461
548
  }
462
549
 
463
550
  /**
464
551
  * 删除命令
465
552
  */
466
553
  deleteCommand(name, scope, projectPath = null, namespace = null) {
467
- const baseDir = scope === 'user'
468
- ? this.userCommandsDir
469
- : this.getProjectCommandsDir(projectPath);
470
-
471
- const relativePath = namespace
472
- ? path.join(namespace, `${name}.md`)
473
- : `${name}.md`;
474
-
475
- const filePath = path.join(baseDir, relativePath);
554
+ const safeName = normalizeCommandName(name);
555
+ const safeNamespace = normalizeCommandNamespace(namespace);
556
+ const baseDir = this._getBaseDir(scope, projectPath);
557
+ const { fullPath: filePath } = this._resolveCommandPath(baseDir, safeName, safeNamespace);
476
558
 
477
559
  if (!fs.existsSync(filePath)) {
478
560
  return { success: false, message: '命令不存在' };
@@ -481,8 +563,8 @@ class CommandsService {
481
563
  fs.unlinkSync(filePath);
482
564
 
483
565
  // 如果目录为空,删除目录
484
- if (namespace) {
485
- const namespaceDir = path.join(baseDir, namespace);
566
+ if (safeNamespace) {
567
+ const namespaceDir = resolveInsideRoot(baseDir, safeNamespace, 'Command namespace');
486
568
  try {
487
569
  const remaining = fs.readdirSync(namespaceDir);
488
570
  if (remaining.length === 0) {
@@ -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