From ac32c6758b1fa6c80dfbbcd72e3ccc51c1536d8c Mon Sep 17 00:00:00 2001 From: Windsander Date: Thu, 29 Oct 2026 14:19:40 +0800 Subject: [PATCH] fix(skill): cover all folder-based agent skill targets --- packages/mcp/src/clients.mjs | 57 ++++-- packages/skill/SKILL.md | 2 + packages/skill/scripts/install.mjs | 48 ++++- scripts/verify-mcp-skill.mjs | 44 +++- tests/mcp/skill-targets.test.mjs | 317 +++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 32 deletions(-) create mode 100644 tests/mcp/skill-targets.test.mjs diff --git a/packages/mcp/src/clients.mjs b/packages/mcp/src/clients.mjs index d5dd2fef..7cef59af 100644 --- a/packages/mcp/src/clients.mjs +++ b/packages/mcp/src/clients.mjs @@ -245,7 +245,7 @@ export function builtinAssistants(options = {}) { 'opencode.managed.json 为 App 所有且会被重写,禁写', 'opencode mcp list 经运行服务回答,只读但勿频繁调用', ], - skillsDirs: [join(userHome, '.config', 'opencode', 'skills')], + skillsDirs: [join(userHome, '.config', 'opencode', 'skills'), join(cwd, '.opencode', 'skills')], reuseOf: 'opencode', note: 'OpenChamber 复用 OpenCode 的 global 配置(接一次即可)', }, @@ -255,6 +255,7 @@ export function builtinAssistants(options = {}) { probe: { signals: [ { kind: 'config-path', paths: [hermesPath] }, + { kind: 'dir', paths: [join(userHome, '.hermes')] }, { kind: 'cli', command: 'hermes --version', timeoutMs: 2500 }, { kind: 'path', paths: [join(userHome, '.local', 'bin', 'hermes')] }, ], @@ -269,7 +270,7 @@ export function builtinAssistants(options = {}) { 'Hermes 无法 env 沙箱(HERMES_CONFIG_PATH 被剥离)→ 写前字节级备份 + 失败回滚为硬要求', '写入仅经 `hermes config set --force`(ruamel 保注释);未知子键必须 --force', ], - skillsDirs: [join(userHome, '.agents', 'skills')], + skillsDirs: [join(userHome, '.hermes', 'skills')], reuseOf: null, note: 'Hermes 的 MCP 配置在 ~/.hermes/config.yaml', }, @@ -287,19 +288,19 @@ export function builtinAssistants(options = {}) { verify: { cli: { command: 'opencode mcp list', expect: 'mebular', timeoutMs: 4000 }, configReadback: true }, activation: { kind: 'user-reload', note: 'OpenCode 侧重载/重启后生效' }, risks: ['opencode mcp list 经运行服务回答,只读但勿频繁调用'], - skillsDirs: [join(userHome, '.config', 'opencode', 'skills'), join(userHome, '.agents', 'skills'), join(cwd, '.agents', 'skills'), join(cwd, '.opencode', 'skills')], + skillsDirs: [join(userHome, '.config', 'opencode', 'skills'), join(cwd, '.opencode', 'skills')], reuseOf: null, note: null, }, { id: 'claude-code', name: 'Claude Code', kind: 'file', format: 'json-mcpServers', landing: { primaryPath: join(userHome, '.claude.json'), keyPath: 'mcpServers.mebular', forbiddenPaths: [], format: 'json-mcpServers', httpShape: 'claude-http' }, - probe: { signals: [{ kind: 'config-path', paths: [join(userHome, '.claude.json')] }, { kind: 'cli', command: 'claude --version', timeoutMs: 1500 }] }, + probe: { signals: [{ kind: 'config-path', paths: [join(userHome, '.claude.json')] }, { kind: 'dir', paths: [join(userHome, '.claude')] }, { kind: 'cli', command: 'claude --version', timeoutMs: 1500 }] }, writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true }, verify: { configReadback: true }, activation: { kind: 'user-reload', note: '重启 Claude Code 后生效' }, risks: [], - skillsDirs: [join(userHome, '.agents', 'skills'), join(cwd, '.agents', 'skills')], + skillsDirs: [join(userHome, '.claude', 'skills'), join(cwd, '.claude', 'skills')], reuseOf: null, note: null, }, { @@ -319,17 +320,24 @@ export function builtinAssistants(options = {}) { writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true }, verify: { configReadback: true }, activation: { kind: 'user-reload', note: '重启 Cursor 后生效' }, - risks: ['HTTP /mcp 默认只监听回环:客户端与守护须**同机**(跨机请在 config 里启用 TLS+鉴权并用可达主机名)'], skillsDirs: [], reuseOf: null, note: null, + risks: ['HTTP /mcp 默认只监听回环:客户端与守护须**同机**(跨机请在 config 里启用 TLS+鉴权并用可达主机名)'], + skillsDirs: [join(userHome, '.cursor', 'skills'), join(cwd, '.cursor', 'skills')], reuseOf: null, note: null, }, { id: 'dsh', name: 'DeepSeek Harness (DSH)', kind: 'file', format: 'dsh', landing: { primaryPath: join(cwd, '.dsh', 'cordis.yml'), keyPath: 'plugins', forbiddenPaths: [], format: 'dsh' }, - probe: { signals: [{ kind: 'config-path', paths: [join(cwd, '.dsh', 'cordis.yml')] }, { kind: 'dir', paths: [join(cwd, '.dsh')] }] }, + probe: { + signals: [ + { kind: 'config-path', paths: [join(cwd, '.dsh', 'cordis.yml')] }, + // DSH_HOME 默认 ~/.dsh;全局安装也要能被自动检测(Skill 仍写 ~/.agents/skills)。 + { kind: 'dir', paths: [join(cwd, '.dsh'), join(userHome, '.dsh')] }, + ], + }, writer: { strategy: 'merge-key', command: null, backup: 'byte-level', atomic: true }, verify: { configReadback: true }, activation: { kind: 'user-reload', note: '重启 DSH 后生效' }, risks: ['DSH 配置为 YAML 插件列表:结构未知时不自动改写', 'HTTP /mcp 默认只监听回环:客户端与守护须同机(跨机请启用 TLS+鉴权并用可达主机名)'], - skillsDirs: [join(cwd, '.dsh', 'skills'), join(userHome, '.agents', 'skills')], reuseOf: null, note: null, + skillsDirs: [join(userHome, '.agents', 'skills'), join(cwd, '.dsh', 'skills')], reuseOf: null, note: null, }, { id: 'generic', name: '其他 AI 助手(复制片段)', kind: 'snippet', format: 'generic-snippet', @@ -1594,6 +1602,7 @@ export async function repairClients(options = {}) { const list = await listAssistantsAsync({ ...options, refresh: true }); const candidates = list.assistants.filter((assistant) => skillStateDetected(assistant)); const clients = []; + const seenSkillTargets = new Map(); for (const assistant of candidates) { const base = { id: assistant.id, @@ -1614,11 +1623,33 @@ export async function repairClients(options = {}) { } catch (error) { base.connect = { ok: false, reason: `接入失败:${redactSecrets(error?.message ?? error)}` }; } - if ((assistant.skillsDirs ?? []).length > 0) { - try { - base.skills = await installSkills(assistant.id, { ...options, force: true }); - } catch (error) { - base.skills = { ok: false, reason: `Skill 安装失败:${redactSecrets(error?.message ?? error)}` }; + const skillDirs = (assistant.skillsDirs ?? []).filter(Boolean); + if (skillDirs.length > 0) { + // 与 ensureSkills 同判据:共享全局目录(OpenChamber / OpenCode)只 force 安装一次。 + const target = preferredSkillTarget(skillDirs); + const shared = seenSkillTargets.get(target); + if (shared) { + // 共享目录(OpenChamber / OpenCode)复用首次安装结果,但明确标注 skipped;实际只写一次。 + base.skills = { + ...(shared.result ?? {}), + id: assistant.id, + ok: shared.result?.ok ?? true, + skipped: true, + targetDir: target, + sharedWith: shared.id, + reason: `与 ${shared.id} 共享 Skill 目录,已由前者处理`, + }; + } else { + seenSkillTargets.set(target, { id: assistant.id, result: null }); + try { + const result = await installSkills(assistant.id, { ...options, force: true }); + seenSkillTargets.set(target, { id: assistant.id, result }); + base.skills = result; + } catch (error) { + const failure = { ok: false, targetDir: target, reason: `Skill 安装失败:${redactSecrets(error?.message ?? error)}` }; + seenSkillTargets.set(target, { id: assistant.id, result: failure }); + base.skills = failure; + } } } else { base.skills = { ok: true, skipped: true, reason: '该客户端没有已知的 Skill 目录' }; diff --git a/packages/skill/SKILL.md b/packages/skill/SKILL.md index a7aba834..6357acc0 100644 --- a/packages/skill/SKILL.md +++ b/packages/skill/SKILL.md @@ -49,6 +49,8 @@ Mebular 是一个本地优先的分布式图记忆层。通过 MCP 工具读写 本 Skill 目录附各客户端 MCP 接入片段(见 `mcp/`);`scripts/install.mjs` 可将本 Skill(含 `mcp/` 片段)安装到常见 Skill 目录。 +支持的文件夹式 Skill 客户端(`--global` 只写**已检测到**的客户端):OpenCode/OpenChamber `~/.config/opencode/skills`、Hermes `~/.hermes/skills`、Claude Code `~/.claude/skills`、Cursor `~/.cursor/skills`、DeepSeek Harness `~/.agents/skills`;Claude Desktop 无文件夹式自动目录(用 zip/UI 上传)。 + **会话里没有 Mebular 工具时的第一步**(每次会话): 1. **先看 App/守护是否已在跑**:读 `HOME_DIR="${MEBULAR_HOME:-$HOME/.mebular}"` 下的 `runtime.json`,用其中的 `host`/`port` 请求 `/healthz`;200 → 已在跑,直接接入,**不要因为 `command -v mebular` 失败就再装一套 CLI**。 diff --git a/packages/skill/scripts/install.mjs b/packages/skill/scripts/install.mjs index 87a6c07c..931c634c 100644 --- a/packages/skill/scripts/install.mjs +++ b/packages/skill/scripts/install.mjs @@ -5,7 +5,13 @@ // // 用法: // node scripts/install.mjs # 探测并安装到 cwd 的 .agents/skills、.dsh/skills(及存在的 .opencode/skills) -// node scripts/install.mjs --global # 另装到 ~/.agents/skills;OpenCode/OpenChamber 已存在时也装 ~/.config/opencode/skills +// node scripts/install.mjs --global # 另装到**已检测到**客户端的全局 Skill 目录: +// # OpenCode/OpenChamber ~/.config/opencode/skills +// # Hermes ~/.hermes/skills +// # Claude Code ~/.claude/skills +// # Cursor ~/.cursor/skills +// # DSH ~/.agents/skills(检测到 ~/.dsh、~/.agents 或 cwd/.dsh) +// # 未检测到的客户端不创建目录(Claude Desktop 无文件夹式目录) // node scripts/install.mjs --target # 只装到指定目录(测试/自定义用) // node scripts/install.mjs --force # 覆盖用户改过的 Skill(默认保留) // @@ -44,6 +50,31 @@ function parseFlags(argv) { const flags = parseFlags(process.argv.slice(2)); const force = flags.force === true || flags.force === 'true'; const cwd = process.cwd(); +const home = homedir(); + +/** + * 已检测到客户端的全局 Skill 目录(路径契约与 `packages/mcp/src/clients.mjs` 一致)。 + * 只看目录/配置文件是否存在;未检测到的客户端一律不创建目录。 + */ +function detectedGlobalSkillDirs() { + const dirs = []; + // OpenChamber / OpenCode 共用 global 配置与 Skill 目录,只加入一次。 + const opencodeDir = join(home, '.config', 'opencode'); + if (existsSync(join(opencodeDir, 'opencode.json')) || existsSync(opencodeDir)) { + dirs.push(join(opencodeDir, 'skills')); + } + if (existsSync(join(home, '.hermes'))) dirs.push(join(home, '.hermes', 'skills')); + if (existsSync(join(home, '.claude')) || existsSync(join(home, '.claude.json'))) { + dirs.push(join(home, '.claude', 'skills')); + } + if (existsSync(join(home, '.cursor'))) dirs.push(join(home, '.cursor', 'skills')); + // DSH:全局目录是 ~/.agents/skills;DSH_HOME(~/.dsh)、~/.agents 或当前项目的 .dsh 都是检测信号。 + if (existsSync(join(home, '.dsh')) || existsSync(join(home, '.agents')) || existsSync(join(cwd, '.dsh'))) { + dirs.push(join(home, '.agents', 'skills')); + } + return dirs; +} + const targets = []; if (typeof flags.target === 'string') { targets.push(flags.target); @@ -51,17 +82,12 @@ if (typeof flags.target === 'string') { targets.push(join(cwd, '.agents', 'skills')); targets.push(join(cwd, '.dsh', 'skills')); if (existsSync(join(cwd, '.opencode'))) targets.push(join(cwd, '.opencode', 'skills')); - if (flags.global === true) { - targets.push(join(homedir(), '.agents', 'skills')); - // OpenChamber / OpenCode 共用 global 配置;只有客户端已存在时才写它的 Skill 目录。 - const opencodeDir = join(homedir(), '.config', 'opencode'); - if (existsSync(join(opencodeDir, 'opencode.json')) || existsSync(opencodeDir)) { - targets.push(join(opencodeDir, 'skills')); - } - } + if (flags.global === true) targets.push(...detectedGlobalSkillDirs()); } -if (targets.length === 0) { +// 去重:同一目录(如 OpenChamber/OpenCode 共享的全局目录)只安装一次。 +const uniqueTargets = [...new Set(targets)]; +if (uniqueTargets.length === 0) { console.error('没有可安装的目标目录'); process.exit(1); } @@ -245,7 +271,7 @@ async function fallbackInstall(target, force = false) { const installed = []; let failed = false; -for (const target of targets) { +for (const target of uniqueTargets) { try { const result = shared?.installSkillsToDir ? await shared.installSkillsToDir(target, { skillRoot: pkgRoot, force }) diff --git a/scripts/verify-mcp-skill.mjs b/scripts/verify-mcp-skill.mjs index 26b650f3..aae589a8 100644 --- a/scripts/verify-mcp-skill.mjs +++ b/scripts/verify-mcp-skill.mjs @@ -262,21 +262,49 @@ try { && existsSync(join(installTarget, 'mebular-memory', 'mcp', 'claude.json'))); } - // ---------- install.mjs --global:已存在 OpenCode/OpenChamber 客户端才写其 Skill 目录 ---------- + // ---------- install.mjs --global:只写已检测客户端的全局 Skill 目录 ---------- { const globalHome = join(dir, 'global-home'); await mkdir(join(globalHome, '.config', 'opencode'), { recursive: true }); await writeFile(join(globalHome, '.config', 'opencode', 'opencode.json'), '{}', 'utf-8'); - run([join(skillDir, 'scripts', 'install.mjs'), '--global'], { HOME: globalHome }, globalHome); - check('install.mjs --global 安装到 OpenChamber 的 ~/.config/opencode/skills', - existsSync(join(globalHome, '.config', 'opencode', 'skills', 'mebular-memory', 'SKILL.md')) - && existsSync(join(globalHome, '.config', 'opencode', 'skills', 'mebular-memory', 'mcp', 'opencode.json'))); + await mkdir(join(globalHome, '.hermes'), { recursive: true }); + await writeFile(join(globalHome, '.hermes', 'config.yaml'), 'mcp_servers: {}\n', 'utf-8'); + await writeFile(join(globalHome, '.claude.json'), '{}', 'utf-8'); + await mkdir(join(globalHome, '.cursor'), { recursive: true }); + await writeFile(join(globalHome, '.cursor', 'mcp.json'), '{}', 'utf-8'); + await mkdir(join(globalHome, '.dsh'), { recursive: true }); + const globalProject = join(dir, 'global-project'); + await mkdir(globalProject, { recursive: true }); + run([join(skillDir, 'scripts', 'install.mjs'), '--global'], { HOME: globalHome, USERPROFILE: globalHome }, globalProject); + const globalTargets = [ + ['OpenCode/OpenChamber', join(globalHome, '.config', 'opencode', 'skills'), 'opencode.json'], + ['Hermes', join(globalHome, '.hermes', 'skills'), null], + ['Claude Code', join(globalHome, '.claude', 'skills'), null], + ['Cursor', join(globalHome, '.cursor', 'skills'), null], + ['DSH', join(globalHome, '.agents', 'skills'), null], + ]; + for (const [label, target, fragment] of globalTargets) { + check(`install.mjs --global 安装到 ${label} 的全局 Skill 目录(${target.replace(globalHome, '~')})`, + existsSync(join(target, 'mebular-memory', 'SKILL.md')) + && existsSync(join(target, 'mebular-tasks', 'SKILL.md')) + && (fragment === null || existsSync(join(target, 'mebular-memory', 'mcp', fragment)))); + } const bareHome = join(dir, 'bare-home'); + const bareProject = join(dir, 'bare-project'); await mkdir(bareHome, { recursive: true }); - run([join(skillDir, 'scripts', 'install.mjs'), '--global'], { HOME: bareHome }, bareHome); - check('install.mjs --global 未检测到 OpenCode 客户端时不创建 ~/.config/opencode/skills', - !existsSync(join(bareHome, '.config', 'opencode', 'skills'))); + await mkdir(bareProject, { recursive: true }); + run([join(skillDir, 'scripts', 'install.mjs'), '--global'], { HOME: bareHome, USERPROFILE: bareHome }, bareProject); + const absentTargets = [ + ['.config', 'opencode', 'skills'], + ['.hermes', 'skills'], + ['.claude', 'skills'], + ['.cursor', 'skills'], + ['.agents', 'skills'], + ]; + const created = absentTargets.filter((parts) => existsSync(join(bareHome, ...parts))); + check('install.mjs --global 未检测到客户端时不创建任何全局 Skill 目录', + created.length === 0, created.map((parts) => parts.join('/')).join(', ')); } } catch (error) { check('G6.4 验证', false, String(error?.message ?? error).substring(0, 400)); diff --git a/tests/mcp/skill-targets.test.mjs b/tests/mcp/skill-targets.test.mjs new file mode 100644 index 00000000..a0747b23 --- /dev/null +++ b/tests/mcp/skill-targets.test.mjs @@ -0,0 +1,317 @@ +// 文件夹式 Skill 目标矩阵:所有受支持的 Agent 都必须自动准备 Skill。 +// +// 全局目录契约(与 packages/mcp/src/clients.mjs / install.mjs --global 同源): +// OpenCode / OpenChamber ~/.config/opencode/skills +// Hermes ~/.hermes/skills +// Claude Code ~/.claude/skills +// Cursor ~/.cursor/skills +// DeepSeek Harness ~/.agents/skills(全局优先,不回落到 App cwd) +// Claude Desktop / Generic 无文件夹式自动目录(不伪造支持) +// +// 全部使用临时 userHome / cwd;不触碰真实用户目录。 + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + SKILL_MANIFEST_FILE, + builtinAssistants, + connectClient, + ensureSkills, + installSkills, + repairClients, +} from '../../packages/mcp/src/clients.mjs'; + +const ROOT = fileURLToPath(new URL('../..', import.meta.url)); +const SKILL_ROOT = join(ROOT, 'packages', 'skill'); +const INSTALL_SCRIPT = join(SKILL_ROOT, 'scripts', 'install.mjs'); + +jest.setTimeout(30_000); + +describe('文件夹式 Skill 目标矩阵', () => { + let root; + let home; + let userHome; + let cwd; + + const opts = (extra = {}) => ({ + home, + userHome, + cwd, + platform: 'darwin', + env: {}, + run: () => ({ ok: false, code: 1, stdout: '', stderr: '' }), + processList: '', + skillRoot: SKILL_ROOT, + ...extra, + }); + + const byId = (list) => Object.fromEntries(list.map((entry) => [entry.id, entry])); + const globalOpencodeSkills = () => join(userHome, '.config', 'opencode', 'skills'); + const globalHermesSkills = () => join(userHome, '.hermes', 'skills'); + const globalClaudeSkills = () => join(userHome, '.claude', 'skills'); + const globalCursorSkills = () => join(userHome, '.cursor', 'skills'); + const globalDshSkills = () => join(userHome, '.agents', 'skills'); + + async function writeClientFile(rel, body = '{}') { + const path = join(userHome, ...rel.split('/')); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, body, 'utf-8'); + return path; + } + + async function detectClients() { + await writeClientFile('.config/opencode/opencode.json'); + await writeClientFile('.hermes/config.yaml', 'mcp_servers: {}\n'); + await writeClientFile('.claude.json'); + await writeClientFile('.cursor/mcp.json'); + // DSH 的项目级检测信号在当前 cwd(全局 Skill 目录仍是 ~/.agents/skills)。 + await mkdir(join(cwd, '.dsh'), { recursive: true }); + await writeFile(join(cwd, '.dsh', 'cordis.yml'), '', 'utf-8'); + } + + function expectInstalledFiles(target) { + expect(existsSync(join(target, 'mebular-memory', 'SKILL.md'))).toBe(true); + expect(existsSync(join(target, 'mebular-memory', 'MEMORY_POLICY.md'))).toBe(true); + expect(existsSync(join(target, 'mebular-memory', 'mcp', 'opencode.json'))).toBe(true); + expect(existsSync(join(target, 'mebular-memory', SKILL_MANIFEST_FILE))).toBe(true); + expect(existsSync(join(target, 'mebular-tasks', 'SKILL.md'))).toBe(true); + } + + function runGlobalInstall(homeDir, projectDir) { + const env = { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + NODE_NO_WARNINGS: '1', + }; + delete env.MEBULAR_SKILL_INSTALL_FALLBACK; + return spawnSync(process.execPath, [INSTALL_SCRIPT, '--global'], { + cwd: projectDir, + encoding: 'utf-8', + env, + }); + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'skill-targets-')); + home = join(root, '.mebular'); + userHome = join(root, 'user'); + cwd = join(root, 'project'); + await mkdir(home, { recursive: true }); + await mkdir(userHome, { recursive: true }); + await mkdir(cwd, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('ensureSkills:5 类文件夹式 Agent 各写自己的全局目录(OpenCode 共享目录只装一次)', async () => { + await detectClients(); + const out = await ensureSkills(opts()); + expect(out.ok).toBe(true); + const clients = byId(out.clients); + + expect(clients.openchamber).toMatchObject({ + detected: true, status: 'installed', targetDir: globalOpencodeSkills(), + }); + expect(clients.hermes).toMatchObject({ + detected: true, status: 'installed', targetDir: globalHermesSkills(), + }); + expect(clients['claude-code']).toMatchObject({ + detected: true, status: 'installed', targetDir: globalClaudeSkills(), + }); + expect(clients.cursor).toMatchObject({ + detected: true, status: 'installed', targetDir: globalCursorSkills(), + }); + expect(clients.dsh).toMatchObject({ + detected: true, status: 'installed', targetDir: globalDshSkills(), + }); + + for (const target of [ + globalOpencodeSkills(), + globalHermesSkills(), + globalClaudeSkills(), + globalCursorSkills(), + globalDshSkills(), + ]) { + expectInstalledFiles(target); + } + + // OpenChamber / OpenCode 共享同一个全局目录:只由前者安装一次。 + expect(clients.opencode).toMatchObject({ + detected: true, status: 'skipped', targetDir: globalOpencodeSkills(), + }); + expect(clients.opencode.reason).toMatch(/共享/); + expect(out.summary.installed).toBe(5); + + // DSH 自动路径必须写 ~/.agents/skills,不再写 App cwd 的 .dsh/skills。 + expect(existsSync(join(cwd, '.dsh', 'skills'))).toBe(false); + + // Claude Desktop / Generic 没有文件夹式自动目录。 + expect(clients['claude-desktop'].status).toBe('skipped'); + expect(clients['claude-desktop'].reason).toMatch(/没有已知的 Skill 目录/); + expect(clients.generic.status).toBe('skipped'); + expect(clients.generic.reason).toMatch(/没有已知的 Skill 目录/); + }); + + it('descriptor 目录矩阵:全局优先;Claude Desktop / Generic 不伪造 skillsDirs', () => { + const list = byId(builtinAssistants(opts())); + + expect(list.openchamber.skillsDirs).toEqual([ + globalOpencodeSkills(), + join(cwd, '.opencode', 'skills'), + ]); + expect(list.opencode.skillsDirs).toEqual([ + globalOpencodeSkills(), + join(cwd, '.opencode', 'skills'), + ]); + expect(list.hermes.skillsDirs).toEqual([globalHermesSkills()]); + expect(list['claude-code'].skillsDirs).toEqual([ + globalClaudeSkills(), + join(cwd, '.claude', 'skills'), + ]); + expect(list.cursor.skillsDirs).toEqual([ + globalCursorSkills(), + join(cwd, '.cursor', 'skills'), + ]); + expect(list.dsh.skillsDirs).toEqual([ + globalDshSkills(), + join(cwd, '.dsh', 'skills'), + ]); + + // 不再把 ~/.agents 当成 Hermes / Claude Code / OpenCode 的 Skill 目录。 + for (const id of ['openchamber', 'opencode', 'hermes', 'claude-code']) { + expect(list[id].skillsDirs).not.toContain(join(userHome, '.agents', 'skills')); + } + + expect(list['claude-desktop'].skillsDirs).toEqual([]); + expect(list.generic.skillsDirs).toEqual([]); + }); + + it('installSkills:显式客户端使用第一个(全局)目录,DSH 不写 cwd', async () => { + const dsh = await installSkills('dsh', opts()); + expect(dsh.ok).toBe(true); + expect(dsh.targetDir).toBe(globalDshSkills()); + expectInstalledFiles(globalDshSkills()); + expect(existsSync(join(cwd, '.dsh', 'skills'))).toBe(false); + + const claude = await installSkills('claude-code', opts()); + expect(claude.ok).toBe(true); + expect(claude.targetDir).toBe(globalClaudeSkills()); + expectInstalledFiles(globalClaudeSkills()); + + const desktop = await installSkills('claude-desktop', opts()); + expect(desktop.ok).toBe(false); + expect(desktop.reason).toMatch(/没有已知的 Skill 目录/); + + const generic = await installSkills('generic', opts()); + expect(generic.ok).toBe(false); + expect(generic.reason).toMatch(/没有已知的 Skill 目录/); + }); + + it('connectClient 自动路径同样使用全局目录(Hermes / Claude Code / Cursor / DSH)', async () => { + const mcpUrl = 'http://127.0.0.1:7331/mcp'; + const hermesRun = (cmd) => (cmd.startsWith('hermes config get') + ? { ok: true, code: 0, stdout: 'mebular:\n url: http://127.0.0.1:7331/mcp\n', stderr: '' } + : { ok: true, code: 0, stdout: '', stderr: '' }); + + await writeClientFile('.hermes/config.yaml', 'mcp_servers: {}\n'); + await writeClientFile('.claude.json'); + await writeClientFile('.cursor/mcp.json'); + await mkdir(join(cwd, '.dsh'), { recursive: true }); + await writeFile(join(cwd, '.dsh', 'cordis.yml'), '', 'utf-8'); + + const hermes = await connectClient('hermes', opts({ run: hermesRun, mcpUrl })); + expect(hermes.ok).toBe(true); + expect(hermes.skills).toMatchObject({ ok: true, targetDir: globalHermesSkills() }); + expectInstalledFiles(globalHermesSkills()); + + const claude = await connectClient('claude-code', opts({ mcpUrl })); + expect(claude.ok).toBe(true); + expect(claude.skills).toMatchObject({ ok: true, targetDir: globalClaudeSkills() }); + expectInstalledFiles(globalClaudeSkills()); + + const cursor = await connectClient('cursor', opts({ mcpUrl })); + expect(cursor.ok).toBe(true); + expect(cursor.skills).toMatchObject({ ok: true, targetDir: globalCursorSkills() }); + expectInstalledFiles(globalCursorSkills()); + + const dsh = await connectClient('dsh', opts({ mcpUrl })); + expect(dsh.ok).toBe(true); + expect(dsh.skills).toMatchObject({ ok: true, targetDir: globalDshSkills() }); + expectInstalledFiles(globalDshSkills()); + expect(existsSync(join(cwd, '.dsh', 'skills'))).toBe(false); + }); + + it('repairClients:共享全局目录只 force 安装一次,DSH 写全局', async () => { + await detectClients(); + const out = await repairClients(opts({ mcpUrl: 'http://127.0.0.1:7331/mcp' })); + const clients = byId(out.clients); + + expect(clients.openchamber.skills).toMatchObject({ + ok: true, targetDir: globalOpencodeSkills(), + }); + expect(clients.opencode.skills).toMatchObject({ + ok: true, skipped: true, targetDir: globalOpencodeSkills(), + }); + expect(clients.opencode.skills.reason).toMatch(/共享/); + expect(clients.dsh.skills).toMatchObject({ + ok: true, targetDir: globalDshSkills(), + }); + expectInstalledFiles(globalOpencodeSkills()); + expectInstalledFiles(globalDshSkills()); + expect(existsSync(join(cwd, '.dsh', 'skills'))).toBe(false); + }); + + it('install.mjs --global:覆盖已检测的 5 类客户端;未检测的客户端不创建目录', async () => { + // 已检测:OpenCode/OpenChamber、Hermes、Claude Code、Cursor、DSH。 + await writeClientFile('.config/opencode/opencode.json'); + await writeClientFile('.hermes/config.yaml', 'mcp_servers: {}\n'); + await writeClientFile('.claude.json'); + await writeClientFile('.cursor/mcp.json'); + await mkdir(join(userHome, '.dsh'), { recursive: true }); + + const project = join(root, 'project-detected'); + await mkdir(project, { recursive: true }); + const detected = runGlobalInstall(userHome, project); + expect(detected.status).toBe(0); + + for (const target of [ + globalOpencodeSkills(), + globalHermesSkills(), + globalClaudeSkills(), + globalCursorSkills(), + globalDshSkills(), + ]) { + expectInstalledFiles(target); + } + + const installed = JSON.parse(detected.stdout.slice(detected.stdout.lastIndexOf('{'))).installed; + expect(installed.filter((dir) => dir === join(globalOpencodeSkills(), 'mebular-memory'))).toHaveLength(1); + + // 第二个 HOME 只检测 Hermes:其余全局目录不得被创建。 + const bareHome = join(root, 'bare-home'); + const bareProject = join(root, 'bare-project'); + await mkdir(join(bareHome, '.hermes'), { recursive: true }); + await writeFile(join(bareHome, '.hermes', 'config.yaml'), 'mcp_servers: {}\n', 'utf-8'); + await mkdir(bareProject, { recursive: true }); + const bare = runGlobalInstall(bareHome, bareProject); + expect(bare.status).toBe(0); + + expect(existsSync(join(bareHome, '.hermes', 'skills', 'mebular-memory', 'SKILL.md'))).toBe(true); + for (const rel of [ + ['.config', 'opencode', 'skills'], + ['.claude', 'skills'], + ['.cursor', 'skills'], + ['.agents', 'skills'], + ]) { + expect(existsSync(join(bareHome, ...rel))).toBe(false); + } + }); +});