@yottameta/yotta-skills 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +5 -0
- package/README.zh-CN.md +5 -0
- package/SKILL.md +53 -3
- package/bin/yotta-skills.js +40 -1
- package/lib/skills-scan.js +318 -0
- package/package.json +3 -1
- package/scripts/yotta-skills-mcp.py +245 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## v0.2.1 (2026-08-31)
|
|
4
|
+
|
|
5
|
+
MCP 配置说明补全(按需加载口径)。
|
|
6
|
+
|
|
7
|
+
- SKILL.md「技能盘点」节补「MCP:按需加载(可选)」:明确本技能与 MCP 均为按需触发、不走常驻;mcpServers 配置 JSON 示例、按需写入步骤(用后可移除)、重启/重载提示、未加载降级 CLI 兜底;frontmatter description 同步按需加载口径。
|
|
8
|
+
- 测试:mcp-e2e serverInfo.version 断言改为动态读 package.json;python 探测加候选兜底(YOTTA_TEST_PYTHON / python / python3 / Scoop python38),Windows 无需手动设环境变量。
|
|
9
|
+
- 版本升至 0.2.1。
|
|
10
|
+
|
|
11
|
+
## v0.2.0 (2026-08-31)
|
|
12
|
+
|
|
13
|
+
技能生态盘点层(阶段 A):元阁从「编排策划 + 安装器」升级为「编排策划 + 安装器 + 技能盘点」。
|
|
14
|
+
|
|
15
|
+
- 新增 lib/skills-scan.js:技能扫描核心(零依赖 Node.js 18+)——frontmatter 解析(含 YAML block scalar)、多根目录扫描、同名技能去重合并来源、注册表增量合并(新增/更新/消失,幂等)、原子写 ~/.yottaskills/registry.json。
|
|
16
|
+
- 新增 CLI --inventory:盘点本机已装技能(文本表格 / --json 机器可读;--dir 追加目录;--project 附扫项目级目录),自包含输出不依赖任何元技能。
|
|
17
|
+
- 新增 MCP scripts/yotta-skills-mcp.py:stdio JSON-RPC(零依赖),list_installed_skills / describe_skill / reindex 三工具,按需加载。
|
|
18
|
+
- SKILL.md / README 中英同步「技能盘点」层与用法;frontmatter 触发语加「盘点技能 / 查看已装技能」。
|
|
19
|
+
- 测试:skills-scan 7 + cli-inventory 2 + mcp-e2e 2(共 23 用例全绿)。
|
|
20
|
+
- 版本升至 0.2.0。
|
|
21
|
+
|
|
3
22
|
## v0.1.3 (2026-08-31)
|
|
4
23
|
|
|
5
24
|
编排策划层升级:元阁从「一键安装器」升级为「总编排策划 + 一键安装器」两层。
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Installing the whole YottaMeta family used to mean running <code>npx</code> for
|
|
|
30
30
|
- **Update** — incremental update: add missing skills, upgrade version-skewed ones.
|
|
31
31
|
- **Idempotent** — a skill already at the manifest version is skipped; re-running is safe.
|
|
32
32
|
- **Pre-install summary** — if yotta-verify (元信) is available, each skill is scanned first; the verdict is informational only.
|
|
33
|
+
- **Inventory** — scan the skill directories on this machine and keep a local registry (<code>~/.yottaskills/registry.json</code>); self-contained, no other skills required. An optional <code>yotta-skills</code> MCP (on-demand, not resident) exposes <code>list_installed_skills</code> / <code>describe_skill</code> / <code>reindex</code>; see <code>SKILL.md</code> for the config.
|
|
33
34
|
|
|
34
35
|
Boundaries: it only downloads, places and summarizes — it does **not** develop skill content, does **not** bundle any skill body, and does **not** use <code>-g</code> global installs. It never writes outside the target you specify.
|
|
35
36
|
|
|
@@ -53,6 +54,9 @@ npx -y @yottameta/yotta-skills update --agent codex
|
|
|
53
54
|
|
|
54
55
|
# Preview what would be installed (no network, no changes)
|
|
55
56
|
npx -y @yottameta/yotta-skills --dry-run
|
|
57
|
+
|
|
58
|
+
# Inventory installed skills on this machine (self-contained scan, no other skills needed)
|
|
59
|
+
npx -y @yottameta/yotta-skills --inventory
|
|
56
60
|
```
|
|
57
61
|
|
|
58
62
|
Requirements: Node.js 18+, npm, and system <code>tar</code> (built into Windows 10+ / macOS / most Linux distributions).
|
|
@@ -66,6 +70,7 @@ Requirements: Node.js 18+, npm, and system <code>tar</code> (built into Windows
|
|
|
66
70
|
| `install --dir <path>` | Install everything into a directory; each skill lands in `<path>/<slug>` |
|
|
67
71
|
| `install <skill>... [--agent <name> \| --dir <path>]` | Install only the given skills |
|
|
68
72
|
| `update [--agent <name> \| --dir <path>]` | Incremental update: add missing, upgrade version-skewed |
|
|
73
|
+
| `--inventory` | Inventory installed skills: scan skill directories and update the local registry (self-contained); `--json` for JSON, `--project` adds project-level dirs |
|
|
69
74
|
| `--dry-run` | Preview the install / update list; no network, no changes |
|
|
70
75
|
| `--pin` | Lock the exact manifest versions (default: range, latest patch of the same major) |
|
|
71
76
|
| `--force` | Reinstall even when already at the latest version |
|
package/README.zh-CN.md
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
- **更新**——增量更新:补齐缺失技能、升级版本不一致的技能。
|
|
34
34
|
- **幂等**——已在清单版本的技能跳过;重复运行安全。
|
|
35
35
|
- **装前摘要**——若装了元信(yotta-verify),先对每个待装技能做一次装前扫描;verdict 仅提示、不拦截。
|
|
36
|
+
- **盘点**——扫描本机各智能体技能目录,维护本地注册表(<code>~/.yottaskills/registry.json</code>);自包含,不需要任何其他技能。可选 <code>yotta-skills</code> MCP(按需加载、不常驻)提供 <code>list_installed_skills</code> / <code>describe_skill</code> / <code>reindex</code> 三工具,配置见 <code>SKILL.md</code>。
|
|
36
37
|
|
|
37
38
|
边界:只做「下载 + 落位 + 汇总」——**不**开发技能内容、**不**内置任何技能本体、**不**用 <code>-g</code>
|
|
38
39
|
全局安装;只在你指定的目标内写文件。
|
|
@@ -57,6 +58,9 @@ npx -y @yottameta/yotta-skills update --agent codex
|
|
|
57
58
|
|
|
58
59
|
# 预览将安装清单(不联网、不改动)
|
|
59
60
|
npx -y @yottameta/yotta-skills --dry-run
|
|
61
|
+
|
|
62
|
+
# 盘点本机已装技能(自包含扫描,不依赖任何元技能)
|
|
63
|
+
npx -y @yottameta/yotta-skills --inventory
|
|
60
64
|
```
|
|
61
65
|
|
|
62
66
|
前置:Node.js 18+、npm、系统 tar(Windows 10+ / macOS / 多数 Linux 自带)。
|
|
@@ -70,6 +74,7 @@ npx -y @yottameta/yotta-skills --dry-run
|
|
|
70
74
|
| `install --dir <path>` | 装全家到指定目录,每个技能落在 `<path>/<slug>` |
|
|
71
75
|
| `install <skill>... [--agent <name> \| --dir <path>]` | 只装指定的一个或多个技能 |
|
|
72
76
|
| `update [--agent <name> \| --dir <path>]` | 增量更新:补齐缺失、升级版本不一致的技能 |
|
|
77
|
+
| `--inventory` | 盘点已装技能:扫描技能目录并更新本地注册表(自包含);`--json` 输出 JSON、`--project` 附扫项目级目录 |
|
|
73
78
|
| `--dry-run` | 预览将执行的安装 / 更新清单;不联网、不改动 |
|
|
74
79
|
| `--pin` | 锁死清单精确版本(默认 range:跟随同 major 最新 patch) |
|
|
75
80
|
| `--force` | 已是最新也重新安装 |
|
package/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-skills
|
|
3
|
-
version: 0.1
|
|
4
|
-
description: 元阁 —— 元阁全家技能的总编排策划 +
|
|
3
|
+
version: 0.2.1
|
|
4
|
+
description: 元阁 —— 元阁全家技能的总编排策划 + 一键安装器 + 技能盘点。策划层:按场景给出「该组合哪几个元技能、组合强在哪、怎么自动装+自动用」;安装层:一条命令把 YottaMeta 已发布的全部 yotta-* 技能装进指定智能体或目录;盘点层:--inventory 扫描本机已装技能生成注册表(自包含零依赖,不依赖任何元技能);MCP 按需加载(可选:list_installed_skills/describe_skill/reindex,不常驻,未加载降级 CLI)。支持 --list 清单 / install / update / --inventory / --dry-run 预览 / --pin 锁版本。触发:需要批量安装或更新元阁全家技能、按场景组合多个元技能、盘点或查看本机已装技能、给某个智能体或目录一次性铺齐 yotta-* 技能、预览安装清单、锁版本安装、或用户说 元阁/装全家/一次装齐/yotta-skills/install-all/更新全家/盘点技能/查看已装技能 等。边界(Do NOT trigger):只做「组合策划 + 清单 + 下载 + 落位 + 汇总 + 盘点」,不含技能本体、不做技能内容开发、不 -g 污染全局;装前摘要仅供参考,安装决策由用户确认。
|
|
5
5
|
license: MIT
|
|
6
6
|
metadata:
|
|
7
7
|
zh_name: 元阁
|
|
@@ -26,10 +26,11 @@ metadata:
|
|
|
26
26
|
|
|
27
27
|
# 元阁(yotta-skills)
|
|
28
28
|
|
|
29
|
-
**元阁全家的总编排策划 +
|
|
29
|
+
**元阁全家的总编排策划 + 一键安装 + 技能盘点**,一句话三层:
|
|
30
30
|
|
|
31
31
|
- **策划层(怎么用)**:接到需求,先按「编排策划」定位命中哪个组合——哪些元技能搭配起来最强、适合什么场景、AI 该怎么自动装并自动用起来。
|
|
32
32
|
- **安装层(怎么装)**:一条 `npx -y @yottameta/yotta-skills` 把组合/全家装进指定智能体或目录——`--list` 看清单、`install` 装、`update` 增量更新、`--dry-run` 预览、`--pin` 锁版本。
|
|
33
|
+
- **盘点层(已装了什么)**:`--inventory` 扫描本机各智能体技能目录,生成/更新本地注册表——自包含扫描,不依赖任何元技能。
|
|
33
34
|
|
|
34
35
|
每个技能仍走各自独立的 npm 包(版本源唯一);本包只做「清单 + 下载 + 落位 + 汇总」,**不内置任何技能本体**。
|
|
35
36
|
> **本包不含技能本体**:拿到它只代表有了「编排策划 + 安装工具」;真正装齐需运行 `install`。
|
|
@@ -96,6 +97,9 @@ npx -y @yottameta/yotta-skills update --agent codex
|
|
|
96
97
|
|
|
97
98
|
# 预览将安装清单(不联网、不改动)
|
|
98
99
|
npx -y @yottameta/yotta-skills --dry-run
|
|
100
|
+
|
|
101
|
+
# 盘点本机已装技能(自包含扫描,不依赖任何元技能)
|
|
102
|
+
npx -y @yottameta/yotta-skills --inventory
|
|
99
103
|
```
|
|
100
104
|
|
|
101
105
|
## 命令与选项
|
|
@@ -107,6 +111,7 @@ npx -y @yottameta/yotta-skills --dry-run
|
|
|
107
111
|
| `install --dir <path>` | 装全家到指定目录,每个技能落在 `<path>/<slug>` |
|
|
108
112
|
| `install <skill>... [--agent <name> \| --dir <path>]` | 只装指定的一个或多个技能 |
|
|
109
113
|
| `update [--agent <name> \| --dir <path>]` | 增量更新:补齐缺失技能、升级版本不一致的技能 |
|
|
114
|
+
| `--inventory` | 盘点本机已装技能:扫描技能目录生成/更新注册表(自包含,不依赖元技能);`--json` 输出 JSON、`--project` 附扫项目级目录 |
|
|
110
115
|
| `--dry-run` | 预览将执行的安装 / 更新清单;不联网、不改动 |
|
|
111
116
|
| `--pin` | 锁死清单精确版本(默认 range:跟随同 major 最新 patch) |
|
|
112
117
|
| `--force` | 已是最新也重新安装 |
|
|
@@ -118,6 +123,51 @@ npx -y @yottameta/yotta-skills --dry-run
|
|
|
118
123
|
|
|
119
124
|
不带命令直接给技能名时,等价于 `install <skill>`。
|
|
120
125
|
|
|
126
|
+
## 技能盘点(--inventory)
|
|
127
|
+
|
|
128
|
+
元阁自带技能扫描核心(零依赖,不依赖任何元技能):扫描各智能体技能目录,解析
|
|
129
|
+
`SKILL.md` frontmatter,生成/更新本地注册表 `~/.yottaskills/registry.json`,数据不出本机。
|
|
130
|
+
适合「装了哪些技能、各干嘛、从哪来」的快速盘点。
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# 盘点本机已装技能(文本表格)
|
|
134
|
+
npx -y @yottameta/yotta-skills --inventory
|
|
135
|
+
|
|
136
|
+
# 机器可读(JSON)
|
|
137
|
+
npx -y @yottameta/yotta-skills --inventory --json
|
|
138
|
+
|
|
139
|
+
# 追加扫描任意目录 / 当前项目级目录
|
|
140
|
+
npx -y @yottameta/yotta-skills --inventory --dir ~/my-skills --project
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
注册表增量合并:新增(added)/ 更新(updated)/ 消失(gone)随输出列出;同名技能来自多个目录时合并来源,版本不一致记录冲突。
|
|
144
|
+
|
|
145
|
+
### MCP:按需加载(可选)
|
|
146
|
+
|
|
147
|
+
本技能自带一个 MCP server:`yotta-skills`(`scripts/yotta-skills-mcp.py`,零依赖、数据不出本机),
|
|
148
|
+
提供 `list_installed_skills`(盘点)/ `describe_skill`(单技能详情)/ `reindex`(强制重扫)三个工具。
|
|
149
|
+
|
|
150
|
+
**按需加载,不走常驻**:本技能与 MCP 均为按需触发。默认以 CLI 为主
|
|
151
|
+
(`npx -y @yottameta/yotta-skills --inventory`);需要让 AI 通过工具直接调用时,再按下面配置启用:
|
|
152
|
+
|
|
153
|
+
1. **检查当前客户端的 `mcpServers`** 是否已有 `yotta-skills` 条目;已有 → 直接使用。
|
|
154
|
+
2. **没有 → 按需写入**下面这一条 server 配置(用后可移除,不要求常驻):
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"mcpServers": {
|
|
158
|
+
"yotta-skills": {
|
|
159
|
+
"command": "python",
|
|
160
|
+
"args": ["<技能目录>/scripts/yotta-skills-mcp.py"]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
> `<技能目录>` = 本技能实际安装目录,**不要写死盘符路径**;Windows 用 `python`,Linux/macOS 用 `python3`。
|
|
166
|
+
3. **提醒用户**:改 `mcpServers` 后多数客户端需**重启 / 重载一次** MCP server 才生效;加载后应看到
|
|
167
|
+
`list_installed_skills` / `describe_skill` / `reindex` 三个工具。
|
|
168
|
+
4. **降级兜底(重要)**:若客户端未暴露 MCP 工具 / 无法改配置 / server 未加载,**自动降级 CLI**
|
|
169
|
+
(同一套扫描核心、结果一致):`npx -y @yottameta/yotta-skills --inventory ...`。
|
|
170
|
+
|
|
121
171
|
## 版本策略
|
|
122
172
|
|
|
123
173
|
- 默认 `range`:按清单 `major.x` 取 npm 最新 patch(如 `0.x`),维护性更新随最新;
|
package/bin/yotta-skills.js
CHANGED
|
@@ -22,7 +22,7 @@ const os = require('os');
|
|
|
22
22
|
const { spawnSync } = require('child_process');
|
|
23
23
|
|
|
24
24
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
25
|
-
let VERSION = '0.1
|
|
25
|
+
let VERSION = '0.2.1';
|
|
26
26
|
try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
|
|
27
27
|
|
|
28
28
|
function loadManifest() {
|
|
@@ -131,6 +131,7 @@ function parseArgs(argv) {
|
|
|
131
131
|
list: false, dryRun: false, pin: false, skipScan: false, force: false,
|
|
132
132
|
help: false, version: false, agent: null, dir: null, npm: null,
|
|
133
133
|
python: null, verify: null, command: null, skill: null, rest: [],
|
|
134
|
+
inventory: false, rescan: false, json: false, project: false,
|
|
134
135
|
};
|
|
135
136
|
const positionals = [];
|
|
136
137
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -143,6 +144,10 @@ function parseArgs(argv) {
|
|
|
143
144
|
else if (a === '--force') opts.force = true;
|
|
144
145
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
145
146
|
else if (a === '--version' || a === '-v') opts.version = true;
|
|
147
|
+
else if (a === '--inventory' || a === '--inv') opts.inventory = true;
|
|
148
|
+
else if (a === '--rescan') opts.rescan = true;
|
|
149
|
+
else if (a === '--json') opts.json = true;
|
|
150
|
+
else if (a === '--project') opts.project = true;
|
|
146
151
|
else if (a === '--agent') opts.agent = take('--agent').toLowerCase();
|
|
147
152
|
else if (a === '--dir') opts.dir = take('--dir');
|
|
148
153
|
else if (a === '--npm') opts.npm = take('--npm');
|
|
@@ -431,6 +436,7 @@ function printHelp() {
|
|
|
431
436
|
out(' yotta-skills install <skill> --dir <path> 装单个技能(可多个)');
|
|
432
437
|
out(' yotta-skills update --agent <name> 增量更新已装技能(补齐缺失 / 版本不一致)');
|
|
433
438
|
out(' yotta-skills --dry-run 预览将安装清单(不联网、不改动)');
|
|
439
|
+
out(' yotta-skills --inventory 盘点本机已装技能(自研扫描,不依赖任何元技能)');
|
|
434
440
|
out('');
|
|
435
441
|
out('选项:');
|
|
436
442
|
out(' --agent <name> 智能体键名(--list 可查看;未知智能体请用 --dir)');
|
|
@@ -441,6 +447,8 @@ function printHelp() {
|
|
|
441
447
|
out(' --npm <path> 指定 npm 可执行文件(默认 npm / npm.cmd)');
|
|
442
448
|
out(' --python <path> 指定 python 可执行文件(元信 scan 用)');
|
|
443
449
|
out(' --verify <path> 指定 yotta_verify.py 路径(默认找目标目录已装的元信)');
|
|
450
|
+
out(' --json inventory 时输出 JSON');
|
|
451
|
+
out(' --project inventory 时附加扫描当前项目 .agents/skills / .codex/skills');
|
|
444
452
|
out(' -h, --help 帮助');
|
|
445
453
|
out(' -v, --version 版本');
|
|
446
454
|
out('');
|
|
@@ -448,12 +456,43 @@ function printHelp() {
|
|
|
448
456
|
out('依赖: Node.js 18+ / npm / 系统 tar;环境变量 YOTTA_SKILLS_NPM / YOTTA_SKILLS_PYTHON / YOTTA_SKILLS_VERIFY / YOTTA_SKILLS_NPM_FLAGS 可覆盖。');
|
|
449
457
|
}
|
|
450
458
|
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
// ── 技能盘点(--inventory,自研零依赖扫描) ────────────────────────────────
|
|
462
|
+
function runInventory(opts) {
|
|
463
|
+
const scan = require('../lib/skills-scan');
|
|
464
|
+
const extraDirs = opts.dir ? [opts.dir] : [];
|
|
465
|
+
const roots = scan.defaultRoots({ extraDirs: extraDirs, project: opts.project });
|
|
466
|
+
const result = scan.scanRoots(roots);
|
|
467
|
+
const prev = scan.readRegistry();
|
|
468
|
+
const { registry, changes } = scan.mergeRegistry(result, prev);
|
|
469
|
+
scan.saveRegistry(registry);
|
|
470
|
+
if (opts.json) {
|
|
471
|
+
out(JSON.stringify({
|
|
472
|
+
generated_at: registry.updated,
|
|
473
|
+
note: registry.note,
|
|
474
|
+
scanned: result.scanned,
|
|
475
|
+
errors: result.errors,
|
|
476
|
+
changes: changes,
|
|
477
|
+
skills: Object.values(registry.skills).sort((a, b) => a.slug.localeCompare(b.slug)),
|
|
478
|
+
}, null, 2));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
out(scan.formatInventory(registry));
|
|
482
|
+
out('');
|
|
483
|
+
out('本次变化: 新增 ' + changes.added.length + ' / 更新 ' + changes.updated.length + ' / 消失 ' + changes.gone.length);
|
|
484
|
+
if (result.errors.length) {
|
|
485
|
+
out('跳过不存在目录 ' + result.errors.length + ' 个: ' + result.errors.map((e) => e.dir).join('; '));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
451
489
|
// ── main ───────────────────────────────────────────────────────────────────
|
|
452
490
|
function main() {
|
|
453
491
|
const opts = parseArgs(process.argv.slice(2));
|
|
454
492
|
if (opts.help) { printHelp(); return; }
|
|
455
493
|
if (opts.version) { out('yotta-skills v' + VERSION); return; }
|
|
456
494
|
if (opts.list) { printList(opts); return; }
|
|
495
|
+
if (opts.inventory) { runInventory(opts); return; }
|
|
457
496
|
|
|
458
497
|
const command = opts.command || 'install';
|
|
459
498
|
let dest = resolveTargetDir(opts);
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* yotta-skills 技能扫描核心(零依赖 Node.js 18+)
|
|
4
|
+
*
|
|
5
|
+
* 技能生态盘点:扫描各智能体技能目录,解析 SKILL.md frontmatter,
|
|
6
|
+
* 建立本地技能注册表(~/.yottaskills/registry.json)。
|
|
7
|
+
*
|
|
8
|
+
* 自包含原则:本模块只依赖 Node.js 标准库,不依赖任何元技能
|
|
9
|
+
* (不调用元信扫描、不调用元呈渲染、不调用元忆存储);输出为
|
|
10
|
+
* 结构化 JSON / 简单文本,由调用方(CLI / MCP)自行呈现。
|
|
11
|
+
*
|
|
12
|
+
* 用法(由 bin/yotta-skills.js 与 scripts/yotta-skills-mcp.py 复用):
|
|
13
|
+
* const scan = require('../lib/skills-scan');
|
|
14
|
+
* const result = scan.scanRoots(scan.defaultRoots({ extraDirs, project }));
|
|
15
|
+
* scan.saveRegistry(result); // 增量合并写 ~/.yottaskills/registry.json
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const REGISTRY_DIR_NAME = '.yottaskills';
|
|
22
|
+
const REGISTRY_FILE_NAME = 'registry.json';
|
|
23
|
+
const SKILL_FILE = 'SKILL.md';
|
|
24
|
+
|
|
25
|
+
// 智能体 -> 用户级默认技能目录(与 bin/yotta-skills.js AGENT_DIRS 同源;
|
|
26
|
+
// 依据 agent-skill-dir-map 权威映射,2026-08-23 核实)
|
|
27
|
+
const AGENT_DIRS = {
|
|
28
|
+
claude: { label: 'Claude Code', dirs: ['.claude/skills'] },
|
|
29
|
+
cursor: { label: 'Cursor', dirs: ['.cursor/skills', '.agents/skills'] },
|
|
30
|
+
codex: { label: 'Codex', dirs: [] }, // 特判:$CODEX_HOME/skills
|
|
31
|
+
gemini: { label: 'Gemini CLI', dirs: ['.gemini/skills', '.agents/skills'] },
|
|
32
|
+
goose: { label: 'Goose', dirs: ['.config/goose/skills', '.agents/skills'] },
|
|
33
|
+
amp: { label: 'Amp', dirs: ['.config/agents/skills', '.agents/skills'] },
|
|
34
|
+
opencode: { label: 'OpenCode', dirs: [] }, // 特判:$XDG_CONFIG_HOME
|
|
35
|
+
windsurf: { label: 'Windsurf', dirs: ['.codeium/windsurf/skills'] },
|
|
36
|
+
workbuddy: { label: 'WorkBuddy', dirs: ['.workbuddy/skills'] },
|
|
37
|
+
kiro: { label: 'Kiro', dirs: ['.kiro/skills'] },
|
|
38
|
+
trae: { label: 'Trae Code CLI', dirs: ['.traecli/skills'] },
|
|
39
|
+
'trae-cn': { label: 'Trae IDE(国内)', dirs: ['.trae-cn/skills'] },
|
|
40
|
+
qwen: { label: 'Qwen Code', dirs: ['.qwen/skills'] },
|
|
41
|
+
comate: { label: 'Comate 文心快码', dirs: ['.comate/skills'] },
|
|
42
|
+
codebuddy: { label: 'CodeBuddy Code', dirs: ['.codebuddy/skills'] },
|
|
43
|
+
kimi: { label: 'Kimi Code CLI', dirs: ['.kimi/skills'] },
|
|
44
|
+
agents: { label: '通用 AGENTS.md', dirs: ['.agents/skills'] },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** 解析 SKILL.md 前部 YAML frontmatter(name/version/description 单行值)。 */
|
|
48
|
+
function parseFrontmatter(text) {
|
|
49
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
50
|
+
if (!m) return null;
|
|
51
|
+
const fm = {};
|
|
52
|
+
const lines = m[1].split(/\r?\n/);
|
|
53
|
+
let blockKey = null;
|
|
54
|
+
let blockChunks = [];
|
|
55
|
+
for (const raw of lines) {
|
|
56
|
+
if (blockKey) {
|
|
57
|
+
if (!raw.trim()) continue; // 空行:跳过并继续 block
|
|
58
|
+
if (/^\s/.test(raw)) { blockChunks.push(raw.trim()); continue; }
|
|
59
|
+
fm[blockKey] = blockChunks.join(' ');
|
|
60
|
+
blockKey = null;
|
|
61
|
+
blockChunks = [];
|
|
62
|
+
}
|
|
63
|
+
const t = raw.trim();
|
|
64
|
+
if (!t || t.startsWith('#') || t.startsWith('-')) continue;
|
|
65
|
+
const idx = t.indexOf(':');
|
|
66
|
+
if (idx <= 0) continue;
|
|
67
|
+
const key = t.slice(0, idx).trim();
|
|
68
|
+
let val = t.slice(idx + 1).trim();
|
|
69
|
+
if (!val) continue;
|
|
70
|
+
if (/^[>|][-+]?$/.test(val)) {
|
|
71
|
+
blockKey = key; // YAML block scalar(多行 description)
|
|
72
|
+
blockChunks = [];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
76
|
+
val = val.slice(1, -1);
|
|
77
|
+
}
|
|
78
|
+
fm[key] = val;
|
|
79
|
+
}
|
|
80
|
+
if (blockKey) fm[blockKey] = blockChunks.join(' ');
|
|
81
|
+
return fm;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 扫描单个技能目录:子目录含 SKILL.md 且 frontmatter 有 name 即视为技能。 */
|
|
85
|
+
function scanSkillDir(dir) {
|
|
86
|
+
const results = [];
|
|
87
|
+
let entries;
|
|
88
|
+
try {
|
|
89
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
90
|
+
} catch (_) {
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
if (!e.isDirectory()) continue;
|
|
95
|
+
const skillDir = path.join(dir, e.name);
|
|
96
|
+
let text;
|
|
97
|
+
try {
|
|
98
|
+
text = fs.readFileSync(path.join(skillDir, SKILL_FILE), 'utf8');
|
|
99
|
+
} catch (_) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const fm = parseFrontmatter(text);
|
|
103
|
+
if (!fm || !fm.name) continue;
|
|
104
|
+
results.push({
|
|
105
|
+
slug: String(fm.name).trim(),
|
|
106
|
+
version: fm.version ? String(fm.version).trim() : '',
|
|
107
|
+
description: fm.description ? String(fm.description).trim() : '',
|
|
108
|
+
source_dir: skillDir,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Codex 用户级技能目录:$CODEX_HOME/skills(默认 ~/.codex/skills)。 */
|
|
115
|
+
function codexUserDir() {
|
|
116
|
+
const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
117
|
+
return path.join(base, 'skills');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** OpenCode 用户级技能目录:$XDG_CONFIG_HOME/opencode/skills。 */
|
|
121
|
+
function opencodeUserDir() {
|
|
122
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
123
|
+
return path.join(base, 'opencode', 'skills');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveUserDir(rel) {
|
|
127
|
+
if (rel === '.codex/skills') return codexUserDir();
|
|
128
|
+
if (rel === '.config/opencode/skills') return opencodeUserDir();
|
|
129
|
+
return path.join(os.homedir(), rel);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 默认扫描根(agent-skill-dir-map 用户级目录)。
|
|
134
|
+
* opts: { extraDirs: string[], project: boolean }
|
|
135
|
+
* extraDirs 为任意额外技能目录;project 开启时附加当前项目
|
|
136
|
+
* .agents/skills / .codex/skills。
|
|
137
|
+
*/
|
|
138
|
+
function defaultRoots(opts) {
|
|
139
|
+
opts = opts || {};
|
|
140
|
+
const roots = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const add = (dir, label) => {
|
|
143
|
+
if (!dir) return;
|
|
144
|
+
const norm = path.resolve(dir);
|
|
145
|
+
if (seen.has(norm)) return;
|
|
146
|
+
seen.add(norm);
|
|
147
|
+
roots.push({ dir: norm, label });
|
|
148
|
+
};
|
|
149
|
+
const home = os.homedir();
|
|
150
|
+
for (const key of Object.keys(AGENT_DIRS)) {
|
|
151
|
+
const info = AGENT_DIRS[key];
|
|
152
|
+
for (const rel of info.dirs) add(resolveUserDir(rel), info.label);
|
|
153
|
+
}
|
|
154
|
+
add(codexUserDir(), 'Codex');
|
|
155
|
+
add(opencodeUserDir(), 'OpenCode');
|
|
156
|
+
if (Array.isArray(opts.extraDirs)) {
|
|
157
|
+
for (const d of opts.extraDirs) add(d, '指定目录');
|
|
158
|
+
}
|
|
159
|
+
if (opts.project) {
|
|
160
|
+
add(path.join(process.cwd(), '.agents', 'skills'), '项目 .agents/skills');
|
|
161
|
+
add(path.join(process.cwd(), '.codex', 'skills'), '项目 .codex/skills');
|
|
162
|
+
}
|
|
163
|
+
return roots;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 扫描多根目录并去重合并。
|
|
168
|
+
* 同名技能(slug)合并:记录多个来源;版本不一致时记录 conflicts。
|
|
169
|
+
*/
|
|
170
|
+
function scanRoots(roots) {
|
|
171
|
+
const map = new Map();
|
|
172
|
+
const scanned = [];
|
|
173
|
+
const errors = [];
|
|
174
|
+
for (const r of roots) {
|
|
175
|
+
if (!fs.existsSync(r.dir)) {
|
|
176
|
+
errors.push({ dir: r.dir, label: r.label, reason: 'missing' });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
scanned.push({ dir: r.dir, label: r.label });
|
|
180
|
+
for (const s of scanSkillDir(r.dir)) {
|
|
181
|
+
const key = s.slug;
|
|
182
|
+
const item = map.get(key);
|
|
183
|
+
if (item) {
|
|
184
|
+
if (!item.sources.includes(r.label)) item.sources.push(r.label);
|
|
185
|
+
if (!item.source_dirs.includes(s.source_dir)) item.source_dirs.push(s.source_dir);
|
|
186
|
+
if (item.version && s.version && item.version !== s.version) {
|
|
187
|
+
item.conflicts = item.conflicts || [];
|
|
188
|
+
item.conflicts.push({ dir: s.source_dir, version: s.version });
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
s.sources = [r.label];
|
|
192
|
+
s.source_dirs = [s.source_dir];
|
|
193
|
+
map.set(key, s);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
generated_at: new Date().toISOString(),
|
|
199
|
+
skills: Array.from(map.values()).sort((a, b) => a.slug.localeCompare(b.slug)),
|
|
200
|
+
scanned,
|
|
201
|
+
errors,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 注册表文件路径:~/.yottaskills/registry.json。 */
|
|
206
|
+
function registryPath() {
|
|
207
|
+
return path.join(os.homedir(), REGISTRY_DIR_NAME, REGISTRY_FILE_NAME);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 读取注册表(不存在返回 null)。 */
|
|
211
|
+
function readRegistry(filePath) {
|
|
212
|
+
const p = filePath || registryPath();
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
215
|
+
} catch (_) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 增量合并:把扫描结果合并进注册表。
|
|
222
|
+
* 新增(added) / 更新(updated,版本或来源变化) / 消失(gone,上次有本次无)。
|
|
223
|
+
* 幂等:技能仍存在且版本来源一致则保持原记录不动。
|
|
224
|
+
*/
|
|
225
|
+
function mergeRegistry(scanResult, previous) {
|
|
226
|
+
const prev = (previous && previous.skills) ? previous.skills : {};
|
|
227
|
+
const next = {};
|
|
228
|
+
const changes = { added: [], updated: [], gone: [] };
|
|
229
|
+
const now = new Date().toISOString();
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
for (const s of scanResult.skills) {
|
|
232
|
+
seen.add(s.slug);
|
|
233
|
+
const old = prev[s.slug];
|
|
234
|
+
if (!old) {
|
|
235
|
+
next[s.slug] = {
|
|
236
|
+
slug: s.slug,
|
|
237
|
+
version: s.version,
|
|
238
|
+
description: s.description,
|
|
239
|
+
sources: s.sources,
|
|
240
|
+
source_dirs: s.source_dirs,
|
|
241
|
+
first_seen: now,
|
|
242
|
+
last_seen: now,
|
|
243
|
+
status: 'known',
|
|
244
|
+
};
|
|
245
|
+
changes.added.push(s.slug);
|
|
246
|
+
} else {
|
|
247
|
+
const changed = old.version !== s.version
|
|
248
|
+
|| old.description !== s.description
|
|
249
|
+
|| JSON.stringify(old.sources || []) !== JSON.stringify(s.sources);
|
|
250
|
+
next[s.slug] = {
|
|
251
|
+
...old,
|
|
252
|
+
version: s.version,
|
|
253
|
+
description: s.description,
|
|
254
|
+
sources: s.sources,
|
|
255
|
+
source_dirs: s.source_dirs,
|
|
256
|
+
last_seen: now,
|
|
257
|
+
};
|
|
258
|
+
if (changed) changes.updated.push(s.slug);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const key of Object.keys(prev)) {
|
|
262
|
+
if (!seen.has(key)) {
|
|
263
|
+
const was = prev[key];
|
|
264
|
+
next[key] = { ...was, last_seen: now, status: 'gone' };
|
|
265
|
+
if (was.status !== 'gone') changes.gone.push(key);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const registry = {
|
|
269
|
+
updated: now,
|
|
270
|
+
note: '本地技能注册表(yotta-skills --inventory 生成)。数据不出本机。',
|
|
271
|
+
skills: next,
|
|
272
|
+
};
|
|
273
|
+
return { registry, changes };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 原子写注册表(tmp + rename,防写一半损坏)。 */
|
|
277
|
+
function saveRegistry(registry, filePath) {
|
|
278
|
+
const p = filePath || registryPath();
|
|
279
|
+
const dir = path.dirname(p);
|
|
280
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
281
|
+
const tmp = p + '.tmp';
|
|
282
|
+
fs.writeFileSync(tmp, JSON.stringify(registry, null, 2) + '\n', 'utf8');
|
|
283
|
+
fs.renameSync(tmp, p);
|
|
284
|
+
return p;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 把注册表渲染为自包含文本表格(不依赖元呈)。 */
|
|
288
|
+
function formatInventory(registry, filePath) {
|
|
289
|
+
const skills = Object.values(registry.skills || {})
|
|
290
|
+
.filter((s) => s.status !== 'gone')
|
|
291
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
292
|
+
const lines = [];
|
|
293
|
+
lines.push('已装技能盘点(yotta-skills --inventory)');
|
|
294
|
+
lines.push('技能名/版本/功能/来源');
|
|
295
|
+
for (const s of skills) {
|
|
296
|
+
const ver = s.version ? 'v' + s.version : '-';
|
|
297
|
+
const src = (s.sources || []).join(',');
|
|
298
|
+
lines.push(`${s.slug} ${ver} ${s.description || ''} [${src}]`);
|
|
299
|
+
}
|
|
300
|
+
const rp = filePath || registryPath();
|
|
301
|
+
lines.push(`共 ${skills.length} 个技能;注册表:${rp}`);
|
|
302
|
+
return lines.join('\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
AGENT_DIRS,
|
|
307
|
+
parseFrontmatter,
|
|
308
|
+
scanSkillDir,
|
|
309
|
+
defaultRoots,
|
|
310
|
+
scanRoots,
|
|
311
|
+
registryPath,
|
|
312
|
+
readRegistry,
|
|
313
|
+
mergeRegistry,
|
|
314
|
+
saveRegistry,
|
|
315
|
+
formatInventory,
|
|
316
|
+
REGISTRY_DIR_NAME,
|
|
317
|
+
REGISTRY_FILE_NAME,
|
|
318
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yottameta/yotta-skills",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "YuanGe (元阁) — one-command installer for the whole YottaMeta skill family: list, install, and update all published yotta-* skills into any agent (npx one-shot, zero dependencies, Node.js only).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"references",
|
|
23
23
|
"assets",
|
|
24
24
|
"bin",
|
|
25
|
+
"lib",
|
|
26
|
+
"scripts",
|
|
25
27
|
"skills.json",
|
|
26
28
|
"!**/__pycache__",
|
|
27
29
|
"!**/*.pyc"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""yotta-skills-mcp.py — 元阁(yotta-skills)技能盘点 MCP server。
|
|
4
|
+
|
|
5
|
+
stdio MCP server(JSON-RPC 2.0,换行分隔),把元阁技能扫描核心暴露为 MCP 工具:
|
|
6
|
+
list_installed_skills 盘点本机已装技能(读本地注册表;未生成则先扫描一次)
|
|
7
|
+
describe_skill 查看单个技能详情(slug / 版本 / 功能 / 来源)
|
|
8
|
+
reindex 强制重新扫描并更新注册表
|
|
9
|
+
|
|
10
|
+
自包含原则:扫描由元阁自带核心(bin/yotta-skills.js --inventory)完成,
|
|
11
|
+
不依赖任何元技能;数据只写本机 ~/.yottaskills/registry.json,不出本机。
|
|
12
|
+
|
|
13
|
+
运行:python scripts/yotta-skills-mcp.py
|
|
14
|
+
MCP 客户端配置:
|
|
15
|
+
{"mcpServers":{"yotta-skills":{"command":"python",
|
|
16
|
+
"args":["<绝对路径>/scripts/yotta-skills-mcp.py"]}}}
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
VERSION = "0.2.1"
|
|
27
|
+
TOOL_NAME = "yotta-skills"
|
|
28
|
+
CN_NAME = "元阁"
|
|
29
|
+
MCP_PROTOCOL = "2025-03-26"
|
|
30
|
+
SERVERS = {TOOL_NAME: {"name": TOOL_NAME, "cn": CN_NAME, "version": VERSION}}
|
|
31
|
+
|
|
32
|
+
_HERE = Path(__file__).resolve().parent
|
|
33
|
+
BIN_JS = (_HERE.parent / "bin" / "yotta-skills.js").resolve()
|
|
34
|
+
REGISTRY_FILE = Path(os.path.expanduser("~/.yottaskills/registry.json"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tool_error(message, extra=None):
|
|
38
|
+
payload = {"error": message}
|
|
39
|
+
if extra:
|
|
40
|
+
payload.update(extra)
|
|
41
|
+
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}],
|
|
42
|
+
"isError": True}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tool_spec(name, description, properties, required=None):
|
|
46
|
+
return {
|
|
47
|
+
"name": name,
|
|
48
|
+
"description": description,
|
|
49
|
+
"inputSchema": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": properties,
|
|
52
|
+
**({"required": required} if required else {}),
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_cli(args):
|
|
58
|
+
"""运行元阁 CLI(Node),返回 stdout 文本;失败抛 RuntimeError。"""
|
|
59
|
+
node = shutil.which("node")
|
|
60
|
+
if not node:
|
|
61
|
+
raise RuntimeError("未找到 node 可执行文件(元阁 CLI 依赖 Node.js 18+)")
|
|
62
|
+
proc = subprocess.run(
|
|
63
|
+
[node, str(BIN_JS)] + args,
|
|
64
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
65
|
+
timeout=120,
|
|
66
|
+
)
|
|
67
|
+
if proc.returncode != 0:
|
|
68
|
+
raise RuntimeError("yotta-skills CLI 失败(exit %s):%s" % (proc.returncode, (proc.stderr or proc.stdout).strip()[:500]))
|
|
69
|
+
return proc.stdout
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_registry():
|
|
73
|
+
if not REGISTRY_FILE.is_file():
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
return json.loads(REGISTRY_FILE.read_text(encoding="utf-8"))
|
|
77
|
+
except Exception: # noqa: BLE001
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ensure_registry():
|
|
82
|
+
"""注册表不存在时先扫描生成一次;返回注册表 dict。"""
|
|
83
|
+
reg = _read_registry()
|
|
84
|
+
if reg is not None:
|
|
85
|
+
return reg
|
|
86
|
+
_run_cli(["--inventory"])
|
|
87
|
+
reg = _read_registry()
|
|
88
|
+
if reg is None:
|
|
89
|
+
raise RuntimeError("技能注册表生成失败(~/.yottaskills/registry.json)")
|
|
90
|
+
return reg
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _skills_list(registry):
|
|
94
|
+
return sorted(
|
|
95
|
+
[s for s in registry.get("skills", {}).values() if s.get("status") != "gone"],
|
|
96
|
+
key=lambda s: s.get("slug", ""),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _tool_list(arguments): # noqa: ARG001
|
|
101
|
+
registry = _ensure_registry()
|
|
102
|
+
skills = _skills_list(registry)
|
|
103
|
+
payload = {
|
|
104
|
+
"count": len(skills),
|
|
105
|
+
"registry": str(REGISTRY_FILE),
|
|
106
|
+
"skills": skills,
|
|
107
|
+
}
|
|
108
|
+
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}],
|
|
109
|
+
"isError": False}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _tool_describe(arguments):
|
|
113
|
+
slug = str(arguments.get("slug") or "").strip()
|
|
114
|
+
if not slug:
|
|
115
|
+
return _tool_error("describe_skill 需要 slug 参数")
|
|
116
|
+
registry = _ensure_registry()
|
|
117
|
+
skill = registry.get("skills", {}).get(slug)
|
|
118
|
+
if not skill or skill.get("status") == "gone":
|
|
119
|
+
return _tool_error("未找到技能: %s" % slug)
|
|
120
|
+
payload = {"skill": skill}
|
|
121
|
+
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}],
|
|
122
|
+
"isError": False}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _tool_reindex(arguments): # noqa: ARG001
|
|
126
|
+
stdout = _run_cli(["--inventory", "--json"])
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(stdout)
|
|
129
|
+
except json.JSONDecodeError as e:
|
|
130
|
+
return _tool_error("reindex 输出解析失败:%s" % e)
|
|
131
|
+
payload = {
|
|
132
|
+
"count": len(data.get("skills", [])),
|
|
133
|
+
"changes": data.get("changes"),
|
|
134
|
+
"errors": data.get("errors"),
|
|
135
|
+
"registry": str(REGISTRY_FILE),
|
|
136
|
+
}
|
|
137
|
+
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}],
|
|
138
|
+
"isError": False}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
TOOL_HANDLERS = {
|
|
142
|
+
"list_installed_skills": _tool_list,
|
|
143
|
+
"describe_skill": _tool_describe,
|
|
144
|
+
"reindex": _tool_reindex,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def mcp_tools():
|
|
149
|
+
return [
|
|
150
|
+
_tool_spec(
|
|
151
|
+
"list_installed_skills",
|
|
152
|
+
"盘点本机已装技能:返回技能列表(slug / 版本 / 功能一句话 / 来源)。"
|
|
153
|
+
"读本地注册表 ~/.yottaskills/registry.json;未生成则先扫描一次。"
|
|
154
|
+
"扫描由元阁自带核心完成,不依赖任何其他技能。数据不出本机。",
|
|
155
|
+
{},
|
|
156
|
+
[],
|
|
157
|
+
),
|
|
158
|
+
_tool_spec(
|
|
159
|
+
"describe_skill",
|
|
160
|
+
"查看单个技能的详情(slug / 版本 / 功能 / 来源目录)。",
|
|
161
|
+
{"slug": {"type": "string", "description": "技能 slug(如 yotta-memory)"}},
|
|
162
|
+
["slug"],
|
|
163
|
+
),
|
|
164
|
+
_tool_spec(
|
|
165
|
+
"reindex",
|
|
166
|
+
"强制重新扫描所有技能目录并更新本地注册表,返回本次变化(新增 / 更新 / 消失)。",
|
|
167
|
+
{},
|
|
168
|
+
[],
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def handle_message(msg):
|
|
174
|
+
"""处理一行 JSON-RPC 消息,返回响应 dict;通知返回 None。"""
|
|
175
|
+
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
|
|
176
|
+
rid = msg.get("id") if isinstance(msg, dict) else None
|
|
177
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}}
|
|
178
|
+
method = msg.get("method")
|
|
179
|
+
rid = msg.get("id")
|
|
180
|
+
if rid is None: # JSON-RPC 通知(无 id)不响应
|
|
181
|
+
return None
|
|
182
|
+
if method is None:
|
|
183
|
+
return None
|
|
184
|
+
params = msg.get("params") or {}
|
|
185
|
+
|
|
186
|
+
if method == "initialize":
|
|
187
|
+
return {
|
|
188
|
+
"jsonrpc": "2.0", "id": rid,
|
|
189
|
+
"result": {
|
|
190
|
+
"protocolVersion": MCP_PROTOCOL,
|
|
191
|
+
"capabilities": {"tools": {}},
|
|
192
|
+
"serverInfo": {"name": TOOL_NAME, "version": VERSION},
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
if method == "ping":
|
|
196
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {}}
|
|
197
|
+
if method == "tools/list":
|
|
198
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": mcp_tools()}}
|
|
199
|
+
if method == "tools/call":
|
|
200
|
+
name = params.get("name")
|
|
201
|
+
arguments = params.get("arguments") or {}
|
|
202
|
+
handler = TOOL_HANDLERS.get(name)
|
|
203
|
+
if not handler:
|
|
204
|
+
return {
|
|
205
|
+
"jsonrpc": "2.0", "id": rid,
|
|
206
|
+
"result": {"content": [{"type": "text", "text": "未知工具: %s" % name}], "isError": True},
|
|
207
|
+
}
|
|
208
|
+
try:
|
|
209
|
+
return {"jsonrpc": "2.0", "id": rid, "result": handler(arguments)}
|
|
210
|
+
except Exception as e: # noqa: BLE001
|
|
211
|
+
return {
|
|
212
|
+
"jsonrpc": "2.0", "id": rid,
|
|
213
|
+
"result": {"content": [{"type": "text", "text": "工具执行异常:%s" % e}], "isError": True},
|
|
214
|
+
}
|
|
215
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "Method not found: " + str(method)}}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main():
|
|
219
|
+
"""stdio 主循环:读行 -> JSON-RPC -> 响应行。"""
|
|
220
|
+
try:
|
|
221
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
222
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
223
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
224
|
+
except Exception: # noqa: BLE001
|
|
225
|
+
pass
|
|
226
|
+
for line in sys.stdin:
|
|
227
|
+
line = line.strip()
|
|
228
|
+
if not line:
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
msg = json.loads(line)
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
sys.stdout.write(json.dumps(
|
|
234
|
+
{"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}},
|
|
235
|
+
ensure_ascii=False) + "\n")
|
|
236
|
+
sys.stdout.flush()
|
|
237
|
+
continue
|
|
238
|
+
resp = handle_message(msg)
|
|
239
|
+
if resp is not None:
|
|
240
|
+
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
|
|
241
|
+
sys.stdout.flush()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
main()
|