Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions packages/mcp/src/clients.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 配置(接一次即可)',
},
Expand All @@ -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')] },
],
Expand All @@ -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',
},
Expand All @@ -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,
},
{
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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 目录' };
Expand Down
2 changes: 2 additions & 0 deletions packages/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**。
Expand Down
48 changes: 37 additions & 11 deletions packages/skill/scripts/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> # 只装到指定目录(测试/自定义用)
// node scripts/install.mjs --force # 覆盖用户改过的 Skill(默认保留)
//
Expand Down Expand Up @@ -44,24 +50,44 @@ 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);
} else {
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);
}
Expand Down Expand Up @@ -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 })
Expand Down
44 changes: 36 additions & 8 deletions scripts/verify-mcp-skill.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading