codexmate 0.0.14 → 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');
@@ -108,12 +109,10 @@ const MAX_SESSION_PATH_LIST_SIZE = 2000;
108
109
  const AGENTS_FILE_NAME = 'AGENTS.md';
109
110
  const CODEX_SKILLS_DIR = path.join(CONFIG_DIR, 'skills');
110
111
  const CLAUDE_SKILLS_DIR = path.join(CLAUDE_DIR, 'skills');
111
- const GEMINI_SKILLS_DIR = path.join(os.homedir(), '.gemini', 'skills');
112
- const OPENCODE_SKILLS_DIR = path.join(os.homedir(), '.opencode', 'skills');
112
+ const AGENTS_SKILLS_DIR = path.join(os.homedir(), '.agents', 'skills');
113
113
  const SKILL_IMPORT_SOURCES = Object.freeze([
114
114
  { app: 'claude', label: 'Claude Code', dir: CLAUDE_SKILLS_DIR },
115
- { app: 'gemini', label: 'Gemini CLI', dir: GEMINI_SKILLS_DIR },
116
- { app: 'opencode', label: 'OpenCode', dir: OPENCODE_SKILLS_DIR }
115
+ { app: 'agents', label: 'Agents', dir: AGENTS_SKILLS_DIR }
117
116
  ]);
118
117
  const MODELS_CACHE_TTL_MS = 60 * 1000;
119
118
  const MODELS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
@@ -121,6 +120,11 @@ const MODELS_CACHE_MAX_ENTRIES = 50;
121
120
  const MODELS_RESPONSE_MAX_BYTES = 1024 * 1024;
122
121
  const MAX_RECENT_CONFIGS = 3;
123
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();
124
128
  const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy';
125
129
  const DEFAULT_BUILTIN_PROXY_SETTINGS = Object.freeze({
126
130
  enabled: false,
@@ -1804,6 +1808,7 @@ function importCodexSkills(params = {}) {
1804
1808
  const visitedRealPaths = new Set([sourceDirForCopy]);
1805
1809
  copyDirRecursive(sourceDirForCopy, targetPath, {
1806
1810
  dereferenceSymlinks: true,
1811
+ allowedRootRealPath: sourceDirForCopy,
1807
1812
  visitedRealPaths
1808
1813
  });
1809
1814
  copiedToTarget = true;
@@ -1835,6 +1840,306 @@ function importCodexSkills(params = {}) {
1835
1840
  };
1836
1841
  }
1837
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
+ });
2032
+ }
2033
+
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
+ }
2042
+
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);
2048
+
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
+ }
2066
+
2067
+ try {
2068
+ const lstat = fs.lstatSync(sourcePath);
2069
+ if (!lstat.isDirectory() && !lstat.isSymbolicLink()) {
2070
+ failed.push({ name: normalizedName.name, error: '来源不是技能目录' });
2071
+ continue;
2072
+ }
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
2085
+ });
2086
+ exported.push({
2087
+ name: normalizedName.name,
2088
+ path: sourcePath
2089
+ });
2090
+ } catch (e) {
2091
+ failed.push({
2092
+ name: normalizedName.name,
2093
+ error: e && e.message ? e.message : '导出失败'
2094
+ });
2095
+ }
2096
+ }
2097
+
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
+ };
2105
+ }
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 (_) {}
2114
+ }
2115
+ await zipLib.archiveFolder(stagingRoot, zipFilePath);
2116
+ const artifact = registerDownloadArtifact(zipFilePath, {
2117
+ fileName: zipFileName,
2118
+ deleteAfterDownload: true
2119
+ });
2120
+
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 (_) {}
2140
+ }
2141
+ }
2142
+
1838
2143
  function removeDirectoryRecursive(targetPath) {
1839
2144
  if (typeof fs.rmSync === 'function') {
1840
2145
  fs.rmSync(targetPath, { recursive: true, force: false });
@@ -6629,6 +6934,70 @@ function readClaudeSettingsInfo() {
6629
6934
  };
6630
6935
  }
6631
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
6998
+ };
6999
+ }
7000
+
6632
7001
  // API: 打包 Claude 配置目录(系统 zip 可用则使用,否则回退 zip-lib)
6633
7002
  async function prepareClaudeDirDownload() {
6634
7003
  try {
@@ -6693,12 +7062,16 @@ async function prepareCodexDirDownload() {
6693
7062
 
6694
7063
  function copyDirRecursive(srcDir, destDir, options = {}) {
6695
7064
  const dereferenceSymlinks = !!(options && options.dereferenceSymlinks);
7065
+ const allowedRootRealPath = (options && typeof options.allowedRootRealPath === 'string')
7066
+ ? options.allowedRootRealPath
7067
+ : '';
6696
7068
  const visitedRealPaths = options && options.visitedRealPaths instanceof Set
6697
7069
  ? options.visitedRealPaths
6698
7070
  : new Set();
6699
7071
  const childOptions = {
6700
7072
  ...options,
6701
7073
  dereferenceSymlinks,
7074
+ allowedRootRealPath,
6702
7075
  visitedRealPaths
6703
7076
  };
6704
7077
  ensureDir(destDir);
@@ -6712,6 +7085,9 @@ function copyDirRecursive(srcDir, destDir, options = {}) {
6712
7085
  continue;
6713
7086
  }
6714
7087
  const realPath = fs.realpathSync(srcPath);
7088
+ if (allowedRootRealPath && !isPathInside(realPath, allowedRootRealPath)) {
7089
+ throw new Error(`symlink escapes skill root: ${srcPath}`);
7090
+ }
6715
7091
  if (visitedRealPaths.has(realPath)) {
6716
7092
  continue;
6717
7093
  }
@@ -6724,6 +7100,9 @@ function copyDirRecursive(srcDir, destDir, options = {}) {
6724
7100
  } else if (entry.isSymbolicLink()) {
6725
7101
  if (dereferenceSymlinks) {
6726
7102
  const realPath = fs.realpathSync(srcPath);
7103
+ if (allowedRootRealPath && !isPathInside(realPath, allowedRootRealPath)) {
7104
+ throw new Error(`symlink escapes skill root: ${srcPath}`);
7105
+ }
6727
7106
  const realStat = fs.statSync(realPath);
6728
7107
  if (realStat.isDirectory()) {
6729
7108
  if (visitedRealPaths.has(realPath)) {
@@ -6748,6 +7127,139 @@ function copyDirRecursive(srcDir, destDir, options = {}) {
6748
7127
  }
6749
7128
  }
6750
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
+
6751
7263
  function writeUploadZip(base64, prefix, originalName = '') {
6752
7264
  let buffer;
6753
7265
  try {
@@ -7443,7 +7955,7 @@ async function cmdExportSession(args = []) {
7443
7955
  }
7444
7956
 
7445
7957
  function parseStartOptions(args = []) {
7446
- const options = { host: '' };
7958
+ const options = { host: '', noBrowser: false };
7447
7959
  if (!Array.isArray(args)) {
7448
7960
  return options;
7449
7961
  }
@@ -7451,6 +7963,10 @@ function parseStartOptions(args = []) {
7451
7963
  for (let i = 0; i < args.length; i++) {
7452
7964
  const arg = args[i];
7453
7965
  if (!arg) continue;
7966
+ if (arg === '--no-browser') {
7967
+ options.noBrowser = true;
7968
+ continue;
7969
+ }
7454
7970
  if (arg.startsWith('--host=')) {
7455
7971
  options.host = arg.slice('--host='.length);
7456
7972
  continue;
@@ -7523,11 +8039,125 @@ function watchPathsForRestart(targets, onChange) {
7523
8039
  };
7524
8040
  }
7525
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
+
7526
8152
  function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {
7527
8153
  const connections = new Set();
7528
8154
 
7529
8155
  const server = http.createServer((req, res) => {
7530
8156
  const requestPath = (req.url || '/').split('?')[0];
8157
+ if (requestPath === '/api/import-codex-skills-zip') {
8158
+ void handleImportCodexSkillsZipUpload(req, res);
8159
+ return;
8160
+ }
7531
8161
  if (requestPath === '/api') {
7532
8162
  let body = '';
7533
8163
  req.on('data', chunk => body += chunk);
@@ -7651,6 +8281,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
7651
8281
  case 'import-codex-skills':
7652
8282
  result = importCodexSkills(params || {});
7653
8283
  break;
8284
+ case 'export-codex-skills':
8285
+ result = await exportCodexSkills(params || {});
8286
+ break;
7654
8287
  case 'get-openclaw-config':
7655
8288
  result = readOpenclawConfigFile();
7656
8289
  break;
@@ -7909,32 +8542,35 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
7909
8542
  fs.createReadStream(filePath).pipe(res);
7910
8543
  } else if (requestPath.startsWith('/download/')) {
7911
8544
  const fileName = requestPath.slice('/download/'.length);
7912
- const decodedFileName = decodeURIComponent(fileName);
7913
- const tempDir = os.tmpdir();
7914
- const filePath = path.join(tempDir, decodedFileName);
7915
-
7916
- if (!isPathInside(filePath, tempDir)) {
7917
- res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
7918
- 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');
7919
8551
  return;
7920
8552
  }
7921
- if (!fs.existsSync(filePath)) {
7922
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
7923
- 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
+ });
7924
8560
  return;
7925
8561
  }
7926
- const stat = fs.statSync(filePath);
7927
- if (!stat.isFile()) {
7928
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
7929
- 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');
7930
8568
  return;
7931
8569
  }
7932
- res.writeHead(200, {
7933
- 'Content-Type': 'application/zip',
7934
- 'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
7935
- 'Content-Length': stat.size
8570
+ streamZipDownloadResponse(res, legacyFilePath, {
8571
+ fileName: path.basename(legacyFilePath),
8572
+ deleteAfterDownload: false
7936
8573
  });
7937
- fs.createReadStream(filePath).pipe(res);
7938
8574
  } else if (requestPath.startsWith('/res/')) {
7939
8575
  const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
7940
8576
  const filePath = path.join(__dirname, normalized);
@@ -8057,7 +8693,7 @@ function cmdStart(options = {}) {
8057
8693
  webDir,
8058
8694
  host,
8059
8695
  port,
8060
- openBrowser: true
8696
+ openBrowser: !options.noBrowser
8061
8697
  });
8062
8698
 
8063
8699
  const proxySettings = readBuiltinProxySettings();
@@ -8669,10 +9305,6 @@ async function cmdQwen(args = []) {
8669
9305
  return runProxyCommand('Qwen', ['qwen', 'qwen-code'], args, 'npm install -g @qwen-code/qwen-code');
8670
9306
  }
8671
9307
 
8672
- async function cmdGemini(args = []) {
8673
- return runProxyCommand('Gemini', ['gemini', 'gemini-cli'], args, 'npm install -g @google/gemini-cli');
8674
- }
8675
-
8676
9308
  function parseMcpOptions(args = []) {
8677
9309
  const options = {
8678
9310
  subcommand: 'serve',
@@ -10132,10 +10764,9 @@ async function main() {
10132
10764
  console.log(' codexmate auth <list|import|switch|delete|status> 认证文件管理');
10133
10765
  console.log(' codexmate proxy <status|set|apply|enable|start|stop> 内建代理');
10134
10766
  console.log(' codexmate workflow <list|get|validate|run|runs> MCP 工作流中心');
10135
- console.log(' codexmate run [--host <HOST>] 启动 Web 界面');
10767
+ console.log(' codexmate run [--host <HOST>] [--no-browser] 启动 Web 界面');
10136
10768
  console.log(' codexmate codex [参数...] 等同于 codex --yolo');
10137
10769
  console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
10138
- console.log(' codexmate gemini [参数...] 等同于 gemini --yolo');
10139
10770
  console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
10140
10771
  console.log(' codexmate export-session --source <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
10141
10772
  console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
@@ -10174,11 +10805,6 @@ async function main() {
10174
10805
  process.exit(exitCode);
10175
10806
  break;
10176
10807
  }
10177
- case 'gemini': {
10178
- const exitCode = await cmdGemini(args.slice(1));
10179
- process.exit(exitCode);
10180
- break;
10181
- }
10182
10808
  case 'mcp': await cmdMcp(args.slice(1)); break;
10183
10809
  case 'export-session': await cmdExportSession(args.slice(1)); break;
10184
10810
  case 'zip': {