diff --git a/README.en.md b/README.en.md index 4ed73ee..369f425 100644 --- a/README.en.md +++ b/README.en.md @@ -23,9 +23,10 @@ Codex Mate is a local-first CLI + Web UI for unified management of: - Codex provider/model switching and config writes - Claude Code profiles (writes to `~/.claude/settings.json`) - OpenClaw JSON5 profiles and workspace `AGENTS.md` +- Local skills market for Codex / Claude Code (target switching, local skills management, cross-app import, ZIP distribution) - Local Codex/Claude sessions (list/filter/export/delete) -It works on local files directly and does not require cloud hosting. +It works on local files directly and does not require cloud hosting. The skills market is also local-first: it operates on local directories and does not depend on a remote marketplace. ## Why Codex Mate? @@ -34,6 +35,7 @@ It works on local files directly and does not require cloud hosting. | Multi-tool management | Codex + Claude Code + OpenClaw in one entry | Different files and folders per tool | | Operation mode | CLI + local Web UI | Manual TOML/JSON/JSON5 edits | | Session handling | Browse/export/batch cleanup | Manual file location and processing | +| Skills reuse | Local skills market + cross-app import + ZIP distribution | Manual folder copy and reconciliation | | Rollback readiness | Backup before first takeover | Easy to overwrite by mistake | | Automation integration | MCP stdio (read-only by default) | Requires custom scripting | @@ -52,6 +54,12 @@ It works on local files directly and does not require cloud hosting. - Markdown export - Session-level and message-level delete (supports batch) +**Skills Market** +- Switch the skills install target between Codex and Claude Code +- Inspect local installed skills, root paths, and status +- Scan importable sources from `Codex` / `Claude Code` / `Agents` +- Support cross-app import, ZIP import/export, and batch delete + **Engineering Utilities** - MCP stdio domains (`tools`, `resources`, `prompts`) - Built-in proxy controls (`proxy`) @@ -74,15 +82,16 @@ flowchart TB API["Local HTTP API"] MCPS["MCP stdio Server"] PROXY["Built-in Proxy"] - SERVICES["Config / Sessions / Skills / Workflow"] + SERVICES["Config / Sessions / Skills Market / Workflow"] CORE["File IO / Network / Diff / Session Utils"] end subgraph Data["Local Files"] - CODEX["~/.codex"] - CLAUDE["~/.claude"] - OPENCLAW["~/.openclaw"] - STATE["sessions / trash / workflow runs"] + CODEX["~/.codex/config + auth + models"] + CLAUDE["~/.claude/settings.json"] + OPENCLAW["~/.openclaw/*.json5 + ~/.openclaw/openclaw.json + workspace/AGENTS.md"] + SKILLS["~/.codex/skills / ~/.claude/skills / ~/.agents/skills"] + STATE["sessions / trash / workflow runs / skill exports"] end CLI --> ENTRY @@ -100,6 +109,7 @@ flowchart TB CORE --> CODEX CORE --> CLAUDE CORE --> OPENCLAW + CORE --> SKILLS CORE --> STATE ``` @@ -114,7 +124,9 @@ codexmate status codexmate run ``` -Default listen address is `127.0.0.1:3737`, and browser auto-open is enabled by default. +Default listen address is `0.0.0.0:3737` for LAN access, and browser auto-open is enabled by default. + +> Safety note: the unauthenticated management UI is exposed to your current LAN by default. Use trusted networks only; for local-only access, set `CODEXMATE_HOST=127.0.0.1` or pass `--host 127.0.0.1`. ### Run from source @@ -170,8 +182,6 @@ codexmate codex --model gpt-5.3-codex --follow-up "step1" --follow-up "step2" - Provider/model switching - Model list management - `~/.codex/AGENTS.md` editing -- `~/.codex/skills` management (filter, batch delete, cross-app import) - ### Claude Code Mode - Multi-profile management @@ -188,6 +198,12 @@ codexmate codex --model gpt-5.3-codex --follow-up "step1" --follow-up "step2" - Local pin/unpin with persistent storage and pinned-first ordering - Search, filter, export, delete, batch cleanup +### Skills Market Tab +- Switch the skills install target between `Codex` and `Claude Code` +- Show the current local skills root, installed items, and importable items +- Scan importable sources under `Codex` / `Claude Code` / `Agents` +- Support cross-app import, ZIP import/export, and batch delete + ## MCP > Transport: `stdio` @@ -218,7 +234,7 @@ codexmate mcp serve --allow-write | Variable | Default | Description | | --- | --- | --- | | `CODEXMATE_PORT` | `3737` | Web server port | -| `CODEXMATE_HOST` | `127.0.0.1` | Web listen host | +| `CODEXMATE_HOST` | `0.0.0.0` | Web listen host (set `127.0.0.1` for local-only access) | | `CODEXMATE_NO_BROWSER` | unset | Set `1` to disable browser auto-open | | `CODEXMATE_MCP_ALLOW_WRITE` | unset | Set `1` to allow MCP write tools by default | | `CODEXMATE_FORCE_RESET_EXISTING_CONFIG` | `0` | Set `1` to force bootstrap reset of existing config | diff --git a/README.md b/README.md index e5dab80..4e7af9b 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - Codex 的 provider / model 切换与配置写入 - Claude Code 配置方案(写入 `~/.claude/settings.json`) - OpenClaw JSON5 配置与 Workspace `AGENTS.md` +- Codex / Claude Code Skills 市场(安装目标切换、本地 skills 管理、跨应用导入、ZIP 分发) - Codex / Claude 本地会话浏览、筛选、导出、删除 -项目不依赖云端托管,配置写入你的本地文件,便于审计和回滚。 +项目不依赖云端托管,配置写入你的本地文件,便于审计和回滚。Skills 市场同样坚持本地优先,只操作本地目录,不依赖远程在线市场。 ## 为什么选择 Codex Mate? @@ -34,6 +35,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: | 多工具管理 | Codex + Claude Code + OpenClaw 统一入口 | 多文件、多目录分散修改 | | 使用方式 | CLI + 本地 Web UI | 纯手改 TOML / JSON / JSON5 | | 会话处理 | 支持浏览、导出、批量清理 | 需要手动定位和处理文件 | +| Skills 复用 | 本地 Skills 市场 + 跨应用导入 + ZIP 分发 | 目录手动复制,容易遗漏 | | 可回滚性 | 首次接管前自动备份 | 易误覆盖、回滚成本高 | | 自动化接入 | 提供 MCP stdio(默认只读) | 需自行封装脚本 | @@ -52,6 +54,12 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - 会话导出 Markdown - 会话与消息级删除(支持批量) +**Skills 市场** +- 在 Codex 与 Claude Code 之间切换 skills 安装目标 +- 查看本地已安装 skills、根目录与状态 +- 扫描 `Codex` / `Claude Code` / `Agents` 可导入来源 +- 支持跨应用导入、ZIP 导入 / 导出、批量删除 + **工程能力** - MCP stdio 能力(tools/resources/prompts) - 内建代理配置与状态控制(`proxy`) @@ -74,15 +82,16 @@ flowchart TB API["Local HTTP API"] MCPS["MCP stdio Server"] PROXY["Built-in Proxy"] - SERVICES["Config / Sessions / Skills / Workflow"] + SERVICES["Config / Sessions / Skills Market / Workflow"] CORE["File IO / Network / Diff / Session Utils"] end subgraph State["Local State"] - CODEX["~/.codex"] - CLAUDE["~/.claude"] - OPENCLAW["~/.openclaw"] - STATE["sessions / trash / workflow runs"] + CODEX["~/.codex/config + auth + models"] + CLAUDE["~/.claude/settings.json"] + OPENCLAW["~/.openclaw/*.json5 + ~/.openclaw/openclaw.json + workspace/AGENTS.md"] + SKILLS["~/.codex/skills / ~/.claude/skills / ~/.agents/skills"] + STATE["sessions / trash / workflow runs / skill exports"] end CLI --> ENTRY @@ -100,6 +109,7 @@ flowchart TB CORE --> CODEX CORE --> CLAUDE CORE --> OPENCLAW + CORE --> SKILLS CORE --> STATE ``` @@ -114,7 +124,9 @@ codexmate status codexmate run ``` -默认监听 `127.0.0.1:3737`,并尝试自动打开浏览器。 +默认监听 `0.0.0.0:3737`,支持局域网访问,并尝试自动打开浏览器。 + +> 安全提示:默认监听会在当前局域网暴露未鉴权的管理界面。若包含 API Key、provider 配置或 skills 管理,请仅在可信网络中使用;如需仅本机访问,可设置 `CODEXMATE_HOST=127.0.0.1` 或启动时传入 `--host 127.0.0.1`。 ### 从源码运行 @@ -170,8 +182,6 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2 - provider / model 切换 - 模型管理 - `~/.codex/AGENTS.md` 编辑 -- `~/.codex/skills` 管理(筛选、批量删除、跨应用导入) - ### Claude Code 配置模式 - 多配置方案管理 @@ -188,6 +198,12 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2 - 支持本地会话置顶、持久化保存与置顶优先排序 - 搜索、筛选、导出、删除、批量清理 +### Skills 市场标签页 +- 在 `Codex` 与 `Claude Code` 之间切换 skills 安装目标 +- 展示当前目标的本地 skills 根目录、已安装项和可导入项 +- 扫描 `Codex` / `Claude Code` / `Agents` 目录中的可导入来源 +- 支持跨应用导入、ZIP 导入 / 导出、批量删除 + ## MCP > 传输:`stdio` @@ -219,7 +235,7 @@ codexmate mcp serve --allow-write | 变量 | 默认值 | 说明 | | --- | --- | --- | | `CODEXMATE_PORT` | `3737` | Web 服务端口 | -| `CODEXMATE_HOST` | `127.0.0.1` | Web 服务监听地址 | +| `CODEXMATE_HOST` | `0.0.0.0` | Web 服务监听地址(如需仅本机访问,显式设为 `127.0.0.1`) | | `CODEXMATE_NO_BROWSER` | 未设置 | 设为 `1` 后不自动打开浏览器 | | `CODEXMATE_MCP_ALLOW_WRITE` | 未设置 | 设为 `1` 后默认允许 MCP 写工具 | | `CODEXMATE_FORCE_RESET_EXISTING_CONFIG` | `0` | 设为 `1` 时首次可强制重建托管配置 | diff --git a/cli.js b/cli.js index 3e2a30d..f8328b5 100644 --- a/cli.js +++ b/cli.js @@ -64,7 +64,8 @@ const { } = require('./lib/workflow-engine'); const DEFAULT_WEB_PORT = 3737; -const DEFAULT_WEB_HOST = '127.0.0.1'; +const DEFAULT_WEB_HOST = '0.0.0.0'; +const DEFAULT_WEB_OPEN_HOST = '127.0.0.1'; // ============================================================================ // 配置 @@ -116,9 +117,13 @@ const AGENTS_FILE_NAME = 'AGENTS.md'; const CODEX_SKILLS_DIR = path.join(CONFIG_DIR, 'skills'); const CLAUDE_SKILLS_DIR = path.join(CLAUDE_DIR, 'skills'); const AGENTS_SKILLS_DIR = path.join(os.homedir(), '.agents', 'skills'); +const SKILL_TARGETS = Object.freeze([ + Object.freeze({ app: 'codex', label: 'Codex', dir: getCodexSkillsDir() }), + Object.freeze({ app: 'claude', label: 'Claude Code', dir: getClaudeSkillsDir() }) +]); const SKILL_IMPORT_SOURCES = Object.freeze([ - { app: 'claude', label: 'Claude Code', dir: CLAUDE_SKILLS_DIR }, - { app: 'agents', label: 'Agents', dir: AGENTS_SKILLS_DIR } + ...SKILL_TARGETS, + Object.freeze({ app: 'agents', label: 'Agents', dir: AGENTS_SKILLS_DIR }) ]); const MODELS_CACHE_TTL_MS = 60 * 1000; const MODELS_NEGATIVE_CACHE_TTL_MS = 5 * 1000; @@ -167,6 +172,38 @@ const CLI_INSTALL_TARGETS = Object.freeze([ const HTTP_KEEP_ALIVE_AGENT = new http.Agent({ keepAlive: true }); const HTTPS_KEEP_ALIVE_AGENT = new https.Agent({ keepAlive: true }); +function getCodexSkillsDir() { + const envCodexHome = typeof process.env.CODEX_HOME === 'string' ? process.env.CODEX_HOME.trim() : ''; + if (envCodexHome) { + const target = path.join(envCodexHome, 'skills'); + return resolveExistingDir([target], target); + } + const xdgConfig = typeof process.env.XDG_CONFIG_HOME === 'string' ? process.env.XDG_CONFIG_HOME.trim() : ''; + if (xdgConfig) { + const target = path.join(xdgConfig, 'codex', 'skills'); + return resolveExistingDir([target], target); + } + const homeConfigDir = path.join(os.homedir(), '.config', 'codex', 'skills'); + return resolveExistingDir([homeConfigDir], CODEX_SKILLS_DIR); +} + +function getClaudeSkillsDir() { + const envClaudeHome = typeof process.env.CLAUDE_HOME === 'string' && process.env.CLAUDE_HOME.trim() + ? process.env.CLAUDE_HOME.trim() + : (typeof process.env.CLAUDE_CONFIG_DIR === 'string' ? process.env.CLAUDE_CONFIG_DIR.trim() : ''); + if (envClaudeHome) { + const target = path.join(envClaudeHome, 'skills'); + return resolveExistingDir([target], target); + } + const xdgConfig = typeof process.env.XDG_CONFIG_HOME === 'string' ? process.env.XDG_CONFIG_HOME.trim() : ''; + if (xdgConfig) { + const target = path.join(xdgConfig, 'claude', 'skills'); + return resolveExistingDir([target], target); + } + const homeConfigDir = path.join(os.homedir(), '.config', 'claude', 'skills'); + return resolveExistingDir([homeConfigDir], CLAUDE_SKILLS_DIR); +} + function resolveWebPort() { const raw = process.env.CODEXMATE_PORT; if (!raw) return DEFAULT_WEB_PORT; @@ -1390,8 +1427,40 @@ function normalizeCodexSkillName(name) { return { name: value }; } -function isSkillDirectoryEntry(entryName) { - const targetPath = path.join(CODEX_SKILLS_DIR, entryName); +function normalizeSkillTargetApp(app) { + const value = typeof app === 'string' ? app.trim().toLowerCase() : ''; + return SKILL_TARGETS.some((item) => item.app === value) ? value : ''; +} + +function getSkillTargetByApp(app) { + const normalizedApp = normalizeSkillTargetApp(app); + if (!normalizedApp) return null; + return SKILL_TARGETS.find((item) => item.app === normalizedApp) || null; +} + +function resolveSkillTarget(params = {}, defaultApp = 'codex') { + const hasExplicitTargetApp = !!(params && typeof params === 'object' + && Object.prototype.hasOwnProperty.call(params, 'targetApp')); + const hasExplicitTarget = !!(params && typeof params === 'object' + && Object.prototype.hasOwnProperty.call(params, 'target')); + const hasAnyExplicitTarget = hasExplicitTargetApp || hasExplicitTarget; + const rawTargetApp = hasExplicitTargetApp ? params.targetApp : ''; + const rawTarget = hasExplicitTarget ? params.target : ''; + const raw = rawTargetApp || rawTarget || ''; + if (hasAnyExplicitTarget && raw === '') { + return null; + } + if (hasAnyExplicitTarget && !getSkillTargetByApp(raw)) { + return null; + } + return getSkillTargetByApp(raw) + || getSkillTargetByApp(defaultApp) + || SKILL_TARGETS[0] + || null; +} + +function isSkillDirectoryEntryAtRoot(rootDir, entryName) { + const targetPath = path.join(rootDir, entryName); try { const stat = fs.statSync(targetPath); return stat.isDirectory(); @@ -1560,13 +1629,13 @@ function readCodexSkillMetadata(skillPath) { } } -function getCodexSkillEntryInfoByName(entryName) { - const targetPath = path.join(CODEX_SKILLS_DIR, entryName); +function getSkillEntryInfoByName(rootDir, entryName) { + const targetPath = path.join(rootDir, entryName); const normalized = normalizeCodexSkillName(entryName); if (normalized.error) { return null; } - const relativePath = path.relative(CODEX_SKILLS_DIR, targetPath); + const relativePath = path.relative(rootDir, targetPath); if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return null; } @@ -1577,7 +1646,7 @@ function getCodexSkillEntryInfoByName(entryName) { if (!lstat.isDirectory() && !isSymbolicLink) { return null; } - if (isSymbolicLink && !isSkillDirectoryEntry(entryName)) { + if (isSymbolicLink && !isSkillDirectoryEntryAtRoot(rootDir, entryName)) { return null; } const metadata = readCodexSkillMetadata(targetPath); @@ -1595,26 +1664,34 @@ function getCodexSkillEntryInfoByName(entryName) { } } -function listCodexSkills() { - if (!fs.existsSync(CODEX_SKILLS_DIR)) { +function listSkills(params = {}) { + const target = resolveSkillTarget(params); + if (!target) { + return { error: '目标宿主不支持' }; + } + if (!fs.existsSync(target.dir)) { return { - root: CODEX_SKILLS_DIR, + targetApp: target.app, + targetLabel: target.label, + root: target.dir, exists: false, items: [] }; } try { - const entries = fs.readdirSync(CODEX_SKILLS_DIR, { withFileTypes: true }); + const entries = fs.readdirSync(target.dir, { withFileTypes: true }); const items = entries .map((entry) => { const name = entry && entry.name ? entry.name : ''; if (!name || name.startsWith('.')) return null; - return getCodexSkillEntryInfoByName(name); + return getSkillEntryInfoByName(target.dir, name); }) .filter(Boolean) .sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')); return { - root: CODEX_SKILLS_DIR, + targetApp: target.app, + targetLabel: target.label, + root: target.dir, exists: true, items }; @@ -1623,6 +1700,10 @@ function listCodexSkills() { } } +function listCodexSkills() { + return listSkills({ targetApp: 'codex' }); +} + function listSkillEntriesByRoot(rootDir) { if (!rootDir || !fs.existsSync(rootDir)) { return []; @@ -1668,8 +1749,13 @@ function listSkillEntriesByRoot(rootDir) { } } -function scanUnmanagedCodexSkills() { - const existing = listCodexSkills(); +function scanUnmanagedSkills(params = {}) { + const target = resolveSkillTarget(params); + if (!target) { + return { error: '目标宿主不支持' }; + } + const targetRoot = resolveCopyTargetRoot(target.dir); + const existing = listSkills({ targetApp: target.app }); if (existing.error) { return { error: existing.error }; } @@ -1678,9 +1764,14 @@ function scanUnmanagedCodexSkills() { .filter(Boolean)); const items = []; - for (const source of SKILL_IMPORT_SOURCES) { + const sources = SKILL_IMPORT_SOURCES.filter((source) => source.app !== target.app); + for (const source of sources) { const sourceEntries = listSkillEntriesByRoot(source.dir); for (const entry of sourceEntries) { + const targetCandidate = path.join(targetRoot, entry.name); + if (fs.existsSync(targetCandidate)) { + continue; + } if (existingNames.has(entry.name)) { continue; } @@ -1706,9 +1797,11 @@ function scanUnmanagedCodexSkills() { }); return { - root: CODEX_SKILLS_DIR, + targetApp: target.app, + targetLabel: target.label, + root: target.dir, items, - sources: SKILL_IMPORT_SOURCES.map((source) => ({ + sources: sources.map((source) => ({ app: source.app, label: source.label, path: source.dir, @@ -1717,14 +1810,21 @@ function scanUnmanagedCodexSkills() { }; } -function importCodexSkills(params = {}) { +function scanUnmanagedCodexSkills() { + return scanUnmanagedSkills({ targetApp: 'codex' }); +} + +function importSkills(params = {}) { + const target = resolveSkillTarget(params); + if (!target) { + return { error: '目标宿主不支持' }; + } + const targetRoot = resolveCopyTargetRoot(target.dir); const rawItems = Array.isArray(params.items) ? params.items : []; if (!rawItems.length) { return { error: '请先选择要导入的 skill' }; } - ensureDir(CODEX_SKILLS_DIR); - const imported = []; const failed = []; const dedup = new Set(); @@ -1749,6 +1849,14 @@ function importCodexSkills(params = {}) { }); continue; } + if (source.app === target.app) { + failed.push({ + name: normalizedName.name, + sourceApp: source.app, + error: '来源与目标相同,无需导入' + }); + continue; + } const dedupKey = `${source.app}:${normalizedName.name}`; if (dedup.has(dedupKey)) { continue; @@ -1774,8 +1882,8 @@ function importCodexSkills(params = {}) { continue; } - const targetPath = path.join(CODEX_SKILLS_DIR, normalizedName.name); - const targetRelative = path.relative(CODEX_SKILLS_DIR, targetPath); + const targetPath = path.join(targetRoot, normalizedName.name); + const targetRelative = path.relative(targetRoot, targetPath); if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) { failed.push({ name: normalizedName.name, @@ -1788,7 +1896,7 @@ function importCodexSkills(params = {}) { failed.push({ name: normalizedName.name, sourceApp: source.app, - error: 'Codex 中已存在同名 skill' + error: `${target.label} 中已存在同名 skill` }); continue; } @@ -1814,6 +1922,15 @@ function importCodexSkills(params = {}) { }); continue; } + if (isPathInside(targetRoot, sourceDirForCopy)) { + failed.push({ + name: normalizedName.name, + sourceApp: source.app, + error: '目标路径不能位于来源 skill 目录内' + }); + continue; + } + ensureDir(targetRoot); const visitedRealPaths = new Set([sourceDirForCopy]); copyDirRecursive(sourceDirForCopy, targetPath, { dereferenceSymlinks: true, @@ -1825,6 +1942,8 @@ function importCodexSkills(params = {}) { name: normalizedName.name, sourceApp: source.app, sourceLabel: source.label, + targetApp: target.app, + targetLabel: target.label, path: targetPath }); } catch (e) { @@ -1845,10 +1964,16 @@ function importCodexSkills(params = {}) { success: failed.length === 0, imported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: targetRoot }; } +function importCodexSkills(params = {}) { + return importSkills({ ...(params || {}), targetApp: 'codex' }); +} + function collectSkillDirectoriesFromRoot(rootDir, limit = MAX_SKILLS_ZIP_ENTRY_COUNT) { const results = []; let truncated = false; @@ -1902,15 +2027,22 @@ function resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbac return normalizeCodexSkillName(candidate); } -async function importCodexSkillsFromZipFile(zipPath, options = {}) { +async function importSkillsFromZipFile(zipPath, options = {}) { const fallbackName = typeof options.fallbackName === 'string' ? options.fallbackName : ''; const tempDir = typeof options.tempDir === 'string' ? options.tempDir : ''; const imported = []; const failed = []; const dedupNames = new Set(); const extractionRoot = path.join(tempDir || path.dirname(zipPath), 'extract'); + let target = null; + let targetRoot = ''; try { + target = resolveSkillTarget(options, 'codex'); + if (!target) { + return { error: '目标宿主不支持' }; + } + targetRoot = resolveCopyTargetRoot(target.dir); await inspectZipArchiveLimits(zipPath, { maxEntryCount: MAX_SKILLS_ZIP_ENTRY_COUNT, maxUncompressedBytes: MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES @@ -1926,7 +2058,6 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { return { error: '压缩包中的技能目录数量超出导入上限' }; } - ensureDir(CODEX_SKILLS_DIR); for (const skillDir of discoveredDirs) { const normalizedName = resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbackName); if (normalizedName.error) { @@ -1942,8 +2073,8 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { } dedupNames.add(dedupKey); - const targetPath = path.join(CODEX_SKILLS_DIR, normalizedName.name); - const targetRelative = path.relative(CODEX_SKILLS_DIR, targetPath); + const targetPath = path.join(targetRoot, normalizedName.name); + const targetRelative = path.relative(targetRoot, targetPath); if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) { failed.push({ name: normalizedName.name, @@ -1954,7 +2085,7 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { if (fs.existsSync(targetPath)) { failed.push({ name: normalizedName.name, - error: 'Codex 中已存在同名 skill' + error: `${target.label} 中已存在同名 skill` }); continue; } @@ -1970,6 +2101,14 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { }); continue; } + if (isPathInside(targetRoot, sourceRealPath)) { + failed.push({ + name: normalizedName.name, + error: '目标路径不能位于来源 skill 目录内' + }); + continue; + } + ensureDir(targetRoot); const visitedRealPaths = new Set([sourceRealPath]); copyDirRecursive(sourceRealPath, targetPath, { dereferenceSymlinks: true, @@ -1979,6 +2118,8 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { copiedToTarget = true; imported.push({ name: normalizedName.name, + targetApp: target.app, + targetLabel: target.label, path: targetPath }); } catch (e) { @@ -1999,7 +2140,9 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { error: failed[0].error || '导入失败', imported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: targetRoot }; } @@ -2007,7 +2150,9 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { success: failed.length === 0, imported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: targetRoot }; } catch (e) { return { @@ -2026,21 +2171,40 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { } } -async function importCodexSkillsFromZip(payload = {}) { +async function importCodexSkillsFromZipFile(zipPath, options = {}) { + return importSkillsFromZipFile(zipPath, { ...(options || {}), targetApp: 'codex' }); +} + +async function importSkillsFromZip(payload = {}) { if (!payload || typeof payload.fileBase64 !== 'string' || !payload.fileBase64.trim()) { return { error: '缺少技能压缩包内容' }; } - const upload = writeUploadZip(payload.fileBase64, 'codex-skills-import', payload.fileName || 'codex-skills.zip'); + const fallbackTarget = resolveSkillTarget(payload, 'codex'); + const fallbackTargetApp = fallbackTarget ? fallbackTarget.app : 'codex'; + const fallbackName = payload.fileName || `${fallbackTargetApp}-skills.zip`; + const upload = writeUploadZip(payload.fileBase64, 'codex-skills-import', fallbackName); if (upload.error) { return { error: upload.error }; } - return importCodexSkillsFromZipFile(upload.zipPath, { - tempDir: upload.tempDir, - fallbackName: payload.fileName || '' - }); + const importOptions = { tempDir: upload.tempDir, fallbackName }; + if (Object.prototype.hasOwnProperty.call(payload, 'targetApp')) { + importOptions.targetApp = payload.targetApp; + } + if (Object.prototype.hasOwnProperty.call(payload, 'target')) { + importOptions.target = payload.target; + } + return importSkillsFromZipFile(upload.zipPath, importOptions); } -async function exportCodexSkills(params = {}) { +async function importCodexSkillsFromZip(payload = {}) { + return importSkillsFromZip({ ...(payload || {}), targetApp: 'codex' }); +} + +async function exportSkills(params = {}) { + const target = resolveSkillTarget(params); + if (!target) { + return { error: '目标宿主不支持' }; + } const rawNames = Array.isArray(params.names) ? params.names : []; const uniqueNames = Array.from(new Set(rawNames .map((item) => (typeof item === 'string' ? item.trim() : '')) @@ -2062,8 +2226,8 @@ async function exportCodexSkills(params = {}) { failed.push({ name: rawName, error: normalizedName.error }); continue; } - const sourcePath = path.join(CODEX_SKILLS_DIR, normalizedName.name); - const sourceRelative = path.relative(CODEX_SKILLS_DIR, sourcePath); + const sourcePath = path.join(target.dir, normalizedName.name); + const sourceRelative = path.relative(target.dir, sourcePath); if (sourceRelative.startsWith('..') || path.isAbsolute(sourceRelative)) { failed.push({ name: normalizedName.name, error: '来源路径非法' }); continue; @@ -2109,12 +2273,14 @@ async function exportCodexSkills(params = {}) { error: failed[0] && failed[0].error ? failed[0].error : '无可导出的 skill', exported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: target.dir }; } const randomToken = crypto.randomBytes(12).toString('hex'); - const zipFileName = `codex-skills-${randomToken}.zip`; + const zipFileName = `${target.app}-skills-${randomToken}.zip`; const zipFilePath = path.join(os.tmpdir(), zipFileName); if (fs.existsSync(zipFilePath)) { try { @@ -2133,14 +2299,18 @@ async function exportCodexSkills(params = {}) { downloadPath: artifact.downloadPath, exported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: target.dir }; } catch (e) { return { error: `导出失败:${e && e.message ? e.message : '未知错误'}`, exported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: target.dir }; } finally { try { @@ -2149,6 +2319,10 @@ async function exportCodexSkills(params = {}) { } } +async function exportCodexSkills(params = {}) { + return exportSkills({ ...(params || {}), targetApp: 'codex' }); +} + function removeDirectoryRecursive(targetPath) { if (typeof fs.rmSync === 'function') { fs.rmSync(targetPath, { recursive: true, force: false }); @@ -2157,7 +2331,11 @@ function removeDirectoryRecursive(targetPath) { fs.rmdirSync(targetPath, { recursive: true }); } -function deleteCodexSkills(params = {}) { +function deleteSkills(params = {}) { + const target = resolveSkillTarget(params); + if (!target) { + return { error: '目标宿主不支持' }; + } const rawList = Array.isArray(params.names) ? params.names : []; const uniqueNames = Array.from(new Set(rawList .map((item) => (typeof item === 'string' ? item.trim() : '')) @@ -2175,8 +2353,8 @@ function deleteCodexSkills(params = {}) { continue; } - const skillPath = path.join(CODEX_SKILLS_DIR, normalized.name); - const relativePath = path.relative(CODEX_SKILLS_DIR, skillPath); + const skillPath = path.join(target.dir, normalized.name); + const relativePath = path.relative(target.dir, skillPath); if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { failed.push({ name: normalized.name, error: '技能路径非法' }); continue; @@ -2206,10 +2384,16 @@ function deleteCodexSkills(params = {}) { success: failed.length === 0, deleted, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: target.dir }; } +function deleteCodexSkills(params = {}) { + return deleteSkills({ ...(params || {}), targetApp: 'codex' }); +} + function readAgentsFile(params = {}) { const filePath = resolveAgentsFilePath(params); const dirCheck = validateAgentsBaseDir(filePath); @@ -3625,6 +3809,27 @@ function isPathInside(targetPath, rootPath) { return resolvedTarget.startsWith(rootWithSlash); } +function resolveCopyTargetRoot(targetDir) { + const suffixSegments = []; + let current = path.resolve(targetDir || ''); + while (current && !fs.existsSync(current)) { + const parent = path.dirname(current); + if (!parent || parent === current) { + break; + } + suffixSegments.unshift(path.basename(current)); + current = parent; + } + let resolvedRoot = normalizePathForCompare(current || targetDir); + if (!resolvedRoot) { + resolvedRoot = path.resolve(targetDir || ''); + } + for (const segment of suffixSegments) { + resolvedRoot = path.join(resolvedRoot, segment); + } + return resolvedRoot; +} + function collectJsonlFiles(rootDir, maxFiles = 5000) { if (!fs.existsSync(rootDir)) { return []; @@ -9633,22 +9838,55 @@ function resolveUploadFileNameFromRequest(req, fallbackName = 'codex-skills.zip' return normalized || fallback; } -async function handleImportCodexSkillsZipUpload(req, res) { +function resolveSkillTargetAppFromRequest(req, fallbackApp = 'codex') { + const fallbackTarget = resolveSkillTarget({}, fallbackApp); + const fallback = fallbackTarget ? fallbackTarget.app : 'codex'; + try { + const parsed = new URL(req.url || '/', 'http://localhost'); + const hasTargetApp = parsed.searchParams.has('targetApp'); + const hasTarget = parsed.searchParams.has('target'); + if (hasTargetApp || hasTarget) { + const target = resolveSkillTarget({ + ...(hasTargetApp ? { targetApp: parsed.searchParams.get('targetApp') } : {}), + ...(hasTarget ? { target: parsed.searchParams.get('target') } : {}) + }, fallback); + return target ? target.app : null; + } + return fallback; + } catch (_) { + return fallback; + } +} + +async function handleImportSkillsZipUpload(req, res, options = {}) { if (req.method !== 'POST') { + if (req && typeof req.resume === 'function') { + req.resume(); + } writeJsonResponse(res, 405, { error: 'Method Not Allowed' }); return; } try { - const fileName = resolveUploadFileNameFromRequest(req, 'codex-skills.zip'); + const forcedTargetApp = normalizeSkillTargetApp(options && options.targetApp ? options.targetApp : ''); + const targetApp = forcedTargetApp || resolveSkillTargetAppFromRequest(req, 'codex'); + if (!targetApp) { + if (req && typeof req.resume === 'function') { + req.resume(); + } + writeJsonResponse(res, 400, { error: '目标宿主不支持' }); + return; + } + const fileName = resolveUploadFileNameFromRequest(req, `${targetApp}-skills.zip`); const upload = await writeUploadZipStream( req, 'codex-skills-import', fileName, MAX_SKILLS_ZIP_UPLOAD_SIZE ); - const result = await importCodexSkillsFromZipFile(upload.zipPath, { + const result = await importSkillsFromZipFile(upload.zipPath, { tempDir: upload.tempDir, - fallbackName: fileName + fallbackName: fileName, + targetApp }); writeJsonResponse(res, 200, result || {}); } catch (e) { @@ -9662,8 +9900,12 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser const server = http.createServer((req, res) => { const requestPath = (req.url || '/').split('?')[0]; + if (requestPath === '/api/import-skills-zip') { + void handleImportSkillsZipUpload(req, res); + return; + } if (requestPath === '/api/import-codex-skills-zip') { - void handleImportCodexSkillsZipUpload(req, res); + void handleImportSkillsZipUpload(req, res, { targetApp: 'codex' }); return; } if (requestPath === '/api') { @@ -9795,6 +10037,21 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'preview-agents-diff': result = buildAgentsDiff(params || {}); break; + case 'list-skills': + result = listSkills(params || {}); + break; + case 'delete-skills': + result = deleteSkills(params || {}); + break; + case 'scan-unmanaged-skills': + result = scanUnmanagedSkills(params || {}); + break; + case 'import-skills': + result = importSkills(params || {}); + break; + case 'export-skills': + result = await exportSkills(params || {}); + break; case 'list-codex-skills': result = listCodexSkills(); break; @@ -10154,7 +10411,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser process.exit(1); }); - const openHost = isAnyAddressHost(host) ? DEFAULT_WEB_HOST : host; + const openHost = host === '::' + ? '::1' + : (host === '0.0.0.0' ? DEFAULT_WEB_OPEN_HOST : host); const openUrl = `http://${formatHostForUrl(openHost)}:${port}`; server.listen(port, host, () => { console.log('\n✓ Web UI 已启动:', openUrl); diff --git a/site/guide/getting-started.md b/site/guide/getting-started.md index 373f8a7..f220d18 100644 --- a/site/guide/getting-started.md +++ b/site/guide/getting-started.md @@ -27,7 +27,9 @@ codexmate status codexmate run ``` -默认监听 `127.0.0.1:3737`,并尝试自动打开浏览器。 +默认监听 `0.0.0.0:3737`,支持局域网访问,并尝试自动打开浏览器。 +如需仅本机访问,可设置 `CODEXMATE_HOST=127.0.0.1`,或启动时传入 `--host 127.0.0.1`。 +> 安全提示:默认监听会在当前局域网暴露未鉴权管理界面。请仅在可信网络使用;如涉及 API Key、配置或 skills 管理,建议改用 `127.0.0.1`。 仅启动服务(测试 / CI): diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 3c1b702..da94065 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -40,6 +40,10 @@ async function main() { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome, + CODEX_HOME: '', + CLAUDE_HOME: '', + CLAUDE_CONFIG_DIR: '', + XDG_CONFIG_HOME: '', CODEXMATE_FORCE_RESET_EXISTING_CONFIG: '1', CODEXMATE_NO_BROWSER: '1' }; diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 3af9f01..2904d68 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -34,6 +34,48 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /settingsTab === 'backup'/); assert.match(html, /settingsTab === 'trash'/); assert.match(html, /sessionTrashCount/); + assert.match(html, /id="side-tab-market"/); + assert.match(html, /id="tab-market"/); + assert.match(html, /data-main-tab="market"/); + assert.match(html, /onMainTabPointerDown\('market', \$event\)/); + assert.match(html, /onMainTabClick\('market', \$event\)/); + assert.match(html, /aria-controls="panel-market"/); + assert.match(html, /:aria-selected="mainTab === 'market'"/); + assert.match(html, /id="panel-market"/); + assert.match(html, /v-show="mainTab === 'market'"/); + assert.match(html, /loadSkillsMarketOverview\(\{ forceRefresh: true, silent: false \}\)/); + assert.match(html, /class="market-grid"/); + assert.match(html, /class="market-action-grid"/); + assert.match(html, /skillsTargetApp === 'codex'/); + assert.match(html, /skillsTargetApp === 'claude'/); + assert.match(html, /setSkillsTargetApp\('codex', \{ silent: false \}\)/); + assert.match(html, /setSkillsTargetApp\('claude', \{ silent: false \}\)/); + const targetSwitchButtons = [...html.matchAll( + //g + )]; + assert.strictEqual(targetSwitchButtons.length, 4); + for (const [buttonMarkup] of targetSwitchButtons) { + assert.match(buttonMarkup, /:disabled="loading \|\| !!initError \|\| skillsMarketBusy"/); + } + assert.match(html, /@click="loadSkillsMarketOverview\(\{ forceRefresh: true, silent: false \}\)" :disabled="loading \|\| !!initError \|\| skillsMarketBusy"/); + assert.match(html, / + + +
设置
+
+
+ 当前目标 + {{ skillsTargetLabel }} +
+
+ 本地 Skills + {{ skillsList.length }} +
+
+ 可导入 + {{ skillsImportList.length }} +
+
+ 可直接导入 + {{ skillsImportConfiguredCount }} +
+
@@ -430,8 +481,8 @@

Skills 管理
-
管理 ~/.codex/skills 自定义 skills,弹窗提供统计概览、筛选检索、多选删除、ZIP 导入与导出。
-

@@ -1231,6 +1282,182 @@

+
+
+
+
+ Skills 市场概览 +
当前市场页只保留本地 skills 能力:切换 Codex / Claude Code 安装目标,查看已安装项,执行跨应用导入与 ZIP 分发。
+
+
+ + +
+
+ +
+ + +
+ +
{{ skillsRootPath || skillsDefaultRootPath }}
+ +
+
+ 安装目标 + {{ skillsTargetLabel }} +
+
+ 本地总数 + {{ skillsList.length }} +
+
+ 含 SKILL.md + {{ skillsConfiguredCount }} +
+
+ 缺少 SKILL.md + {{ skillsMissingSkillFileCount }} +
+
+ 可导入 + {{ skillsImportList.length }} +
+
+ 可直接导入 + {{ skillsImportConfiguredCount }} +
+
+
+ +
+
+
+
+
已安装 Skills
+
展示当前已落地到 {{ skillsRootPath || skillsDefaultRootPath }} 的前 6 个目录,可继续进入管理弹窗做筛选、导出和删除。
+
+ +
+
正在加载本地 Skills...
+
当前还没有已安装 skill,可通过 ZIP 或跨应用导入补充。
+
+
+
+
{{ skill.displayName || skill.name }}
+
{{ skill.description || skill.path }}
+
+ + {{ skill.hasSkillFile ? '已验证' : '待补 SKILL.md' }} + +
+
+
+ +
+
+
+
可导入来源
+
扫描其他应用下未托管的 skill,先确认来源和目录,再批量导入到当前 {{ skillsTargetLabel }} skills 目录。
+
+ +
+
正在扫描可导入 skill...
+
还没有扫描到可导入 skill,可点击“扫描来源”重新读取。
+
+
+
+
{{ skill.displayName || skill.name }}
+
{{ skill.sourceLabel }} · {{ skill.sourcePath }}
+
+ + {{ skill.hasSkillFile ? '可直接导入' : '缺少 SKILL.md' }} + +
+
+
+ +
+
+
+
分发入口
+
市场页聚焦本地落地:本地管理、跨应用导入、ZIP 分发,全部作用到当前安装目标。
+
+
+
+ + + +
+
+ +
+
+
+
市场说明
+
这里不再展示任何 MCP 在线目录或外部市场,只保留当前项目自己的本地 skills 视图和导入分发流程。
+
+
+
+
+
+
目标宿主切换
+
在 Codex 和 Claude Code 之间切换后,后续扫描、导入、导出、删除都会落到当前 {{ skillsTargetLabel }} 目录。
+
+
+
+
+
跨应用导入
+
扫描 `Codex`、`Claude Code` 与 `Agents` 目录里的未托管 skills,筛选后批量导入到当前宿主。
+
+
+
+
+
ZIP 分发
+
通过压缩包在不同环境间分发技能目录,保持本地可控,不依赖外部目录服务。
+
+
+
+
+
+
+
加载配置中... @@ -1905,9 +2132,27 @@