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);
+ }
+ });
+});