From 947a3be90541d681525573536682b6e07308e094 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:50:50 +0800 Subject: [PATCH 01/19] feat: add local skills market and LAN web defaults --- README.en.md | 34 ++- README.md | 34 ++- cli.js | 245 ++++++++++++---- site/guide/getting-started.md | 2 +- tests/unit/config-tabs-ui.test.mjs | 45 +++ tests/unit/run.mjs | 2 + .../session-tab-switch-performance.test.mjs | 21 ++ tests/unit/skills-market-runtime.test.mjs | 127 ++++++++ tests/unit/skills-modal-ui.test.mjs | 13 + tests/unit/web-run-host.test.mjs | 147 ++++++++++ web-ui/app.js | 6 +- web-ui/index.html | 276 +++++++++++++++++- web-ui/modules/config-mode.computed.mjs | 1 + web-ui/modules/skills.computed.mjs | 27 +- web-ui/modules/skills.methods.mjs | 108 +++++-- web-ui/session-helpers.mjs | 5 + web-ui/styles.css | 158 ++++++++++ 17 files changed, 1143 insertions(+), 108 deletions(-) create mode 100644 tests/unit/skills-market-runtime.test.mjs create mode 100644 tests/unit/web-run-host.test.mjs diff --git a/README.en.md b/README.en.md index 4ed73ee..fb35085 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 + 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,7 @@ 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. ### Run from source @@ -170,8 +180,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 +196,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 unmanaged skills under `Codex` / `Claude Code` / `Agents` +- Support cross-app import, ZIP import/export, and batch delete + ## MCP > Transport: `stdio` @@ -218,7 +232,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 | | `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..71bd9e7 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 + 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,7 @@ codexmate status codexmate run ``` -默认监听 `127.0.0.1:3737`,并尝试自动打开浏览器。 +默认监听 `0.0.0.0:3737`,支持局域网访问,并尝试自动打开浏览器。 ### 从源码运行 @@ -170,8 +180,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 +196,12 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2 - 支持本地会话置顶、持久化保存与置顶优先排序 - 搜索、筛选、导出、删除、批量清理 +### Skills 市场标签页 +- 在 `Codex` 与 `Claude Code` 之间切换 skills 安装目标 +- 展示当前目标的本地 skills 根目录、已安装项和可导入项 +- 扫描 `Codex` / `Claude Code` / `Agents` 目录下未托管的 skills +- 支持跨应用导入、ZIP 导入 / 导出、批量删除 + ## MCP > 传输:`stdio` @@ -219,7 +233,7 @@ codexmate mcp serve --allow-write | 变量 | 默认值 | 说明 | | --- | --- | --- | | `CODEXMATE_PORT` | `3737` | Web 服务端口 | -| `CODEXMATE_HOST` | `127.0.0.1` | Web 服务监听地址 | +| `CODEXMATE_HOST` | `0.0.0.0` | Web 服务监听地址 | | `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..7bb795e 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,8 +117,12 @@ 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: CODEX_SKILLS_DIR }), + Object.freeze({ app: 'claude', label: 'Claude Code', dir: CLAUDE_SKILLS_DIR }) +]); const SKILL_IMPORT_SOURCES = Object.freeze([ - { app: 'claude', label: 'Claude Code', dir: CLAUDE_SKILLS_DIR }, + ...SKILL_TARGETS, { app: 'agents', label: 'Agents', dir: AGENTS_SKILLS_DIR } ]); const MODELS_CACHE_TTL_MS = 60 * 1000; @@ -1390,8 +1395,29 @@ 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 raw = params && typeof params === 'object' + ? (params.targetApp || params.target || '') + : ''; + 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 +1586,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 +1603,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 +1621,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 +1657,10 @@ function listCodexSkills() { } } +function listCodexSkills() { + return listSkills({ targetApp: 'codex' }); +} + function listSkillEntriesByRoot(rootDir) { if (!rootDir || !fs.existsSync(rootDir)) { return []; @@ -1668,8 +1706,12 @@ function listSkillEntriesByRoot(rootDir) { } } -function scanUnmanagedCodexSkills() { - const existing = listCodexSkills(); +function scanUnmanagedSkills(params = {}) { + const target = resolveSkillTarget(params); + if (!target) { + return { error: '目标宿主不支持' }; + } + const existing = listSkills({ targetApp: target.app }); if (existing.error) { return { error: existing.error }; } @@ -1678,7 +1720,8 @@ 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) { if (existingNames.has(entry.name)) { @@ -1706,9 +1749,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,13 +1762,21 @@ function scanUnmanagedCodexSkills() { }; } -function importCodexSkills(params = {}) { +function scanUnmanagedCodexSkills() { + return scanUnmanagedSkills({ targetApp: 'codex' }); +} + +function importSkills(params = {}) { + const target = resolveSkillTarget(params); + if (!target) { + return { error: '目标宿主不支持' }; + } const rawItems = Array.isArray(params.items) ? params.items : []; if (!rawItems.length) { return { error: '请先选择要导入的 skill' }; } - ensureDir(CODEX_SKILLS_DIR); + ensureDir(target.dir); const imported = []; const failed = []; @@ -1749,6 +1802,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 +1835,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(target.dir, normalizedName.name); + const targetRelative = path.relative(target.dir, targetPath); if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) { failed.push({ name: normalizedName.name, @@ -1788,7 +1849,7 @@ function importCodexSkills(params = {}) { failed.push({ name: normalizedName.name, sourceApp: source.app, - error: 'Codex 中已存在同名 skill' + error: `${target.label} 中已存在同名 skill` }); continue; } @@ -1825,6 +1886,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 +1908,16 @@ function importCodexSkills(params = {}) { success: failed.length === 0, imported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: target.dir }; } +function importCodexSkills(params = {}) { + return importSkills({ ...(params || {}), targetApp: 'codex' }); +} + function collectSkillDirectoriesFromRoot(rootDir, limit = MAX_SKILLS_ZIP_ENTRY_COUNT) { const results = []; let truncated = false; @@ -1902,7 +1971,11 @@ function resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbac return normalizeCodexSkillName(candidate); } -async function importCodexSkillsFromZipFile(zipPath, options = {}) { +async function importSkillsFromZipFile(zipPath, options = {}) { + const target = resolveSkillTarget(options, 'codex'); + if (!target) { + return { error: '目标宿主不支持' }; + } const fallbackName = typeof options.fallbackName === 'string' ? options.fallbackName : ''; const tempDir = typeof options.tempDir === 'string' ? options.tempDir : ''; const imported = []; @@ -1926,7 +1999,7 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { return { error: '压缩包中的技能目录数量超出导入上限' }; } - ensureDir(CODEX_SKILLS_DIR); + ensureDir(target.dir); for (const skillDir of discoveredDirs) { const normalizedName = resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbackName); if (normalizedName.error) { @@ -1942,8 +2015,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(target.dir, normalizedName.name); + const targetRelative = path.relative(target.dir, targetPath); if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) { failed.push({ name: normalizedName.name, @@ -1954,7 +2027,7 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { if (fs.existsSync(targetPath)) { failed.push({ name: normalizedName.name, - error: 'Codex 中已存在同名 skill' + error: `${target.label} 中已存在同名 skill` }); continue; } @@ -1979,6 +2052,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 +2074,9 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { error: failed[0].error || '导入失败', imported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: target.dir }; } @@ -2007,7 +2084,9 @@ async function importCodexSkillsFromZipFile(zipPath, options = {}) { success: failed.length === 0, imported, failed, - root: CODEX_SKILLS_DIR + targetApp: target.app, + targetLabel: target.label, + root: target.dir }; } catch (e) { return { @@ -2026,7 +2105,11 @@ 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: '缺少技能压缩包内容' }; } @@ -2034,13 +2117,22 @@ async function importCodexSkillsFromZip(payload = {}) { if (upload.error) { return { error: upload.error }; } - return importCodexSkillsFromZipFile(upload.zipPath, { + return importSkillsFromZipFile(upload.zipPath, { tempDir: upload.tempDir, - fallbackName: payload.fileName || '' + fallbackName: payload.fileName || '', + targetApp: payload.targetApp || payload.target || 'codex' }); } -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 +2154,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 +2201,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 +2227,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 +2247,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 +2259,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 +2281,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 +2312,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); @@ -9633,12 +9745,23 @@ function resolveUploadFileNameFromRequest(req, fallbackName = 'codex-skills.zip' return normalized || fallback; } -async function handleImportCodexSkillsZipUpload(req, res) { +function resolveSkillTargetAppFromRequest(req, fallbackApp = 'codex') { + const fallback = normalizeSkillTargetApp(fallbackApp) || 'codex'; + try { + const parsed = new URL(req.url || '/', 'http://localhost'); + return normalizeSkillTargetApp(parsed.searchParams.get('targetApp')) || fallback; + } catch (_) { + return fallback; + } +} + +async function handleImportSkillsZipUpload(req, res, options = {}) { if (req.method !== 'POST') { writeJsonResponse(res, 405, { error: 'Method Not Allowed' }); return; } try { + const targetApp = resolveSkillTargetAppFromRequest(req, options && options.targetApp ? options.targetApp : 'codex'); const fileName = resolveUploadFileNameFromRequest(req, 'codex-skills.zip'); const upload = await writeUploadZipStream( req, @@ -9646,9 +9769,10 @@ async function handleImportCodexSkillsZipUpload(req, res) { 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 +9786,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 +9923,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 +10297,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser process.exit(1); }); - const openHost = isAnyAddressHost(host) ? DEFAULT_WEB_HOST : host; + const openHost = isAnyAddressHost(host) ? 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..77c8c38 100644 --- a/site/guide/getting-started.md +++ b/site/guide/getting-started.md @@ -27,7 +27,7 @@ codexmate status codexmate run ``` -默认监听 `127.0.0.1:3737`,并尝试自动打开浏览器。 +默认监听 `0.0.0.0:3737`,支持局域网访问,并尝试自动打开浏览器。 仅启动服务(测试 / CI): diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 3af9f01..7755f5e 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -34,6 +34,31 @@ 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 \}\)/); + assert.match(html, /skillsDefaultRootPath/); + assert.match(html, /可直接导入/); + assert.match(html, /不再展示任何 MCP 在线目录或外部市场/); + assert.match(html, /本地 skills 视图和导入分发流程/); + assert.doesNotMatch(html, /skillsMarketRemoteCount/); + assert.doesNotMatch(html, /loadOnlineSkillsMarket\(\{ forceRefresh: true, silent: false \}\)/); + assert.doesNotMatch(html, /resetOnlineSkillsMarketSearch/); + assert.doesNotMatch(html, /class="market-online-list"/); + assert.doesNotMatch(html, /class="market-ecosystem-grid"/); assert.match(html, /id="settings-tab-backup"/); assert.match(html, /id="settings-tab-trash"/); assert.match(html, /role="tab"/); @@ -59,8 +84,10 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /@click="loadMoreSessionTrashItems"/); assert.match(html, /回收站列表加载失败,请刷新重试/); assert.match(html, /data-main-tab=\"sessions\"/); + assert.match(html, /data-main-tab=\"market\"/); assert.match(html, /data-config-mode=\"codex\"/); assert.match(html, /isMainTabNavActive\('settings'\)/); + assert.match(html, /isMainTabNavActive\('market'\)/); assert.match(html, /isConfigModeNavActive\('codex'\)/); assert.match(html, /:aria-pressed="isSessionPinned\(session\)"/); assert.match(html, /class="session-item-copy session-item-pin"/); @@ -145,6 +172,15 @@ test('web ui script defines provider mode metadata for codex only', () => { assert.match(appScript, /const SESSION_TRASH_LIST_LIMIT = 500;/); assert.match(appScript, /const SESSION_TRASH_PAGE_SIZE = 200;/); assert.match(appScript, /settingsTab:\s*'backup'/); + assert.match(appScript, /skillsTargetApp:\s*'codex'/); + assert.match(appScript, /skillsMarketLoading:\s*false/); + assert.match(appScript, /skillsMarketLocalLoadedOnce:\s*false/); + assert.match(appScript, /skillsMarketImportLoadedOnce:\s*false/); + assert.doesNotMatch(appScript, /skillsMarketRemoteLoading:\s*false/); + assert.doesNotMatch(appScript, /skillsMarketRemoteLoadedOnce:\s*false/); + assert.doesNotMatch(appScript, /skillsMarketRemoteItems:\s*\[\]/); + assert.doesNotMatch(appScript, /skillsMarketRemoteLatestOnly:\s*true/); + assert.doesNotMatch(appScript, /skillsMarketEcosystems:\s*\[\]/); assert.match(appScript, /sessionTrashItems:\s*\[\]/); assert.match(appScript, /sessionTrashVisibleCount:\s*SESSION_TRASH_PAGE_SIZE/); assert.match(appScript, /sessionTrashTotalCount:\s*0/); @@ -194,6 +230,8 @@ test('session helper deferred claude refresh validates live tab and mode before assert.match(helperScript, /this\.settingsTab !== 'trash'/); assert.match(helperScript, /this\.sessionTrashLoadedOnce = false;/); assert.match(helperScript, /this\.loadSessionTrashCount\(\{ silent: true \}\);/); + assert.match(helperScript, /const shouldLoadSkillsMarketOnEnter = nextTab === 'market'/); + assert.match(helperScript, /this\.loadSkillsMarketOverview\(\{ silent: true \}\);/); }); test('trash item styles stay aligned with session card layout and keep mobile usability', () => { @@ -231,4 +269,11 @@ test('settings tab header actions keep compact tool buttons inline on wider scre styles, /\.settings-tab-actions \.btn-tool,\s*\.settings-tab-actions \.btn-tool-compact\s*\{[\s\S]*width:\s*auto;/ ); + assert.match(styles, /\.market-grid\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/); + assert.match(styles, /\.market-action-grid\s*\{[\s\S]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\);/); + assert.match(styles, /\.market-target-switch\s*\{/); + assert.match(styles, /\.market-target-chip\.active\s*\{/); + assert.match(styles, /\.market-panel-wide\s*\{/); + assert.doesNotMatch(styles, /\.market-online-list\s*\{/); + assert.doesNotMatch(styles, /\.market-ecosystem-grid\s*\{/); }); diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index ae69545..06b9b8e 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -13,6 +13,7 @@ await import(pathToFileURL(path.join(__dirname, 'session-query.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'mcp-stdio.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'workflow-engine.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'skills-modal-ui.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'skills-market-runtime.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'config-tabs-ui.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'compact-layout-ui.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'agents-diff-ui.test.mjs'))); @@ -25,6 +26,7 @@ await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.test.mjs') await import(pathToFileURL(path.join(__dirname, 'session-tab-switch-performance.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-trash-state.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'web-ui-restart.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'web-run-host.test.mjs'))); let failures = 0; for (const { name, fn } of tests) { diff --git a/tests/unit/session-tab-switch-performance.test.mjs b/tests/unit/session-tab-switch-performance.test.mjs index a2ec755..a682c52 100644 --- a/tests/unit/session-tab-switch-performance.test.mjs +++ b/tests/unit/session-tab-switch-performance.test.mjs @@ -127,6 +127,27 @@ test('switchMainTab primes trash badge count and invalidates the cached trash li assert.deepStrictEqual(calls, [{ silent: true }]); }); +test('switchMainTab loads skills market overview when entering market', () => { + const calls = []; + const vm = { + mainTab: 'config', + configMode: 'codex', + sessionsLoadedOnce: true, + teardownSessionTabRender() {}, + prepareSessionTabRender() {}, + loadSessions() {}, + loadSkillsMarketOverview(options) { + calls.push(options); + }, + refreshClaudeModelContext() {} + }; + + switchMainTab.call(vm, 'market'); + + assert.strictEqual(vm.mainTab, 'market'); + assert.deepStrictEqual(calls, [{ silent: true }]); +}); + test('switchMainTab defers session teardown when scheduler exists to keep tab selection responsive', () => { let deferredTask = null; let teardownCount = 0; diff --git a/tests/unit/skills-market-runtime.test.mjs b/tests/unit/skills-market-runtime.test.mjs new file mode 100644 index 0000000..d661b0a --- /dev/null +++ b/tests/unit/skills-market-runtime.test.mjs @@ -0,0 +1,127 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { createSkillsMethods } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'skills.methods.mjs'))); +const { createSkillsComputed } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'skills.computed.mjs'))); + +function buildVm(apiImpl, overrides = {}) { + const methods = createSkillsMethods({ api: apiImpl }); + const vm = { + skillsTargetApp: 'codex', + skillsRootPath: '', + skillsList: [], + skillsSelectedNames: [], + skillsLoading: false, + skillsDeleting: false, + skillsKeyword: '', + skillsStatusFilter: 'all', + skillsImportList: [], + skillsImportSelectedKeys: [], + skillsScanningImports: false, + skillsImporting: false, + skillsZipImporting: false, + skillsExporting: false, + skillsMarketLoading: false, + skillsMarketLocalLoadedOnce: false, + skillsMarketImportLoadedOnce: false, + showSkillsModal: false, + messageLog: [], + showMessage(message, type) { + this.messageLog.push({ message, type }); + }, + requestConfirmDialog: async () => true, + $refs: {}, + ...methods, + ...overrides + }; + + const computed = createSkillsComputed(); + for (const [key, getter] of Object.entries(computed)) { + Object.defineProperty(vm, key, { + configurable: true, + enumerable: true, + get() { + return getter.call(vm); + } + }); + } + + return vm; +} + +test('refreshSkillsList requests generic skills API for current host target', async () => { + const calls = []; + const vm = buildVm(async (action, params) => { + calls.push({ action, params }); + return { + exists: true, + root: '/tmp/claude-skills', + items: [{ name: 'demo-skill', hasSkillFile: true }] + }; + }, { + skillsTargetApp: 'claude' + }); + + const ok = await vm.refreshSkillsList({ silent: true }); + + assert.strictEqual(ok, true); + assert.deepStrictEqual(calls, [{ + action: 'list-skills', + params: { targetApp: 'claude' } + }]); + assert.strictEqual(vm.skillsRootPath, '/tmp/claude-skills'); + assert.strictEqual(vm.skillsList.length, 1); +}); + +test('setSkillsTargetApp resets local state and reloads local market slices only', async () => { + let receivedOptions = null; + const vm = buildVm(async () => ({}), { + skillsTargetApp: 'codex', + skillsRootPath: '/tmp/codex-skills', + skillsList: [{ name: 'alpha', hasSkillFile: true }], + skillsImportList: [{ name: 'beta', sourceApp: 'claude' }] + }); + vm.loadSkillsMarketOverview = async (options) => { + receivedOptions = options; + return true; + }; + + const ok = await vm.setSkillsTargetApp('claude', { silent: false }); + + assert.strictEqual(ok, true); + assert.strictEqual(vm.skillsTargetApp, 'claude'); + assert.deepStrictEqual(vm.skillsList, []); + assert.deepStrictEqual(vm.skillsImportList, []); + assert.deepStrictEqual(receivedOptions, { + forceRefresh: true, + silent: false + }); +}); + +test('loadSkillsMarketOverview refreshes installed skills and importable sources only', async () => { + const steps = []; + const vm = buildVm(async () => ({})); + vm.refreshSkillsList = async (options) => { + steps.push(['refresh', options]); + vm.skillsMarketLocalLoadedOnce = true; + return true; + }; + vm.scanImportableSkills = async (options) => { + steps.push(['scan', options]); + vm.skillsMarketImportLoadedOnce = true; + return true; + }; + + const ok = await vm.loadSkillsMarketOverview({ forceRefresh: true, silent: true }); + + assert.strictEqual(ok, true); + assert.deepStrictEqual(steps, [ + ['refresh', { silent: true }], + ['scan', { silent: true }] + ]); + assert.strictEqual(vm.skillsMarketLoading, false); +}); diff --git a/tests/unit/skills-modal-ui.test.mjs b/tests/unit/skills-modal-ui.test.mjs index f5b59c4..3abebf9 100644 --- a/tests/unit/skills-modal-ui.test.mjs +++ b/tests/unit/skills-modal-ui.test.mjs @@ -40,13 +40,23 @@ test('skills modal script is modularized and exposes computed/methods from skill assert.match(skillsComputed, /skillsImportConfiguredCount\(\)/); assert.match(skillsComputed, /skillsImportMissingSkillFileCount\(\)/); assert.match(skillsComputed, /skillsFilterDirty\(\)/); + assert.match(skillsComputed, /skillsTargetLabel\(\)/); + assert.match(skillsComputed, /skillsDefaultRootPath\(\)/); + assert.doesNotMatch(skillsComputed, /skillsMarketRemoteCount\(\)/); + assert.match(skillsMethods, /setSkillsTargetApp\(app,\s*options = \{\}\)/); assert.match(skillsMethods, /resetSkillsFilters\(\)/); assert.match(skillsMethods, /skillsZipImporting/); assert.match(skillsMethods, /skillsExporting/); assert.match(skillsMethods, /importSkillsFromZipFile/); assert.match(skillsMethods, /exportSelectedSkills\(\)/); assert.match(skillsMethods, /requestConfirmDialog\(/); + assert.match(skillsMethods, /api\('list-skills'/); + assert.match(skillsMethods, /api\('scan-unmanaged-skills'/); + assert.match(skillsMethods, /api\('import-skills'/); + assert.match(skillsMethods, /api\('export-skills'/); + assert.match(skillsMethods, /api\('delete-skills'/); + assert.doesNotMatch(skillsMethods, /api\('list-online-skills-market'/); assert.doesNotMatch(skillsMethods, /window\.confirm\(/); }); @@ -62,4 +72,7 @@ test('skills modal styles define summary and panel layout hooks', () => { assert.match(styles, /\.skill-list::\-webkit-scrollbar-thumb/); assert.match(styles, /\.confirm-dialog/); assert.match(styles, /\.confirm-dialog-message/); + assert.match(styles, /\.market-target-chip/); + assert.doesNotMatch(styles, /\.market-online-toolbar/); + assert.doesNotMatch(styles, /\.market-ecosystem-card/); }); diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs new file mode 100644 index 0000000..d97e2c4 --- /dev/null +++ b/tests/unit/web-run-host.test.mjs @@ -0,0 +1,147 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); +const fs = require('fs'); + +const cliPath = path.join(__dirname, '..', '..', 'cli.js'); +const cliContent = fs.readFileSync(cliPath, 'utf-8'); + +function findMatchingBrace(source, startIndex) { + let depth = 0; + let quote = ''; + let escape = false; + let inLineComment = false; + let inBlockComment = false; + let templateDepth = 0; + + for (let index = startIndex; index < source.length; index += 1) { + const char = source[index]; + const next = source[index + 1]; + const prev = source[index - 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + if (prev === '*' && char === '/') inBlockComment = false; + continue; + } + if (quote) { + if (escape) { + escape = false; + continue; + } + if (char === '\\') { + escape = true; + continue; + } + if (quote === '`') { + if (char === '$' && next === '{') { + templateDepth += 1; + depth += 1; + index += 1; + continue; + } + if (char === '}' && templateDepth > 0) { + templateDepth -= 1; + depth -= 1; + continue; + } + } + if (char === quote && templateDepth === 0) { + quote = ''; + } + continue; + } + + if (char === '/' && next === '/') { + inLineComment = true; + index += 1; + continue; + } + if (char === '/' && next === '*') { + inBlockComment = true; + index += 1; + continue; + } + if (char === '\'' || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === '{') { + depth += 1; + continue; + } + if (char === '}') { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + throw new Error('Matching brace not found'); +} + +function extractFunctionBySignature(source, signature, funcName) { + const startIndex = source.indexOf(signature); + if (startIndex === -1) { + throw new Error(`Signature not found: ${signature}`); + } + const signatureBraceOffset = signature.lastIndexOf('{'); + const braceStart = signatureBraceOffset >= 0 + ? (startIndex + signatureBraceOffset) + : source.indexOf('{', startIndex + signature.length); + const endIndex = findMatchingBrace(source, braceStart); + const block = source.slice(startIndex, endIndex + 1).trim(); + return `${block}\nreturn ${funcName};`; +} + +function instantiateFunction(funcSource, funcName, bindings = {}) { + const bindingNames = Object.keys(bindings); + const bindingValues = Object.values(bindings); + return Function(...bindingNames, `${funcSource}\nreturn ${funcName};`)(...bindingValues); +} + +const defaultHostMatch = cliContent.match(/const DEFAULT_WEB_HOST = '([^']+)';/); +if (!defaultHostMatch) { + throw new Error('DEFAULT_WEB_HOST not found'); +} +const resolveWebHostSource = extractFunctionBySignature( + cliContent, + 'function resolveWebHost(options = {}) {', + 'resolveWebHost' +); +const resolveWebHost = instantiateFunction(resolveWebHostSource, 'resolveWebHost', { + DEFAULT_WEB_HOST: defaultHostMatch[1], + process: { env: {} } +}); + +test('resolveWebHost defaults to all interfaces for LAN access', () => { + assert.strictEqual(resolveWebHost({}), '0.0.0.0'); + assert.strictEqual(resolveWebHost(), '0.0.0.0'); +}); + +test('resolveWebHost still prefers CLI host over environment and default host', () => { + const withEnv = instantiateFunction(resolveWebHostSource, 'resolveWebHost', { + DEFAULT_WEB_HOST: defaultHostMatch[1], + process: { env: { CODEXMATE_HOST: '192.168.1.10' } } + }); + + assert.strictEqual(withEnv({ host: '10.0.0.8' }), '10.0.0.8'); +}); + +test('resolveWebHost still prefers environment host over default host', () => { + const withEnv = instantiateFunction(resolveWebHostSource, 'resolveWebHost', { + DEFAULT_WEB_HOST: defaultHostMatch[1], + process: { env: { CODEXMATE_HOST: '192.168.1.10' } } + }); + + assert.strictEqual(withEnv({}), '192.168.1.10'); +}); diff --git a/web-ui/app.js b/web-ui/app.js index ad61d1c..b75867b 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -170,6 +170,7 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; agentsContext: 'codex', agentsModalTitle: 'AGENTS.md 编辑器', agentsModalHint: '保存后会写入目标 AGENTS.md(与 config.toml 同级)。', + skillsTargetApp: 'codex', skillsRootPath: '', skillsList: [], skillsSelectedNames: [], @@ -183,6 +184,9 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; skillsImporting: false, skillsZipImporting: false, skillsExporting: false, + skillsMarketLoading: false, + skillsMarketLocalLoadedOnce: false, + skillsMarketImportLoadedOnce: false, sessionPinnedMap: {}, sessionsList: [], sessionsLoadedOnce: false, @@ -5768,5 +5772,3 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; app.mount('#app'); }); - - diff --git a/web-ui/index.html b/web-ui/index.html index 4147841..ba57fd7 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -96,6 +96,16 @@ :class="{ active: isMainTabNavActive('sessions') }" @pointerdown="onMainTabPointerDown('sessions', $event)" @click="onMainTabClick('sessions', $event)">会话浏览 + + +
设置
+
+
+ 当前目标 + {{ skillsTargetLabel }} +
+
+ 本地 Skills + {{ skillsList.length }} +
+
+ 可导入 + {{ skillsImportList.length }} +
+
+ 可直接导入 + {{ skillsImportConfiguredCount }} +
+
@@ -430,8 +481,8 @@

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

@@ -1231,6 +1282,180 @@

+
+
+
+
+ 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 +2130,25 @@

-
+
@@ -1385,7 +1385,7 @@

可导入来源
扫描其他应用下未托管的 skill,先确认来源和目录,再批量导入到当前 {{ skillsTargetLabel }} skills 目录。

-

@@ -1412,15 +1412,15 @@

- - - @@ -2140,7 +2140,7 @@

type="button" :class="['market-target-chip', { active: skillsTargetApp === 'codex' }]" :aria-pressed="skillsTargetApp === 'codex'" - :disabled="skillsMarketBusy" + :disabled="loading || !!initError || skillsMarketBusy" @click="setSkillsTargetApp('codex', { silent: false })"> Codex @@ -2148,7 +2148,7 @@

type="button" :class="['market-target-chip', { active: skillsTargetApp === 'claude' }]" :aria-pressed="skillsTargetApp === 'claude'" - :disabled="skillsMarketBusy" + :disabled="loading || !!initError || skillsMarketBusy" @click="setSkillsTargetApp('claude', { silent: false })"> Claude Code diff --git a/web-ui/modules/skills.methods.mjs b/web-ui/modules/skills.methods.mjs index 7c06d52..f78b404 100644 --- a/web-ui/modules/skills.methods.mjs +++ b/web-ui/modules/skills.methods.mjs @@ -65,6 +65,17 @@ export function createSkillsMethods({ api }) { }, async openSkillsManager(options = {}) { + if ( + this.skillsLoading + || this.skillsDeleting + || this.skillsScanningImports + || this.skillsImporting + || this.skillsZipImporting + || this.skillsExporting + || this.skillsMarketLoading + ) { + return false; + } let targetApp; try { targetApp = this.normalizeSkillsTargetApp(options && options.targetApp ? options.targetApp : this.skillsTargetApp); diff --git a/web-ui/session-helpers.mjs b/web-ui/session-helpers.mjs index 7529af5..bbc2149 100644 --- a/web-ui/session-helpers.mjs +++ b/web-ui/session-helpers.mjs @@ -65,7 +65,13 @@ export function switchMainTab(tab) { && previousTab !== 'market' && typeof this.loadSkillsMarketOverview === 'function'; if (shouldLoadSkillsMarketOnEnter) { - void Promise.resolve(this.loadSkillsMarketOverview({ silent: true })).catch(() => {}); + let marketOverviewLoad = null; + try { + marketOverviewLoad = this.loadSkillsMarketOverview({ silent: true }); + } catch (_) { + marketOverviewLoad = null; + } + void Promise.resolve(marketOverviewLoad).catch(() => {}); } if (nextTab === 'config' && this.configMode === 'claude') { const expectedTab = nextTab; diff --git a/web-ui/styles.css b/web-ui/styles.css index 8b3b8f9..cfacdde 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -3708,6 +3708,14 @@ body::after { transition: border-color var(--transition-fast) var(--ease-smooth), background var(--transition-fast) var(--ease-smooth), color var(--transition-fast) var(--ease-smooth), box-shadow var(--transition-fast) var(--ease-smooth); } +.market-target-chip:disabled, +.market-target-chip[disabled] { + cursor: not-allowed; + opacity: 0.64; + pointer-events: none; + box-shadow: none; +} + .market-target-chip.active { border-color: rgba(208, 88, 58, 0.4); background: linear-gradient(to bottom, rgba(255, 243, 236, 0.98) 0%, rgba(255, 232, 220, 0.86) 100%);