codexmate 0.0.14 → 0.0.16

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,12 @@ 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 DEFAULT_EXTRACT_SUFFIXES = Object.freeze(['.json']);
127
+ const DOWNLOAD_ARTIFACT_TTL_MS = 10 * 60 * 1000;
128
+ const g_downloadArtifacts = new Map();
124
129
  const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy';
125
130
  const DEFAULT_BUILTIN_PROXY_SETTINGS = Object.freeze({
126
131
  enabled: false,
@@ -1804,6 +1809,7 @@ function importCodexSkills(params = {}) {
1804
1809
  const visitedRealPaths = new Set([sourceDirForCopy]);
1805
1810
  copyDirRecursive(sourceDirForCopy, targetPath, {
1806
1811
  dereferenceSymlinks: true,
1812
+ allowedRootRealPath: sourceDirForCopy,
1807
1813
  visitedRealPaths
1808
1814
  });
1809
1815
  copiedToTarget = true;
@@ -1835,6 +1841,306 @@ function importCodexSkills(params = {}) {
1835
1841
  };
1836
1842
  }
1837
1843
 
1844
+ function collectSkillDirectoriesFromRoot(rootDir, limit = MAX_SKILLS_ZIP_ENTRY_COUNT) {
1845
+ const results = [];
1846
+ let truncated = false;
1847
+ if (!rootDir || !fs.existsSync(rootDir)) {
1848
+ return { results, truncated };
1849
+ }
1850
+ const normalizedLimit = Number.isFinite(limit) && limit > 0
1851
+ ? Math.floor(limit)
1852
+ : MAX_SKILLS_ZIP_ENTRY_COUNT;
1853
+ const stack = [rootDir];
1854
+ while (stack.length > 0) {
1855
+ if (results.length >= normalizedLimit) {
1856
+ truncated = true;
1857
+ break;
1858
+ }
1859
+ const currentDir = stack.pop();
1860
+ let entries = [];
1861
+ try {
1862
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
1863
+ } catch (e) {
1864
+ continue;
1865
+ }
1866
+
1867
+ const hasSkillFile = entries.some((entry) => entry && entry.isFile() && String(entry.name || '') === 'SKILL.md');
1868
+ if (hasSkillFile) {
1869
+ results.push(currentDir);
1870
+ continue;
1871
+ }
1872
+
1873
+ for (const entry of entries) {
1874
+ if (!entry || !entry.isDirectory()) continue;
1875
+ const entryName = typeof entry.name === 'string' ? entry.name.trim() : '';
1876
+ if (!entryName || entryName.startsWith('.')) {
1877
+ continue;
1878
+ }
1879
+ stack.push(path.join(currentDir, entryName));
1880
+ }
1881
+ }
1882
+ return { results, truncated };
1883
+ }
1884
+
1885
+ function resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbackName = '') {
1886
+ const directoryBaseName = path.basename(skillDir || '');
1887
+ const extractionBaseName = path.basename(extractionRoot || '');
1888
+ let candidate = directoryBaseName;
1889
+ if (!candidate || candidate === extractionBaseName || candidate.startsWith('.')) {
1890
+ const fallback = typeof fallbackName === 'string' ? fallbackName.trim() : '';
1891
+ const fallbackBase = fallback ? path.basename(fallback, path.extname(fallback)) : '';
1892
+ candidate = fallbackBase || candidate;
1893
+ }
1894
+ return normalizeCodexSkillName(candidate);
1895
+ }
1896
+
1897
+ async function importCodexSkillsFromZipFile(zipPath, options = {}) {
1898
+ const fallbackName = typeof options.fallbackName === 'string' ? options.fallbackName : '';
1899
+ const tempDir = typeof options.tempDir === 'string' ? options.tempDir : '';
1900
+ const imported = [];
1901
+ const failed = [];
1902
+ const dedupNames = new Set();
1903
+ const extractionRoot = path.join(tempDir || path.dirname(zipPath), 'extract');
1904
+
1905
+ try {
1906
+ await inspectZipArchiveLimits(zipPath, {
1907
+ maxEntryCount: MAX_SKILLS_ZIP_ENTRY_COUNT,
1908
+ maxUncompressedBytes: MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES
1909
+ });
1910
+
1911
+ await extractUploadZip(zipPath, extractionRoot);
1912
+ const discovery = collectSkillDirectoriesFromRoot(extractionRoot, MAX_SKILLS_ZIP_ENTRY_COUNT);
1913
+ const discoveredDirs = discovery.results;
1914
+ if (discoveredDirs.length === 0) {
1915
+ return { error: '压缩包中未发现包含 SKILL.md 的技能目录' };
1916
+ }
1917
+ if (discovery.truncated) {
1918
+ return { error: '压缩包中的技能目录数量超出导入上限' };
1919
+ }
1920
+
1921
+ ensureDir(CODEX_SKILLS_DIR);
1922
+ for (const skillDir of discoveredDirs) {
1923
+ const normalizedName = resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbackName);
1924
+ if (normalizedName.error) {
1925
+ failed.push({
1926
+ name: path.basename(skillDir || ''),
1927
+ error: normalizedName.error
1928
+ });
1929
+ continue;
1930
+ }
1931
+ const dedupKey = normalizedName.name.toLowerCase();
1932
+ if (dedupNames.has(dedupKey)) {
1933
+ continue;
1934
+ }
1935
+ dedupNames.add(dedupKey);
1936
+
1937
+ const targetPath = path.join(CODEX_SKILLS_DIR, normalizedName.name);
1938
+ const targetRelative = path.relative(CODEX_SKILLS_DIR, targetPath);
1939
+ if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) {
1940
+ failed.push({
1941
+ name: normalizedName.name,
1942
+ error: '目标路径非法'
1943
+ });
1944
+ continue;
1945
+ }
1946
+ if (fs.existsSync(targetPath)) {
1947
+ failed.push({
1948
+ name: normalizedName.name,
1949
+ error: 'Codex 中已存在同名 skill'
1950
+ });
1951
+ continue;
1952
+ }
1953
+
1954
+ let copiedToTarget = false;
1955
+ try {
1956
+ const sourceRealPath = fs.realpathSync(skillDir);
1957
+ const sourceStat = fs.statSync(sourceRealPath);
1958
+ if (!sourceStat.isDirectory()) {
1959
+ failed.push({
1960
+ name: normalizedName.name,
1961
+ error: '来源 skill 无法读取'
1962
+ });
1963
+ continue;
1964
+ }
1965
+ const visitedRealPaths = new Set([sourceRealPath]);
1966
+ copyDirRecursive(sourceRealPath, targetPath, {
1967
+ dereferenceSymlinks: true,
1968
+ allowedRootRealPath: sourceRealPath,
1969
+ visitedRealPaths
1970
+ });
1971
+ copiedToTarget = true;
1972
+ imported.push({
1973
+ name: normalizedName.name,
1974
+ path: targetPath
1975
+ });
1976
+ } catch (e) {
1977
+ if (!copiedToTarget && fs.existsSync(targetPath)) {
1978
+ try {
1979
+ removeDirectoryRecursive(targetPath);
1980
+ } catch (_) {}
1981
+ }
1982
+ failed.push({
1983
+ name: normalizedName.name,
1984
+ error: e && e.message ? e.message : '导入失败'
1985
+ });
1986
+ }
1987
+ }
1988
+
1989
+ if (imported.length === 0 && failed.length > 0) {
1990
+ return {
1991
+ error: failed[0].error || '导入失败',
1992
+ imported,
1993
+ failed,
1994
+ root: CODEX_SKILLS_DIR
1995
+ };
1996
+ }
1997
+
1998
+ return {
1999
+ success: failed.length === 0,
2000
+ imported,
2001
+ failed,
2002
+ root: CODEX_SKILLS_DIR
2003
+ };
2004
+ } catch (e) {
2005
+ return {
2006
+ error: `导入失败:${e && e.message ? e.message : '未知错误'}`
2007
+ };
2008
+ } finally {
2009
+ if (tempDir) {
2010
+ try {
2011
+ fs.rmSync(tempDir, { recursive: true, force: true });
2012
+ } catch (_) {}
2013
+ } else if (fs.existsSync(extractionRoot)) {
2014
+ try {
2015
+ fs.rmSync(extractionRoot, { recursive: true, force: true });
2016
+ } catch (_) {}
2017
+ }
2018
+ }
2019
+ }
2020
+
2021
+ async function importCodexSkillsFromZip(payload = {}) {
2022
+ if (!payload || typeof payload.fileBase64 !== 'string' || !payload.fileBase64.trim()) {
2023
+ return { error: '缺少技能压缩包内容' };
2024
+ }
2025
+ const upload = writeUploadZip(payload.fileBase64, 'codex-skills-import', payload.fileName || 'codex-skills.zip');
2026
+ if (upload.error) {
2027
+ return { error: upload.error };
2028
+ }
2029
+ return importCodexSkillsFromZipFile(upload.zipPath, {
2030
+ tempDir: upload.tempDir,
2031
+ fallbackName: payload.fileName || ''
2032
+ });
2033
+ }
2034
+
2035
+ async function exportCodexSkills(params = {}) {
2036
+ const rawNames = Array.isArray(params.names) ? params.names : [];
2037
+ const uniqueNames = Array.from(new Set(rawNames
2038
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
2039
+ .filter(Boolean)));
2040
+ if (uniqueNames.length === 0) {
2041
+ return { error: '请先选择要导出的 skill' };
2042
+ }
2043
+
2044
+ const exported = [];
2045
+ const failed = [];
2046
+ const stagingTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-skills-export-'));
2047
+ const stagingRoot = path.join(stagingTempDir, 'skills');
2048
+ ensureDir(stagingRoot);
2049
+
2050
+ try {
2051
+ for (const rawName of uniqueNames) {
2052
+ const normalizedName = normalizeCodexSkillName(rawName);
2053
+ if (normalizedName.error) {
2054
+ failed.push({ name: rawName, error: normalizedName.error });
2055
+ continue;
2056
+ }
2057
+ const sourcePath = path.join(CODEX_SKILLS_DIR, normalizedName.name);
2058
+ const sourceRelative = path.relative(CODEX_SKILLS_DIR, sourcePath);
2059
+ if (sourceRelative.startsWith('..') || path.isAbsolute(sourceRelative)) {
2060
+ failed.push({ name: normalizedName.name, error: '来源路径非法' });
2061
+ continue;
2062
+ }
2063
+ if (!fs.existsSync(sourcePath)) {
2064
+ failed.push({ name: normalizedName.name, error: 'skill 不存在' });
2065
+ continue;
2066
+ }
2067
+
2068
+ try {
2069
+ const lstat = fs.lstatSync(sourcePath);
2070
+ if (!lstat.isDirectory() && !lstat.isSymbolicLink()) {
2071
+ failed.push({ name: normalizedName.name, error: '来源不是技能目录' });
2072
+ continue;
2073
+ }
2074
+ const sourceDirForCopy = lstat.isSymbolicLink() ? fs.realpathSync(sourcePath) : sourcePath;
2075
+ const sourceStat = fs.statSync(sourceDirForCopy);
2076
+ if (!sourceStat.isDirectory()) {
2077
+ failed.push({ name: normalizedName.name, error: '来源 skill 无法读取' });
2078
+ continue;
2079
+ }
2080
+ const targetPath = path.join(stagingRoot, normalizedName.name);
2081
+ const visitedRealPaths = new Set([sourceDirForCopy]);
2082
+ copyDirRecursive(sourceDirForCopy, targetPath, {
2083
+ dereferenceSymlinks: true,
2084
+ allowedRootRealPath: sourceDirForCopy,
2085
+ visitedRealPaths
2086
+ });
2087
+ exported.push({
2088
+ name: normalizedName.name,
2089
+ path: sourcePath
2090
+ });
2091
+ } catch (e) {
2092
+ failed.push({
2093
+ name: normalizedName.name,
2094
+ error: e && e.message ? e.message : '导出失败'
2095
+ });
2096
+ }
2097
+ }
2098
+
2099
+ if (exported.length === 0) {
2100
+ return {
2101
+ error: failed[0] && failed[0].error ? failed[0].error : '无可导出的 skill',
2102
+ exported,
2103
+ failed,
2104
+ root: CODEX_SKILLS_DIR
2105
+ };
2106
+ }
2107
+
2108
+ const randomToken = crypto.randomBytes(12).toString('hex');
2109
+ const zipFileName = `codex-skills-${randomToken}.zip`;
2110
+ const zipFilePath = path.join(os.tmpdir(), zipFileName);
2111
+ if (fs.existsSync(zipFilePath)) {
2112
+ try {
2113
+ fs.unlinkSync(zipFilePath);
2114
+ } catch (_) {}
2115
+ }
2116
+ await zipLib.archiveFolder(stagingRoot, zipFilePath);
2117
+ const artifact = registerDownloadArtifact(zipFilePath, {
2118
+ fileName: zipFileName,
2119
+ deleteAfterDownload: true
2120
+ });
2121
+
2122
+ return {
2123
+ success: failed.length === 0,
2124
+ fileName: zipFileName,
2125
+ downloadPath: artifact.downloadPath,
2126
+ exported,
2127
+ failed,
2128
+ root: CODEX_SKILLS_DIR
2129
+ };
2130
+ } catch (e) {
2131
+ return {
2132
+ error: `导出失败:${e && e.message ? e.message : '未知错误'}`,
2133
+ exported,
2134
+ failed,
2135
+ root: CODEX_SKILLS_DIR
2136
+ };
2137
+ } finally {
2138
+ try {
2139
+ fs.rmSync(stagingTempDir, { recursive: true, force: true });
2140
+ } catch (_) {}
2141
+ }
2142
+ }
2143
+
1838
2144
  function removeDirectoryRecursive(targetPath) {
1839
2145
  if (typeof fs.rmSync === 'function') {
1840
2146
  fs.rmSync(targetPath, { recursive: true, force: false });
@@ -6629,6 +6935,70 @@ function readClaudeSettingsInfo() {
6629
6935
  };
6630
6936
  }
6631
6937
 
6938
+ function registerDownloadArtifact(filePath, options = {}) {
6939
+ const token = crypto.randomBytes(16).toString('hex');
6940
+ const fileName = typeof options.fileName === 'string' && options.fileName.trim()
6941
+ ? options.fileName.trim()
6942
+ : path.basename(filePath || '');
6943
+ const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0
6944
+ ? Math.floor(options.ttlMs)
6945
+ : DOWNLOAD_ARTIFACT_TTL_MS;
6946
+ const expiresAt = Date.now() + ttlMs;
6947
+ const deleteAfterDownload = options.deleteAfterDownload !== false;
6948
+
6949
+ g_downloadArtifacts.set(token, {
6950
+ filePath,
6951
+ fileName,
6952
+ deleteAfterDownload,
6953
+ expiresAt
6954
+ });
6955
+
6956
+ setTimeout(() => {
6957
+ const artifact = g_downloadArtifacts.get(token);
6958
+ if (!artifact) return;
6959
+ if (Date.now() < artifact.expiresAt) return;
6960
+ g_downloadArtifacts.delete(token);
6961
+ if (artifact.deleteAfterDownload && artifact.filePath && fs.existsSync(artifact.filePath)) {
6962
+ try {
6963
+ fs.unlinkSync(artifact.filePath);
6964
+ } catch (_) {}
6965
+ }
6966
+ }, ttlMs + 2000);
6967
+
6968
+ return {
6969
+ token,
6970
+ fileName,
6971
+ downloadPath: `/download/${encodeURIComponent(token)}`
6972
+ };
6973
+ }
6974
+
6975
+ function resolveDownloadArtifact(tokenOrFileName, options = {}) {
6976
+ if (!tokenOrFileName) return null;
6977
+ const token = typeof tokenOrFileName === 'string' ? tokenOrFileName.trim() : '';
6978
+ if (!token) return null;
6979
+
6980
+ const artifact = g_downloadArtifacts.get(token);
6981
+ if (!artifact) {
6982
+ return null;
6983
+ }
6984
+ if (Date.now() > artifact.expiresAt) {
6985
+ g_downloadArtifacts.delete(token);
6986
+ if (artifact.deleteAfterDownload && artifact.filePath && fs.existsSync(artifact.filePath)) {
6987
+ try {
6988
+ fs.unlinkSync(artifact.filePath);
6989
+ } catch (_) {}
6990
+ }
6991
+ return null;
6992
+ }
6993
+ if (options && options.consume === true) {
6994
+ g_downloadArtifacts.delete(token);
6995
+ }
6996
+ return {
6997
+ token,
6998
+ ...artifact
6999
+ };
7000
+ }
7001
+
6632
7002
  // API: 打包 Claude 配置目录(系统 zip 可用则使用,否则回退 zip-lib)
6633
7003
  async function prepareClaudeDirDownload() {
6634
7004
  try {
@@ -6693,12 +7063,16 @@ async function prepareCodexDirDownload() {
6693
7063
 
6694
7064
  function copyDirRecursive(srcDir, destDir, options = {}) {
6695
7065
  const dereferenceSymlinks = !!(options && options.dereferenceSymlinks);
7066
+ const allowedRootRealPath = (options && typeof options.allowedRootRealPath === 'string')
7067
+ ? options.allowedRootRealPath
7068
+ : '';
6696
7069
  const visitedRealPaths = options && options.visitedRealPaths instanceof Set
6697
7070
  ? options.visitedRealPaths
6698
7071
  : new Set();
6699
7072
  const childOptions = {
6700
7073
  ...options,
6701
7074
  dereferenceSymlinks,
7075
+ allowedRootRealPath,
6702
7076
  visitedRealPaths
6703
7077
  };
6704
7078
  ensureDir(destDir);
@@ -6712,6 +7086,9 @@ function copyDirRecursive(srcDir, destDir, options = {}) {
6712
7086
  continue;
6713
7087
  }
6714
7088
  const realPath = fs.realpathSync(srcPath);
7089
+ if (allowedRootRealPath && !isPathInside(realPath, allowedRootRealPath)) {
7090
+ throw new Error(`symlink escapes skill root: ${srcPath}`);
7091
+ }
6715
7092
  if (visitedRealPaths.has(realPath)) {
6716
7093
  continue;
6717
7094
  }
@@ -6724,6 +7101,9 @@ function copyDirRecursive(srcDir, destDir, options = {}) {
6724
7101
  } else if (entry.isSymbolicLink()) {
6725
7102
  if (dereferenceSymlinks) {
6726
7103
  const realPath = fs.realpathSync(srcPath);
7104
+ if (allowedRootRealPath && !isPathInside(realPath, allowedRootRealPath)) {
7105
+ throw new Error(`symlink escapes skill root: ${srcPath}`);
7106
+ }
6727
7107
  const realStat = fs.statSync(realPath);
6728
7108
  if (realStat.isDirectory()) {
6729
7109
  if (visitedRealPaths.has(realPath)) {
@@ -6748,6 +7128,139 @@ function copyDirRecursive(srcDir, destDir, options = {}) {
6748
7128
  }
6749
7129
  }
6750
7130
 
7131
+ function inspectZipArchiveLimits(zipPath, options = {}) {
7132
+ const maxEntryCount = Number.isFinite(options.maxEntryCount) && options.maxEntryCount > 0
7133
+ ? Math.floor(options.maxEntryCount)
7134
+ : MAX_SKILLS_ZIP_ENTRY_COUNT;
7135
+ const maxUncompressedBytes = Number.isFinite(options.maxUncompressedBytes) && options.maxUncompressedBytes > 0
7136
+ ? Math.floor(options.maxUncompressedBytes)
7137
+ : MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES;
7138
+
7139
+ return new Promise((resolve, reject) => {
7140
+ yauzl.open(zipPath, { lazyEntries: true, autoClose: true }, (openErr, zipFile) => {
7141
+ if (openErr) {
7142
+ reject(openErr);
7143
+ return;
7144
+ }
7145
+ if (!zipFile) {
7146
+ reject(new Error('无法读取 ZIP 文件'));
7147
+ return;
7148
+ }
7149
+ let entryCount = 0;
7150
+ let totalUncompressedBytes = 0;
7151
+ let settled = false;
7152
+ const finish = (err, data) => {
7153
+ if (settled) return;
7154
+ settled = true;
7155
+ try {
7156
+ zipFile.close();
7157
+ } catch (_) {}
7158
+ if (err) {
7159
+ reject(err);
7160
+ } else {
7161
+ resolve(data);
7162
+ }
7163
+ };
7164
+
7165
+ zipFile.on('entry', (entry) => {
7166
+ if (settled) return;
7167
+ entryCount += 1;
7168
+ const entrySize = Number.isFinite(entry.uncompressedSize) ? entry.uncompressedSize : 0;
7169
+ totalUncompressedBytes += entrySize;
7170
+ if (entryCount > maxEntryCount) {
7171
+ finish(new Error(`压缩包条目过多(>${maxEntryCount})`));
7172
+ return;
7173
+ }
7174
+ if (totalUncompressedBytes > maxUncompressedBytes) {
7175
+ finish(new Error(`压缩包解压总大小超限(>${Math.floor(maxUncompressedBytes / 1024 / 1024)}MB)`));
7176
+ return;
7177
+ }
7178
+ zipFile.readEntry();
7179
+ });
7180
+
7181
+ zipFile.on('end', () => {
7182
+ finish(null, { entryCount, totalUncompressedBytes });
7183
+ });
7184
+
7185
+ zipFile.on('error', (zipErr) => {
7186
+ finish(zipErr);
7187
+ });
7188
+
7189
+ zipFile.readEntry();
7190
+ });
7191
+ });
7192
+ }
7193
+
7194
+ function writeUploadZipStream(req, prefix, originalName = '', maxSize = MAX_SKILLS_ZIP_UPLOAD_SIZE) {
7195
+ return new Promise((resolve, reject) => {
7196
+ const lengthHeader = parseInt(req.headers['content-length'] || '0', 10);
7197
+ if (Number.isFinite(lengthHeader) && lengthHeader > maxSize) {
7198
+ reject(new Error(`备份文件过大(>${Math.floor(maxSize / 1024 / 1024)}MB)`));
7199
+ return;
7200
+ }
7201
+
7202
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
7203
+ const rawName = originalName && typeof originalName === 'string' ? originalName : `${prefix}.zip`;
7204
+ const fileName = path.basename(rawName);
7205
+ const zipPath = path.join(tempDir, fileName.toLowerCase().endsWith('.zip') ? fileName : `${fileName}.zip`);
7206
+ const stream = fs.createWriteStream(zipPath);
7207
+ let bytesWritten = 0;
7208
+ let settled = false;
7209
+ let hasContent = false;
7210
+
7211
+ const fail = (err) => {
7212
+ if (settled) return;
7213
+ settled = true;
7214
+ try {
7215
+ stream.destroy();
7216
+ } catch (_) {}
7217
+ try {
7218
+ fs.rmSync(tempDir, { recursive: true, force: true });
7219
+ } catch (_) {}
7220
+ reject(err);
7221
+ };
7222
+
7223
+ const done = () => {
7224
+ if (settled) return;
7225
+ settled = true;
7226
+ if (!hasContent || bytesWritten <= 0) {
7227
+ try {
7228
+ fs.rmSync(tempDir, { recursive: true, force: true });
7229
+ } catch (_) {}
7230
+ reject(new Error('备份文件为空'));
7231
+ return;
7232
+ }
7233
+ resolve({ tempDir, zipPath });
7234
+ };
7235
+
7236
+ req.on('error', (err) => fail(err));
7237
+ req.on('aborted', () => fail(new Error('上传已中断')));
7238
+ req.on('close', () => {
7239
+ if (!settled && !req.complete) {
7240
+ fail(new Error('上传已中断'));
7241
+ }
7242
+ });
7243
+ stream.on('error', (err) => fail(err));
7244
+ req.on('data', (chunk) => {
7245
+ if (settled) return;
7246
+ hasContent = true;
7247
+ bytesWritten += chunk.length;
7248
+ if (bytesWritten > maxSize) {
7249
+ fail(new Error(`备份文件过大(>${Math.floor(maxSize / 1024 / 1024)}MB)`));
7250
+ try {
7251
+ req.destroy();
7252
+ } catch (_) {}
7253
+ return;
7254
+ }
7255
+ stream.write(chunk);
7256
+ });
7257
+ req.on('end', () => {
7258
+ if (settled) return;
7259
+ stream.end(() => done());
7260
+ });
7261
+ });
7262
+ }
7263
+
6751
7264
  function writeUploadZip(base64, prefix, originalName = '') {
6752
7265
  let buffer;
6753
7266
  try {
@@ -7272,27 +7785,339 @@ async function cmdUnzip(zipPath, outputDir) {
7272
7785
  }
7273
7786
  }
7274
7787
 
7275
- function resolveExportOutputPath(outputPath, defaultFileName) {
7276
- const fallback = path.resolve(process.cwd(), defaultFileName);
7277
- if (typeof outputPath !== 'string' || !outputPath.trim()) {
7278
- return fallback;
7788
+ function splitExtractSuffixInput(rawValue) {
7789
+ if (Array.isArray(rawValue)) {
7790
+ return rawValue.flatMap((item) => splitExtractSuffixInput(item));
7279
7791
  }
7280
-
7281
- const trimmed = outputPath.trim();
7282
- const resolved = path.resolve(trimmed);
7283
- const hasTrailingSep = /[\\\/]$/.test(trimmed);
7284
- if (hasTrailingSep) {
7285
- ensureDir(resolved);
7286
- return path.join(resolved, defaultFileName);
7792
+ if (typeof rawValue !== 'string') {
7793
+ return [];
7287
7794
  }
7795
+ return rawValue
7796
+ .split(/[,\s]+/g)
7797
+ .map((item) => item.trim())
7798
+ .filter(Boolean);
7799
+ }
7288
7800
 
7289
- if (fs.existsSync(resolved)) {
7290
- try {
7291
- const stat = fs.statSync(resolved);
7292
- if (stat.isDirectory()) {
7293
- return path.join(resolved, defaultFileName);
7294
- }
7295
- } catch (e) {}
7801
+ function normalizeExtractSuffix(rawSuffix, fallbackSuffixes = DEFAULT_EXTRACT_SUFFIXES) {
7802
+ const fallbackItems = splitExtractSuffixInput(fallbackSuffixes);
7803
+ const sourceItems = splitExtractSuffixInput(rawSuffix);
7804
+ const source = sourceItems.length > 0 ? sourceItems : fallbackItems;
7805
+ const dedup = new Set();
7806
+
7807
+ for (const item of source) {
7808
+ const lower = item.toLowerCase();
7809
+ if (!lower) {
7810
+ continue;
7811
+ }
7812
+ const normalized = lower.startsWith('.') ? lower : `.${lower}`;
7813
+ if (normalized.length > 1) {
7814
+ dedup.add(normalized);
7815
+ }
7816
+ }
7817
+
7818
+ if (dedup.size === 0) {
7819
+ return [...DEFAULT_EXTRACT_SUFFIXES];
7820
+ }
7821
+ return Array.from(dedup);
7822
+ }
7823
+
7824
+ function buildDefaultExtractOutputDir(baseCwd = process.cwd()) {
7825
+ const normalizedCwd = path.resolve(baseCwd);
7826
+ const parentDir = path.dirname(normalizedCwd);
7827
+ const timestamp = formatTimestampForFileName().replace(/-/g, '');
7828
+ return path.join(parentDir, timestamp);
7829
+ }
7830
+
7831
+ function sanitizeNameSegment(rawValue, fallback = 'item') {
7832
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
7833
+ const sanitized = value
7834
+ .replace(/[^\w.-]+/g, '_')
7835
+ .replace(/^_+|_+$/g, '');
7836
+ return sanitized || fallback;
7837
+ }
7838
+
7839
+ function resolveDuplicateOutputPath(outputDir, originalFileName, zipPath = '', counters = new Map()) {
7840
+ const fallbackName = `file${path.extname(originalFileName || '')}`;
7841
+ const fileName = path.basename(originalFileName || '') || fallbackName;
7842
+ const firstChoice = path.join(outputDir, fileName);
7843
+ const firstChoiceKey = `exact:${fileName}`;
7844
+ if (!counters.has(firstChoiceKey)) {
7845
+ counters.set(firstChoiceKey, true);
7846
+ if (!fs.existsSync(firstChoice)) {
7847
+ return firstChoice;
7848
+ }
7849
+ }
7850
+
7851
+ const ext = path.extname(fileName);
7852
+ const baseName = path.basename(fileName, ext);
7853
+ const safeBaseName = sanitizeNameSegment(baseName, 'file');
7854
+ const zipBaseName = sanitizeNameSegment(path.basename(zipPath || '', '.zip'), 'zip');
7855
+ const duplicateKey = `dup:${safeBaseName}|${zipBaseName}|${ext}`;
7856
+ let index = counters.has(duplicateKey) ? counters.get(duplicateKey) : 1;
7857
+
7858
+ for (; index <= 100000; index++) {
7859
+ const candidateName = `${safeBaseName}__${zipBaseName}__${index}${ext}`;
7860
+ const candidatePath = path.join(outputDir, candidateName);
7861
+ if (!fs.existsSync(candidatePath)) {
7862
+ counters.set(duplicateKey, index + 1);
7863
+ return candidatePath;
7864
+ }
7865
+ }
7866
+
7867
+ throw new Error(`重名文件过多,无法生成唯一文件名: ${fileName}`);
7868
+ }
7869
+
7870
+ function collectZipFilesFromDir(rootDir, recursive = true) {
7871
+ const queue = [rootDir];
7872
+ const result = [];
7873
+
7874
+ while (queue.length > 0) {
7875
+ const currentDir = queue.shift();
7876
+ let entries = [];
7877
+ try {
7878
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
7879
+ } catch (e) {
7880
+ throw new Error(`读取目录失败: ${currentDir} (${e.message})`);
7881
+ }
7882
+
7883
+ for (const entry of entries) {
7884
+ const entryPath = path.join(currentDir, entry.name);
7885
+ if (entry.isDirectory()) {
7886
+ if (recursive) {
7887
+ queue.push(entryPath);
7888
+ }
7889
+ continue;
7890
+ }
7891
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.zip')) {
7892
+ result.push(entryPath);
7893
+ }
7894
+ }
7895
+ }
7896
+
7897
+ result.sort((a, b) => a.localeCompare(b));
7898
+ return result;
7899
+ }
7900
+
7901
+ function extractMatchedEntriesFromZip(zipPath, outputDir, suffixes, duplicateCounters = new Map()) {
7902
+ const normalizedSuffixes = normalizeExtractSuffix(suffixes);
7903
+ return new Promise((resolve, reject) => {
7904
+ yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (openErr, zipFile) => {
7905
+ if (openErr) {
7906
+ reject(openErr);
7907
+ return;
7908
+ }
7909
+ if (!zipFile) {
7910
+ reject(new Error('无法读取 ZIP 文件'));
7911
+ return;
7912
+ }
7913
+
7914
+ let settled = false;
7915
+ let matched = 0;
7916
+ let extracted = 0;
7917
+ let skippedDir = 0;
7918
+ let skippedExt = 0;
7919
+
7920
+ const finish = (err) => {
7921
+ if (settled) return;
7922
+ settled = true;
7923
+ try {
7924
+ zipFile.close();
7925
+ } catch (_) {}
7926
+ if (err) {
7927
+ reject(err);
7928
+ } else {
7929
+ resolve({ matched, extracted, skippedDir, skippedExt });
7930
+ }
7931
+ };
7932
+
7933
+ zipFile.on('entry', (entry) => {
7934
+ if (settled) return;
7935
+ const rawEntryName = typeof entry.fileName === 'string' ? entry.fileName : '';
7936
+ const normalizedEntryName = rawEntryName.replace(/\\/g, '/');
7937
+
7938
+ if (!normalizedEntryName || normalizedEntryName.endsWith('/')) {
7939
+ skippedDir += 1;
7940
+ zipFile.readEntry();
7941
+ return;
7942
+ }
7943
+
7944
+ const entryBaseName = path.basename(normalizedEntryName);
7945
+ const lowerBaseName = entryBaseName.toLowerCase();
7946
+ const matchedSuffix = normalizedSuffixes.some((suffix) => lowerBaseName.endsWith(suffix));
7947
+ if (!entryBaseName || !matchedSuffix) {
7948
+ skippedExt += 1;
7949
+ zipFile.readEntry();
7950
+ return;
7951
+ }
7952
+
7953
+ matched += 1;
7954
+ zipFile.openReadStream(entry, (streamErr, readStream) => {
7955
+ if (streamErr || !readStream) {
7956
+ finish(streamErr || new Error('无法读取 ZIP 条目流'));
7957
+ return;
7958
+ }
7959
+
7960
+ let completed = false;
7961
+ const outputPath = resolveDuplicateOutputPath(outputDir, entryBaseName, zipPath, duplicateCounters);
7962
+ const writeStream = fs.createWriteStream(outputPath);
7963
+ const fail = (writeErr) => {
7964
+ if (completed) return;
7965
+ completed = true;
7966
+ try {
7967
+ readStream.destroy();
7968
+ } catch (_) {}
7969
+ try {
7970
+ writeStream.destroy();
7971
+ } catch (_) {}
7972
+ try {
7973
+ if (fs.existsSync(outputPath)) {
7974
+ fs.unlinkSync(outputPath);
7975
+ }
7976
+ } catch (_) {}
7977
+ finish(writeErr);
7978
+ };
7979
+
7980
+ readStream.on('error', fail);
7981
+ writeStream.on('error', fail);
7982
+ writeStream.on('finish', () => {
7983
+ if (completed || settled) return;
7984
+ completed = true;
7985
+ extracted += 1;
7986
+ zipFile.readEntry();
7987
+ });
7988
+
7989
+ readStream.pipe(writeStream);
7990
+ });
7991
+ });
7992
+
7993
+ zipFile.on('end', () => {
7994
+ finish(null);
7995
+ });
7996
+ zipFile.on('error', (zipErr) => {
7997
+ finish(zipErr);
7998
+ });
7999
+
8000
+ zipFile.readEntry();
8001
+ });
8002
+ });
8003
+ }
8004
+
8005
+ async function cmdUnzipExt(zipDirPath, outputDir, options = {}) {
8006
+ if (!zipDirPath) {
8007
+ console.error('用法: codexmate unzip-ext <zip目录> [输出目录] [--ext:后缀[,后缀...]] [--no-recursive]');
8008
+ console.log('\n示例:');
8009
+ console.log(' codexmate unzip-ext ./archives');
8010
+ console.log(' codexmate unzip-ext ./archives ./output --ext:json,txt');
8011
+ console.log(' codexmate unzip-ext D:/data/zips --ext:txt --no-recursive');
8012
+ console.log(' 说明: 默认递归扫描子目录,可通过 --no-recursive 关闭递归');
8013
+ process.exit(1);
8014
+ }
8015
+
8016
+ const recursive = options.recursive !== false;
8017
+ const suffixes = normalizeExtractSuffix(options.ext);
8018
+ const absZipDir = path.resolve(zipDirPath);
8019
+ const absOutputDir = outputDir ? path.resolve(outputDir) : buildDefaultExtractOutputDir(process.cwd());
8020
+
8021
+ if (!fs.existsSync(absZipDir)) {
8022
+ console.error('错误: 目录不存在:', absZipDir);
8023
+ process.exit(1);
8024
+ }
8025
+ try {
8026
+ if (!fs.statSync(absZipDir).isDirectory()) {
8027
+ console.error('错误: 仅支持目录路径:', absZipDir);
8028
+ process.exit(1);
8029
+ }
8030
+ } catch (e) {
8031
+ console.error('错误: 无法读取目录信息:', e.message);
8032
+ process.exit(1);
8033
+ }
8034
+
8035
+ let zipFiles = [];
8036
+ try {
8037
+ zipFiles = collectZipFilesFromDir(absZipDir, recursive);
8038
+ } catch (e) {
8039
+ console.error('扫描 ZIP 文件失败:', e.message);
8040
+ process.exit(1);
8041
+ }
8042
+
8043
+ if (zipFiles.length === 0) {
8044
+ console.error('错误: 未找到任何 ZIP 文件');
8045
+ process.exit(1);
8046
+ }
8047
+
8048
+ ensureDir(absOutputDir);
8049
+
8050
+ console.log('\n批量解压配置:');
8051
+ console.log(' ZIP 目录:', absZipDir);
8052
+ console.log(' 输出目录:', absOutputDir);
8053
+ console.log(' 后缀过滤:', suffixes.join(', '));
8054
+ console.log(' 递归扫描:', recursive ? '是' : '否');
8055
+ console.log(' ZIP 数量:', zipFiles.length);
8056
+ console.log('\n开始提取...\n');
8057
+
8058
+ let totalMatched = 0;
8059
+ let totalExtracted = 0;
8060
+ let totalSkippedDir = 0;
8061
+ let totalSkippedExt = 0;
8062
+ const failed = [];
8063
+ const duplicateCounters = new Map();
8064
+
8065
+ for (const zipFilePath of zipFiles) {
8066
+ try {
8067
+ await inspectZipArchiveLimits(zipFilePath, {
8068
+ maxEntryCount: MAX_SKILLS_ZIP_ENTRY_COUNT,
8069
+ maxUncompressedBytes: MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES
8070
+ });
8071
+ const result = await extractMatchedEntriesFromZip(zipFilePath, absOutputDir, suffixes, duplicateCounters);
8072
+ totalMatched += result.matched;
8073
+ totalExtracted += result.extracted;
8074
+ totalSkippedDir += result.skippedDir;
8075
+ totalSkippedExt += result.skippedExt;
8076
+ console.log(`✓ ${path.basename(zipFilePath)}: 命中 ${result.matched},提取 ${result.extracted}`);
8077
+ } catch (e) {
8078
+ failed.push({ zipFilePath, message: e && e.message ? e.message : String(e) });
8079
+ console.error(`✗ ${path.basename(zipFilePath)}: ${e && e.message ? e.message : e}`);
8080
+ }
8081
+ }
8082
+
8083
+ console.log('\n提取结果:');
8084
+ console.log(' 输出目录:', absOutputDir);
8085
+ console.log(' 扫描 ZIP:', zipFiles.length);
8086
+ console.log(' 命中条目:', totalMatched);
8087
+ console.log(' 已提取:', totalExtracted);
8088
+ console.log(' 已跳过(目录条目):', totalSkippedDir);
8089
+ console.log(' 已跳过(后缀不匹配):', totalSkippedExt);
8090
+ if (failed.length > 0) {
8091
+ console.error(' 失败数量:', failed.length);
8092
+ for (const item of failed) {
8093
+ console.error(` - ${item.zipFilePath}: ${item.message}`);
8094
+ }
8095
+ process.exit(1);
8096
+ }
8097
+ console.log();
8098
+ }
8099
+
8100
+ function resolveExportOutputPath(outputPath, defaultFileName) {
8101
+ const fallback = path.resolve(process.cwd(), defaultFileName);
8102
+ if (typeof outputPath !== 'string' || !outputPath.trim()) {
8103
+ return fallback;
8104
+ }
8105
+
8106
+ const trimmed = outputPath.trim();
8107
+ const resolved = path.resolve(trimmed);
8108
+ const hasTrailingSep = /[\\\/]$/.test(trimmed);
8109
+ if (hasTrailingSep) {
8110
+ ensureDir(resolved);
8111
+ return path.join(resolved, defaultFileName);
8112
+ }
8113
+
8114
+ if (fs.existsSync(resolved)) {
8115
+ try {
8116
+ const stat = fs.statSync(resolved);
8117
+ if (stat.isDirectory()) {
8118
+ return path.join(resolved, defaultFileName);
8119
+ }
8120
+ } catch (e) {}
7296
8121
  }
7297
8122
 
7298
8123
  return resolved;
@@ -7443,7 +8268,7 @@ async function cmdExportSession(args = []) {
7443
8268
  }
7444
8269
 
7445
8270
  function parseStartOptions(args = []) {
7446
- const options = { host: '' };
8271
+ const options = { host: '', noBrowser: false };
7447
8272
  if (!Array.isArray(args)) {
7448
8273
  return options;
7449
8274
  }
@@ -7451,6 +8276,10 @@ function parseStartOptions(args = []) {
7451
8276
  for (let i = 0; i < args.length; i++) {
7452
8277
  const arg = args[i];
7453
8278
  if (!arg) continue;
8279
+ if (arg === '--no-browser') {
8280
+ options.noBrowser = true;
8281
+ continue;
8282
+ }
7454
8283
  if (arg.startsWith('--host=')) {
7455
8284
  options.host = arg.slice('--host='.length);
7456
8285
  continue;
@@ -7523,11 +8352,125 @@ function watchPathsForRestart(targets, onChange) {
7523
8352
  };
7524
8353
  }
7525
8354
 
8355
+ function writeJsonResponse(res, statusCode, payload) {
8356
+ const body = JSON.stringify(payload, null, 2);
8357
+ res.writeHead(statusCode, {
8358
+ 'Content-Type': 'application/json; charset=utf-8',
8359
+ 'Content-Length': Buffer.byteLength(body, 'utf-8')
8360
+ });
8361
+ res.end(body, 'utf-8');
8362
+ }
8363
+
8364
+ function streamZipDownloadResponse(res, filePath, options = {}) {
8365
+ if (!filePath || !fs.existsSync(filePath)) {
8366
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
8367
+ res.end('File Not Found');
8368
+ return;
8369
+ }
8370
+ const stat = fs.statSync(filePath);
8371
+ if (!stat.isFile()) {
8372
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
8373
+ res.end('Not a File');
8374
+ return;
8375
+ }
8376
+ const downloadName = typeof options.fileName === 'string' && options.fileName.trim()
8377
+ ? options.fileName.trim()
8378
+ : path.basename(filePath);
8379
+ const deleteAfterDownload = !!options.deleteAfterDownload;
8380
+ const onAfterComplete = typeof options.onAfterComplete === 'function'
8381
+ ? options.onAfterComplete
8382
+ : null;
8383
+ res.writeHead(200, {
8384
+ 'Content-Type': 'application/zip',
8385
+ 'Content-Disposition': `attachment; filename="${path.basename(downloadName)}"`,
8386
+ 'Content-Length': stat.size
8387
+ });
8388
+
8389
+ const stream = fs.createReadStream(filePath);
8390
+ let finished = false;
8391
+ const finalize = () => {
8392
+ if (finished) return;
8393
+ finished = true;
8394
+ if (deleteAfterDownload && fs.existsSync(filePath)) {
8395
+ try {
8396
+ fs.unlinkSync(filePath);
8397
+ } catch (_) {}
8398
+ }
8399
+ if (onAfterComplete) {
8400
+ try {
8401
+ onAfterComplete();
8402
+ } catch (_) {}
8403
+ }
8404
+ };
8405
+ stream.on('error', () => {
8406
+ if (!res.headersSent) {
8407
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
8408
+ res.end('Download Error');
8409
+ } else {
8410
+ try {
8411
+ res.destroy();
8412
+ } catch (_) {}
8413
+ }
8414
+ finalize();
8415
+ });
8416
+ res.on('finish', finalize);
8417
+ res.on('close', finalize);
8418
+ stream.pipe(res);
8419
+ }
8420
+
8421
+ function resolveUploadFileNameFromRequest(req, fallbackName = 'codex-skills.zip') {
8422
+ const rawHeader = req.headers['x-codexmate-file-name'];
8423
+ const source = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
8424
+ const fallback = typeof fallbackName === 'string' && fallbackName.trim()
8425
+ ? fallbackName.trim()
8426
+ : 'codex-skills.zip';
8427
+ if (!source || typeof source !== 'string') {
8428
+ return fallback;
8429
+ }
8430
+ const decoded = (() => {
8431
+ try {
8432
+ return decodeURIComponent(source);
8433
+ } catch (_) {
8434
+ return source;
8435
+ }
8436
+ })();
8437
+ const normalized = path.basename(decoded.trim());
8438
+ return normalized || fallback;
8439
+ }
8440
+
8441
+ async function handleImportCodexSkillsZipUpload(req, res) {
8442
+ if (req.method !== 'POST') {
8443
+ writeJsonResponse(res, 405, { error: 'Method Not Allowed' });
8444
+ return;
8445
+ }
8446
+ try {
8447
+ const fileName = resolveUploadFileNameFromRequest(req, 'codex-skills.zip');
8448
+ const upload = await writeUploadZipStream(
8449
+ req,
8450
+ 'codex-skills-import',
8451
+ fileName,
8452
+ MAX_SKILLS_ZIP_UPLOAD_SIZE
8453
+ );
8454
+ const result = await importCodexSkillsFromZipFile(upload.zipPath, {
8455
+ tempDir: upload.tempDir,
8456
+ fallbackName: fileName
8457
+ });
8458
+ writeJsonResponse(res, 200, result || {});
8459
+ } catch (e) {
8460
+ const message = e && e.message ? e.message : '上传失败';
8461
+ writeJsonResponse(res, 400, { error: message });
8462
+ }
8463
+ }
8464
+
7526
8465
  function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {
7527
8466
  const connections = new Set();
7528
8467
 
7529
8468
  const server = http.createServer((req, res) => {
7530
8469
  const requestPath = (req.url || '/').split('?')[0];
8470
+ if (requestPath === '/api/import-codex-skills-zip') {
8471
+ void handleImportCodexSkillsZipUpload(req, res);
8472
+ return;
8473
+ }
7531
8474
  if (requestPath === '/api') {
7532
8475
  let body = '';
7533
8476
  req.on('data', chunk => body += chunk);
@@ -7651,6 +8594,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
7651
8594
  case 'import-codex-skills':
7652
8595
  result = importCodexSkills(params || {});
7653
8596
  break;
8597
+ case 'export-codex-skills':
8598
+ result = await exportCodexSkills(params || {});
8599
+ break;
7654
8600
  case 'get-openclaw-config':
7655
8601
  result = readOpenclawConfigFile();
7656
8602
  break;
@@ -7909,32 +8855,35 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
7909
8855
  fs.createReadStream(filePath).pipe(res);
7910
8856
  } else if (requestPath.startsWith('/download/')) {
7911
8857
  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');
8858
+ let decodedFileName = '';
8859
+ try {
8860
+ decodedFileName = decodeURIComponent(fileName);
8861
+ } catch (_) {
8862
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
8863
+ res.end('Bad Request');
7919
8864
  return;
7920
8865
  }
7921
- if (!fs.existsSync(filePath)) {
7922
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
7923
- res.end('File Not Found');
8866
+
8867
+ const artifact = resolveDownloadArtifact(decodedFileName, { consume: true });
8868
+ if (artifact) {
8869
+ streamZipDownloadResponse(res, artifact.filePath, {
8870
+ fileName: artifact.fileName,
8871
+ deleteAfterDownload: artifact.deleteAfterDownload !== false
8872
+ });
7924
8873
  return;
7925
8874
  }
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');
8875
+
8876
+ const tempDir = os.tmpdir();
8877
+ const legacyFilePath = path.join(tempDir, decodedFileName);
8878
+ if (!isPathInside(legacyFilePath, tempDir)) {
8879
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
8880
+ res.end('Forbidden');
7930
8881
  return;
7931
8882
  }
7932
- res.writeHead(200, {
7933
- 'Content-Type': 'application/zip',
7934
- 'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
7935
- 'Content-Length': stat.size
8883
+ streamZipDownloadResponse(res, legacyFilePath, {
8884
+ fileName: path.basename(legacyFilePath),
8885
+ deleteAfterDownload: false
7936
8886
  });
7937
- fs.createReadStream(filePath).pipe(res);
7938
8887
  } else if (requestPath.startsWith('/res/')) {
7939
8888
  const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
7940
8889
  const filePath = path.join(__dirname, normalized);
@@ -8057,7 +9006,7 @@ function cmdStart(options = {}) {
8057
9006
  webDir,
8058
9007
  host,
8059
9008
  port,
8060
- openBrowser: true
9009
+ openBrowser: !options.noBrowser
8061
9010
  });
8062
9011
 
8063
9012
  const proxySettings = readBuiltinProxySettings();
@@ -8597,7 +9546,317 @@ async function cmdWorkflow(args = []) {
8597
9546
  throw new Error(`未知 workflow 子命令: ${subcommand}`);
8598
9547
  }
8599
9548
 
8600
- async function runProxyCommand(displayName, binNames, args = [], installTip = '') {
9549
+ // #region parseCodexProxyOptions
9550
+ function parseCodexProxyOptions(args = []) {
9551
+ const options = {
9552
+ passthroughArgs: [],
9553
+ queuedFollowUps: []
9554
+ };
9555
+ const argv = Array.isArray(args) ? args : [];
9556
+
9557
+ const pushFollowUp = (value, optionName) => {
9558
+ const raw = value === undefined || value === null ? '' : String(value);
9559
+ if (!raw.trim()) {
9560
+ throw new Error(`${optionName} 需要提供非空内容`);
9561
+ }
9562
+ options.queuedFollowUps.push(raw);
9563
+ };
9564
+
9565
+ for (let i = 0; i < argv.length; i++) {
9566
+ const arg = argv[i];
9567
+ if (arg === undefined || arg === null) {
9568
+ continue;
9569
+ }
9570
+ const text = String(arg);
9571
+ if (text === '--') {
9572
+ options.passthroughArgs.push(...argv.slice(i).map((item) => String(item)));
9573
+ break;
9574
+ }
9575
+ if (text === '--queued-follow-up' || text === '--follow-up') {
9576
+ const next = argv[i + 1];
9577
+ if (next === undefined) {
9578
+ throw new Error(`${text} 需要提供内容`);
9579
+ }
9580
+ pushFollowUp(next, text);
9581
+ i += 1;
9582
+ continue;
9583
+ }
9584
+ if (text.startsWith('--queued-follow-up=')) {
9585
+ pushFollowUp(text.slice('--queued-follow-up='.length), '--queued-follow-up');
9586
+ continue;
9587
+ }
9588
+ if (text.startsWith('--follow-up=')) {
9589
+ pushFollowUp(text.slice('--follow-up='.length), '--follow-up');
9590
+ continue;
9591
+ }
9592
+ options.passthroughArgs.push(text);
9593
+ }
9594
+
9595
+ return options;
9596
+ }
9597
+ // #endregion parseCodexProxyOptions
9598
+
9599
+ function shellEscapePosixArg(value) {
9600
+ const text = value === undefined || value === null ? '' : String(value);
9601
+ return `'${text.replace(/'/g, `'\"'\"'`)}'`;
9602
+ }
9603
+
9604
+ // #region buildScriptCommandArgs
9605
+ function buildScriptCommandArgs(commandLine) {
9606
+ const platform = process.platform;
9607
+ // util-linux script needs -e/--return to propagate child exit code.
9608
+ if (platform === 'linux' || platform === 'android') {
9609
+ return ['-q', '-e', '-c', commandLine, '/dev/null'];
9610
+ }
9611
+ // NetBSD supports -e/-c, matching util-linux style contract.
9612
+ if (platform === 'netbsd') {
9613
+ return ['-q', '-e', '-c', commandLine, '/dev/null'];
9614
+ }
9615
+ // OpenBSD supports "-c <command>" with a trailing output file path.
9616
+ if (platform === 'openbsd') {
9617
+ return ['-c', commandLine, '/dev/null'];
9618
+ }
9619
+ // BSD/macOS script does not support util-linux "-c <cmd>" syntax.
9620
+ if (platform === 'darwin' || platform === 'freebsd') {
9621
+ return ['-q', '/dev/null', 'sh', '-lc', commandLine];
9622
+ }
9623
+ throw new Error(`当前平台暂不支持 --follow-up 自动排队(platform=${platform})`);
9624
+ }
9625
+ // #endregion buildScriptCommandArgs
9626
+
9627
+ // #region runProxyCommandWithQueuedFollowUps
9628
+ async function runProxyCommandWithQueuedFollowUps(selectedBin, finalArgs = [], queuedFollowUps = []) {
9629
+ if (!process.stdin || !process.stdin.isTTY) {
9630
+ throw new Error('当前 stdin 不是 TTY,无法使用 --follow-up 自动排队。');
9631
+ }
9632
+
9633
+ const scriptPath = resolveCommandPath('script');
9634
+ if (!scriptPath) {
9635
+ throw new Error('未找到 script 命令,无法自动注入 queued follow-up 消息。');
9636
+ }
9637
+
9638
+ const commandLine = [selectedBin, ...finalArgs].map((item) => shellEscapePosixArg(item)).join(' ');
9639
+ const scriptArgs = buildScriptCommandArgs(commandLine);
9640
+
9641
+ return new Promise((resolve, reject) => {
9642
+ let settled = false;
9643
+ const child = spawn(scriptPath, scriptArgs, {
9644
+ stdio: ['pipe', 'pipe', 'pipe']
9645
+ });
9646
+
9647
+ const stdin = process.stdin;
9648
+ const hadRawMode = !!stdin.isRaw;
9649
+ let cleanedUp = false;
9650
+ let waitingDrain = false;
9651
+ let followUpsFlushed = false;
9652
+ let outputReadyDetected = false;
9653
+ const timers = [];
9654
+ const pendingWrites = [];
9655
+ let onChildStdinDrain = null;
9656
+ let onChildStdinError = null;
9657
+ const resolveOnce = (code) => {
9658
+ if (settled) return;
9659
+ settled = true;
9660
+ resolve(code);
9661
+ };
9662
+ const rejectOnce = (error) => {
9663
+ if (settled) return;
9664
+ settled = true;
9665
+ reject(error);
9666
+ };
9667
+ const handleWriteFailure = (error) => {
9668
+ const err = error instanceof Error ? error : new Error(String(error || 'unknown'));
9669
+ cleanup();
9670
+ try {
9671
+ if (!child.killed) {
9672
+ child.kill('SIGTERM');
9673
+ }
9674
+ } catch (_) {
9675
+ // Ignore failure to terminate child after stdin write failure.
9676
+ }
9677
+ rejectOnce(new Error(`写入 ${selectedBin} stdin 失败: ${err.message}`));
9678
+ };
9679
+ const flushPendingWrites = () => {
9680
+ if (cleanedUp || child.stdin.destroyed) {
9681
+ pendingWrites.length = 0;
9682
+ return;
9683
+ }
9684
+ while (pendingWrites.length > 0) {
9685
+ const chunk = pendingWrites[0];
9686
+ let canContinue = true;
9687
+ try {
9688
+ canContinue = child.stdin.write(chunk, (error) => {
9689
+ if (error) {
9690
+ handleWriteFailure(error);
9691
+ }
9692
+ });
9693
+ } catch (error) {
9694
+ handleWriteFailure(error);
9695
+ return;
9696
+ }
9697
+ pendingWrites.shift();
9698
+ if (!canContinue) {
9699
+ waitingDrain = true;
9700
+ try {
9701
+ stdin.pause();
9702
+ } catch (_) {
9703
+ // Ignore stdin pause failures.
9704
+ }
9705
+ return;
9706
+ }
9707
+ }
9708
+ waitingDrain = false;
9709
+ try {
9710
+ stdin.resume();
9711
+ } catch (_) {
9712
+ // Ignore stdin resume failures.
9713
+ }
9714
+ };
9715
+ const enqueueWrite = (chunk) => {
9716
+ if (cleanedUp) return;
9717
+ pendingWrites.push(chunk);
9718
+ flushPendingWrites();
9719
+ };
9720
+ const onInput = (chunk) => {
9721
+ if (!child.stdin.destroyed) {
9722
+ enqueueWrite(chunk);
9723
+ }
9724
+ };
9725
+ const flushQueuedFollowUps = () => {
9726
+ if (followUpsFlushed) return;
9727
+ followUpsFlushed = true;
9728
+ queuedFollowUps.forEach((message, index) => {
9729
+ const timer = setTimeout(() => {
9730
+ if (!child.stdin.destroyed) {
9731
+ // PTY submit should use CR instead of LF.
9732
+ enqueueWrite(`${message}\r`);
9733
+ }
9734
+ }, index * 80);
9735
+ timers.push(timer);
9736
+ });
9737
+ };
9738
+ const markOutputReady = () => {
9739
+ if (outputReadyDetected) return;
9740
+ outputReadyDetected = true;
9741
+ timers.push(setTimeout(() => {
9742
+ flushQueuedFollowUps();
9743
+ }, 120));
9744
+ };
9745
+ const onStdoutData = (chunk) => {
9746
+ process.stdout.write(chunk);
9747
+ markOutputReady();
9748
+ };
9749
+ const onStderrData = (chunk) => {
9750
+ process.stderr.write(chunk);
9751
+ markOutputReady();
9752
+ };
9753
+ const onProcessExit = () => {
9754
+ cleanup();
9755
+ };
9756
+ const onProcessSigint = () => {
9757
+ cleanup();
9758
+ try {
9759
+ if (!child.killed) {
9760
+ child.kill('SIGINT');
9761
+ }
9762
+ } catch (_) {
9763
+ // Ignore forwarding failures and keep exit path deterministic.
9764
+ }
9765
+ process.exit(130);
9766
+ };
9767
+ const onProcessSigterm = () => {
9768
+ cleanup();
9769
+ try {
9770
+ if (!child.killed) {
9771
+ child.kill('SIGTERM');
9772
+ }
9773
+ } catch (_) {
9774
+ // Ignore forwarding failures and keep exit path deterministic.
9775
+ }
9776
+ process.exit(143);
9777
+ };
9778
+ const cleanup = () => {
9779
+ if (cleanedUp) return;
9780
+ cleanedUp = true;
9781
+ stdin.removeListener('data', onInput);
9782
+ process.removeListener('exit', onProcessExit);
9783
+ process.removeListener('SIGINT', onProcessSigint);
9784
+ process.removeListener('SIGTERM', onProcessSigterm);
9785
+ child.stdout.removeListener('data', onStdoutData);
9786
+ child.stderr.removeListener('data', onStderrData);
9787
+ if (onChildStdinDrain) {
9788
+ child.stdin.removeListener('drain', onChildStdinDrain);
9789
+ }
9790
+ if (onChildStdinError) {
9791
+ child.stdin.removeListener('error', onChildStdinError);
9792
+ }
9793
+ while (timers.length > 0) {
9794
+ clearTimeout(timers.pop());
9795
+ }
9796
+ try {
9797
+ if (typeof stdin.setRawMode === 'function' && !hadRawMode) {
9798
+ stdin.setRawMode(false);
9799
+ }
9800
+ } catch (_) {
9801
+ // Ignore raw mode restore failures at shutdown.
9802
+ }
9803
+ };
9804
+
9805
+ process.on('exit', onProcessExit);
9806
+ process.on('SIGINT', onProcessSigint);
9807
+ process.on('SIGTERM', onProcessSigterm);
9808
+ child.stdout.on('data', onStdoutData);
9809
+ child.stderr.on('data', onStderrData);
9810
+ onChildStdinDrain = () => {
9811
+ waitingDrain = false;
9812
+ flushPendingWrites();
9813
+ };
9814
+ onChildStdinError = (error) => {
9815
+ handleWriteFailure(error);
9816
+ };
9817
+ child.stdin.on('drain', onChildStdinDrain);
9818
+ child.stdin.on('error', onChildStdinError);
9819
+ try {
9820
+ if (typeof stdin.setRawMode === 'function' && !hadRawMode) {
9821
+ stdin.setRawMode(true);
9822
+ }
9823
+ } catch (_) {
9824
+ // Keep graceful fallback if raw mode toggle is not supported.
9825
+ }
9826
+
9827
+ stdin.resume();
9828
+ stdin.on('data', onInput);
9829
+ // Fallback in case the child stays silent before prompt render.
9830
+ timers.push(setTimeout(() => {
9831
+ flushQueuedFollowUps();
9832
+ }, 1500));
9833
+
9834
+ child.on('error', (err) => {
9835
+ cleanup();
9836
+ rejectOnce(new Error(`运行 ${selectedBin} 失败: ${err.message}`));
9837
+ });
9838
+
9839
+ child.on('close', (code, signal) => {
9840
+ cleanup();
9841
+ if (typeof code === 'number') {
9842
+ resolveOnce(code);
9843
+ return;
9844
+ }
9845
+ if (signal === 'SIGINT') {
9846
+ resolveOnce(130);
9847
+ return;
9848
+ }
9849
+ if (signal === 'SIGTERM') {
9850
+ resolveOnce(143);
9851
+ return;
9852
+ }
9853
+ resolveOnce(1);
9854
+ });
9855
+ });
9856
+ }
9857
+ // #endregion runProxyCommandWithQueuedFollowUps
9858
+
9859
+ async function runProxyCommand(displayName, binNames, args = [], installTip = '', runtimeOptions = {}) {
8601
9860
  const extraArgs = Array.isArray(args) ? args.filter(arg => arg !== undefined) : [];
8602
9861
  const hasYolo = extraArgs.includes('--yolo');
8603
9862
  const finalArgs = hasYolo ? extraArgs : ['--yolo', ...extraArgs];
@@ -8623,6 +9882,14 @@ async function runProxyCommand(displayName, binNames, args = [], installTip = ''
8623
9882
  throw new Error(msg);
8624
9883
  }
8625
9884
 
9885
+ const queuedFollowUps = runtimeOptions && Array.isArray(runtimeOptions.queuedFollowUps)
9886
+ ? runtimeOptions.queuedFollowUps.filter((item) => typeof item === 'string' && item.trim())
9887
+ : [];
9888
+
9889
+ if (queuedFollowUps.length > 0) {
9890
+ return runProxyCommandWithQueuedFollowUps(selectedBin, finalArgs, queuedFollowUps);
9891
+ }
9892
+
8626
9893
  return new Promise((resolve, reject) => {
8627
9894
  const child = spawn(selectedBin, finalArgs, {
8628
9895
  stdio: 'inherit',
@@ -8652,27 +9919,16 @@ async function runProxyCommand(displayName, binNames, args = [], installTip = ''
8652
9919
  }
8653
9920
 
8654
9921
  async function cmdCodex(args = []) {
8655
- const ensureResult = await ensureBuiltinProxyForCodexDefault({});
8656
- if (!ensureResult || ensureResult.success !== true) {
8657
- const message = ensureResult && ensureResult.error
8658
- ? ensureResult.error
8659
- : '内建代理准备失败';
8660
- throw new Error(message);
8661
- }
8662
- if (ensureResult.runtime && ensureResult.runtime.listenUrl) {
8663
- console.log(`~ Codex 默认走内建代理: ${ensureResult.runtime.listenUrl}`);
8664
- }
8665
- return runProxyCommand('Codex', 'codex', args);
9922
+ const parsed = parseCodexProxyOptions(args);
9923
+ return runProxyCommand('Codex', 'codex', parsed.passthroughArgs, '', {
9924
+ queuedFollowUps: parsed.queuedFollowUps
9925
+ });
8666
9926
  }
8667
9927
 
8668
9928
  async function cmdQwen(args = []) {
8669
9929
  return runProxyCommand('Qwen', ['qwen', 'qwen-code'], args, 'npm install -g @qwen-code/qwen-code');
8670
9930
  }
8671
9931
 
8672
- async function cmdGemini(args = []) {
8673
- return runProxyCommand('Gemini', ['gemini', 'gemini-cli'], args, 'npm install -g @google/gemini-cli');
8674
- }
8675
-
8676
9932
  function parseMcpOptions(args = []) {
8677
9933
  const options = {
8678
9934
  subcommand: 'serve',
@@ -10132,14 +11388,15 @@ async function main() {
10132
11388
  console.log(' codexmate auth <list|import|switch|delete|status> 认证文件管理');
10133
11389
  console.log(' codexmate proxy <status|set|apply|enable|start|stop> 内建代理');
10134
11390
  console.log(' codexmate workflow <list|get|validate|run|runs> MCP 工作流中心');
10135
- console.log(' codexmate run [--host <HOST>] 启动 Web 界面');
10136
- console.log(' codexmate codex [参数...] 等同于 codex --yolo');
11391
+ console.log(' codexmate run [--host <HOST>] [--no-browser] 启动 Web 界面');
11392
+ console.log(' codexmate codex [参数...] [--follow-up <文本>|--queued-follow-up <文本> 可重复] 等同于 codex --yolo(不会自动启用内建代理)');
11393
+ console.log(' 注: follow-up 自动排队仅支持 linux/android/netbsd/openbsd/darwin/freebsd 且 stdin 必须是 TTY,其他平台会报错');
10137
11394
  console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
10138
- console.log(' codexmate gemini [参数...] 等同于 gemini --yolo');
10139
11395
  console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
10140
11396
  console.log(' codexmate export-session --source <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
10141
11397
  console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
10142
11398
  console.log(' codexmate unzip <zip文件> [输出目录] 解压(zip-lib)');
11399
+ console.log(' codexmate unzip-ext <zip目录> [输出目录] [--ext:后缀[,后缀...]] [--no-recursive] 批量提取 ZIP 指定后缀文件(默认递归)');
10143
11400
  console.log('');
10144
11401
  process.exit(0);
10145
11402
  }
@@ -10174,11 +11431,6 @@ async function main() {
10174
11431
  process.exit(exitCode);
10175
11432
  break;
10176
11433
  }
10177
- case 'gemini': {
10178
- const exitCode = await cmdGemini(args.slice(1));
10179
- process.exit(exitCode);
10180
- break;
10181
- }
10182
11434
  case 'mcp': await cmdMcp(args.slice(1)); break;
10183
11435
  case 'export-session': await cmdExportSession(args.slice(1)); break;
10184
11436
  case 'zip': {
@@ -10197,6 +11449,38 @@ async function main() {
10197
11449
  break;
10198
11450
  }
10199
11451
  case 'unzip': await cmdUnzip(args[1], args[2]); break;
11452
+ case 'unzip-ext': {
11453
+ const unzipExtOptions = {
11454
+ ext: [],
11455
+ recursive: true
11456
+ };
11457
+ let zipDirPath = null;
11458
+ let outputDir = null;
11459
+ for (let i = 1; i < args.length; i++) {
11460
+ const arg = args[i];
11461
+ if (arg.startsWith('--ext:')) {
11462
+ unzipExtOptions.ext.push(...splitExtractSuffixInput(arg.substring(6)));
11463
+ } else if (arg.startsWith('--ext=')) {
11464
+ unzipExtOptions.ext.push(...splitExtractSuffixInput(arg.substring(6)));
11465
+ } else if (arg === '--ext') {
11466
+ const nextArg = args[i + 1];
11467
+ if (typeof nextArg === 'string' && !nextArg.startsWith('--')) {
11468
+ unzipExtOptions.ext.push(...splitExtractSuffixInput(nextArg));
11469
+ i += 1;
11470
+ }
11471
+ } else if (arg === '--recursive') {
11472
+ unzipExtOptions.recursive = true;
11473
+ } else if (arg === '--no-recursive') {
11474
+ unzipExtOptions.recursive = false;
11475
+ } else if (!zipDirPath) {
11476
+ zipDirPath = arg;
11477
+ } else if (!outputDir) {
11478
+ outputDir = arg;
11479
+ }
11480
+ }
11481
+ await cmdUnzipExt(zipDirPath, outputDir, unzipExtOptions);
11482
+ break;
11483
+ }
10200
11484
  default:
10201
11485
  console.error('错误: 未知命令:', command);
10202
11486
  console.log('运行 "codexmate" 查看帮助');