codexmate 0.0.13 → 0.0.15

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.
package/cli.js CHANGED
@@ -6,6 +6,7 @@ const crypto = require('crypto');
6
6
  const toml = require('@iarna/toml');
7
7
  const JSON5 = require('json5');
8
8
  const zipLib = require('zip-lib');
9
+ const yauzl = require('yauzl');
9
10
  const { exec, execSync, spawn, spawnSync } = require('child_process');
10
11
  const http = require('http');
11
12
  const https = require('https');
@@ -21,6 +22,8 @@ const {
21
22
  detectLineEnding,
22
23
  normalizeLineEnding,
23
24
  isValidProviderName,
25
+ escapeTomlBasicString,
26
+ buildModelProviderTableHeader,
24
27
  buildModelsCandidates,
25
28
  isValidHttpUrl,
26
29
  normalizeBaseUrl,
@@ -54,6 +57,10 @@ const {
54
57
  resolveMaxMessagesValue
55
58
  } = require('./lib/cli-session-utils');
56
59
  const { createMcpStdioServer } = require('./lib/mcp-stdio');
60
+ const {
61
+ validateWorkflowDefinition,
62
+ executeWorkflowDefinition
63
+ } = require('./lib/workflow-engine');
57
64
 
58
65
  const DEFAULT_WEB_PORT = 3737;
59
66
  const DEFAULT_WEB_HOST = '127.0.0.1';
@@ -78,6 +85,8 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
78
85
  const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
79
86
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
80
87
  const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
88
+ const WORKFLOW_DEFINITIONS_FILE = path.join(CONFIG_DIR, 'codexmate-workflows.json');
89
+ const WORKFLOW_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-workflow-runs.jsonl');
81
90
  const DEFAULT_CLAUDE_MODEL = 'glm-4.7';
82
91
  const CODEX_BACKUP_NAME = 'codex-config';
83
92
 
@@ -98,12 +107,24 @@ const SESSION_SCAN_FACTOR = 4;
98
107
  const SESSION_SCAN_MIN_FILES = 800;
99
108
  const MAX_SESSION_PATH_LIST_SIZE = 2000;
100
109
  const AGENTS_FILE_NAME = 'AGENTS.md';
110
+ const CODEX_SKILLS_DIR = path.join(CONFIG_DIR, 'skills');
111
+ const CLAUDE_SKILLS_DIR = path.join(CLAUDE_DIR, 'skills');
112
+ const AGENTS_SKILLS_DIR = path.join(os.homedir(), '.agents', 'skills');
113
+ const SKILL_IMPORT_SOURCES = Object.freeze([
114
+ { app: 'claude', label: 'Claude Code', dir: CLAUDE_SKILLS_DIR },
115
+ { app: 'agents', label: 'Agents', dir: AGENTS_SKILLS_DIR }
116
+ ]);
101
117
  const MODELS_CACHE_TTL_MS = 60 * 1000;
102
118
  const MODELS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
103
119
  const MODELS_CACHE_MAX_ENTRIES = 50;
104
120
  const MODELS_RESPONSE_MAX_BYTES = 1024 * 1024;
105
121
  const MAX_RECENT_CONFIGS = 3;
106
122
  const MAX_UPLOAD_SIZE = 200 * 1024 * 1024;
123
+ const MAX_SKILLS_ZIP_UPLOAD_SIZE = 20 * 1024 * 1024;
124
+ const MAX_SKILLS_ZIP_ENTRY_COUNT = 2000;
125
+ const MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
126
+ const DOWNLOAD_ARTIFACT_TTL_MS = 10 * 60 * 1000;
127
+ const g_downloadArtifacts = new Map();
107
128
  const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy';
108
129
  const DEFAULT_BUILTIN_PROXY_SETTINGS = Object.freeze({
109
130
  enabled: false,
@@ -215,16 +236,50 @@ function ensureConfigDir() {
215
236
  }
216
237
  }
217
238
 
239
+ function createConfigLoadError(type, message, detail) {
240
+ const err = new Error(detail || message);
241
+ err.configErrorType = type || 'read';
242
+ err.configPublicReason = message || '读取 config.toml 失败';
243
+ err.configDetail = detail || message || '';
244
+ return err;
245
+ }
246
+
218
247
  function readConfig() {
219
248
  if (!fs.existsSync(CONFIG_FILE)) {
220
- throw new Error(`配置文件不存在: ${CONFIG_FILE}`);
249
+ throw createConfigLoadError(
250
+ 'missing',
251
+ '未检测到 config.toml',
252
+ `配置文件不存在: ${CONFIG_FILE}`
253
+ );
254
+ }
255
+
256
+ let content = '';
257
+ try {
258
+ content = fs.readFileSync(CONFIG_FILE, 'utf-8');
259
+ } catch (e) {
260
+ throw createConfigLoadError(
261
+ 'read',
262
+ '读取 config.toml 失败',
263
+ `读取配置文件失败: ${e && e.message ? e.message : e}`
264
+ );
221
265
  }
266
+
267
+ let parsed;
222
268
  try {
223
- const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
224
- return toml.parse(content);
269
+ parsed = toml.parse(content);
225
270
  } catch (e) {
226
- throw new Error(`配置文件解析失败: ${e.message}`);
271
+ throw createConfigLoadError(
272
+ 'parse',
273
+ 'config.toml 解析失败',
274
+ `配置文件解析失败: ${e && e.message ? e.message : e}`
275
+ );
276
+ }
277
+
278
+ if (isPlainObject(parsed) && isPlainObject(parsed.model_providers)) {
279
+ const providerHeaderSegmentKeySet = collectModelProviderHeaderSegmentKeySet(content);
280
+ parsed.model_providers = normalizeLegacyModelProviders(parsed.model_providers, providerHeaderSegmentKeySet);
227
281
  }
282
+ return parsed;
228
283
  }
229
284
 
230
285
  function writeConfig(content) {
@@ -277,6 +332,519 @@ function isPlainObject(value) {
277
332
  return !!value && typeof value === 'object' && !Array.isArray(value);
278
333
  }
279
334
 
335
+ const PROVIDER_CONFIG_KEYS = new Set([
336
+ 'name',
337
+ 'base_url',
338
+ 'wire_api',
339
+ 'requires_openai_auth',
340
+ 'preferred_auth_method',
341
+ 'request_max_retries',
342
+ 'stream_max_retries',
343
+ 'stream_idle_timeout_ms'
344
+ ]);
345
+ const RECOVERABLE_PROVIDER_SIGNAL_KEYS = [...PROVIDER_CONFIG_KEYS].filter((key) => key !== 'name' && key !== 'base_url');
346
+
347
+ function looksLikeProviderConfig(value) {
348
+ if (!isPlainObject(value)) return false;
349
+ return Object.keys(value).some((key) => PROVIDER_CONFIG_KEYS.has(key));
350
+ }
351
+
352
+ function isRecoverableNestedProviderConfig(value) {
353
+ if (!isPlainObject(value)) return false;
354
+ const hasBaseUrl = typeof value.base_url === 'string' && value.base_url.trim() !== '';
355
+ if (!hasBaseUrl) return false;
356
+ const hasName = typeof value.name === 'string' && value.name.trim() !== '';
357
+ const hasProviderSignals = RECOVERABLE_PROVIDER_SIGNAL_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
358
+ return hasName || hasProviderSignals;
359
+ }
360
+
361
+ function collectNestedProviderConfigs(node, pathSegments, collector) {
362
+ if (!isPlainObject(node)) return;
363
+ const segments = Array.isArray(pathSegments) ? pathSegments : [String(pathSegments || '')];
364
+ const lastSegment = segments.length > 0 ? segments[segments.length - 1] : '';
365
+ if (segments.length > 1 && lastSegment === 'metadata') {
366
+ return;
367
+ }
368
+ if (isRecoverableNestedProviderConfig(node)) {
369
+ collector.push({
370
+ name: segments.join('.'),
371
+ segments: segments.slice(),
372
+ provider: node
373
+ });
374
+ }
375
+ for (const [childKey, childValue] of Object.entries(node)) {
376
+ if (!isPlainObject(childValue)) continue;
377
+ collectNestedProviderConfigs(childValue, [...segments, childKey], collector);
378
+ }
379
+ }
380
+
381
+ function normalizeLegacySegments(segments) {
382
+ if (!Array.isArray(segments) || segments.length === 0) return null;
383
+ return segments.map((item) => String(item));
384
+ }
385
+
386
+ function buildLegacySegmentsKey(segments) {
387
+ const normalized = normalizeLegacySegments(segments);
388
+ return normalized ? JSON.stringify(normalized) : '';
389
+ }
390
+
391
+ function appendLegacySegmentsVariant(provider, segments) {
392
+ if (!isPlainObject(provider)) return;
393
+ const normalized = normalizeLegacySegments(segments);
394
+ if (!normalized) return;
395
+
396
+ const variants = [];
397
+ const seen = new Set();
398
+ const pushVariant = (candidate) => {
399
+ const key = buildLegacySegmentsKey(candidate);
400
+ if (!key || seen.has(key)) return;
401
+ seen.add(key);
402
+ variants.push(normalizeLegacySegments(candidate));
403
+ };
404
+
405
+ if (Array.isArray(provider.__codexmate_legacy_segments)) {
406
+ pushVariant(provider.__codexmate_legacy_segments);
407
+ }
408
+ if (Array.isArray(provider.__codexmate_legacy_segment_variants)) {
409
+ for (const candidate of provider.__codexmate_legacy_segment_variants) {
410
+ pushVariant(candidate);
411
+ }
412
+ }
413
+ pushVariant(normalized);
414
+
415
+ try {
416
+ if (!Array.isArray(provider.__codexmate_legacy_segments)) {
417
+ Object.defineProperty(provider, '__codexmate_legacy_segments', {
418
+ value: normalized,
419
+ enumerable: false,
420
+ configurable: true,
421
+ writable: true
422
+ });
423
+ }
424
+ Object.defineProperty(provider, '__codexmate_legacy_segment_variants', {
425
+ value: variants,
426
+ enumerable: false,
427
+ configurable: true,
428
+ writable: true
429
+ });
430
+ } catch (e) {}
431
+ }
432
+
433
+ function setLegacySegmentsMetadata(provider, segments) {
434
+ appendLegacySegmentsVariant(provider, segments);
435
+ }
436
+
437
+ function normalizeLegacyModelProviders(modelProviders, providerHeaderSegmentKeySet = null) {
438
+ if (!isPlainObject(modelProviders)) {
439
+ return modelProviders;
440
+ }
441
+
442
+ let changed = false;
443
+ const normalized = {};
444
+ const addRecovered = (entry) => {
445
+ const name = entry && typeof entry.name === 'string' ? entry.name : '';
446
+ const segments = entry && Array.isArray(entry.segments) ? entry.segments.slice() : null;
447
+ const provider = entry ? entry.provider : null;
448
+ if (!name || !isPlainObject(provider)) return;
449
+ const segmentKey = buildLegacySegmentsKey(segments);
450
+ if (providerHeaderSegmentKeySet instanceof Set && segmentKey && !providerHeaderSegmentKeySet.has(segmentKey)) {
451
+ return;
452
+ }
453
+ const existing = Object.prototype.hasOwnProperty.call(normalized, name)
454
+ ? normalized[name]
455
+ : (Object.prototype.hasOwnProperty.call(modelProviders, name) ? modelProviders[name] : null);
456
+ if (isPlainObject(existing)) {
457
+ if (!Array.isArray(existing.__codexmate_legacy_segments)) {
458
+ setLegacySegmentsMetadata(existing, [name]);
459
+ }
460
+ appendLegacySegmentsVariant(existing, segments);
461
+ return;
462
+ }
463
+ if (Object.prototype.hasOwnProperty.call(modelProviders, name)) return;
464
+ if (Object.prototype.hasOwnProperty.call(normalized, name)) return;
465
+ setLegacySegmentsMetadata(provider, segments);
466
+ normalized[name] = provider;
467
+ changed = true;
468
+ };
469
+
470
+ for (const [name, provider] of Object.entries(modelProviders)) {
471
+ normalized[name] = provider;
472
+ if (!isPlainObject(provider)) continue;
473
+
474
+ if (looksLikeProviderConfig(provider)) {
475
+ setLegacySegmentsMetadata(provider, [name]);
476
+ for (const [childKey, childValue] of Object.entries(provider)) {
477
+ if (!isPlainObject(childValue)) continue;
478
+ const recovered = [];
479
+ collectNestedProviderConfigs(childValue, [name, childKey], recovered);
480
+ for (const recoveredEntry of recovered) {
481
+ addRecovered(recoveredEntry);
482
+ }
483
+ }
484
+ continue;
485
+ }
486
+
487
+ const recovered = [];
488
+ collectNestedProviderConfigs(provider, [name], recovered);
489
+ delete normalized[name];
490
+ changed = true;
491
+ for (const recoveredEntry of recovered) {
492
+ addRecovered(recoveredEntry);
493
+ }
494
+ }
495
+
496
+ return changed ? normalized : modelProviders;
497
+ }
498
+
499
+ function escapeRegex(value) {
500
+ return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
501
+ }
502
+
503
+ function areStringArraysEqual(a, b) {
504
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
505
+ for (let i = 0; i < a.length; i++) {
506
+ if (String(a[i]) !== String(b[i])) return false;
507
+ }
508
+ return true;
509
+ }
510
+
511
+ function parseTomlDottedKeyExpression(expression) {
512
+ const text = String(expression || '');
513
+ let index = 0;
514
+ const segments = [];
515
+ const skipWhitespace = () => {
516
+ while (index < text.length && /\s/.test(text[index])) index++;
517
+ };
518
+
519
+ while (index < text.length) {
520
+ skipWhitespace();
521
+ if (index >= text.length) break;
522
+
523
+ const ch = text[index];
524
+ if (ch === "'") {
525
+ const end = text.indexOf("'", index + 1);
526
+ if (end === -1) return null;
527
+ segments.push(text.slice(index + 1, end));
528
+ index = end + 1;
529
+ } else if (ch === '"') {
530
+ index += 1;
531
+ let value = '';
532
+ let closed = false;
533
+ while (index < text.length) {
534
+ const cur = text[index];
535
+ if (cur === '"') {
536
+ index += 1;
537
+ closed = true;
538
+ break;
539
+ }
540
+ if (cur !== '\\') {
541
+ value += cur;
542
+ index += 1;
543
+ continue;
544
+ }
545
+ if (index + 1 >= text.length) return null;
546
+ const esc = text[index + 1];
547
+ if (esc === 'u' || esc === 'U') {
548
+ const hexLen = esc === 'u' ? 4 : 8;
549
+ const hex = text.slice(index + 2, index + 2 + hexLen);
550
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
551
+ try {
552
+ value += String.fromCodePoint(parseInt(hex, 16));
553
+ } catch (e) {
554
+ return null;
555
+ }
556
+ index += 2 + hexLen;
557
+ continue;
558
+ }
559
+ const unescaped = {
560
+ b: '\b',
561
+ t: '\t',
562
+ n: '\n',
563
+ f: '\f',
564
+ r: '\r',
565
+ '"': '"',
566
+ '\\': '\\'
567
+ }[esc];
568
+ if (unescaped === undefined) return null;
569
+ value += unescaped;
570
+ index += 2;
571
+ }
572
+ if (!closed) return null;
573
+ segments.push(value);
574
+ } else {
575
+ const start = index;
576
+ while (index < text.length && !/\s|\./.test(text[index])) index++;
577
+ const bare = text.slice(start, index);
578
+ if (!bare) return null;
579
+ segments.push(bare);
580
+ }
581
+
582
+ skipWhitespace();
583
+ if (index >= text.length) break;
584
+ if (text[index] !== '.') return null;
585
+ index += 1;
586
+ }
587
+
588
+ return segments.length > 0 ? segments : null;
589
+ }
590
+
591
+ function collectTomlMultilineStringRanges(text) {
592
+ const source = typeof text === 'string' ? text : '';
593
+ const ranges = [];
594
+ let i = 0;
595
+ let inMultilineBasic = false;
596
+ let inMultilineLiteral = false;
597
+ let rangeStart = -1;
598
+
599
+ while (i < source.length) {
600
+ if (inMultilineBasic) {
601
+ if (source.slice(i, i + 3) === '"""') {
602
+ let slashCount = 0;
603
+ for (let j = i - 1; j >= 0 && source[j] === '\\'; j--) {
604
+ slashCount++;
605
+ }
606
+ if (slashCount % 2 === 0) {
607
+ let runEnd = i + 3;
608
+ while (runEnd < source.length && source[runEnd] === '"') runEnd++;
609
+ ranges.push({ start: rangeStart, end: runEnd });
610
+ inMultilineBasic = false;
611
+ rangeStart = -1;
612
+ i = runEnd;
613
+ continue;
614
+ }
615
+ }
616
+ i++;
617
+ continue;
618
+ }
619
+
620
+ if (inMultilineLiteral) {
621
+ if (source.slice(i, i + 3) === "'''") {
622
+ let runEnd = i + 3;
623
+ while (runEnd < source.length && source[runEnd] === '\'') runEnd++;
624
+ ranges.push({ start: rangeStart, end: runEnd });
625
+ inMultilineLiteral = false;
626
+ rangeStart = -1;
627
+ i = runEnd;
628
+ continue;
629
+ }
630
+ i++;
631
+ continue;
632
+ }
633
+
634
+ const ch = source[i];
635
+ if (ch === '#') {
636
+ while (i < source.length && source[i] !== '\n') i++;
637
+ continue;
638
+ }
639
+
640
+ if (source.slice(i, i + 3) === '"""') {
641
+ inMultilineBasic = true;
642
+ rangeStart = i;
643
+ i += 3;
644
+ continue;
645
+ }
646
+
647
+ if (source.slice(i, i + 3) === "'''") {
648
+ inMultilineLiteral = true;
649
+ rangeStart = i;
650
+ i += 3;
651
+ continue;
652
+ }
653
+
654
+ if (ch === '"') {
655
+ i++;
656
+ while (i < source.length) {
657
+ if (source[i] === '\\') {
658
+ i += 2;
659
+ continue;
660
+ }
661
+ if (source[i] === '"' || source[i] === '\n') {
662
+ i++;
663
+ break;
664
+ }
665
+ i++;
666
+ }
667
+ continue;
668
+ }
669
+
670
+ if (ch === '\'') {
671
+ i++;
672
+ while (i < source.length) {
673
+ if (source[i] === '\'' || source[i] === '\n') {
674
+ i++;
675
+ break;
676
+ }
677
+ i++;
678
+ }
679
+ continue;
680
+ }
681
+
682
+ i++;
683
+ }
684
+
685
+ if (rangeStart >= 0) {
686
+ ranges.push({ start: rangeStart, end: source.length });
687
+ }
688
+ return ranges;
689
+ }
690
+
691
+ function isIndexInRanges(index, ranges) {
692
+ for (const range of ranges) {
693
+ if (index < range.start) return false;
694
+ if (index >= range.start && index < range.end) return true;
695
+ }
696
+ return false;
697
+ }
698
+
699
+ function findProviderSectionRanges(content, providerName, exactSegments = null) {
700
+ const text = typeof content === 'string' ? content : '';
701
+ const name = typeof providerName === 'string' ? providerName.trim() : '';
702
+ const targetSegments = Array.isArray(exactSegments) ? exactSegments.map((item) => String(item)) : null;
703
+ if (!text || !name) return [];
704
+
705
+ const safeName = escapeRegex(name);
706
+ const headerPatterns = [
707
+ { priority: 0, regex: new RegExp(`^\\s*model_providers\\s*\\.\\s*"${safeName}"\\s*$`) },
708
+ { priority: 1, regex: new RegExp(`^\\s*model_providers\\s*\\.\\s*'${safeName}'\\s*$`) },
709
+ { priority: 2, regex: new RegExp(`^\\s*model_providers\\s*\\.\\s*${safeName}\\s*$`) }
710
+ ];
711
+
712
+ const allHeaders = [];
713
+ const targetPriorityByStart = new Map();
714
+ const multilineStringRanges = collectTomlMultilineStringRanges(text);
715
+ const sectionLineRegex = /^[ \t]*\[(?!\[)([^\]\n]+)\][ \t]*(?:#.*)?$/gm;
716
+ let match;
717
+ while ((match = sectionLineRegex.exec(text)) !== null) {
718
+ const start = match.index;
719
+ if (isIndexInRanges(start, multilineStringRanges)) {
720
+ continue;
721
+ }
722
+ allHeaders.push(start);
723
+ const headerExpr = String(match[1] || '').trim();
724
+
725
+ const parsedSegments = parseTomlDottedKeyExpression(headerExpr);
726
+ if (Array.isArray(parsedSegments) && parsedSegments.length >= 2 && parsedSegments[0] === 'model_providers') {
727
+ const providerSegments = parsedSegments.slice(1);
728
+ if (targetSegments && targetSegments.length > 0 && areStringArraysEqual(providerSegments, targetSegments)) {
729
+ const prev = targetPriorityByStart.get(start);
730
+ if (prev === undefined || -3 < prev) {
731
+ targetPriorityByStart.set(start, -3);
732
+ }
733
+ continue;
734
+ }
735
+ if (!targetSegments || targetSegments.length === 0) {
736
+ const parsedName = providerSegments.join('.');
737
+ if (parsedName === name) {
738
+ const prev = targetPriorityByStart.get(start);
739
+ if (prev === undefined || -2 < prev) {
740
+ targetPriorityByStart.set(start, -2);
741
+ }
742
+ continue;
743
+ }
744
+ }
745
+ }
746
+
747
+ for (const pattern of headerPatterns) {
748
+ if (pattern.regex.test(headerExpr)) {
749
+ const prev = targetPriorityByStart.get(start);
750
+ if (prev === undefined || pattern.priority < prev) {
751
+ targetPriorityByStart.set(start, pattern.priority);
752
+ }
753
+ break;
754
+ }
755
+ }
756
+ }
757
+
758
+ if (targetPriorityByStart.size === 0) {
759
+ return [];
760
+ }
761
+
762
+ const ranges = [];
763
+ for (let i = 0; i < allHeaders.length; i++) {
764
+ const start = allHeaders[i];
765
+ if (!targetPriorityByStart.has(start)) continue;
766
+ const end = i + 1 < allHeaders.length ? allHeaders[i + 1] : text.length;
767
+ ranges.push({
768
+ start,
769
+ end,
770
+ priority: targetPriorityByStart.get(start)
771
+ });
772
+ }
773
+ const exactMatches = ranges.filter((range) => range.priority === -3);
774
+ return exactMatches.length > 0 ? exactMatches : ranges;
775
+ }
776
+
777
+ function doesSegmentsStartWith(segments, prefix) {
778
+ if (!Array.isArray(segments) || !Array.isArray(prefix) || prefix.length === 0 || segments.length < prefix.length) {
779
+ return false;
780
+ }
781
+ for (let i = 0; i < prefix.length; i++) {
782
+ if (String(segments[i]) !== String(prefix[i])) return false;
783
+ }
784
+ return true;
785
+ }
786
+
787
+ function findProviderDescendantSectionRanges(content, prefixSegments) {
788
+ const text = typeof content === 'string' ? content : '';
789
+ const prefix = Array.isArray(prefixSegments) ? prefixSegments.map((item) => String(item)) : [];
790
+ if (!text || prefix.length === 0) return [];
791
+
792
+ const allHeaders = [];
793
+ const parsedProviderSegmentsByStart = new Map();
794
+ const multilineStringRanges = collectTomlMultilineStringRanges(text);
795
+ const sectionLineRegex = /^[ \t]*\[(?!\[)([^\]\n]+)\][ \t]*(?:#.*)?$/gm;
796
+ let match;
797
+ while ((match = sectionLineRegex.exec(text)) !== null) {
798
+ const start = match.index;
799
+ if (isIndexInRanges(start, multilineStringRanges)) {
800
+ continue;
801
+ }
802
+ allHeaders.push(start);
803
+ const headerExpr = String(match[1] || '').trim();
804
+ const parsedSegments = parseTomlDottedKeyExpression(headerExpr);
805
+ if (!Array.isArray(parsedSegments) || parsedSegments.length < 2 || parsedSegments[0] !== 'model_providers') {
806
+ continue;
807
+ }
808
+ parsedProviderSegmentsByStart.set(start, parsedSegments.slice(1));
809
+ }
810
+
811
+ const ranges = [];
812
+ for (let i = 0; i < allHeaders.length; i++) {
813
+ const start = allHeaders[i];
814
+ const providerSegments = parsedProviderSegmentsByStart.get(start);
815
+ if (!providerSegments) continue;
816
+ if (!doesSegmentsStartWith(providerSegments, prefix)) continue;
817
+ if (providerSegments.length <= prefix.length) continue;
818
+ const end = i + 1 < allHeaders.length ? allHeaders[i + 1] : text.length;
819
+ ranges.push({ start, end, priority: 0 });
820
+ }
821
+ return ranges;
822
+ }
823
+
824
+ function collectModelProviderHeaderSegmentKeySet(content) {
825
+ const text = typeof content === 'string' ? content : '';
826
+ const keys = new Set();
827
+ if (!text) return keys;
828
+
829
+ const multilineStringRanges = collectTomlMultilineStringRanges(text);
830
+ const sectionLineRegex = /^[ \t]*\[(?!\[)([^\]\n]+)\][ \t]*(?:#.*)?$/gm;
831
+ let match;
832
+ while ((match = sectionLineRegex.exec(text)) !== null) {
833
+ const start = match.index;
834
+ if (isIndexInRanges(start, multilineStringRanges)) {
835
+ continue;
836
+ }
837
+ const headerExpr = String(match[1] || '').trim();
838
+ const parsedSegments = parseTomlDottedKeyExpression(headerExpr);
839
+ if (!Array.isArray(parsedSegments) || parsedSegments.length < 2 || parsedSegments[0] !== 'model_providers') {
840
+ continue;
841
+ }
842
+ const key = buildLegacySegmentsKey(parsedSegments.slice(1));
843
+ if (key) keys.add(key);
844
+ }
845
+ return keys;
846
+ }
847
+
280
848
  function normalizeAuthProfileName(value) {
281
849
  const raw = typeof value === 'string' ? value.trim() : '';
282
850
  if (!raw) return '';
@@ -656,138 +1224,981 @@ async function fetchModelsFromBaseUrl(baseUrl, apiKey) {
656
1224
  promise.finally(() => {
657
1225
  g_modelsInFlight.delete(cacheKey);
658
1226
  });
659
- return promise;
1227
+ return promise;
1228
+ }
1229
+
1230
+ async function fetchModelsFromBaseUrlCore(baseUrl, apiKey) {
1231
+ const candidates = buildModelsCandidates(baseUrl);
1232
+ if (candidates.length === 0) return { error: 'Provider missing URL' };
1233
+
1234
+ let lastError = '';
1235
+ for (const modelsUrl of candidates) {
1236
+ let parsed;
1237
+ try {
1238
+ parsed = new URL(modelsUrl);
1239
+ } catch (e) {
1240
+ lastError = 'Invalid URL';
1241
+ continue;
1242
+ }
1243
+
1244
+ const transport = parsed.protocol === 'https:' ? https : http;
1245
+ const agent = parsed.protocol === 'https:' ? HTTPS_KEEP_ALIVE_AGENT : HTTP_KEEP_ALIVE_AGENT;
1246
+ const headers = {
1247
+ 'User-Agent': 'codexmate-models',
1248
+ 'Accept': 'application/json'
1249
+ };
1250
+ if (apiKey) {
1251
+ headers['Authorization'] = `Bearer ${apiKey}`;
1252
+ headers['x-api-key'] = apiKey;
1253
+ }
1254
+
1255
+ const result = await new Promise((innerResolve) => {
1256
+ let settled = false;
1257
+ const finish = (payload) => {
1258
+ if (settled) return;
1259
+ settled = true;
1260
+ innerResolve(payload);
1261
+ };
1262
+ const req = transport.request(parsed, { method: 'GET', headers, agent }, (res) => {
1263
+ const status = res.statusCode || 0;
1264
+ const contentType = String(res.headers['content-type'] || '').toLowerCase();
1265
+ if (status === 404 || status === 405 || status === 501) {
1266
+ res.resume();
1267
+ return finish({ unavailable: true });
1268
+ }
1269
+ let body = '';
1270
+ let receivedBytes = 0;
1271
+ res.on('data', chunk => {
1272
+ receivedBytes += chunk.length || 0;
1273
+ if (receivedBytes > MODELS_RESPONSE_MAX_BYTES) {
1274
+ res.destroy();
1275
+ return finish({ unavailable: true });
1276
+ }
1277
+ body += chunk;
1278
+ });
1279
+ res.on('end', () => {
1280
+ if (settled) return;
1281
+ if (status >= 400) {
1282
+ return finish({ error: `Request failed: ${status}` });
1283
+ }
1284
+ if (contentType && !contentType.includes('application/json')) {
1285
+ return finish({ unavailable: true });
1286
+ }
1287
+ try {
1288
+ const payload = JSON.parse(body || '{}');
1289
+ if (!hasModelsListPayload(payload)) {
1290
+ return finish({ unavailable: true });
1291
+ }
1292
+ const models = extractModelNames(payload);
1293
+ return finish({ models });
1294
+ } catch (e) {
1295
+ return finish({ unavailable: true });
1296
+ }
1297
+ });
1298
+ });
1299
+
1300
+ req.setTimeout(SPEED_TEST_TIMEOUT_MS, () => {
1301
+ req.destroy(new Error('timeout'));
1302
+ });
1303
+ req.on('error', (err) => {
1304
+ finish({ error: err.message || 'Request failed' });
1305
+ });
1306
+ req.end();
1307
+ });
1308
+
1309
+ if (result && Array.isArray(result.models)) {
1310
+ return { models: result.models };
1311
+ }
1312
+ if (result && result.error) {
1313
+ lastError = result.error;
1314
+ continue;
1315
+ }
1316
+ }
1317
+
1318
+ if (lastError) {
1319
+ return { error: lastError };
1320
+ }
1321
+ return { unlimited: true };
1322
+ }
1323
+
1324
+ async function fetchProviderModels(providerName, overrides = {}) {
1325
+ const { config } = readConfigOrVirtualDefault();
1326
+ const targetProvider = providerName || config.model_provider || '';
1327
+ if (!targetProvider) return { error: '未设置当前提供商' };
1328
+
1329
+ const providers = config.model_providers || {};
1330
+ const provider = providers[targetProvider];
1331
+ if (!provider) return { error: `提供商不存在: ${targetProvider}` };
1332
+
1333
+ const baseUrl = overrides.baseUrl || provider.base_url || '';
1334
+ const apiKey = overrides.apiKey ?? provider.preferred_auth_method ?? '';
1335
+ const res = await fetchModelsFromBaseUrl(baseUrl, apiKey);
1336
+ if (res.unlimited) return { models: [], provider: targetProvider, unlimited: true };
1337
+ if (res.error) return { error: res.error };
1338
+ return { models: res.models || [], provider: targetProvider, unlimited: false };
1339
+ }
1340
+
1341
+ function resolveAgentsFilePath(params = {}) {
1342
+ const baseDir = typeof params.baseDir === 'string' && params.baseDir.trim()
1343
+ ? params.baseDir.trim()
1344
+ : CONFIG_DIR;
1345
+ return path.join(baseDir, AGENTS_FILE_NAME);
1346
+ }
1347
+
1348
+ function validateAgentsBaseDir(filePath) {
1349
+ const dirPath = path.dirname(filePath);
1350
+ try {
1351
+ const stat = fs.statSync(dirPath);
1352
+ if (!stat.isDirectory()) {
1353
+ return { error: `目标不是目录: ${dirPath}` };
1354
+ }
1355
+ } catch (e) {
1356
+ return { error: `目标目录不存在: ${dirPath}` };
1357
+ }
1358
+ return { ok: true, dirPath };
1359
+ }
1360
+
1361
+ function normalizeCodexSkillName(name) {
1362
+ const value = typeof name === 'string' ? name.trim() : '';
1363
+ if (!value) {
1364
+ return { error: '技能名称不能为空' };
1365
+ }
1366
+ if (value.includes('\0')) {
1367
+ return { error: '技能名称非法' };
1368
+ }
1369
+ if (value === '.' || value === '..') {
1370
+ return { error: '技能名称非法' };
1371
+ }
1372
+ if (value.includes('/') || value.includes('\\')) {
1373
+ return { error: '技能名称非法' };
1374
+ }
1375
+ if (path.basename(value) !== value) {
1376
+ return { error: '技能名称非法' };
1377
+ }
1378
+ if (value.startsWith('.')) {
1379
+ return { error: '系统技能不可删除' };
1380
+ }
1381
+ return { name: value };
1382
+ }
1383
+
1384
+ function isSkillDirectoryEntry(entryName) {
1385
+ const targetPath = path.join(CODEX_SKILLS_DIR, entryName);
1386
+ try {
1387
+ const stat = fs.statSync(targetPath);
1388
+ return stat.isDirectory();
1389
+ } catch (e) {
1390
+ return false;
1391
+ }
1392
+ }
1393
+
1394
+ function normalizeSkillImportSourceApp(app) {
1395
+ const value = typeof app === 'string' ? app.trim().toLowerCase() : '';
1396
+ return SKILL_IMPORT_SOURCES.some((item) => item.app === value) ? value : '';
1397
+ }
1398
+
1399
+ function getSkillImportSourceByApp(app) {
1400
+ const normalizedApp = normalizeSkillImportSourceApp(app);
1401
+ if (!normalizedApp) return null;
1402
+ return SKILL_IMPORT_SOURCES.find((item) => item.app === normalizedApp) || null;
1403
+ }
1404
+
1405
+ function parseSimpleSkillFrontmatter(content = '') {
1406
+ const normalized = String(content || '').replace(/\r\n/g, '\n');
1407
+ if (!normalized.startsWith('---\n')) {
1408
+ return {};
1409
+ }
1410
+ const endIndex = normalized.indexOf('\n---\n', 4);
1411
+ if (endIndex <= 4) {
1412
+ return {};
1413
+ }
1414
+ const frontmatterRaw = normalized.slice(4, endIndex);
1415
+ const result = {};
1416
+ const lines = frontmatterRaw.split('\n');
1417
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
1418
+ const line = lines[lineIndex];
1419
+ const trimmed = line.trim();
1420
+ if (!trimmed || trimmed.startsWith('#')) continue;
1421
+ const matched = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
1422
+ if (!matched) continue;
1423
+ const key = matched[1];
1424
+ let value = matched[2] || '';
1425
+ const indicator = value.trim();
1426
+ if (/^[>|]/.test(indicator)) {
1427
+ const blockLines = [];
1428
+ let cursor = lineIndex + 1;
1429
+ while (cursor < lines.length) {
1430
+ const candidateLine = lines[cursor];
1431
+ if (!candidateLine.trim()) {
1432
+ blockLines.push('');
1433
+ cursor += 1;
1434
+ continue;
1435
+ }
1436
+ if (/^\s/.test(candidateLine)) {
1437
+ blockLines.push(candidateLine);
1438
+ cursor += 1;
1439
+ continue;
1440
+ }
1441
+ break;
1442
+ }
1443
+ lineIndex = cursor - 1;
1444
+ const indents = blockLines
1445
+ .filter((item) => item.trim())
1446
+ .map((item) => {
1447
+ const indentMatch = item.match(/^[ \t]*/);
1448
+ return indentMatch ? indentMatch[0].length : 0;
1449
+ });
1450
+ const commonIndent = indents.length ? Math.min(...indents) : 0;
1451
+ const deindented = blockLines.map((item) => {
1452
+ if (!item.trim()) return '';
1453
+ return item.slice(commonIndent);
1454
+ });
1455
+ if (indicator.startsWith('>')) {
1456
+ const paragraphs = [];
1457
+ let paragraphLines = [];
1458
+ for (const blockLine of deindented) {
1459
+ const blockTrimmed = blockLine.trim();
1460
+ if (!blockTrimmed) {
1461
+ if (paragraphLines.length) {
1462
+ paragraphs.push(paragraphLines.join(' '));
1463
+ paragraphLines = [];
1464
+ }
1465
+ continue;
1466
+ }
1467
+ paragraphLines.push(blockTrimmed);
1468
+ }
1469
+ if (paragraphLines.length) {
1470
+ paragraphs.push(paragraphLines.join(' '));
1471
+ }
1472
+ value = paragraphs.join('\n');
1473
+ } else {
1474
+ value = deindented.join('\n');
1475
+ }
1476
+ }
1477
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
1478
+ value = value.slice(1, -1);
1479
+ }
1480
+ result[key] = value.trim();
1481
+ }
1482
+ return result;
1483
+ }
1484
+
1485
+ function stripMarkdownFrontmatter(content = '') {
1486
+ const normalized = String(content || '').replace(/\r\n/g, '\n');
1487
+ if (!normalized.startsWith('---\n')) {
1488
+ return normalized;
1489
+ }
1490
+ const endIndex = normalized.indexOf('\n---\n', 4);
1491
+ if (endIndex <= 4) {
1492
+ return normalized;
1493
+ }
1494
+ return normalized.slice(endIndex + 5);
1495
+ }
1496
+
1497
+ function extractSkillDescriptionFromMarkdown(content = '') {
1498
+ const normalized = String(content || '').replace(/\r\n/g, '\n');
1499
+ const lines = normalized.split('\n');
1500
+ let inFence = false;
1501
+ for (const line of lines) {
1502
+ const trimmedStart = line.trimStart();
1503
+ if (trimmedStart.startsWith('```')) {
1504
+ inFence = !inFence;
1505
+ continue;
1506
+ }
1507
+ if (inFence) continue;
1508
+ if (/^( {4}|\t)/.test(line)) continue;
1509
+ const trimmed = line.trim();
1510
+ if (!trimmed) continue;
1511
+ if (trimmed.startsWith('#')) continue;
1512
+ if (trimmed.startsWith('---')) continue;
1513
+ if (/^([A-Za-z0-9_-]+)\s*:\s*/.test(trimmed)) continue;
1514
+ return trimmed.slice(0, 200);
1515
+ }
1516
+ return '';
1517
+ }
1518
+
1519
+ function readCodexSkillMetadata(skillPath) {
1520
+ const skillFile = path.join(skillPath, 'SKILL.md');
1521
+ if (!fs.existsSync(skillFile)) {
1522
+ return {
1523
+ hasSkillFile: false,
1524
+ displayName: '',
1525
+ description: ''
1526
+ };
1527
+ }
1528
+ try {
1529
+ const raw = fs.readFileSync(skillFile, 'utf-8');
1530
+ const content = stripUtf8Bom(raw);
1531
+ const frontmatter = parseSimpleSkillFrontmatter(content);
1532
+ const contentWithoutFrontmatter = stripMarkdownFrontmatter(content);
1533
+ const heading = contentWithoutFrontmatter.match(/^\s*#\s+(.+)$/m);
1534
+ const displayName = typeof frontmatter.name === 'string' && frontmatter.name.trim()
1535
+ ? frontmatter.name.trim()
1536
+ : (heading && heading[1] ? heading[1].trim() : '');
1537
+ const description = typeof frontmatter.description === 'string' && frontmatter.description.trim()
1538
+ ? frontmatter.description.trim().slice(0, 200)
1539
+ : extractSkillDescriptionFromMarkdown(contentWithoutFrontmatter);
1540
+ return {
1541
+ hasSkillFile: true,
1542
+ displayName,
1543
+ description
1544
+ };
1545
+ } catch (e) {
1546
+ return {
1547
+ hasSkillFile: false,
1548
+ displayName: '',
1549
+ description: ''
1550
+ };
1551
+ }
1552
+ }
1553
+
1554
+ function getCodexSkillEntryInfoByName(entryName) {
1555
+ const targetPath = path.join(CODEX_SKILLS_DIR, entryName);
1556
+ const normalized = normalizeCodexSkillName(entryName);
1557
+ if (normalized.error) {
1558
+ return null;
1559
+ }
1560
+ const relativePath = path.relative(CODEX_SKILLS_DIR, targetPath);
1561
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
1562
+ return null;
1563
+ }
1564
+
1565
+ try {
1566
+ const lstat = fs.lstatSync(targetPath);
1567
+ const isSymbolicLink = lstat.isSymbolicLink();
1568
+ if (!lstat.isDirectory() && !isSymbolicLink) {
1569
+ return null;
1570
+ }
1571
+ if (isSymbolicLink && !isSkillDirectoryEntry(entryName)) {
1572
+ return null;
1573
+ }
1574
+ const metadata = readCodexSkillMetadata(targetPath);
1575
+ return {
1576
+ name: entryName,
1577
+ path: targetPath,
1578
+ hasSkillFile: !!metadata.hasSkillFile,
1579
+ displayName: metadata.displayName || entryName,
1580
+ description: metadata.description || '',
1581
+ sourceType: isSymbolicLink ? 'symlink' : 'directory',
1582
+ updatedAt: Number.isFinite(lstat.mtimeMs) ? Math.floor(lstat.mtimeMs) : 0
1583
+ };
1584
+ } catch (e) {
1585
+ return null;
1586
+ }
1587
+ }
1588
+
1589
+ function listCodexSkills() {
1590
+ if (!fs.existsSync(CODEX_SKILLS_DIR)) {
1591
+ return {
1592
+ root: CODEX_SKILLS_DIR,
1593
+ exists: false,
1594
+ items: []
1595
+ };
1596
+ }
1597
+ try {
1598
+ const entries = fs.readdirSync(CODEX_SKILLS_DIR, { withFileTypes: true });
1599
+ const items = entries
1600
+ .map((entry) => {
1601
+ const name = entry && entry.name ? entry.name : '';
1602
+ if (!name || name.startsWith('.')) return null;
1603
+ return getCodexSkillEntryInfoByName(name);
1604
+ })
1605
+ .filter(Boolean)
1606
+ .sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'));
1607
+ return {
1608
+ root: CODEX_SKILLS_DIR,
1609
+ exists: true,
1610
+ items
1611
+ };
1612
+ } catch (e) {
1613
+ return { error: `读取 skills 目录失败: ${e.message}` };
1614
+ }
1615
+ }
1616
+
1617
+ function listSkillEntriesByRoot(rootDir) {
1618
+ if (!rootDir || !fs.existsSync(rootDir)) {
1619
+ return [];
1620
+ }
1621
+ try {
1622
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
1623
+ return entries
1624
+ .map((entry) => {
1625
+ const name = entry && entry.name ? entry.name : '';
1626
+ if (!name || name.startsWith('.')) return null;
1627
+ const normalized = normalizeCodexSkillName(name);
1628
+ if (normalized.error) return null;
1629
+ const skillPath = path.join(rootDir, name);
1630
+ const relativePath = path.relative(rootDir, skillPath);
1631
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
1632
+ return null;
1633
+ }
1634
+ try {
1635
+ const lstat = fs.lstatSync(skillPath);
1636
+ const isSymbolicLink = lstat.isSymbolicLink();
1637
+ if (!lstat.isDirectory() && !isSymbolicLink) {
1638
+ return null;
1639
+ }
1640
+ if (isSymbolicLink) {
1641
+ const realPath = fs.realpathSync(skillPath);
1642
+ const realStat = fs.statSync(realPath);
1643
+ if (!realStat.isDirectory()) {
1644
+ return null;
1645
+ }
1646
+ }
1647
+ return {
1648
+ name,
1649
+ path: skillPath,
1650
+ sourceType: isSymbolicLink ? 'symlink' : 'directory'
1651
+ };
1652
+ } catch (e) {
1653
+ return null;
1654
+ }
1655
+ })
1656
+ .filter(Boolean);
1657
+ } catch (e) {
1658
+ return [];
1659
+ }
1660
+ }
1661
+
1662
+ function scanUnmanagedCodexSkills() {
1663
+ const existing = listCodexSkills();
1664
+ if (existing.error) {
1665
+ return { error: existing.error };
1666
+ }
1667
+ const existingNames = new Set((Array.isArray(existing.items) ? existing.items : [])
1668
+ .map((item) => (item && typeof item.name === 'string' ? item.name.trim() : ''))
1669
+ .filter(Boolean));
1670
+
1671
+ const items = [];
1672
+ for (const source of SKILL_IMPORT_SOURCES) {
1673
+ const sourceEntries = listSkillEntriesByRoot(source.dir);
1674
+ for (const entry of sourceEntries) {
1675
+ if (existingNames.has(entry.name)) {
1676
+ continue;
1677
+ }
1678
+ const metadata = readCodexSkillMetadata(entry.path);
1679
+ items.push({
1680
+ key: `${source.app}:${entry.name}`,
1681
+ name: entry.name,
1682
+ displayName: metadata.displayName || entry.name,
1683
+ description: metadata.description || '',
1684
+ sourceApp: source.app,
1685
+ sourceLabel: source.label,
1686
+ sourcePath: entry.path,
1687
+ sourceType: entry.sourceType,
1688
+ hasSkillFile: !!metadata.hasSkillFile
1689
+ });
1690
+ }
1691
+ }
1692
+
1693
+ items.sort((a, b) => {
1694
+ const nameCompare = a.displayName.localeCompare(b.displayName, 'zh-Hans-CN');
1695
+ if (nameCompare !== 0) return nameCompare;
1696
+ return a.sourceLabel.localeCompare(b.sourceLabel, 'zh-Hans-CN');
1697
+ });
1698
+
1699
+ return {
1700
+ root: CODEX_SKILLS_DIR,
1701
+ items,
1702
+ sources: SKILL_IMPORT_SOURCES.map((source) => ({
1703
+ app: source.app,
1704
+ label: source.label,
1705
+ path: source.dir,
1706
+ exists: fs.existsSync(source.dir)
1707
+ }))
1708
+ };
1709
+ }
1710
+
1711
+ function importCodexSkills(params = {}) {
1712
+ const rawItems = Array.isArray(params.items) ? params.items : [];
1713
+ if (!rawItems.length) {
1714
+ return { error: '请先选择要导入的 skill' };
1715
+ }
1716
+
1717
+ ensureDir(CODEX_SKILLS_DIR);
1718
+
1719
+ const imported = [];
1720
+ const failed = [];
1721
+ const dedup = new Set();
1722
+
1723
+ for (const rawItem of rawItems) {
1724
+ const safeItem = rawItem && typeof rawItem === 'object' ? rawItem : {};
1725
+ const normalizedName = normalizeCodexSkillName(safeItem.name);
1726
+ if (normalizedName.error) {
1727
+ failed.push({
1728
+ name: safeItem && safeItem.name ? String(safeItem.name) : '',
1729
+ sourceApp: safeItem && safeItem.sourceApp ? String(safeItem.sourceApp) : '',
1730
+ error: normalizedName.error
1731
+ });
1732
+ continue;
1733
+ }
1734
+ const source = getSkillImportSourceByApp(safeItem.sourceApp);
1735
+ if (!source) {
1736
+ failed.push({
1737
+ name: normalizedName.name,
1738
+ sourceApp: safeItem && safeItem.sourceApp ? String(safeItem.sourceApp) : '',
1739
+ error: '来源应用不支持'
1740
+ });
1741
+ continue;
1742
+ }
1743
+ const dedupKey = `${source.app}:${normalizedName.name}`;
1744
+ if (dedup.has(dedupKey)) {
1745
+ continue;
1746
+ }
1747
+ dedup.add(dedupKey);
1748
+
1749
+ const sourcePath = path.join(source.dir, normalizedName.name);
1750
+ const sourceRelative = path.relative(source.dir, sourcePath);
1751
+ if (sourceRelative.startsWith('..') || path.isAbsolute(sourceRelative)) {
1752
+ failed.push({
1753
+ name: normalizedName.name,
1754
+ sourceApp: source.app,
1755
+ error: '来源路径非法'
1756
+ });
1757
+ continue;
1758
+ }
1759
+ if (!fs.existsSync(sourcePath)) {
1760
+ failed.push({
1761
+ name: normalizedName.name,
1762
+ sourceApp: source.app,
1763
+ error: '来源 skill 不存在'
1764
+ });
1765
+ continue;
1766
+ }
1767
+
1768
+ const targetPath = path.join(CODEX_SKILLS_DIR, normalizedName.name);
1769
+ const targetRelative = path.relative(CODEX_SKILLS_DIR, targetPath);
1770
+ if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) {
1771
+ failed.push({
1772
+ name: normalizedName.name,
1773
+ sourceApp: source.app,
1774
+ error: '目标路径非法'
1775
+ });
1776
+ continue;
1777
+ }
1778
+ if (fs.existsSync(targetPath)) {
1779
+ failed.push({
1780
+ name: normalizedName.name,
1781
+ sourceApp: source.app,
1782
+ error: 'Codex 中已存在同名 skill'
1783
+ });
1784
+ continue;
1785
+ }
1786
+
1787
+ let copiedToTarget = false;
1788
+ try {
1789
+ const lstat = fs.lstatSync(sourcePath);
1790
+ if (!lstat.isDirectory() && !lstat.isSymbolicLink()) {
1791
+ failed.push({
1792
+ name: normalizedName.name,
1793
+ sourceApp: source.app,
1794
+ error: '来源不是技能目录'
1795
+ });
1796
+ continue;
1797
+ }
1798
+ const sourceDirForCopy = lstat.isSymbolicLink() ? fs.realpathSync(sourcePath) : sourcePath;
1799
+ const sourceStat = fs.statSync(sourceDirForCopy);
1800
+ if (!sourceStat.isDirectory()) {
1801
+ failed.push({
1802
+ name: normalizedName.name,
1803
+ sourceApp: source.app,
1804
+ error: '来源 skill 无法读取'
1805
+ });
1806
+ continue;
1807
+ }
1808
+ const visitedRealPaths = new Set([sourceDirForCopy]);
1809
+ copyDirRecursive(sourceDirForCopy, targetPath, {
1810
+ dereferenceSymlinks: true,
1811
+ allowedRootRealPath: sourceDirForCopy,
1812
+ visitedRealPaths
1813
+ });
1814
+ copiedToTarget = true;
1815
+ imported.push({
1816
+ name: normalizedName.name,
1817
+ sourceApp: source.app,
1818
+ sourceLabel: source.label,
1819
+ path: targetPath
1820
+ });
1821
+ } catch (e) {
1822
+ if (!copiedToTarget && fs.existsSync(targetPath)) {
1823
+ try {
1824
+ removeDirectoryRecursive(targetPath);
1825
+ } catch (_) {}
1826
+ }
1827
+ failed.push({
1828
+ name: normalizedName.name,
1829
+ sourceApp: source.app,
1830
+ error: e && e.message ? e.message : '导入失败'
1831
+ });
1832
+ }
1833
+ }
1834
+
1835
+ return {
1836
+ success: failed.length === 0,
1837
+ imported,
1838
+ failed,
1839
+ root: CODEX_SKILLS_DIR
1840
+ };
1841
+ }
1842
+
1843
+ function collectSkillDirectoriesFromRoot(rootDir, limit = MAX_SKILLS_ZIP_ENTRY_COUNT) {
1844
+ const results = [];
1845
+ let truncated = false;
1846
+ if (!rootDir || !fs.existsSync(rootDir)) {
1847
+ return { results, truncated };
1848
+ }
1849
+ const normalizedLimit = Number.isFinite(limit) && limit > 0
1850
+ ? Math.floor(limit)
1851
+ : MAX_SKILLS_ZIP_ENTRY_COUNT;
1852
+ const stack = [rootDir];
1853
+ while (stack.length > 0) {
1854
+ if (results.length >= normalizedLimit) {
1855
+ truncated = true;
1856
+ break;
1857
+ }
1858
+ const currentDir = stack.pop();
1859
+ let entries = [];
1860
+ try {
1861
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
1862
+ } catch (e) {
1863
+ continue;
1864
+ }
1865
+
1866
+ const hasSkillFile = entries.some((entry) => entry && entry.isFile() && String(entry.name || '') === 'SKILL.md');
1867
+ if (hasSkillFile) {
1868
+ results.push(currentDir);
1869
+ continue;
1870
+ }
1871
+
1872
+ for (const entry of entries) {
1873
+ if (!entry || !entry.isDirectory()) continue;
1874
+ const entryName = typeof entry.name === 'string' ? entry.name.trim() : '';
1875
+ if (!entryName || entryName.startsWith('.')) {
1876
+ continue;
1877
+ }
1878
+ stack.push(path.join(currentDir, entryName));
1879
+ }
1880
+ }
1881
+ return { results, truncated };
1882
+ }
1883
+
1884
+ function resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbackName = '') {
1885
+ const directoryBaseName = path.basename(skillDir || '');
1886
+ const extractionBaseName = path.basename(extractionRoot || '');
1887
+ let candidate = directoryBaseName;
1888
+ if (!candidate || candidate === extractionBaseName || candidate.startsWith('.')) {
1889
+ const fallback = typeof fallbackName === 'string' ? fallbackName.trim() : '';
1890
+ const fallbackBase = fallback ? path.basename(fallback, path.extname(fallback)) : '';
1891
+ candidate = fallbackBase || candidate;
1892
+ }
1893
+ return normalizeCodexSkillName(candidate);
1894
+ }
1895
+
1896
+ async function importCodexSkillsFromZipFile(zipPath, options = {}) {
1897
+ const fallbackName = typeof options.fallbackName === 'string' ? options.fallbackName : '';
1898
+ const tempDir = typeof options.tempDir === 'string' ? options.tempDir : '';
1899
+ const imported = [];
1900
+ const failed = [];
1901
+ const dedupNames = new Set();
1902
+ const extractionRoot = path.join(tempDir || path.dirname(zipPath), 'extract');
1903
+
1904
+ try {
1905
+ await inspectZipArchiveLimits(zipPath, {
1906
+ maxEntryCount: MAX_SKILLS_ZIP_ENTRY_COUNT,
1907
+ maxUncompressedBytes: MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES
1908
+ });
1909
+
1910
+ await extractUploadZip(zipPath, extractionRoot);
1911
+ const discovery = collectSkillDirectoriesFromRoot(extractionRoot, MAX_SKILLS_ZIP_ENTRY_COUNT);
1912
+ const discoveredDirs = discovery.results;
1913
+ if (discoveredDirs.length === 0) {
1914
+ return { error: '压缩包中未发现包含 SKILL.md 的技能目录' };
1915
+ }
1916
+ if (discovery.truncated) {
1917
+ return { error: '压缩包中的技能目录数量超出导入上限' };
1918
+ }
1919
+
1920
+ ensureDir(CODEX_SKILLS_DIR);
1921
+ for (const skillDir of discoveredDirs) {
1922
+ const normalizedName = resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbackName);
1923
+ if (normalizedName.error) {
1924
+ failed.push({
1925
+ name: path.basename(skillDir || ''),
1926
+ error: normalizedName.error
1927
+ });
1928
+ continue;
1929
+ }
1930
+ const dedupKey = normalizedName.name.toLowerCase();
1931
+ if (dedupNames.has(dedupKey)) {
1932
+ continue;
1933
+ }
1934
+ dedupNames.add(dedupKey);
1935
+
1936
+ const targetPath = path.join(CODEX_SKILLS_DIR, normalizedName.name);
1937
+ const targetRelative = path.relative(CODEX_SKILLS_DIR, targetPath);
1938
+ if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) {
1939
+ failed.push({
1940
+ name: normalizedName.name,
1941
+ error: '目标路径非法'
1942
+ });
1943
+ continue;
1944
+ }
1945
+ if (fs.existsSync(targetPath)) {
1946
+ failed.push({
1947
+ name: normalizedName.name,
1948
+ error: 'Codex 中已存在同名 skill'
1949
+ });
1950
+ continue;
1951
+ }
1952
+
1953
+ let copiedToTarget = false;
1954
+ try {
1955
+ const sourceRealPath = fs.realpathSync(skillDir);
1956
+ const sourceStat = fs.statSync(sourceRealPath);
1957
+ if (!sourceStat.isDirectory()) {
1958
+ failed.push({
1959
+ name: normalizedName.name,
1960
+ error: '来源 skill 无法读取'
1961
+ });
1962
+ continue;
1963
+ }
1964
+ const visitedRealPaths = new Set([sourceRealPath]);
1965
+ copyDirRecursive(sourceRealPath, targetPath, {
1966
+ dereferenceSymlinks: true,
1967
+ allowedRootRealPath: sourceRealPath,
1968
+ visitedRealPaths
1969
+ });
1970
+ copiedToTarget = true;
1971
+ imported.push({
1972
+ name: normalizedName.name,
1973
+ path: targetPath
1974
+ });
1975
+ } catch (e) {
1976
+ if (!copiedToTarget && fs.existsSync(targetPath)) {
1977
+ try {
1978
+ removeDirectoryRecursive(targetPath);
1979
+ } catch (_) {}
1980
+ }
1981
+ failed.push({
1982
+ name: normalizedName.name,
1983
+ error: e && e.message ? e.message : '导入失败'
1984
+ });
1985
+ }
1986
+ }
1987
+
1988
+ if (imported.length === 0 && failed.length > 0) {
1989
+ return {
1990
+ error: failed[0].error || '导入失败',
1991
+ imported,
1992
+ failed,
1993
+ root: CODEX_SKILLS_DIR
1994
+ };
1995
+ }
1996
+
1997
+ return {
1998
+ success: failed.length === 0,
1999
+ imported,
2000
+ failed,
2001
+ root: CODEX_SKILLS_DIR
2002
+ };
2003
+ } catch (e) {
2004
+ return {
2005
+ error: `导入失败:${e && e.message ? e.message : '未知错误'}`
2006
+ };
2007
+ } finally {
2008
+ if (tempDir) {
2009
+ try {
2010
+ fs.rmSync(tempDir, { recursive: true, force: true });
2011
+ } catch (_) {}
2012
+ } else if (fs.existsSync(extractionRoot)) {
2013
+ try {
2014
+ fs.rmSync(extractionRoot, { recursive: true, force: true });
2015
+ } catch (_) {}
2016
+ }
2017
+ }
2018
+ }
2019
+
2020
+ async function importCodexSkillsFromZip(payload = {}) {
2021
+ if (!payload || typeof payload.fileBase64 !== 'string' || !payload.fileBase64.trim()) {
2022
+ return { error: '缺少技能压缩包内容' };
2023
+ }
2024
+ const upload = writeUploadZip(payload.fileBase64, 'codex-skills-import', payload.fileName || 'codex-skills.zip');
2025
+ if (upload.error) {
2026
+ return { error: upload.error };
2027
+ }
2028
+ return importCodexSkillsFromZipFile(upload.zipPath, {
2029
+ tempDir: upload.tempDir,
2030
+ fallbackName: payload.fileName || ''
2031
+ });
660
2032
  }
661
2033
 
662
- async function fetchModelsFromBaseUrlCore(baseUrl, apiKey) {
663
- const candidates = buildModelsCandidates(baseUrl);
664
- if (candidates.length === 0) return { error: 'Provider missing URL' };
2034
+ async function exportCodexSkills(params = {}) {
2035
+ const rawNames = Array.isArray(params.names) ? params.names : [];
2036
+ const uniqueNames = Array.from(new Set(rawNames
2037
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
2038
+ .filter(Boolean)));
2039
+ if (uniqueNames.length === 0) {
2040
+ return { error: '请先选择要导出的 skill' };
2041
+ }
665
2042
 
666
- let lastError = '';
667
- for (const modelsUrl of candidates) {
668
- let parsed;
669
- try {
670
- parsed = new URL(modelsUrl);
671
- } catch (e) {
672
- lastError = 'Invalid URL';
673
- continue;
674
- }
2043
+ const exported = [];
2044
+ const failed = [];
2045
+ const stagingTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-skills-export-'));
2046
+ const stagingRoot = path.join(stagingTempDir, 'skills');
2047
+ ensureDir(stagingRoot);
675
2048
 
676
- const transport = parsed.protocol === 'https:' ? https : http;
677
- const agent = parsed.protocol === 'https:' ? HTTPS_KEEP_ALIVE_AGENT : HTTP_KEEP_ALIVE_AGENT;
678
- const headers = {
679
- 'User-Agent': 'codexmate-models',
680
- 'Accept': 'application/json'
681
- };
682
- if (apiKey) {
683
- headers['Authorization'] = `Bearer ${apiKey}`;
684
- headers['x-api-key'] = apiKey;
685
- }
2049
+ try {
2050
+ for (const rawName of uniqueNames) {
2051
+ const normalizedName = normalizeCodexSkillName(rawName);
2052
+ if (normalizedName.error) {
2053
+ failed.push({ name: rawName, error: normalizedName.error });
2054
+ continue;
2055
+ }
2056
+ const sourcePath = path.join(CODEX_SKILLS_DIR, normalizedName.name);
2057
+ const sourceRelative = path.relative(CODEX_SKILLS_DIR, sourcePath);
2058
+ if (sourceRelative.startsWith('..') || path.isAbsolute(sourceRelative)) {
2059
+ failed.push({ name: normalizedName.name, error: '来源路径非法' });
2060
+ continue;
2061
+ }
2062
+ if (!fs.existsSync(sourcePath)) {
2063
+ failed.push({ name: normalizedName.name, error: 'skill 不存在' });
2064
+ continue;
2065
+ }
686
2066
 
687
- const result = await new Promise((innerResolve) => {
688
- let settled = false;
689
- const finish = (payload) => {
690
- if (settled) return;
691
- settled = true;
692
- innerResolve(payload);
693
- };
694
- const req = transport.request(parsed, { method: 'GET', headers, agent }, (res) => {
695
- const status = res.statusCode || 0;
696
- const contentType = String(res.headers['content-type'] || '').toLowerCase();
697
- if (status === 404 || status === 405 || status === 501) {
698
- res.resume();
699
- return finish({ unavailable: true });
2067
+ try {
2068
+ const lstat = fs.lstatSync(sourcePath);
2069
+ if (!lstat.isDirectory() && !lstat.isSymbolicLink()) {
2070
+ failed.push({ name: normalizedName.name, error: '来源不是技能目录' });
2071
+ continue;
700
2072
  }
701
- let body = '';
702
- let receivedBytes = 0;
703
- res.on('data', chunk => {
704
- receivedBytes += chunk.length || 0;
705
- if (receivedBytes > MODELS_RESPONSE_MAX_BYTES) {
706
- res.destroy();
707
- return finish({ unavailable: true });
708
- }
709
- body += chunk;
2073
+ const sourceDirForCopy = lstat.isSymbolicLink() ? fs.realpathSync(sourcePath) : sourcePath;
2074
+ const sourceStat = fs.statSync(sourceDirForCopy);
2075
+ if (!sourceStat.isDirectory()) {
2076
+ failed.push({ name: normalizedName.name, error: '来源 skill 无法读取' });
2077
+ continue;
2078
+ }
2079
+ const targetPath = path.join(stagingRoot, normalizedName.name);
2080
+ const visitedRealPaths = new Set([sourceDirForCopy]);
2081
+ copyDirRecursive(sourceDirForCopy, targetPath, {
2082
+ dereferenceSymlinks: true,
2083
+ allowedRootRealPath: sourceDirForCopy,
2084
+ visitedRealPaths
710
2085
  });
711
- res.on('end', () => {
712
- if (settled) return;
713
- if (status >= 400) {
714
- return finish({ error: `Request failed: ${status}` });
715
- }
716
- if (contentType && !contentType.includes('application/json')) {
717
- return finish({ unavailable: true });
718
- }
719
- try {
720
- const payload = JSON.parse(body || '{}');
721
- if (!hasModelsListPayload(payload)) {
722
- return finish({ unavailable: true });
723
- }
724
- const models = extractModelNames(payload);
725
- return finish({ models });
726
- } catch (e) {
727
- return finish({ unavailable: true });
728
- }
2086
+ exported.push({
2087
+ name: normalizedName.name,
2088
+ path: sourcePath
729
2089
  });
730
- });
731
-
732
- req.setTimeout(SPEED_TEST_TIMEOUT_MS, () => {
733
- req.destroy(new Error('timeout'));
734
- });
735
- req.on('error', (err) => {
736
- finish({ error: err.message || 'Request failed' });
737
- });
738
- req.end();
739
- });
2090
+ } catch (e) {
2091
+ failed.push({
2092
+ name: normalizedName.name,
2093
+ error: e && e.message ? e.message : '导出失败'
2094
+ });
2095
+ }
2096
+ }
740
2097
 
741
- if (result && Array.isArray(result.models)) {
742
- return { models: result.models };
2098
+ if (exported.length === 0) {
2099
+ return {
2100
+ error: failed[0] && failed[0].error ? failed[0].error : '无可导出的 skill',
2101
+ exported,
2102
+ failed,
2103
+ root: CODEX_SKILLS_DIR
2104
+ };
743
2105
  }
744
- if (result && result.error) {
745
- lastError = result.error;
746
- continue;
2106
+
2107
+ const randomToken = crypto.randomBytes(12).toString('hex');
2108
+ const zipFileName = `codex-skills-${randomToken}.zip`;
2109
+ const zipFilePath = path.join(os.tmpdir(), zipFileName);
2110
+ if (fs.existsSync(zipFilePath)) {
2111
+ try {
2112
+ fs.unlinkSync(zipFilePath);
2113
+ } catch (_) {}
747
2114
  }
748
- }
2115
+ await zipLib.archiveFolder(stagingRoot, zipFilePath);
2116
+ const artifact = registerDownloadArtifact(zipFilePath, {
2117
+ fileName: zipFileName,
2118
+ deleteAfterDownload: true
2119
+ });
749
2120
 
750
- if (lastError) {
751
- return { error: lastError };
2121
+ return {
2122
+ success: failed.length === 0,
2123
+ fileName: zipFileName,
2124
+ downloadPath: artifact.downloadPath,
2125
+ exported,
2126
+ failed,
2127
+ root: CODEX_SKILLS_DIR
2128
+ };
2129
+ } catch (e) {
2130
+ return {
2131
+ error: `导出失败:${e && e.message ? e.message : '未知错误'}`,
2132
+ exported,
2133
+ failed,
2134
+ root: CODEX_SKILLS_DIR
2135
+ };
2136
+ } finally {
2137
+ try {
2138
+ fs.rmSync(stagingTempDir, { recursive: true, force: true });
2139
+ } catch (_) {}
752
2140
  }
753
- return { unlimited: true };
754
2141
  }
755
2142
 
756
- async function fetchProviderModels(providerName, overrides = {}) {
757
- const { config } = readConfigOrVirtualDefault();
758
- const targetProvider = providerName || config.model_provider || '';
759
- if (!targetProvider) return { error: '未设置当前提供商' };
2143
+ function removeDirectoryRecursive(targetPath) {
2144
+ if (typeof fs.rmSync === 'function') {
2145
+ fs.rmSync(targetPath, { recursive: true, force: false });
2146
+ return;
2147
+ }
2148
+ fs.rmdirSync(targetPath, { recursive: true });
2149
+ }
760
2150
 
761
- const providers = config.model_providers || {};
762
- const provider = providers[targetProvider];
763
- if (!provider) return { error: `提供商不存在: ${targetProvider}` };
2151
+ function deleteCodexSkills(params = {}) {
2152
+ const rawList = Array.isArray(params.names) ? params.names : [];
2153
+ const uniqueNames = Array.from(new Set(rawList
2154
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
2155
+ .filter(Boolean)));
2156
+ if (!uniqueNames.length) {
2157
+ return { error: '请先选择要删除的 skill' };
2158
+ }
764
2159
 
765
- const baseUrl = overrides.baseUrl || provider.base_url || '';
766
- const apiKey = overrides.apiKey ?? provider.preferred_auth_method ?? '';
767
- const res = await fetchModelsFromBaseUrl(baseUrl, apiKey);
768
- if (res.unlimited) return { models: [], provider: targetProvider, unlimited: true };
769
- if (res.error) return { error: res.error };
770
- return { models: res.models || [], provider: targetProvider, unlimited: false };
771
- }
2160
+ const deleted = [];
2161
+ const failed = [];
2162
+ for (const rawName of uniqueNames) {
2163
+ const normalized = normalizeCodexSkillName(rawName);
2164
+ if (normalized.error) {
2165
+ failed.push({ name: rawName, error: normalized.error });
2166
+ continue;
2167
+ }
772
2168
 
773
- function resolveAgentsFilePath(params = {}) {
774
- const baseDir = typeof params.baseDir === 'string' && params.baseDir.trim()
775
- ? params.baseDir.trim()
776
- : CONFIG_DIR;
777
- return path.join(baseDir, AGENTS_FILE_NAME);
778
- }
2169
+ const skillPath = path.join(CODEX_SKILLS_DIR, normalized.name);
2170
+ const relativePath = path.relative(CODEX_SKILLS_DIR, skillPath);
2171
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
2172
+ failed.push({ name: normalized.name, error: '技能路径非法' });
2173
+ continue;
2174
+ }
2175
+ if (!fs.existsSync(skillPath)) {
2176
+ failed.push({ name: normalized.name, error: 'skill 不存在' });
2177
+ continue;
2178
+ }
779
2179
 
780
- function validateAgentsBaseDir(filePath) {
781
- const dirPath = path.dirname(filePath);
782
- try {
783
- const stat = fs.statSync(dirPath);
784
- if (!stat.isDirectory()) {
785
- return { error: `目标不是目录: ${dirPath}` };
2180
+ try {
2181
+ const stat = fs.lstatSync(skillPath);
2182
+ if (!stat.isDirectory() && !stat.isSymbolicLink()) {
2183
+ failed.push({ name: normalized.name, error: '仅支持删除技能目录' });
2184
+ continue;
2185
+ }
2186
+ removeDirectoryRecursive(skillPath);
2187
+ deleted.push(normalized.name);
2188
+ } catch (e) {
2189
+ failed.push({
2190
+ name: normalized.name,
2191
+ error: e && e.message ? e.message : '删除失败'
2192
+ });
786
2193
  }
787
- } catch (e) {
788
- return { error: `目标目录不存在: ${dirPath}` };
789
2194
  }
790
- return { ok: true, dirPath };
2195
+
2196
+ return {
2197
+ success: failed.length === 0,
2198
+ deleted,
2199
+ failed,
2200
+ root: CODEX_SKILLS_DIR
2201
+ };
791
2202
  }
792
2203
 
793
2204
  function readAgentsFile(params = {}) {
@@ -1307,11 +2718,28 @@ async function buildConfigHealthReport(params = {}) {
1307
2718
  const config = status.config || {};
1308
2719
 
1309
2720
  if (status.isVirtual) {
2721
+ const parseFailed = status.errorType === 'parse';
2722
+ const readFailed = status.errorType === 'read';
1310
2723
  issues.push({
1311
- code: 'config-missing',
1312
- message: status.reason || '未检测到 config.toml',
1313
- suggestion: '在模板编辑器中确认应用配置,生成可用的 config.toml'
2724
+ code: parseFailed ? 'config-parse-failed' : (readFailed ? 'config-read-failed' : 'config-missing'),
2725
+ message: status.reason || (parseFailed
2726
+ ? 'config.toml 解析失败'
2727
+ : (readFailed ? '读取 config.toml 失败' : '未检测到 config.toml')),
2728
+ suggestion: parseFailed
2729
+ ? '修复 config.toml 语法错误后重试'
2730
+ : (readFailed ? '检查文件权限后重试' : '在模板编辑器中确认应用配置,生成可用的 config.toml')
1314
2731
  });
2732
+ if (parseFailed || readFailed) {
2733
+ return {
2734
+ ok: false,
2735
+ issues,
2736
+ summary: {
2737
+ currentProvider: '',
2738
+ currentModel: ''
2739
+ },
2740
+ remote: null
2741
+ };
2742
+ }
1315
2743
  }
1316
2744
 
1317
2745
  const providerName = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
@@ -1498,13 +2926,26 @@ function readConfigOrVirtualDefault() {
1498
2926
  return {
1499
2927
  config: readConfig(),
1500
2928
  isVirtual: false,
1501
- reason: ''
2929
+ reason: '',
2930
+ detail: '',
2931
+ errorType: ''
1502
2932
  };
1503
2933
  } catch (e) {
2934
+ const errorType = typeof e.configErrorType === 'string' && e.configErrorType.trim()
2935
+ ? e.configErrorType.trim()
2936
+ : 'read';
2937
+ const publicReason = typeof e.configPublicReason === 'string' && e.configPublicReason.trim()
2938
+ ? e.configPublicReason.trim()
2939
+ : (errorType === 'parse' ? 'config.toml 解析失败' : '读取 config.toml 失败');
2940
+ const detail = typeof e.configDetail === 'string' && e.configDetail.trim()
2941
+ ? e.configDetail.trim()
2942
+ : (e && e.message ? e.message : publicReason);
1504
2943
  return {
1505
- config: buildVirtualDefaultConfig(),
2944
+ config: errorType === 'missing' ? buildVirtualDefaultConfig() : {},
1506
2945
  isVirtual: true,
1507
- reason: e.message || '配置文件无效,已回退到默认模板'
2946
+ reason: publicReason,
2947
+ detail,
2948
+ errorType
1508
2949
  };
1509
2950
  }
1510
2951
  }
@@ -1512,10 +2953,31 @@ function readConfigOrVirtualDefault() {
1512
2953
  return {
1513
2954
  config: buildVirtualDefaultConfig(),
1514
2955
  isVirtual: true,
1515
- reason: `配置文件不存在: ${CONFIG_FILE}`
2956
+ reason: '未检测到 config.toml',
2957
+ detail: `配置文件不存在: ${CONFIG_FILE}`,
2958
+ errorType: 'missing'
1516
2959
  };
1517
2960
  }
1518
2961
 
2962
+ function hasConfigLoadError(result) {
2963
+ return !!(result
2964
+ && result.isVirtual
2965
+ && (result.errorType === 'parse' || result.errorType === 'read'));
2966
+ }
2967
+
2968
+ function printConfigLoadErrorAndMarkExit(result) {
2969
+ const isReadError = result && result.errorType === 'read';
2970
+ const detail = result && typeof result.detail === 'string' && result.detail.trim()
2971
+ ? result.detail.trim()
2972
+ : (isReadError ? '读取配置文件失败' : '配置文件解析失败');
2973
+ console.error(`\n错误: ${isReadError ? '读取 config.toml 失败' : '配置文件解析失败'}`);
2974
+ console.error(` 详情: ${detail}`);
2975
+ console.error(` 路径: ${CONFIG_FILE}`);
2976
+ console.error(` 建议: ${isReadError ? '检查文件权限后重试' : '修复 config.toml 语法后重试'}`);
2977
+ console.error();
2978
+ process.exitCode = 1;
2979
+ }
2980
+
1519
2981
  function normalizeTopLevelConfigWithTemplate(template, selectedProvider, selectedModel) {
1520
2982
  let content = typeof template === 'string' ? template : '';
1521
2983
  if (!content.trim()) {
@@ -1656,6 +3118,9 @@ function addProviderToConfig(params = {}) {
1656
3118
 
1657
3119
  if (!name) return { error: '名称不能为空' };
1658
3120
  if (!url) return { error: 'URL 不能为空' };
3121
+ if (!isValidProviderName(name)) {
3122
+ return { error: '名称仅支持字母/数字/._-' };
3123
+ }
1659
3124
  if (isReservedProviderNameForCreation(name)) {
1660
3125
  return { error: 'local provider 为系统保留名称,不可新增' };
1661
3126
  }
@@ -1687,24 +3152,20 @@ function addProviderToConfig(params = {}) {
1687
3152
  return { error: `config.toml 解析失败: ${e.message}` };
1688
3153
  }
1689
3154
 
1690
- if (!parsed.model_providers || typeof parsed.model_providers !== 'object') {
1691
- parsed.model_providers = {};
1692
- }
1693
-
1694
- if (parsed.model_providers[name]) {
3155
+ const providerHeaderSegmentKeySet = collectModelProviderHeaderSegmentKeySet(content);
3156
+ const normalizedProviders = isPlainObject(parsed.model_providers)
3157
+ ? normalizeLegacyModelProviders(parsed.model_providers, providerHeaderSegmentKeySet)
3158
+ : {};
3159
+ if (normalizedProviders && normalizedProviders[name]) {
1695
3160
  return { error: '提供商已存在' };
1696
3161
  }
1697
3162
 
1698
- const escapeTomlString = (value) => String(value || '')
1699
- .replace(/\\/g, '\\\\')
1700
- .replace(/"/g, '\\"');
1701
-
1702
3163
  const lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
1703
- const safeName = escapeTomlString(name);
1704
- const safeUrl = escapeTomlString(url);
1705
- const safeKey = escapeTomlString(key);
3164
+ const safeName = escapeTomlBasicString(name);
3165
+ const safeUrl = escapeTomlBasicString(url);
3166
+ const safeKey = escapeTomlBasicString(key);
1706
3167
  const block = [
1707
- `[model_providers.${safeName}]`,
3168
+ buildModelProviderTableHeader(name),
1708
3169
  `name = "${safeName}"`,
1709
3170
  `base_url = "${safeUrl}"`,
1710
3171
  `wire_api = "responses"`,
@@ -1804,8 +3265,36 @@ function performProviderDeletion(name, options = {}) {
1804
3265
  const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
1805
3266
  const lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
1806
3267
  const hasBom = content.charCodeAt(0) === 0xFEFF;
1807
- const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1808
- const sectionRegex = new RegExp(`\\[\\s*model_providers\\s*\\.\\s*(?:"${safeName}"|'${safeName}'|${safeName})\\s*\\]`);
3268
+ const providerConfig = config.model_providers[name];
3269
+ const providerSegments = providerConfig && Array.isArray(providerConfig.__codexmate_legacy_segments)
3270
+ ? providerConfig.__codexmate_legacy_segments
3271
+ : null;
3272
+ const providerSegmentVariants = (() => {
3273
+ const variants = [];
3274
+ const seen = new Set();
3275
+ const pushVariant = (segments) => {
3276
+ const normalized = normalizeLegacySegments(segments);
3277
+ const key = buildLegacySegmentsKey(normalized);
3278
+ if (!key || seen.has(key)) return;
3279
+ seen.add(key);
3280
+ variants.push(normalized);
3281
+ };
3282
+ if (providerConfig && Array.isArray(providerConfig.__codexmate_legacy_segments)) {
3283
+ pushVariant(providerConfig.__codexmate_legacy_segments);
3284
+ }
3285
+ if (providerConfig && Array.isArray(providerConfig.__codexmate_legacy_segment_variants)) {
3286
+ for (const segments of providerConfig.__codexmate_legacy_segment_variants) {
3287
+ pushVariant(segments);
3288
+ }
3289
+ }
3290
+ if (providerSegments) {
3291
+ pushVariant(providerSegments);
3292
+ }
3293
+ if (variants.length === 0) {
3294
+ pushVariant(String(name || '').split('.').filter((item) => item));
3295
+ }
3296
+ return variants;
3297
+ })();
1809
3298
 
1810
3299
  const remainingProviders = Object.keys(config.model_providers || {}).filter(item => item !== name);
1811
3300
  if (remainingProviders.length === 0) {
@@ -1844,17 +3333,25 @@ function performProviderDeletion(name, options = {}) {
1844
3333
  };
1845
3334
 
1846
3335
  let updatedContent = null;
1847
- const match = content.match(sectionRegex);
1848
- if (match) {
1849
- const startIdx = match.index;
1850
- const rest = content.slice(startIdx + match[0].length);
1851
- const nextIdx = rest.indexOf('[');
1852
- const endIdx = nextIdx === -1 ? content.length : (startIdx + match[0].length + nextIdx);
1853
-
1854
- const removedContent = (content.slice(0, startIdx) + content.slice(endIdx))
1855
- .replace(/\n{3,}/g, lineEnding + lineEnding);
1856
-
1857
- updatedContent = removedContent;
3336
+ const combinedRanges = [];
3337
+ for (const segments of providerSegmentVariants) {
3338
+ combinedRanges.push(...findProviderSectionRanges(content, name, segments));
3339
+ combinedRanges.push(...findProviderDescendantSectionRanges(content, segments));
3340
+ }
3341
+ if (combinedRanges.length === 0) {
3342
+ combinedRanges.push(...findProviderSectionRanges(content, name, providerSegments));
3343
+ }
3344
+ if (combinedRanges.length > 0) {
3345
+ const sorted = combinedRanges.sort((a, b) => b.start - a.start || b.end - a.end);
3346
+ const seen = new Set();
3347
+ let removedContent = content;
3348
+ for (const range of sorted) {
3349
+ const rangeKey = `${range.start}:${range.end}`;
3350
+ if (seen.has(rangeKey)) continue;
3351
+ seen.add(rangeKey);
3352
+ removedContent = removedContent.slice(0, range.start) + removedContent.slice(range.end);
3353
+ }
3354
+ updatedContent = removedContent.replace(/\n{3,}/g, lineEnding + lineEnding);
1858
3355
  }
1859
3356
 
1860
3357
  if (updatedContent) {
@@ -4857,7 +6354,12 @@ async function cmdSetup() {
4857
6354
 
4858
6355
  // 显示当前状态
4859
6356
  function cmdStatus() {
4860
- const { config, isVirtual } = readConfigOrVirtualDefault();
6357
+ const configResult = readConfigOrVirtualDefault();
6358
+ if (hasConfigLoadError(configResult)) {
6359
+ printConfigLoadErrorAndMarkExit(configResult);
6360
+ return;
6361
+ }
6362
+ const { config, isVirtual } = configResult;
4861
6363
  const current = config.model_provider || '未设置';
4862
6364
  const currentModel = config.model || '未设置';
4863
6365
 
@@ -4873,7 +6375,12 @@ function cmdStatus() {
4873
6375
 
4874
6376
  // 列出所有提供商
4875
6377
  function cmdList() {
4876
- const { config, isVirtual } = readConfigOrVirtualDefault();
6378
+ const configResult = readConfigOrVirtualDefault();
6379
+ if (hasConfigLoadError(configResult)) {
6380
+ printConfigLoadErrorAndMarkExit(configResult);
6381
+ return;
6382
+ }
6383
+ const { config, isVirtual } = configResult;
4877
6384
  const providers = config.model_providers || {};
4878
6385
  const current = config.model_provider;
4879
6386
 
@@ -5028,6 +6535,10 @@ function cmdAdd(name, baseUrl, apiKey, silent = false) {
5028
6535
  }
5029
6536
  throw new Error('名称和URL必填');
5030
6537
  }
6538
+ if (!isValidProviderName(providerName)) {
6539
+ if (!silent) console.error('错误: 名称仅支持字母/数字/._-');
6540
+ throw new Error('名称仅支持字母/数字/._-');
6541
+ }
5031
6542
  if (isReservedProviderNameForCreation(providerName)) {
5032
6543
  if (!silent) console.error('错误: local provider 为系统保留名称,不可新增');
5033
6544
  throw new Error('local provider 为系统保留名称,不可新增');
@@ -5039,13 +6550,16 @@ function cmdAdd(name, baseUrl, apiKey, silent = false) {
5039
6550
  throw new Error('提供商已存在');
5040
6551
  }
5041
6552
 
6553
+ const safeName = escapeTomlBasicString(providerName);
6554
+ const safeBaseUrl = escapeTomlBasicString(providerBaseUrl);
6555
+ const safeApiKey = escapeTomlBasicString(apiKey || '');
5042
6556
  const newBlock = `
5043
- [model_providers.${providerName}]
5044
- name = "${providerName}"
5045
- base_url = "${providerBaseUrl}"
6557
+ ${buildModelProviderTableHeader(providerName)}
6558
+ name = "${safeName}"
6559
+ base_url = "${safeBaseUrl}"
5046
6560
  wire_api = "responses"
5047
6561
  requires_openai_auth = false
5048
- preferred_auth_method = "${apiKey || ''}"
6562
+ preferred_auth_method = "${safeApiKey}"
5049
6563
  request_max_retries = 4
5050
6564
  stream_max_retries = 10
5051
6565
  stream_idle_timeout_ms = 300000
@@ -5105,42 +6619,156 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
5105
6619
  }
5106
6620
 
5107
6621
  const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
5108
- const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5109
- const sectionRegex = new RegExp(`\\[\\s*model_providers\\s*\\.\\s*${safeName}\\s*\\]`);
5110
- const match = content.match(sectionRegex);
5111
- if (!match) {
6622
+ const providerConfig = config.model_providers[name];
6623
+ const providerSegments = providerConfig && Array.isArray(providerConfig.__codexmate_legacy_segments)
6624
+ ? providerConfig.__codexmate_legacy_segments
6625
+ : null;
6626
+ const ranges = findProviderSectionRanges(content, name, providerSegments);
6627
+ if (ranges.length === 0) {
5112
6628
  if (!silent) console.error('错误: 无法找到提供商配置块');
5113
6629
  throw new Error('无法找到提供商配置块');
5114
6630
  }
5115
6631
 
5116
- const startIdx = match.index;
5117
- const rest = content.slice(startIdx + match[0].length);
5118
- const nextIdx = rest.indexOf('[');
5119
- const endIdx = nextIdx === -1 ? content.length : (startIdx + match[0].length + nextIdx);
5120
-
5121
- // 提取该提供商的配置块
5122
- const providerBlock = content.slice(startIdx, endIdx);
6632
+ const replaceTomlStringField = (block, fieldName, rawValue) => {
6633
+ const safeValue = escapeTomlBasicString(rawValue);
6634
+ const escapedFieldName = escapeRegex(fieldName);
6635
+ const multilineRanges = collectTomlMultilineStringRanges(block);
6636
+ const tripleStartRegex = new RegExp(`^(\\s*${escapedFieldName}\\s*=\\s*)(\"\"\"|''')`, 'mg');
6637
+ let tripleStartMatch = null;
6638
+ let tripleCandidate;
6639
+ while ((tripleCandidate = tripleStartRegex.exec(block)) !== null) {
6640
+ if (isIndexInRanges(tripleCandidate.index, multilineRanges)) {
6641
+ continue;
6642
+ }
6643
+ tripleStartMatch = tripleCandidate;
6644
+ break;
6645
+ }
6646
+ if (tripleStartMatch) {
6647
+ const prefixStart = tripleStartMatch.index;
6648
+ const prefixEnd = prefixStart + tripleStartMatch[1].length;
6649
+ const tripleQuote = tripleStartMatch[2];
6650
+ const valueStart = prefixEnd + tripleQuote.length;
6651
+ const quoteChar = tripleQuote[0];
6652
+ let valueEnd = -1;
6653
+ let closingRunLength = 0;
6654
+ for (let i = valueStart; i < block.length; i++) {
6655
+ if (block[i] !== quoteChar) continue;
6656
+ let runEnd = i + 1;
6657
+ while (runEnd < block.length && block[runEnd] === quoteChar) {
6658
+ runEnd++;
6659
+ }
6660
+ const runLength = runEnd - i;
6661
+ if (runLength < tripleQuote.length) {
6662
+ i = runEnd - 1;
6663
+ continue;
6664
+ }
6665
+ if (tripleQuote === '"""') {
6666
+ let slashCount = 0;
6667
+ for (let j = i - 1; j >= valueStart && block[j] === '\\'; j--) {
6668
+ slashCount++;
6669
+ }
6670
+ if (slashCount % 2 !== 0) {
6671
+ continue;
6672
+ }
6673
+ }
6674
+ valueEnd = i;
6675
+ closingRunLength = runLength;
6676
+ break;
6677
+ }
6678
+ if (valueEnd === -1) {
6679
+ throw new Error(`${fieldName} 使用了未闭合的多行 TOML 字符串,无法安全更新`);
6680
+ }
6681
+ const lineEndIndex = block.indexOf('\n', valueEnd + closingRunLength);
6682
+ let tailEnd = lineEndIndex === -1 ? block.length : lineEndIndex;
6683
+ if (lineEndIndex > 0 && block[lineEndIndex - 1] === '\r') {
6684
+ tailEnd = lineEndIndex - 1;
6685
+ }
6686
+ const tail = block.slice(valueEnd + closingRunLength, tailEnd);
6687
+ const tailMatch = tail.match(/^(\s+#.*)?\s*$/);
6688
+ if (!tailMatch) {
6689
+ throw new Error(`${fieldName} 多行字符串后的语法不受支持,无法安全更新`);
6690
+ }
6691
+ const commentSuffix = tailMatch[1] || '';
6692
+ const replacementLine = `${block.slice(prefixStart, prefixEnd)}"${safeValue}"${commentSuffix}`;
6693
+ return block.slice(0, prefixStart) + replacementLine + block.slice(tailEnd);
6694
+ }
5123
6695
 
5124
- // 替换 base_url
5125
- let updatedBlock = providerBlock;
5126
- if (baseUrl) {
5127
- updatedBlock = updatedBlock.replace(
5128
- /^(base_url\s*=\s*)(["']).*?\2/m,
5129
- `$1$2${baseUrl}$2`
6696
+ const withCommentRegex = new RegExp(
6697
+ `^(\\s*${escapedFieldName}\\s*=\\s*)(?:"(?:\\\\.|[^"\\\\])*"|'[^'\\n]*')(\\s+#.*)?$`,
6698
+ 'mg'
5130
6699
  );
5131
- }
5132
-
5133
- // 替换 preferred_auth_method (API Key)
5134
- if (apiKey !== undefined) {
5135
- updatedBlock = updatedBlock.replace(
5136
- /^(preferred_auth_method\s*=\s*)(["']).*?\2/m,
5137
- `$1$2${apiKey}$2`
6700
+ let replaced = false;
6701
+ let next = block.replace(
6702
+ withCommentRegex,
6703
+ (full, prefix, suffix = '', offset) => {
6704
+ if (replaced || isIndexInRanges(offset, multilineRanges)) {
6705
+ return full;
6706
+ }
6707
+ replaced = true;
6708
+ return `${prefix}"${safeValue}"${suffix}`;
6709
+ }
5138
6710
  );
6711
+ if (!replaced) {
6712
+ const fallbackRegex = new RegExp(`^(\\s*${escapedFieldName}\\s*=\\s*)(.*?)(\\s+#.*)?$`, 'mg');
6713
+ let fallbackReplaced = false;
6714
+ const multilineRangesForNext = collectTomlMultilineStringRanges(next);
6715
+ let fallbackMatch;
6716
+ let fallbackCandidate;
6717
+ while ((fallbackCandidate = fallbackRegex.exec(next)) !== null) {
6718
+ if (isIndexInRanges(fallbackCandidate.index, multilineRangesForNext)) {
6719
+ continue;
6720
+ }
6721
+ fallbackMatch = fallbackCandidate;
6722
+ break;
6723
+ }
6724
+ if (fallbackMatch) {
6725
+ const existingValue = String(fallbackMatch[2] || '').trim();
6726
+ const looksLikeMultilineArray = existingValue.startsWith('[') && !existingValue.endsWith(']');
6727
+ const looksLikeMultilineInlineTable = existingValue.startsWith('{') && !existingValue.endsWith('}');
6728
+ if (looksLikeMultilineArray || looksLikeMultilineInlineTable) {
6729
+ throw new Error(`${fieldName} 当前值是多行 TOML 结构,无法安全更新`);
6730
+ }
6731
+ const prefix = fallbackMatch[1];
6732
+ const suffix = fallbackMatch[3] || '';
6733
+ const replacement = `${prefix}"${safeValue}"${suffix}`;
6734
+ next = `${next.slice(0, fallbackMatch.index)}${replacement}${next.slice(fallbackMatch.index + fallbackMatch[0].length)}`;
6735
+ fallbackReplaced = true;
6736
+ }
6737
+ if (!fallbackReplaced) {
6738
+ const keyIndentMatch = block.match(/^(\s*)[A-Za-z0-9_.-]+\s*=/m);
6739
+ const indent = keyIndentMatch ? keyIndentMatch[1] : '';
6740
+ const lineEnding = block.includes('\r\n') ? '\r\n' : '\n';
6741
+ const tailMatch = block.match(/(\s*)$/);
6742
+ const tail = tailMatch ? tailMatch[1] : '';
6743
+ const body = block.slice(0, block.length - tail.length);
6744
+ const separator = body.endsWith('\n') || body.endsWith('\r') ? '' : lineEnding;
6745
+ next = `${body}${separator}${indent}${fieldName} = "${safeValue}"${tail}`;
6746
+ }
6747
+ }
6748
+ return next;
6749
+ };
6750
+
6751
+ let newContent = content;
6752
+ const sorted = ranges.sort((a, b) => b.start - a.start);
6753
+ for (const range of sorted) {
6754
+ const providerBlock = newContent.slice(range.start, range.end);
6755
+ let updatedBlock = providerBlock;
6756
+ if (baseUrl) {
6757
+ updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', baseUrl);
6758
+ }
6759
+ if (apiKey !== undefined) {
6760
+ updatedBlock = replaceTomlStringField(updatedBlock, 'preferred_auth_method', apiKey);
6761
+ }
6762
+ newContent = newContent.slice(0, range.start) + updatedBlock + newContent.slice(range.end);
5139
6763
  }
5140
6764
 
5141
- // 组合新的内容
5142
- const newContent = content.slice(0, startIdx) + updatedBlock + content.slice(endIdx);
5143
- writeConfig(newContent.trim());
6765
+ const finalContent = newContent.trim();
6766
+ try {
6767
+ toml.parse(finalContent);
6768
+ } catch (e) {
6769
+ throw new Error(`更新后的 config.toml 无效: ${e.message}`);
6770
+ }
6771
+ writeConfig(finalContent);
5144
6772
 
5145
6773
  // 如果更新了 API Key 且该提供商是当前激活的,同步更新 auth.json
5146
6774
  const currentProvider = config.model_provider;
@@ -5297,12 +6925,76 @@ function readClaudeSettingsInfo() {
5297
6925
  : {};
5298
6926
 
5299
6927
  return {
5300
- exists: !!readResult.exists,
5301
- targetPath: CLAUDE_SETTINGS_FILE,
5302
- apiKey: typeof env.ANTHROPIC_API_KEY === 'string' ? env.ANTHROPIC_API_KEY : '',
5303
- baseUrl: typeof env.ANTHROPIC_BASE_URL === 'string' ? env.ANTHROPIC_BASE_URL : '',
5304
- model: typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : '',
5305
- env
6928
+ exists: !!readResult.exists,
6929
+ targetPath: CLAUDE_SETTINGS_FILE,
6930
+ apiKey: typeof env.ANTHROPIC_API_KEY === 'string' ? env.ANTHROPIC_API_KEY : '',
6931
+ baseUrl: typeof env.ANTHROPIC_BASE_URL === 'string' ? env.ANTHROPIC_BASE_URL : '',
6932
+ model: typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : '',
6933
+ env
6934
+ };
6935
+ }
6936
+
6937
+ function registerDownloadArtifact(filePath, options = {}) {
6938
+ const token = crypto.randomBytes(16).toString('hex');
6939
+ const fileName = typeof options.fileName === 'string' && options.fileName.trim()
6940
+ ? options.fileName.trim()
6941
+ : path.basename(filePath || '');
6942
+ const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0
6943
+ ? Math.floor(options.ttlMs)
6944
+ : DOWNLOAD_ARTIFACT_TTL_MS;
6945
+ const expiresAt = Date.now() + ttlMs;
6946
+ const deleteAfterDownload = options.deleteAfterDownload !== false;
6947
+
6948
+ g_downloadArtifacts.set(token, {
6949
+ filePath,
6950
+ fileName,
6951
+ deleteAfterDownload,
6952
+ expiresAt
6953
+ });
6954
+
6955
+ setTimeout(() => {
6956
+ const artifact = g_downloadArtifacts.get(token);
6957
+ if (!artifact) return;
6958
+ if (Date.now() < artifact.expiresAt) return;
6959
+ g_downloadArtifacts.delete(token);
6960
+ if (artifact.deleteAfterDownload && artifact.filePath && fs.existsSync(artifact.filePath)) {
6961
+ try {
6962
+ fs.unlinkSync(artifact.filePath);
6963
+ } catch (_) {}
6964
+ }
6965
+ }, ttlMs + 2000);
6966
+
6967
+ return {
6968
+ token,
6969
+ fileName,
6970
+ downloadPath: `/download/${encodeURIComponent(token)}`
6971
+ };
6972
+ }
6973
+
6974
+ function resolveDownloadArtifact(tokenOrFileName, options = {}) {
6975
+ if (!tokenOrFileName) return null;
6976
+ const token = typeof tokenOrFileName === 'string' ? tokenOrFileName.trim() : '';
6977
+ if (!token) return null;
6978
+
6979
+ const artifact = g_downloadArtifacts.get(token);
6980
+ if (!artifact) {
6981
+ return null;
6982
+ }
6983
+ if (Date.now() > artifact.expiresAt) {
6984
+ g_downloadArtifacts.delete(token);
6985
+ if (artifact.deleteAfterDownload && artifact.filePath && fs.existsSync(artifact.filePath)) {
6986
+ try {
6987
+ fs.unlinkSync(artifact.filePath);
6988
+ } catch (_) {}
6989
+ }
6990
+ return null;
6991
+ }
6992
+ if (options && options.consume === true) {
6993
+ g_downloadArtifacts.delete(token);
6994
+ }
6995
+ return {
6996
+ token,
6997
+ ...artifact
5306
6998
  };
5307
6999
  }
5308
7000
 
@@ -5368,23 +7060,206 @@ async function prepareCodexDirDownload() {
5368
7060
  }
5369
7061
  }
5370
7062
 
5371
- function copyDirRecursive(srcDir, destDir) {
7063
+ function copyDirRecursive(srcDir, destDir, options = {}) {
7064
+ const dereferenceSymlinks = !!(options && options.dereferenceSymlinks);
7065
+ const allowedRootRealPath = (options && typeof options.allowedRootRealPath === 'string')
7066
+ ? options.allowedRootRealPath
7067
+ : '';
7068
+ const visitedRealPaths = options && options.visitedRealPaths instanceof Set
7069
+ ? options.visitedRealPaths
7070
+ : new Set();
7071
+ const childOptions = {
7072
+ ...options,
7073
+ dereferenceSymlinks,
7074
+ allowedRootRealPath,
7075
+ visitedRealPaths
7076
+ };
5372
7077
  ensureDir(destDir);
5373
7078
  const entries = fs.readdirSync(srcDir, { withFileTypes: true });
5374
7079
  for (const entry of entries) {
5375
7080
  const srcPath = path.join(srcDir, entry.name);
5376
7081
  const destPath = path.join(destDir, entry.name);
5377
7082
  if (entry.isDirectory()) {
5378
- copyDirRecursive(srcPath, destPath);
7083
+ if (!dereferenceSymlinks) {
7084
+ copyDirRecursive(srcPath, destPath, childOptions);
7085
+ continue;
7086
+ }
7087
+ const realPath = fs.realpathSync(srcPath);
7088
+ if (allowedRootRealPath && !isPathInside(realPath, allowedRootRealPath)) {
7089
+ throw new Error(`symlink escapes skill root: ${srcPath}`);
7090
+ }
7091
+ if (visitedRealPaths.has(realPath)) {
7092
+ continue;
7093
+ }
7094
+ visitedRealPaths.add(realPath);
7095
+ try {
7096
+ copyDirRecursive(srcPath, destPath, childOptions);
7097
+ } finally {
7098
+ visitedRealPaths.delete(realPath);
7099
+ }
5379
7100
  } else if (entry.isSymbolicLink()) {
5380
- const target = fs.readlinkSync(srcPath);
5381
- fs.symlinkSync(target, destPath);
7101
+ if (dereferenceSymlinks) {
7102
+ const realPath = fs.realpathSync(srcPath);
7103
+ if (allowedRootRealPath && !isPathInside(realPath, allowedRootRealPath)) {
7104
+ throw new Error(`symlink escapes skill root: ${srcPath}`);
7105
+ }
7106
+ const realStat = fs.statSync(realPath);
7107
+ if (realStat.isDirectory()) {
7108
+ if (visitedRealPaths.has(realPath)) {
7109
+ continue;
7110
+ }
7111
+ visitedRealPaths.add(realPath);
7112
+ try {
7113
+ copyDirRecursive(realPath, destPath, childOptions);
7114
+ } finally {
7115
+ visitedRealPaths.delete(realPath);
7116
+ }
7117
+ } else {
7118
+ fs.copyFileSync(realPath, destPath);
7119
+ }
7120
+ } else {
7121
+ const target = fs.readlinkSync(srcPath);
7122
+ fs.symlinkSync(target, destPath);
7123
+ }
5382
7124
  } else {
5383
7125
  fs.copyFileSync(srcPath, destPath);
5384
7126
  }
5385
7127
  }
5386
7128
  }
5387
7129
 
7130
+ function inspectZipArchiveLimits(zipPath, options = {}) {
7131
+ const maxEntryCount = Number.isFinite(options.maxEntryCount) && options.maxEntryCount > 0
7132
+ ? Math.floor(options.maxEntryCount)
7133
+ : MAX_SKILLS_ZIP_ENTRY_COUNT;
7134
+ const maxUncompressedBytes = Number.isFinite(options.maxUncompressedBytes) && options.maxUncompressedBytes > 0
7135
+ ? Math.floor(options.maxUncompressedBytes)
7136
+ : MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES;
7137
+
7138
+ return new Promise((resolve, reject) => {
7139
+ yauzl.open(zipPath, { lazyEntries: true, autoClose: true }, (openErr, zipFile) => {
7140
+ if (openErr) {
7141
+ reject(openErr);
7142
+ return;
7143
+ }
7144
+ if (!zipFile) {
7145
+ reject(new Error('无法读取 ZIP 文件'));
7146
+ return;
7147
+ }
7148
+ let entryCount = 0;
7149
+ let totalUncompressedBytes = 0;
7150
+ let settled = false;
7151
+ const finish = (err, data) => {
7152
+ if (settled) return;
7153
+ settled = true;
7154
+ try {
7155
+ zipFile.close();
7156
+ } catch (_) {}
7157
+ if (err) {
7158
+ reject(err);
7159
+ } else {
7160
+ resolve(data);
7161
+ }
7162
+ };
7163
+
7164
+ zipFile.on('entry', (entry) => {
7165
+ if (settled) return;
7166
+ entryCount += 1;
7167
+ const entrySize = Number.isFinite(entry.uncompressedSize) ? entry.uncompressedSize : 0;
7168
+ totalUncompressedBytes += entrySize;
7169
+ if (entryCount > maxEntryCount) {
7170
+ finish(new Error(`压缩包条目过多(>${maxEntryCount})`));
7171
+ return;
7172
+ }
7173
+ if (totalUncompressedBytes > maxUncompressedBytes) {
7174
+ finish(new Error(`压缩包解压总大小超限(>${Math.floor(maxUncompressedBytes / 1024 / 1024)}MB)`));
7175
+ return;
7176
+ }
7177
+ zipFile.readEntry();
7178
+ });
7179
+
7180
+ zipFile.on('end', () => {
7181
+ finish(null, { entryCount, totalUncompressedBytes });
7182
+ });
7183
+
7184
+ zipFile.on('error', (zipErr) => {
7185
+ finish(zipErr);
7186
+ });
7187
+
7188
+ zipFile.readEntry();
7189
+ });
7190
+ });
7191
+ }
7192
+
7193
+ function writeUploadZipStream(req, prefix, originalName = '', maxSize = MAX_SKILLS_ZIP_UPLOAD_SIZE) {
7194
+ return new Promise((resolve, reject) => {
7195
+ const lengthHeader = parseInt(req.headers['content-length'] || '0', 10);
7196
+ if (Number.isFinite(lengthHeader) && lengthHeader > maxSize) {
7197
+ reject(new Error(`备份文件过大(>${Math.floor(maxSize / 1024 / 1024)}MB)`));
7198
+ return;
7199
+ }
7200
+
7201
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
7202
+ const rawName = originalName && typeof originalName === 'string' ? originalName : `${prefix}.zip`;
7203
+ const fileName = path.basename(rawName);
7204
+ const zipPath = path.join(tempDir, fileName.toLowerCase().endsWith('.zip') ? fileName : `${fileName}.zip`);
7205
+ const stream = fs.createWriteStream(zipPath);
7206
+ let bytesWritten = 0;
7207
+ let settled = false;
7208
+ let hasContent = false;
7209
+
7210
+ const fail = (err) => {
7211
+ if (settled) return;
7212
+ settled = true;
7213
+ try {
7214
+ stream.destroy();
7215
+ } catch (_) {}
7216
+ try {
7217
+ fs.rmSync(tempDir, { recursive: true, force: true });
7218
+ } catch (_) {}
7219
+ reject(err);
7220
+ };
7221
+
7222
+ const done = () => {
7223
+ if (settled) return;
7224
+ settled = true;
7225
+ if (!hasContent || bytesWritten <= 0) {
7226
+ try {
7227
+ fs.rmSync(tempDir, { recursive: true, force: true });
7228
+ } catch (_) {}
7229
+ reject(new Error('备份文件为空'));
7230
+ return;
7231
+ }
7232
+ resolve({ tempDir, zipPath });
7233
+ };
7234
+
7235
+ req.on('error', (err) => fail(err));
7236
+ req.on('aborted', () => fail(new Error('上传已中断')));
7237
+ req.on('close', () => {
7238
+ if (!settled && !req.complete) {
7239
+ fail(new Error('上传已中断'));
7240
+ }
7241
+ });
7242
+ stream.on('error', (err) => fail(err));
7243
+ req.on('data', (chunk) => {
7244
+ if (settled) return;
7245
+ hasContent = true;
7246
+ bytesWritten += chunk.length;
7247
+ if (bytesWritten > maxSize) {
7248
+ fail(new Error(`备份文件过大(>${Math.floor(maxSize / 1024 / 1024)}MB)`));
7249
+ try {
7250
+ req.destroy();
7251
+ } catch (_) {}
7252
+ return;
7253
+ }
7254
+ stream.write(chunk);
7255
+ });
7256
+ req.on('end', () => {
7257
+ if (settled) return;
7258
+ stream.end(() => done());
7259
+ });
7260
+ });
7261
+ }
7262
+
5388
7263
  function writeUploadZip(base64, prefix, originalName = '') {
5389
7264
  let buffer;
5390
7265
  try {
@@ -6080,7 +7955,7 @@ async function cmdExportSession(args = []) {
6080
7955
  }
6081
7956
 
6082
7957
  function parseStartOptions(args = []) {
6083
- const options = { host: '' };
7958
+ const options = { host: '', noBrowser: false };
6084
7959
  if (!Array.isArray(args)) {
6085
7960
  return options;
6086
7961
  }
@@ -6088,6 +7963,10 @@ function parseStartOptions(args = []) {
6088
7963
  for (let i = 0; i < args.length; i++) {
6089
7964
  const arg = args[i];
6090
7965
  if (!arg) continue;
7966
+ if (arg === '--no-browser') {
7967
+ options.noBrowser = true;
7968
+ continue;
7969
+ }
6091
7970
  if (arg.startsWith('--host=')) {
6092
7971
  options.host = arg.slice('--host='.length);
6093
7972
  continue;
@@ -6160,11 +8039,125 @@ function watchPathsForRestart(targets, onChange) {
6160
8039
  };
6161
8040
  }
6162
8041
 
8042
+ function writeJsonResponse(res, statusCode, payload) {
8043
+ const body = JSON.stringify(payload, null, 2);
8044
+ res.writeHead(statusCode, {
8045
+ 'Content-Type': 'application/json; charset=utf-8',
8046
+ 'Content-Length': Buffer.byteLength(body, 'utf-8')
8047
+ });
8048
+ res.end(body, 'utf-8');
8049
+ }
8050
+
8051
+ function streamZipDownloadResponse(res, filePath, options = {}) {
8052
+ if (!filePath || !fs.existsSync(filePath)) {
8053
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
8054
+ res.end('File Not Found');
8055
+ return;
8056
+ }
8057
+ const stat = fs.statSync(filePath);
8058
+ if (!stat.isFile()) {
8059
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
8060
+ res.end('Not a File');
8061
+ return;
8062
+ }
8063
+ const downloadName = typeof options.fileName === 'string' && options.fileName.trim()
8064
+ ? options.fileName.trim()
8065
+ : path.basename(filePath);
8066
+ const deleteAfterDownload = !!options.deleteAfterDownload;
8067
+ const onAfterComplete = typeof options.onAfterComplete === 'function'
8068
+ ? options.onAfterComplete
8069
+ : null;
8070
+ res.writeHead(200, {
8071
+ 'Content-Type': 'application/zip',
8072
+ 'Content-Disposition': `attachment; filename="${path.basename(downloadName)}"`,
8073
+ 'Content-Length': stat.size
8074
+ });
8075
+
8076
+ const stream = fs.createReadStream(filePath);
8077
+ let finished = false;
8078
+ const finalize = () => {
8079
+ if (finished) return;
8080
+ finished = true;
8081
+ if (deleteAfterDownload && fs.existsSync(filePath)) {
8082
+ try {
8083
+ fs.unlinkSync(filePath);
8084
+ } catch (_) {}
8085
+ }
8086
+ if (onAfterComplete) {
8087
+ try {
8088
+ onAfterComplete();
8089
+ } catch (_) {}
8090
+ }
8091
+ };
8092
+ stream.on('error', () => {
8093
+ if (!res.headersSent) {
8094
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
8095
+ res.end('Download Error');
8096
+ } else {
8097
+ try {
8098
+ res.destroy();
8099
+ } catch (_) {}
8100
+ }
8101
+ finalize();
8102
+ });
8103
+ res.on('finish', finalize);
8104
+ res.on('close', finalize);
8105
+ stream.pipe(res);
8106
+ }
8107
+
8108
+ function resolveUploadFileNameFromRequest(req, fallbackName = 'codex-skills.zip') {
8109
+ const rawHeader = req.headers['x-codexmate-file-name'];
8110
+ const source = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
8111
+ const fallback = typeof fallbackName === 'string' && fallbackName.trim()
8112
+ ? fallbackName.trim()
8113
+ : 'codex-skills.zip';
8114
+ if (!source || typeof source !== 'string') {
8115
+ return fallback;
8116
+ }
8117
+ const decoded = (() => {
8118
+ try {
8119
+ return decodeURIComponent(source);
8120
+ } catch (_) {
8121
+ return source;
8122
+ }
8123
+ })();
8124
+ const normalized = path.basename(decoded.trim());
8125
+ return normalized || fallback;
8126
+ }
8127
+
8128
+ async function handleImportCodexSkillsZipUpload(req, res) {
8129
+ if (req.method !== 'POST') {
8130
+ writeJsonResponse(res, 405, { error: 'Method Not Allowed' });
8131
+ return;
8132
+ }
8133
+ try {
8134
+ const fileName = resolveUploadFileNameFromRequest(req, 'codex-skills.zip');
8135
+ const upload = await writeUploadZipStream(
8136
+ req,
8137
+ 'codex-skills-import',
8138
+ fileName,
8139
+ MAX_SKILLS_ZIP_UPLOAD_SIZE
8140
+ );
8141
+ const result = await importCodexSkillsFromZipFile(upload.zipPath, {
8142
+ tempDir: upload.tempDir,
8143
+ fallbackName: fileName
8144
+ });
8145
+ writeJsonResponse(res, 200, result || {});
8146
+ } catch (e) {
8147
+ const message = e && e.message ? e.message : '上传失败';
8148
+ writeJsonResponse(res, 400, { error: message });
8149
+ }
8150
+ }
8151
+
6163
8152
  function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {
6164
8153
  const connections = new Set();
6165
8154
 
6166
8155
  const server = http.createServer((req, res) => {
6167
8156
  const requestPath = (req.url || '/').split('?')[0];
8157
+ if (requestPath === '/api/import-codex-skills-zip') {
8158
+ void handleImportCodexSkillsZipUpload(req, res);
8159
+ return;
8160
+ }
6168
8161
  if (requestPath === '/api') {
6169
8162
  let body = '';
6170
8163
  req.on('data', chunk => body += chunk);
@@ -6185,6 +8178,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
6185
8178
  serviceTier,
6186
8179
  modelReasoningEffort,
6187
8180
  configReady: !statusConfigResult.isVirtual,
8181
+ configErrorType: statusConfigResult.errorType || '',
6188
8182
  configNotice: statusConfigResult.reason || '',
6189
8183
  initNotice: consumeInitNotice()
6190
8184
  };
@@ -6199,6 +8193,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
6199
8193
  const current = listConfig.model_provider;
6200
8194
  result = {
6201
8195
  configReady: !listConfigResult.isVirtual,
8196
+ configErrorType: listConfigResult.errorType || '',
8197
+ configNotice: listConfigResult.reason || '',
6202
8198
  providers: Object.entries(providers).map(([name, p]) => ({
6203
8199
  name,
6204
8200
  url: p.base_url || '',
@@ -6273,6 +8269,21 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
6273
8269
  case 'apply-agents-file':
6274
8270
  result = applyAgentsFile(params || {});
6275
8271
  break;
8272
+ case 'list-codex-skills':
8273
+ result = listCodexSkills();
8274
+ break;
8275
+ case 'delete-codex-skills':
8276
+ result = deleteCodexSkills(params || {});
8277
+ break;
8278
+ case 'scan-unmanaged-codex-skills':
8279
+ result = scanUnmanagedCodexSkills();
8280
+ break;
8281
+ case 'import-codex-skills':
8282
+ result = importCodexSkills(params || {});
8283
+ break;
8284
+ case 'export-codex-skills':
8285
+ result = await exportCodexSkills(params || {});
8286
+ break;
6276
8287
  case 'get-openclaw-config':
6277
8288
  result = readOpenclawConfigFile();
6278
8289
  break;
@@ -6433,6 +8444,58 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
6433
8444
  case 'proxy-apply-provider':
6434
8445
  result = applyBuiltinProxyProvider(params || {});
6435
8446
  break;
8447
+ case 'workflow-list':
8448
+ result = listWorkflowDefinitions();
8449
+ break;
8450
+ case 'workflow-get':
8451
+ {
8452
+ const id = params && typeof params.id === 'string' ? params.id.trim() : '';
8453
+ if (!id) {
8454
+ result = { error: 'workflow id is required' };
8455
+ } else {
8456
+ result = getWorkflowDefinitionById(id);
8457
+ }
8458
+ }
8459
+ break;
8460
+ case 'workflow-validate':
8461
+ {
8462
+ const id = params && typeof params.id === 'string' ? params.id.trim() : '';
8463
+ if (!id) {
8464
+ result = { ok: false, error: 'workflow id is required' };
8465
+ break;
8466
+ }
8467
+ const input = params && params.input && typeof params.input === 'object' && !Array.isArray(params.input)
8468
+ ? params.input
8469
+ : {};
8470
+ result = validateWorkflowById(id, input);
8471
+ }
8472
+ break;
8473
+ case 'workflow-run':
8474
+ {
8475
+ const id = params && typeof params.id === 'string' ? params.id.trim() : '';
8476
+ if (!id) {
8477
+ result = { error: 'workflow id is required' };
8478
+ break;
8479
+ }
8480
+ const input = params && params.input && typeof params.input === 'object' && !Array.isArray(params.input)
8481
+ ? params.input
8482
+ : {};
8483
+ result = await runWorkflowById(id, input, {
8484
+ allowWrite: !!(params && params.allowWrite),
8485
+ dryRun: !!(params && params.dryRun)
8486
+ });
8487
+ }
8488
+ break;
8489
+ case 'workflow-runs':
8490
+ {
8491
+ const rawLimit = params && Number.isFinite(params.limit) ? params.limit : parseInt(params && params.limit, 10);
8492
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : 20;
8493
+ result = {
8494
+ runs: listWorkflowRunRecords(limit),
8495
+ limit
8496
+ };
8497
+ }
8498
+ break;
6436
8499
  default:
6437
8500
  result = { error: '未知操作' };
6438
8501
  }
@@ -6479,32 +8542,35 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
6479
8542
  fs.createReadStream(filePath).pipe(res);
6480
8543
  } else if (requestPath.startsWith('/download/')) {
6481
8544
  const fileName = requestPath.slice('/download/'.length);
6482
- const decodedFileName = decodeURIComponent(fileName);
6483
- const tempDir = os.tmpdir();
6484
- const filePath = path.join(tempDir, decodedFileName);
6485
-
6486
- if (!isPathInside(filePath, tempDir)) {
6487
- res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
6488
- res.end('Forbidden');
8545
+ let decodedFileName = '';
8546
+ try {
8547
+ decodedFileName = decodeURIComponent(fileName);
8548
+ } catch (_) {
8549
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
8550
+ res.end('Bad Request');
6489
8551
  return;
6490
8552
  }
6491
- if (!fs.existsSync(filePath)) {
6492
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
6493
- res.end('File Not Found');
8553
+
8554
+ const artifact = resolveDownloadArtifact(decodedFileName, { consume: true });
8555
+ if (artifact) {
8556
+ streamZipDownloadResponse(res, artifact.filePath, {
8557
+ fileName: artifact.fileName,
8558
+ deleteAfterDownload: artifact.deleteAfterDownload !== false
8559
+ });
6494
8560
  return;
6495
8561
  }
6496
- const stat = fs.statSync(filePath);
6497
- if (!stat.isFile()) {
6498
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
6499
- res.end('Not a File');
8562
+
8563
+ const tempDir = os.tmpdir();
8564
+ const legacyFilePath = path.join(tempDir, decodedFileName);
8565
+ if (!isPathInside(legacyFilePath, tempDir)) {
8566
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
8567
+ res.end('Forbidden');
6500
8568
  return;
6501
8569
  }
6502
- res.writeHead(200, {
6503
- 'Content-Type': 'application/zip',
6504
- 'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
6505
- 'Content-Length': stat.size
8570
+ streamZipDownloadResponse(res, legacyFilePath, {
8571
+ fileName: path.basename(legacyFilePath),
8572
+ deleteAfterDownload: false
6506
8573
  });
6507
- fs.createReadStream(filePath).pipe(res);
6508
8574
  } else if (requestPath.startsWith('/res/')) {
6509
8575
  const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
6510
8576
  const filePath = path.join(__dirname, normalized);
@@ -6627,7 +8693,7 @@ function cmdStart(options = {}) {
6627
8693
  webDir,
6628
8694
  host,
6629
8695
  port,
6630
- openBrowser: true
8696
+ openBrowser: !options.noBrowser
6631
8697
  });
6632
8698
 
6633
8699
  const proxySettings = readBuiltinProxySettings();
@@ -6948,7 +9014,223 @@ async function cmdProxy(args = []) {
6948
9014
  return;
6949
9015
  }
6950
9016
 
6951
- throw new Error(`未知 proxy 子命令: ${subcommand}`);
9017
+ throw new Error(`未知 proxy 子命令: ${subcommand}`);
9018
+ }
9019
+
9020
+ function parseWorkflowInputArg(rawInput) {
9021
+ const raw = typeof rawInput === 'string' ? rawInput.trim() : '';
9022
+ if (!raw) {
9023
+ return {};
9024
+ }
9025
+ let content = raw;
9026
+ if (raw.startsWith('@')) {
9027
+ const filePath = path.resolve(raw.slice(1));
9028
+ if (!fs.existsSync(filePath)) {
9029
+ throw new Error(`工作流输入文件不存在: ${filePath}`);
9030
+ }
9031
+ content = fs.readFileSync(filePath, 'utf-8');
9032
+ }
9033
+ let parsed;
9034
+ try {
9035
+ parsed = JSON.parse(content);
9036
+ } catch (e) {
9037
+ throw new Error(`工作流输入 JSON 解析失败: ${e.message}`);
9038
+ }
9039
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
9040
+ throw new Error('工作流输入必须是 JSON 对象');
9041
+ }
9042
+ return parsed;
9043
+ }
9044
+
9045
+ function printWorkflowHelp() {
9046
+ console.log('\n用法: codexmate workflow <list|get|validate|run|runs> [参数]');
9047
+ console.log(' codexmate workflow list');
9048
+ console.log(' codexmate workflow get diagnose-config');
9049
+ console.log(' codexmate workflow validate safe-provider-switch --input \'{"provider":"e2e"}\'');
9050
+ console.log(' codexmate workflow run diagnose-config --input \'{}\'');
9051
+ console.log(' codexmate workflow run safe-provider-switch --input \'{"provider":"e2e","apply":true}\' --allow-write');
9052
+ console.log(' codexmate workflow runs --limit 20');
9053
+ console.log('参数:');
9054
+ console.log(' --input <JSON|@file> 传入工作流输入');
9055
+ console.log(' --allow-write 允许执行写入步骤');
9056
+ console.log(' --dry-run 跳过写入步骤,仅预演');
9057
+ console.log(' --limit <N> 读取最近执行记录数量(runs)');
9058
+ console.log(' --json 以 JSON 输出');
9059
+ console.log();
9060
+ }
9061
+
9062
+ function parseWorkflowCliOptions(args = []) {
9063
+ const options = {
9064
+ inputRaw: '',
9065
+ allowWrite: false,
9066
+ dryRun: false,
9067
+ limit: 20,
9068
+ json: false
9069
+ };
9070
+ const rest = [];
9071
+ for (let i = 0; i < args.length; i += 1) {
9072
+ const arg = args[i];
9073
+ if (arg === '--allow-write') {
9074
+ options.allowWrite = true;
9075
+ continue;
9076
+ }
9077
+ if (arg === '--dry-run') {
9078
+ options.dryRun = true;
9079
+ continue;
9080
+ }
9081
+ if (arg === '--json') {
9082
+ options.json = true;
9083
+ continue;
9084
+ }
9085
+ if (arg === '--input') {
9086
+ options.inputRaw = args[i + 1] || '';
9087
+ i += 1;
9088
+ continue;
9089
+ }
9090
+ if (arg.startsWith('--input=')) {
9091
+ options.inputRaw = arg.slice('--input='.length);
9092
+ continue;
9093
+ }
9094
+ if (arg === '--limit') {
9095
+ const raw = args[i + 1];
9096
+ i += 1;
9097
+ const value = parseInt(raw, 10);
9098
+ if (Number.isFinite(value)) {
9099
+ options.limit = value;
9100
+ }
9101
+ continue;
9102
+ }
9103
+ if (arg.startsWith('--limit=')) {
9104
+ const value = parseInt(arg.slice('--limit='.length), 10);
9105
+ if (Number.isFinite(value)) {
9106
+ options.limit = value;
9107
+ }
9108
+ continue;
9109
+ }
9110
+ rest.push(arg);
9111
+ }
9112
+ return { options, rest };
9113
+ }
9114
+
9115
+ async function cmdWorkflow(args = []) {
9116
+ const argv = Array.isArray(args) ? args : [];
9117
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
9118
+ printWorkflowHelp();
9119
+ return;
9120
+ }
9121
+ const subcommand = String(argv[0] || '').trim().toLowerCase();
9122
+ const parsed = parseWorkflowCliOptions(argv.slice(1));
9123
+ const options = parsed.options;
9124
+ const rest = parsed.rest;
9125
+
9126
+ if (subcommand === 'list') {
9127
+ const result = listWorkflowDefinitions();
9128
+ if (options.json) {
9129
+ console.log(JSON.stringify(result, null, 2));
9130
+ return;
9131
+ }
9132
+ const workflows = Array.isArray(result.workflows) ? result.workflows : [];
9133
+ console.log('\n可用工作流:');
9134
+ for (const item of workflows) {
9135
+ const mode = item.readOnly ? 'read-only' : 'read-write';
9136
+ console.log(` - ${item.id} (${mode}, steps=${item.stepCount})`);
9137
+ if (item.description) {
9138
+ console.log(` ${item.description}`);
9139
+ }
9140
+ }
9141
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
9142
+ console.log('\n警告:');
9143
+ result.warnings.forEach((msg) => console.log(` - ${msg}`));
9144
+ }
9145
+ console.log();
9146
+ return;
9147
+ }
9148
+
9149
+ if (subcommand === 'runs') {
9150
+ const limit = Number.isFinite(options.limit) ? Math.max(1, Math.floor(options.limit)) : 20;
9151
+ const runs = listWorkflowRunRecords(limit);
9152
+ if (options.json) {
9153
+ console.log(JSON.stringify({ runs, limit }, null, 2));
9154
+ return;
9155
+ }
9156
+ console.log(`\n最近执行记录(${runs.length}/${limit}):`);
9157
+ for (const item of runs) {
9158
+ const status = item && item.success ? 'OK' : 'FAIL';
9159
+ console.log(` - [${status}] ${item.workflowId || '(unknown)'} runId=${item.runId || ''} duration=${item.durationMs || 0}ms`);
9160
+ if (item && item.error) {
9161
+ console.log(` error: ${item.error}`);
9162
+ }
9163
+ }
9164
+ console.log();
9165
+ return;
9166
+ }
9167
+
9168
+ const workflowId = typeof rest[0] === 'string' ? rest[0].trim() : '';
9169
+ if (!workflowId) {
9170
+ throw new Error('workflow id is required');
9171
+ }
9172
+ const input = parseWorkflowInputArg(options.inputRaw);
9173
+
9174
+ if (subcommand === 'get') {
9175
+ const result = getWorkflowDefinitionById(workflowId);
9176
+ if (result.error) {
9177
+ throw new Error(result.error);
9178
+ }
9179
+ console.log(JSON.stringify(result, null, 2));
9180
+ return;
9181
+ }
9182
+
9183
+ if (subcommand === 'validate') {
9184
+ const result = validateWorkflowById(workflowId, input);
9185
+ if (!result.ok) {
9186
+ throw new Error(result.error || 'workflow validate failed');
9187
+ }
9188
+ if (options.json) {
9189
+ console.log(JSON.stringify(result, null, 2));
9190
+ } else {
9191
+ console.log(`✓ 工作流校验通过: ${workflowId}`);
9192
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
9193
+ result.warnings.forEach((msg) => console.log(` - ${msg}`));
9194
+ }
9195
+ console.log();
9196
+ }
9197
+ return;
9198
+ }
9199
+
9200
+ if (subcommand === 'run') {
9201
+ const result = await runWorkflowById(workflowId, input, {
9202
+ allowWrite: options.allowWrite,
9203
+ dryRun: options.dryRun
9204
+ });
9205
+ if (options.json) {
9206
+ console.log(JSON.stringify(result, null, 2));
9207
+ } else {
9208
+ if (result.error) {
9209
+ console.error(`✗ 工作流执行失败: ${result.error}`);
9210
+ } else {
9211
+ console.log(`✓ 工作流执行完成: ${workflowId} (${result.durationMs || 0}ms)`);
9212
+ }
9213
+ const steps = Array.isArray(result.steps) ? result.steps : [];
9214
+ for (const step of steps) {
9215
+ const status = step.status || 'unknown';
9216
+ const label = step.id || step.tool || '(step)';
9217
+ console.log(` - ${label}: ${status} (${step.durationMs || 0}ms)`);
9218
+ if (step.error) {
9219
+ console.log(` error: ${step.error}`);
9220
+ }
9221
+ }
9222
+ if (result.runId) {
9223
+ console.log(` runId: ${result.runId}`);
9224
+ }
9225
+ console.log();
9226
+ }
9227
+ if (result.error) {
9228
+ throw new Error(result.error);
9229
+ }
9230
+ return;
9231
+ }
9232
+
9233
+ throw new Error(`未知 workflow 子命令: ${subcommand}`);
6952
9234
  }
6953
9235
 
6954
9236
  async function runProxyCommand(displayName, binNames, args = [], installTip = '') {
@@ -7023,10 +9305,6 @@ async function cmdQwen(args = []) {
7023
9305
  return runProxyCommand('Qwen', ['qwen', 'qwen-code'], args, 'npm install -g @qwen-code/qwen-code');
7024
9306
  }
7025
9307
 
7026
- async function cmdGemini(args = []) {
7027
- return runProxyCommand('Gemini', ['gemini', 'gemini-cli'], args, 'npm install -g @google/gemini-cli');
7028
- }
7029
-
7030
9308
  function parseMcpOptions(args = []) {
7031
9309
  const options = {
7032
9310
  subcommand: 'serve',
@@ -7103,6 +9381,7 @@ function buildMcpStatusPayload() {
7103
9381
  serviceTier,
7104
9382
  modelReasoningEffort,
7105
9383
  configReady: !statusConfigResult.isVirtual,
9384
+ configErrorType: statusConfigResult.errorType || '',
7106
9385
  configNotice: statusConfigResult.reason || '',
7107
9386
  initNotice: consumeInitNotice()
7108
9387
  };
@@ -7115,6 +9394,8 @@ function buildMcpProviderListPayload() {
7115
9394
  const current = listConfig.model_provider;
7116
9395
  return {
7117
9396
  configReady: !listConfigResult.isVirtual,
9397
+ configErrorType: listConfigResult.errorType || '',
9398
+ configNotice: listConfigResult.reason || '',
7118
9399
  providers: Object.entries(providers).map(([name, p]) => ({
7119
9400
  name,
7120
9401
  url: p.base_url || '',
@@ -7167,6 +9448,541 @@ function normalizeMcpSource(value) {
7167
9448
  return null;
7168
9449
  }
7169
9450
 
9451
+ const BUILTIN_WORKFLOW_DEFINITIONS = Object.freeze({
9452
+ 'diagnose-config': {
9453
+ id: 'diagnose-config',
9454
+ name: 'Diagnose Config',
9455
+ description: 'Collect status/providers/proxy snapshots for troubleshooting.',
9456
+ readOnly: true,
9457
+ inputSchema: {
9458
+ type: 'object',
9459
+ properties: {},
9460
+ additionalProperties: false
9461
+ },
9462
+ steps: [
9463
+ { id: 'status', tool: 'codexmate.status.get', arguments: {} },
9464
+ { id: 'providers', tool: 'codexmate.provider.list', arguments: {} },
9465
+ { id: 'proxy', tool: 'codexmate.proxy.status', arguments: {} }
9466
+ ]
9467
+ },
9468
+ 'safe-provider-switch': {
9469
+ id: 'safe-provider-switch',
9470
+ name: 'Safe Provider Switch',
9471
+ description: 'Build template for a provider switch and optionally apply it.',
9472
+ readOnly: false,
9473
+ inputSchema: {
9474
+ type: 'object',
9475
+ properties: {
9476
+ provider: { type: 'string' },
9477
+ model: { type: 'string' },
9478
+ serviceTier: { type: 'string' },
9479
+ reasoningEffort: { type: 'string' },
9480
+ apply: { type: 'boolean' }
9481
+ },
9482
+ required: ['provider'],
9483
+ additionalProperties: false
9484
+ },
9485
+ steps: [
9486
+ { id: 'providers', tool: 'codexmate.provider.list', arguments: {} },
9487
+ {
9488
+ id: 'template',
9489
+ tool: 'codexmate.config.template.get',
9490
+ arguments: {
9491
+ provider: '{{input.provider}}',
9492
+ model: '{{input.model}}',
9493
+ serviceTier: '{{input.serviceTier}}',
9494
+ reasoningEffort: '{{input.reasoningEffort}}'
9495
+ }
9496
+ },
9497
+ {
9498
+ id: 'apply',
9499
+ tool: 'codexmate.config.template.apply',
9500
+ when: { path: 'input.apply', equals: true },
9501
+ arguments: {
9502
+ template: '{{steps.template.output.template}}'
9503
+ }
9504
+ },
9505
+ {
9506
+ id: 'statusAfter',
9507
+ tool: 'codexmate.status.get',
9508
+ when: { path: 'input.apply', equals: true },
9509
+ arguments: {}
9510
+ }
9511
+ ]
9512
+ },
9513
+ 'session-issue-pack': {
9514
+ id: 'session-issue-pack',
9515
+ name: 'Session Issue Pack',
9516
+ description: 'Collect session detail and markdown export for issue reports.',
9517
+ readOnly: true,
9518
+ inputSchema: {
9519
+ type: 'object',
9520
+ properties: {
9521
+ source: { type: 'string' },
9522
+ sessionId: { type: 'string' },
9523
+ file: { type: 'string' },
9524
+ maxMessages: { type: ['string', 'number'] }
9525
+ },
9526
+ additionalProperties: true
9527
+ },
9528
+ steps: [
9529
+ {
9530
+ id: 'detail',
9531
+ tool: 'codexmate.session.detail',
9532
+ arguments: {
9533
+ source: '{{input.source}}',
9534
+ sessionId: '{{input.sessionId}}',
9535
+ file: '{{input.file}}',
9536
+ maxMessages: '{{input.maxMessages}}'
9537
+ }
9538
+ },
9539
+ {
9540
+ id: 'export',
9541
+ tool: 'codexmate.session.export',
9542
+ arguments: {
9543
+ source: '{{input.source}}',
9544
+ sessionId: '{{input.sessionId}}',
9545
+ file: '{{input.file}}',
9546
+ maxMessages: '{{input.maxMessages}}'
9547
+ }
9548
+ }
9549
+ ]
9550
+ }
9551
+ });
9552
+
9553
+ function cloneJson(value, fallback) {
9554
+ try {
9555
+ return JSON.parse(JSON.stringify(value));
9556
+ } catch (_) {
9557
+ return fallback;
9558
+ }
9559
+ }
9560
+
9561
+ function normalizeWorkflowId(value) {
9562
+ const raw = typeof value === 'string' ? value.trim() : '';
9563
+ if (!raw) return '';
9564
+ if (!/^[a-zA-Z0-9._-]+$/.test(raw)) {
9565
+ return '';
9566
+ }
9567
+ return raw.toLowerCase();
9568
+ }
9569
+
9570
+ function normalizeWorkflowDefinition(raw, idHint = '', source = 'custom') {
9571
+ const safe = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
9572
+ if (!safe) {
9573
+ return { ok: false, error: 'workflow must be an object' };
9574
+ }
9575
+ const id = normalizeWorkflowId(safe.id || idHint);
9576
+ if (!id) {
9577
+ return { ok: false, error: 'workflow id is invalid' };
9578
+ }
9579
+ const name = typeof safe.name === 'string' && safe.name.trim()
9580
+ ? safe.name.trim()
9581
+ : id;
9582
+ const description = typeof safe.description === 'string' ? safe.description.trim() : '';
9583
+ const inputSchema = safe.inputSchema && typeof safe.inputSchema === 'object'
9584
+ ? cloneJson(safe.inputSchema, { type: 'object', properties: {}, additionalProperties: true })
9585
+ : { type: 'object', properties: {}, additionalProperties: true };
9586
+ const stepsRaw = Array.isArray(safe.steps) ? safe.steps : [];
9587
+ if (stepsRaw.length === 0) {
9588
+ return { ok: false, error: 'workflow steps cannot be empty' };
9589
+ }
9590
+
9591
+ const steps = [];
9592
+ for (let i = 0; i < stepsRaw.length; i += 1) {
9593
+ const item = stepsRaw[i];
9594
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
9595
+ return { ok: false, error: `workflow step #${i + 1} must be an object` };
9596
+ }
9597
+ const stepIdRaw = typeof item.id === 'string' && item.id.trim()
9598
+ ? item.id.trim()
9599
+ : `step${i + 1}`;
9600
+ const stepId = normalizeWorkflowId(stepIdRaw);
9601
+ if (!stepId) {
9602
+ return { ok: false, error: `workflow step id invalid at #${i + 1}` };
9603
+ }
9604
+ const toolName = typeof item.tool === 'string' ? item.tool.trim() : '';
9605
+ if (!toolName) {
9606
+ return { ok: false, error: `workflow step "${stepId}" missing tool` };
9607
+ }
9608
+ const args = item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
9609
+ ? cloneJson(item.arguments, {})
9610
+ : {};
9611
+ const when = item.when && typeof item.when === 'object' && !Array.isArray(item.when)
9612
+ ? cloneJson(item.when, {})
9613
+ : null;
9614
+ steps.push({
9615
+ id: stepId,
9616
+ name: typeof item.name === 'string' ? item.name.trim() : '',
9617
+ tool: toolName,
9618
+ arguments: args,
9619
+ when,
9620
+ continueOnError: item.continueOnError === true,
9621
+ write: item.write === true
9622
+ });
9623
+ }
9624
+
9625
+ return {
9626
+ ok: true,
9627
+ data: {
9628
+ id,
9629
+ name,
9630
+ description,
9631
+ source,
9632
+ readOnly: safe.readOnly !== false,
9633
+ inputSchema,
9634
+ steps
9635
+ }
9636
+ };
9637
+ }
9638
+
9639
+ function loadBuiltinWorkflowDefinitions() {
9640
+ const items = [];
9641
+ for (const [id, raw] of Object.entries(BUILTIN_WORKFLOW_DEFINITIONS)) {
9642
+ const normalized = normalizeWorkflowDefinition(raw, id, 'builtin');
9643
+ if (!normalized.ok) {
9644
+ continue;
9645
+ }
9646
+ items.push(normalized.data);
9647
+ }
9648
+ return items;
9649
+ }
9650
+
9651
+ function loadCustomWorkflowDefinitions() {
9652
+ const parsed = readJsonObjectFromFile(WORKFLOW_DEFINITIONS_FILE, {});
9653
+ if (!parsed.ok || !parsed.exists) {
9654
+ return {
9655
+ items: [],
9656
+ warnings: parsed.ok ? [] : [parsed.error || 'workflow file parse failed']
9657
+ };
9658
+ }
9659
+ const data = parsed.data && typeof parsed.data === 'object' ? parsed.data : {};
9660
+ let list = [];
9661
+ if (Array.isArray(data.workflows)) {
9662
+ list = data.workflows;
9663
+ } else if (data.workflows && typeof data.workflows === 'object') {
9664
+ list = Object.entries(data.workflows).map(([id, item]) => ({ ...(item || {}), id }));
9665
+ } else {
9666
+ list = Object.entries(data).map(([id, item]) => ({ ...(item || {}), id }));
9667
+ }
9668
+
9669
+ const items = [];
9670
+ const warnings = [];
9671
+ for (const item of list) {
9672
+ const normalized = normalizeWorkflowDefinition(item, item && item.id ? item.id : '', 'custom');
9673
+ if (!normalized.ok) {
9674
+ warnings.push(normalized.error || 'invalid custom workflow');
9675
+ continue;
9676
+ }
9677
+ items.push(normalized.data);
9678
+ }
9679
+ return { items, warnings };
9680
+ }
9681
+
9682
+ function buildWorkflowRegistry() {
9683
+ const registry = new Map();
9684
+ const warnings = [];
9685
+ const builtin = loadBuiltinWorkflowDefinitions();
9686
+ for (const item of builtin) {
9687
+ registry.set(item.id, item);
9688
+ }
9689
+ const custom = loadCustomWorkflowDefinitions();
9690
+ for (const item of custom.items) {
9691
+ if (registry.has(item.id)) {
9692
+ warnings.push(`custom workflow id duplicated with builtin and ignored: ${item.id}`);
9693
+ continue;
9694
+ }
9695
+ registry.set(item.id, item);
9696
+ }
9697
+ warnings.push(...custom.warnings);
9698
+ return { registry, warnings };
9699
+ }
9700
+
9701
+ function listWorkflowDefinitions() {
9702
+ const { registry, warnings } = buildWorkflowRegistry();
9703
+ const workflows = Array.from(registry.values())
9704
+ .sort((a, b) => a.id.localeCompare(b.id))
9705
+ .map((item) => ({
9706
+ id: item.id,
9707
+ name: item.name,
9708
+ description: item.description,
9709
+ source: item.source,
9710
+ readOnly: item.readOnly !== false,
9711
+ stepCount: Array.isArray(item.steps) ? item.steps.length : 0
9712
+ }));
9713
+ return {
9714
+ workflows,
9715
+ warnings
9716
+ };
9717
+ }
9718
+
9719
+ function getWorkflowDefinitionById(rawId) {
9720
+ const id = normalizeWorkflowId(rawId);
9721
+ if (!id) {
9722
+ return { error: 'workflow id is required' };
9723
+ }
9724
+ const { registry, warnings } = buildWorkflowRegistry();
9725
+ const workflow = registry.get(id);
9726
+ if (!workflow) {
9727
+ return { error: `workflow not found: ${id}` };
9728
+ }
9729
+ return {
9730
+ workflow: cloneJson(workflow, {}),
9731
+ warnings
9732
+ };
9733
+ }
9734
+
9735
+ function createWorkflowToolCatalog() {
9736
+ return {
9737
+ 'codexmate.status.get': {
9738
+ readOnly: true,
9739
+ handler: async () => buildMcpStatusPayload()
9740
+ },
9741
+ 'codexmate.provider.list': {
9742
+ readOnly: true,
9743
+ handler: async () => buildMcpProviderListPayload()
9744
+ },
9745
+ 'codexmate.proxy.status': {
9746
+ readOnly: true,
9747
+ handler: async () => getBuiltinProxyStatus()
9748
+ },
9749
+ 'codexmate.session.list': {
9750
+ readOnly: true,
9751
+ handler: async (args = {}) => {
9752
+ const source = normalizeMcpSource(args.source);
9753
+ if (source === null) {
9754
+ return { error: 'Invalid source. Must be codex, claude, or all' };
9755
+ }
9756
+ return {
9757
+ source: source || 'all',
9758
+ sessions: listAllSessions({
9759
+ ...args,
9760
+ source: source || 'all'
9761
+ })
9762
+ };
9763
+ }
9764
+ },
9765
+ 'codexmate.session.detail': {
9766
+ readOnly: true,
9767
+ handler: async (args = {}) => readSessionDetail(args || {})
9768
+ },
9769
+ 'codexmate.session.export': {
9770
+ readOnly: true,
9771
+ handler: async (args = {}) => exportSessionData(args || {})
9772
+ },
9773
+ 'codexmate.config.template.get': {
9774
+ readOnly: true,
9775
+ handler: async (args = {}) => getConfigTemplate(args || {})
9776
+ },
9777
+ 'codexmate.config.template.apply': {
9778
+ readOnly: false,
9779
+ handler: async (args = {}) => applyConfigTemplate(args || {})
9780
+ }
9781
+ };
9782
+ }
9783
+
9784
+ function getWorkflowKnownToolsSet() {
9785
+ return new Set(Object.keys(createWorkflowToolCatalog()));
9786
+ }
9787
+
9788
+ function resolveWorkflowDefinitionWithToolMeta(workflow) {
9789
+ const catalog = createWorkflowToolCatalog();
9790
+ const safe = cloneJson(workflow, {});
9791
+ safe.steps = (Array.isArray(safe.steps) ? safe.steps : []).map((step) => {
9792
+ const tool = catalog[step.tool];
9793
+ return {
9794
+ ...step,
9795
+ write: step.write === true || !!(tool && tool.readOnly === false)
9796
+ };
9797
+ });
9798
+ return safe;
9799
+ }
9800
+
9801
+ function validateWorkflowInputBySchema(inputSchema, input) {
9802
+ const schema = inputSchema && typeof inputSchema === 'object' ? inputSchema : {};
9803
+ if (schema.type && schema.type !== 'object') {
9804
+ return { ok: false, error: `unsupported input schema type: ${schema.type}` };
9805
+ }
9806
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
9807
+ return { ok: false, error: 'workflow input must be an object' };
9808
+ }
9809
+ const required = Array.isArray(schema.required) ? schema.required : [];
9810
+ for (const key of required) {
9811
+ if (!Object.prototype.hasOwnProperty.call(input, key)) {
9812
+ return { ok: false, error: `missing required input field: ${key}` };
9813
+ }
9814
+ }
9815
+ const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
9816
+ for (const [key, expected] of Object.entries(properties)) {
9817
+ if (!Object.prototype.hasOwnProperty.call(input, key)) continue;
9818
+ const value = input[key];
9819
+ if (!expected || typeof expected !== 'object') continue;
9820
+ const type = expected.type;
9821
+ if (!type) continue;
9822
+ const typeList = Array.isArray(type) ? type : [type];
9823
+ const actualType = value === null ? 'null' : (Array.isArray(value) ? 'array' : typeof value);
9824
+ const matched = typeList.some((candidate) => {
9825
+ if (candidate === 'number') return typeof value === 'number' && Number.isFinite(value);
9826
+ if (candidate === 'integer') return Number.isInteger(value);
9827
+ if (candidate === 'array') return Array.isArray(value);
9828
+ if (candidate === 'object') return value && typeof value === 'object' && !Array.isArray(value);
9829
+ if (candidate === 'null') return value === null;
9830
+ return actualType === candidate;
9831
+ });
9832
+ if (!matched) {
9833
+ return { ok: false, error: `input field "${key}" type mismatch` };
9834
+ }
9835
+ }
9836
+ return { ok: true };
9837
+ }
9838
+
9839
+ function appendWorkflowRunRecord(record) {
9840
+ ensureDir(path.dirname(WORKFLOW_RUNS_FILE));
9841
+ const content = `${JSON.stringify(record)}\n`;
9842
+ fs.appendFileSync(WORKFLOW_RUNS_FILE, content, { encoding: 'utf-8', mode: 0o600 });
9843
+ }
9844
+
9845
+ function listWorkflowRunRecords(limit = 20) {
9846
+ const max = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 20;
9847
+ if (!fs.existsSync(WORKFLOW_RUNS_FILE)) {
9848
+ return [];
9849
+ }
9850
+ let content = '';
9851
+ try {
9852
+ content = fs.readFileSync(WORKFLOW_RUNS_FILE, 'utf-8');
9853
+ } catch (_) {
9854
+ return [];
9855
+ }
9856
+ const rows = content
9857
+ .split(/\r?\n/g)
9858
+ .map((line) => line.trim())
9859
+ .filter(Boolean);
9860
+ const parsed = [];
9861
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
9862
+ try {
9863
+ const item = JSON.parse(rows[i]);
9864
+ parsed.push(item);
9865
+ if (parsed.length >= max) {
9866
+ break;
9867
+ }
9868
+ } catch (_) {}
9869
+ }
9870
+ return parsed;
9871
+ }
9872
+
9873
+ function validateWorkflowById(workflowId, input = {}) {
9874
+ const definitionResult = getWorkflowDefinitionById(workflowId);
9875
+ if (definitionResult.error) {
9876
+ return { ok: false, error: definitionResult.error };
9877
+ }
9878
+ const workflow = resolveWorkflowDefinitionWithToolMeta(definitionResult.workflow);
9879
+ const knownTools = getWorkflowKnownToolsSet();
9880
+ const validation = validateWorkflowDefinition(workflow, { knownTools });
9881
+ if (!validation.ok) {
9882
+ return {
9883
+ ok: false,
9884
+ error: validation.error || 'workflow validation failed',
9885
+ issues: validation.issues || []
9886
+ };
9887
+ }
9888
+ const schemaValidation = validateWorkflowInputBySchema(workflow.inputSchema, input || {});
9889
+ if (!schemaValidation.ok) {
9890
+ return { ok: false, error: schemaValidation.error || 'workflow input validation failed' };
9891
+ }
9892
+ return {
9893
+ ok: true,
9894
+ workflow: {
9895
+ id: workflow.id,
9896
+ name: workflow.name,
9897
+ readOnly: workflow.readOnly !== false,
9898
+ stepCount: Array.isArray(workflow.steps) ? workflow.steps.length : 0
9899
+ },
9900
+ warnings: definitionResult.warnings || []
9901
+ };
9902
+ }
9903
+
9904
+ async function runWorkflowById(workflowId, input = {}, options = {}) {
9905
+ const definitionResult = getWorkflowDefinitionById(workflowId);
9906
+ if (definitionResult.error) {
9907
+ return { error: definitionResult.error };
9908
+ }
9909
+ const workflow = resolveWorkflowDefinitionWithToolMeta(definitionResult.workflow);
9910
+ const knownTools = getWorkflowKnownToolsSet();
9911
+ const validation = validateWorkflowDefinition(workflow, { knownTools });
9912
+ if (!validation.ok) {
9913
+ return {
9914
+ error: validation.error || 'workflow validation failed',
9915
+ issues: validation.issues || []
9916
+ };
9917
+ }
9918
+ const schemaValidation = validateWorkflowInputBySchema(workflow.inputSchema, input || {});
9919
+ if (!schemaValidation.ok) {
9920
+ return { error: schemaValidation.error || 'workflow input validation failed' };
9921
+ }
9922
+
9923
+ const catalog = createWorkflowToolCatalog();
9924
+ const allowWrite = options.allowWrite === true;
9925
+ const dryRun = options.dryRun === true;
9926
+ const runId = `wf-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
9927
+ const startedAt = toIsoTime(Date.now());
9928
+
9929
+ const execution = await executeWorkflowDefinition(workflow, input || {}, {
9930
+ allowWrite,
9931
+ dryRun,
9932
+ invokeTool: async (toolName, args = {}) => {
9933
+ const tool = catalog[toolName];
9934
+ if (!tool) {
9935
+ return { error: `workflow tool not supported: ${toolName}` };
9936
+ }
9937
+ if (!tool.readOnly && !allowWrite) {
9938
+ return { error: `workflow requires write permission for tool: ${toolName}` };
9939
+ }
9940
+ return tool.handler(args || {});
9941
+ }
9942
+ });
9943
+
9944
+ const endedAt = toIsoTime(Date.now());
9945
+ const record = {
9946
+ runId,
9947
+ workflowId: workflow.id,
9948
+ workflowName: workflow.name,
9949
+ success: execution.success === true,
9950
+ error: execution.error || '',
9951
+ allowWrite,
9952
+ dryRun,
9953
+ startedAt,
9954
+ endedAt,
9955
+ durationMs: execution.durationMs || 0,
9956
+ steps: Array.isArray(execution.steps) ? execution.steps.map((step) => ({
9957
+ id: step.id,
9958
+ tool: step.tool,
9959
+ status: step.status,
9960
+ durationMs: step.durationMs || 0,
9961
+ error: step.error || ''
9962
+ })) : [],
9963
+ input: cloneJson(input || {}, {})
9964
+ };
9965
+ try {
9966
+ appendWorkflowRunRecord(record);
9967
+ } catch (_) {}
9968
+
9969
+ return {
9970
+ success: execution.success === true,
9971
+ runId,
9972
+ workflowId: workflow.id,
9973
+ workflowName: workflow.name,
9974
+ allowWrite,
9975
+ dryRun,
9976
+ startedAt: execution.startedAt || startedAt,
9977
+ endedAt: execution.endedAt || endedAt,
9978
+ durationMs: execution.durationMs || 0,
9979
+ steps: execution.steps || [],
9980
+ output: execution.output || null,
9981
+ warnings: definitionResult.warnings || [],
9982
+ ...(execution.error ? { error: execution.error } : {})
9983
+ };
9984
+ }
9985
+
7170
9986
  function createMcpTools(options = {}) {
7171
9987
  const allowWrite = !!options.allowWrite;
7172
9988
  const tools = [];
@@ -7362,6 +10178,89 @@ function createMcpTools(options = {}) {
7362
10178
  handler: async () => getBuiltinProxyStatus()
7363
10179
  });
7364
10180
 
10181
+ pushTool({
10182
+ name: 'codexmate.workflow.list',
10183
+ description: 'List available workflows (builtin + custom).',
10184
+ readOnly: true,
10185
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
10186
+ handler: async () => listWorkflowDefinitions()
10187
+ });
10188
+
10189
+ pushTool({
10190
+ name: 'codexmate.workflow.get',
10191
+ description: 'Get one workflow definition by id.',
10192
+ readOnly: true,
10193
+ inputSchema: {
10194
+ type: 'object',
10195
+ properties: {
10196
+ id: { type: 'string' }
10197
+ },
10198
+ required: ['id'],
10199
+ additionalProperties: false
10200
+ },
10201
+ handler: async (args = {}) => {
10202
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
10203
+ if (!id) {
10204
+ return { error: 'workflow id is required' };
10205
+ }
10206
+ return getWorkflowDefinitionById(id);
10207
+ }
10208
+ });
10209
+
10210
+ pushTool({
10211
+ name: 'codexmate.workflow.validate',
10212
+ description: 'Validate workflow definition and input payload.',
10213
+ readOnly: true,
10214
+ inputSchema: {
10215
+ type: 'object',
10216
+ properties: {
10217
+ id: { type: 'string' },
10218
+ input: { type: 'object' }
10219
+ },
10220
+ required: ['id'],
10221
+ additionalProperties: false
10222
+ },
10223
+ handler: async (args = {}) => {
10224
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
10225
+ if (!id) {
10226
+ return { ok: false, error: 'workflow id is required' };
10227
+ }
10228
+ const input = args.input && typeof args.input === 'object' && !Array.isArray(args.input)
10229
+ ? args.input
10230
+ : {};
10231
+ return validateWorkflowById(id, input);
10232
+ }
10233
+ });
10234
+
10235
+ pushTool({
10236
+ name: 'codexmate.workflow.run',
10237
+ description: 'Run workflow by id. Write steps require allow-write mode.',
10238
+ readOnly: true,
10239
+ inputSchema: {
10240
+ type: 'object',
10241
+ properties: {
10242
+ id: { type: 'string' },
10243
+ input: { type: 'object' },
10244
+ dryRun: { type: 'boolean' }
10245
+ },
10246
+ required: ['id'],
10247
+ additionalProperties: false
10248
+ },
10249
+ handler: async (args = {}) => {
10250
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
10251
+ if (!id) {
10252
+ return { error: 'workflow id is required' };
10253
+ }
10254
+ const input = args.input && typeof args.input === 'object' && !Array.isArray(args.input)
10255
+ ? args.input
10256
+ : {};
10257
+ return runWorkflowById(id, input, {
10258
+ allowWrite,
10259
+ dryRun: args.dryRun === true
10260
+ });
10261
+ }
10262
+ });
10263
+
7365
10264
  pushTool({
7366
10265
  name: 'codexmate.config.template.apply',
7367
10266
  description: 'Apply Codex TOML template and sync auth/model pointers.',
@@ -7636,6 +10535,50 @@ function createMcpResources() {
7636
10535
  }]
7637
10536
  };
7638
10537
  }
10538
+ },
10539
+ {
10540
+ uri: 'codexmate://workflows',
10541
+ name: 'Workflows',
10542
+ description: 'Workflow list resource (builtin + custom).',
10543
+ mimeType: 'application/json',
10544
+ read: async () => ({
10545
+ contents: [{
10546
+ uri: 'codexmate://workflows',
10547
+ mimeType: 'application/json',
10548
+ text: JSON.stringify(listWorkflowDefinitions(), null, 2)
10549
+ }]
10550
+ })
10551
+ },
10552
+ {
10553
+ uri: 'codexmate://workflow-runs',
10554
+ name: 'WorkflowRuns',
10555
+ description: 'Recent workflow execution records. Supports ?limit=<N>.',
10556
+ mimeType: 'application/json',
10557
+ read: async (params = {}) => {
10558
+ const uri = typeof params.uri === 'string' ? params.uri : 'codexmate://workflow-runs';
10559
+ let limit = 20;
10560
+ try {
10561
+ const parsed = new URL(uri);
10562
+ const rawLimit = parsed.searchParams.get('limit');
10563
+ if (rawLimit) {
10564
+ const parsedLimit = parseInt(rawLimit, 10);
10565
+ if (Number.isFinite(parsedLimit)) {
10566
+ limit = parsedLimit;
10567
+ }
10568
+ }
10569
+ } catch (_) {}
10570
+ const payload = {
10571
+ runs: listWorkflowRunRecords(limit),
10572
+ limit: Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 20
10573
+ };
10574
+ return {
10575
+ contents: [{
10576
+ uri,
10577
+ mimeType: 'application/json',
10578
+ text: JSON.stringify(payload, null, 2)
10579
+ }]
10580
+ };
10581
+ }
7639
10582
  }
7640
10583
  ];
7641
10584
  }
@@ -7820,10 +10763,10 @@ async function main() {
7820
10763
  console.log(' codexmate delete-model <模型> 删除模型');
7821
10764
  console.log(' codexmate auth <list|import|switch|delete|status> 认证文件管理');
7822
10765
  console.log(' codexmate proxy <status|set|apply|enable|start|stop> 内建代理');
7823
- console.log(' codexmate run [--host <HOST>] 启动 Web 界面');
10766
+ console.log(' codexmate workflow <list|get|validate|run|runs> MCP 工作流中心');
10767
+ console.log(' codexmate run [--host <HOST>] [--no-browser] 启动 Web 界面');
7824
10768
  console.log(' codexmate codex [参数...] 等同于 codex --yolo');
7825
10769
  console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
7826
- console.log(' codexmate gemini [参数...] 等同于 gemini --yolo');
7827
10770
  console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
7828
10771
  console.log(' codexmate export-session --source <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
7829
10772
  console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
@@ -7846,6 +10789,7 @@ async function main() {
7846
10789
  case 'delete-model': cmdDeleteModel(args[1]); break;
7847
10790
  case 'auth': cmdAuth(args.slice(1)); break;
7848
10791
  case 'proxy': await cmdProxy(args.slice(1)); break;
10792
+ case 'workflow': await cmdWorkflow(args.slice(1)); break;
7849
10793
  case 'run': cmdStart(parseStartOptions(args.slice(1))); break;
7850
10794
  case 'start':
7851
10795
  console.error('错误: 命令已更名为 "run",请使用: codexmate run');
@@ -7861,11 +10805,6 @@ async function main() {
7861
10805
  process.exit(exitCode);
7862
10806
  break;
7863
10807
  }
7864
- case 'gemini': {
7865
- const exitCode = await cmdGemini(args.slice(1));
7866
- process.exit(exitCode);
7867
- break;
7868
- }
7869
10808
  case 'mcp': await cmdMcp(args.slice(1)); break;
7870
10809
  case 'export-session': await cmdExportSession(args.slice(1)); break;
7871
10810
  case 'zip': {