moluoxixi-ai-rules 0.2.11 → 0.2.13
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/README-en.md +5 -5
- package/README.md +5 -5
- package/capabilities/README.md +53 -7
- package/dist/scripts/lib/capabilities.js +67 -32
- package/dist/scripts/lib/install.d.ts +1 -1
- package/dist/scripts/lib/install.js +64 -60
- package/dist/scripts/lib/mcp-catalog.d.ts +7 -5
- package/dist/scripts/lib/mcp-catalog.js +48 -3
- package/dist/scripts/lib/role-contract.d.ts +3 -0
- package/dist/scripts/lib/role-contract.js +95 -0
- package/dist/scripts/lib/roles.d.ts +4 -8
- package/dist/scripts/lib/roles.js +32 -39
- package/dist/scripts/lib/tool.js +3 -2
- package/dist/scripts/lib/types/capabilities.d.ts +8 -0
- package/dist/scripts/lib/types/manifest.d.ts +4 -12
- package/dist/scripts/lib/types/mcp.d.ts +13 -0
- package/dist/scripts/lib/types/mcp.js +1 -0
- package/dist/scripts/lib/types/roles.d.ts +29 -0
- package/dist/scripts/lib/types/roles.js +1 -0
- package/dist/scripts/lib/types/vendor-staging.d.ts +51 -0
- package/dist/scripts/lib/types/vendor-staging.js +1 -0
- package/dist/scripts/lib/vendor-staging.d.ts +2 -10
- package/dist/scripts/lib/vendor-staging.js +117 -71
- package/dist/scripts/lib/vendors.js +90 -84
- package/package.json +2 -1
- package/roles/development/README.md +7 -4
- package/roles/development/role.yaml +18 -16
- package/roles/general/README.md +5 -2
- package/roles/general/role.yaml +16 -14
- package/roles/matt/role.yaml +19 -17
- package/roles/moluoxixi/role.yaml +30 -28
- package/roles/trellis/role.yaml +24 -22
package/README-en.md
CHANGED
|
@@ -82,7 +82,7 @@ Use grilling to question this development plan and establish validation criteria
|
|
|
82
82
|
Use frontend-design to design this interface, then inspect it with Playwright.
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
For a new development role, declare `extends_roles: [development]`
|
|
85
|
+
For a new development role, use `schema_version: 2` and declare `extends_roles: [development]` with `provides.capabilities: []` in its `role.yaml`. Add shared capabilities to its own declaration. v2 supports multiple parents, deduplicates shared ancestors and capabilities, and rejects conflicting configurations.
|
|
86
86
|
|
|
87
87
|
## `moluoxixi`
|
|
88
88
|
|
|
@@ -381,17 +381,17 @@ Role source: [`roles/trellis`](roles/trellis).
|
|
|
381
381
|
|
|
382
382
|
## Shared distribution
|
|
383
383
|
|
|
384
|
-
Shared skills, MCP catalogs, and vendor declarations live together in `capabilities/<capability>/`, such as `capabilities/common/skills/`. Each capability is a data-only directory containing `capability.yaml`, skills, MCP catalogs, and documentation
|
|
384
|
+
Shared skills, MCP catalogs, and vendor declarations live together in `capabilities/<capability>/`, such as `capabilities/common/skills/`. Each capability is a data-only directory containing `capability.yaml`, skills, MCP catalogs, and documentation. v2 roles select shared capabilities in `provides.capabilities` and keep their own installation settings in `installation`. The runtime loader lives in `scripts/lib/capabilities.ts`, composes vendors in declaration order, merges compatible projections, deduplicates identical projections, and rejects source or target conflicts. See [capabilities/README.md](capabilities/README.md) for the layout and complete mapping.
|
|
385
385
|
|
|
386
|
-
`extends_roles` combines capabilities
|
|
386
|
+
v2 `extends_roles` combines shared capabilities from multiple parents before the child's own choices. Traversal follows declared parent order and visits each ancestor and capability once. Same-name skills and MCP servers deduplicate only when their sources and effective configurations agree; conflicts, missing parents, and cycles fail. Only `provides.capabilities` is inherited. Private assets, the role vendor, hosts, and CLI packages come from the selected role's `installation`. Staging is validated before setup and managed output is committed. External setup effects cannot be rolled back, and semantic overlap between differently named skills still requires human judgment. v1 and legacy modules retain single inheritance. See [role inheritance](capabilities/README.md#角色继承).
|
|
387
387
|
|
|
388
388
|
Host directories, aliases, skill projection switches, and MCP formats are declared in [hosts/hosts.yaml](hosts/hosts.yaml). `scripts/lib/hosts.ts` loads and validates the declaration and resolves paths; declaration types live in `scripts/lib/types/`. Adding a host only requires a YAML entry. See [hosts/README.md](hosts/README.md) for fields and maintenance guidance.
|
|
389
389
|
|
|
390
390
|
```text
|
|
391
391
|
role manifest
|
|
392
392
|
-> role inheritance and capability composition
|
|
393
|
-
-> pinned vendor checkout
|
|
394
|
-
->
|
|
393
|
+
-> pinned vendor checkout
|
|
394
|
+
-> staging validation / setup / commit
|
|
395
395
|
-> canonical ~/.agents/skills
|
|
396
396
|
-> host skill projection and MCP merge
|
|
397
397
|
```
|
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ general
|
|
|
82
82
|
请使用 frontend-design 设计这个界面,并用 Playwright 检查效果。
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
新增开发角色时,在其 `role.yaml`
|
|
85
|
+
新增开发角色时,在其 `role.yaml` 中使用 `schema_version: 2`,声明 `extends_roles: [development]` 和 `provides.capabilities: []`;附加共享能力可在自身声明中追加。v2 支持多个父角色,重复祖先和能力自动去重,配置冲突时直接报错。
|
|
86
86
|
|
|
87
87
|
## `moluoxixi`
|
|
88
88
|
|
|
@@ -388,9 +388,9 @@ Plan 阶段创建并确认 PRD,复杂任务补齐 design/implement 后 `task.p
|
|
|
388
388
|
|
|
389
389
|
## 公共分发机制
|
|
390
390
|
|
|
391
|
-
共享 skills、MCP 清单和供应商声明按能力集中在 `capabilities/<能力>/`,例如 `capabilities/common/skills/`。每个能力目录是纯数据目录,只包含 `capability.yaml`、skills、MCP 清单和文档;Role 在 `role.yaml` 中选择 capability
|
|
391
|
+
共享 skills、MCP 清单和供应商声明按能力集中在 `capabilities/<能力>/`,例如 `capabilities/common/skills/`。每个能力目录是纯数据目录,只包含 `capability.yaml`、skills、MCP 清单和文档;Role 在 v2 `role.yaml` 的 `provides.capabilities` 中选择 capability,把自身安装设置放在 `installation`。运行时加载器位于 `scripts/lib/capabilities.ts`,负责按声明顺序组合 vendor,合并兼容投影、去重完全相同的 projection,并拒绝来源或目标冲突。目录结构与完整映射见 [capabilities/README.md](capabilities/README.md)。
|
|
392
392
|
|
|
393
|
-
角色通过 `extends_roles`
|
|
393
|
+
v2 角色通过 `extends_roles` 组合多个父模板的共享能力,再加入自身能力。遍历遵循父角色声明顺序,重复祖先和 capability 只处理一次;同名 skill 或 MCP 只有来源和有效配置一致时才去重,冲突、缺失父角色与循环继承会报错。继承只读取 `provides.capabilities`,私有资产、role vendor、宿主和 CLI packages 使用所选角色的 `installation`。校验通过后才执行 setup 和提交受管资产;外部 setup 的环境变化以及不同名称 skill 的语义重复仍需人工判断。v1 与旧模块继续兼容单继承。规则见 [角色继承](capabilities/README.md#角色继承)。
|
|
394
394
|
|
|
395
395
|
宿主目录、别名、skills 投影开关和 MCP 格式统一声明在 [hosts/hosts.yaml](hosts/hosts.yaml)。`scripts/lib/hosts.ts` 负责加载、校验和路径解析,声明类型放在 `scripts/lib/types/`。新增宿主只需更新 YAML;字段与维护说明见 [hosts/README.md](hosts/README.md)。
|
|
396
396
|
|
|
@@ -399,8 +399,8 @@ Plan 阶段创建并确认 PRD,复杂任务补齐 design/implement 后 `task.p
|
|
|
399
399
|
```text
|
|
400
400
|
role manifest
|
|
401
401
|
-> 角色继承与 capabilities 合并
|
|
402
|
-
-> 固定 vendor checkout
|
|
403
|
-
-> vendor staging
|
|
402
|
+
-> 固定 vendor checkout
|
|
403
|
+
-> vendor staging 校验 / setup / 提交
|
|
404
404
|
-> canonical ~/.agents/skills
|
|
405
405
|
-> 宿主 skill 投影与 MCP 合并
|
|
406
406
|
```
|
package/capabilities/README.md
CHANGED
|
@@ -54,25 +54,63 @@ AIRules 自有共享 skills 实际存放在 `capabilities/<能力>/skills/`。
|
|
|
54
54
|
|
|
55
55
|
```yaml
|
|
56
56
|
# roles/<role>/role.yaml
|
|
57
|
+
schema_version: 2
|
|
58
|
+
role_id: example
|
|
57
59
|
extends_roles: [development]
|
|
58
|
-
|
|
60
|
+
provides:
|
|
61
|
+
capabilities: []
|
|
59
62
|
```
|
|
60
63
|
|
|
61
|
-
`roles/<role>/role.yaml`
|
|
64
|
+
`roles/<role>/role.yaml` 把共享能力放在 `provides`,把角色自身的安装设置放在 `installation`。角色自有的 `init-project`、hooks、agents、packages 等仍放在 `roles/<role>/`,由 `role-assets` 全量远程同步。旧包若仍带有 `constants/skills.ts`,安装器会在没有 `role.yaml` 时兼容读取它;v2 声明不会执行这些旧模块。
|
|
62
65
|
|
|
63
|
-
共享能力的上游仓库、固定 commit 和投影路径统一以 `capability.yaml` 的 `vendors` 为准;角色通过 `capabilities` 与 `extends_roles` 引用这些声明。
|
|
66
|
+
共享能力的上游仓库、固定 commit 和投影路径统一以 `capability.yaml` 的 `vendors` 为准;角色通过 `provides.capabilities` 与 `extends_roles` 引用这些声明。
|
|
64
67
|
|
|
65
68
|
## 角色继承
|
|
66
69
|
|
|
67
|
-
`
|
|
70
|
+
`schema_version: 2` 支持在 `extends_roles` 中声明多个父角色。角色可以作为纯能力模板,也可以独立安装。继承只读取 `provides.capabilities`;`installation` 中的设置始终属于当前选择的角色:
|
|
71
|
+
|
|
72
|
+
| 字段 | 用途 | 是否继承 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `provides.capabilities` | 引用共享 skills、MCP 与供应商 setup | 是 |
|
|
75
|
+
| `installation.role_vendor` | 当前角色的远程资产与专属 setup | 否 |
|
|
76
|
+
| `installation.assets` | 当前角色的私有资产路径 | 否 |
|
|
77
|
+
| `installation.hosts` | 宿主支持范围 | 否 |
|
|
78
|
+
| `installation.packages` | 角色 CLI 与 npm 安装声明 | 否 |
|
|
79
|
+
| `installation.distribution`、`installation.entrypoints` | 分发约定与初始化入口 | 否 |
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
schema_version: 2
|
|
83
|
+
role_id: example
|
|
84
|
+
extends_roles: [development, general]
|
|
85
|
+
provides:
|
|
86
|
+
capabilities: []
|
|
87
|
+
installation:
|
|
88
|
+
hosts: all
|
|
89
|
+
role_vendor:
|
|
90
|
+
name: example-role
|
|
91
|
+
source: https://github.com/moluoxixi/AIRules.git
|
|
92
|
+
projections:
|
|
93
|
+
- kind: role-assets
|
|
94
|
+
source_dir: roles/example
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
安装器按父角色声明顺序进行深度优先遍历,先处理祖先,再处理自身。上例中 `development` 已继承 `general`,最终顺序仍是 `general → development → example`;同一祖先与 capability 只处理一次。父角色顺序不会产生隐式覆盖。
|
|
68
98
|
|
|
69
99
|
`general` 提供记忆、skill 编写与文档整理;`development` 继承 `general`,集中维护 `coding`、`productivity` 与 `frontend`。具体开发角色统一继承 `development` 的基础 skills 与 MCP,再选择自己的工程工作流。`engineering` 由 Matt 角色单独选择。两个基础角色也可独立安装。
|
|
70
100
|
|
|
71
|
-
|
|
101
|
+
纯模板只需声明 `schema_version`、`role_id`、`extends_roles` 和 `provides.capabilities`。安装共享能力的具体角色必须提供自己的 `installation.role_vendor`,使第一方能力从该角色的 AIRules 远程 checkout 投影到 vendor;父模板的私有 skills、MCP、hooks、agents 和 packages 不会随继承安装。
|
|
72
102
|
|
|
73
|
-
|
|
103
|
+
重复与冲突按以下规则处理:
|
|
74
104
|
|
|
75
|
-
|
|
105
|
+
- 同一供应商必须使用相同仓库、revision 和有效 setup;兼容的投影可以合并。
|
|
106
|
+
- 同名 skill 只有供应商、来源、revision、源码路径、输出目标和有效 setup 全部相同时才去重。namespace 与显式 skill 选择的重叠也会校验;不同来源的同名 skill 直接报错。
|
|
107
|
+
- 同名 MCP 只有完整连接配置和有效 setup 相同时才去重。JSON 对象字段顺序不影响比较,`args`、`env`、URL、headers 或 setup 的差异都会报错。
|
|
108
|
+
- 相同 setup 命令组只执行一次;组内命令顺序与有意重复的命令保留。
|
|
109
|
+
- 缺失父角色、循环继承、重复填写父角色、单份声明中的重复 capability,以及供应商或文件目标的大小写冲突都会报错。冲突信息包含角色继承路径与能力来源。
|
|
110
|
+
|
|
111
|
+
安装器在执行 setup 前校验来源、内容和受管目标目录,再提交暂存结果。校验或 setup 失败时保留上一版受管资产;提交失败时尝试恢复旧内容,恢复失败会保留备份并报告错误。不同名称的 skills 仍可能在语义上重复,安装器无法判断这类冲突;外部 setup 命令造成的环境变化也不能自动回滚。
|
|
112
|
+
|
|
113
|
+
`schema_version: 1`、未声明版本的旧 YAML 和旧 `extendsRoles` 模块继续兼容单继承。迁移到 v2 时,将顶层 `capabilities` 移入 `provides`,将安装字段移入 `installation`。`grilling` skill 仍归属 `productivity`,没有独立的 grilling 分类。
|
|
76
114
|
|
|
77
115
|
## MCP 清单
|
|
78
116
|
|
|
@@ -100,3 +138,11 @@ capabilities: []
|
|
|
100
138
|
- 角色专属资产:放在 `roles/<role>/`。角色只选择能力,无需逐个复制公共供应商配置。
|
|
101
139
|
|
|
102
140
|
公共分发和能力测试放在 `scripts/lib/__test__/`;角色专属测试放在 `roles/<role>/__test__/`。
|
|
141
|
+
|
|
142
|
+
## 安装验证
|
|
143
|
+
|
|
144
|
+
先执行 `npm run build`。`npm test` 中的 `role-install.e2e.test.mjs` 从空 AIRules home 开始,通过临时 `git://` 远端实际下载供应商,保留完整菱形继承声明,并执行真实 CLI 与 npm setup。用例覆盖重复 skills、MCP 和 setup 去重、父角色私有资产隔离、重复安装、冲突拒绝,以及 setup 失败后保留旧资产和宿主配置。宿主包含 Claude、Codex 和 OpenCode;用户目录、Git 配置与 npm prefix 均隔离。
|
|
145
|
+
|
|
146
|
+
`npm run verify:packed-airules` 把 npm tarball 安装到独立消费项目,再用打包后的 CLI 执行同一组安装用例。CI 在 Linux、Windows 和 macOS 上运行这些检查。
|
|
147
|
+
|
|
148
|
+
`npm run verify:remote-role-install` 从真实 GitHub 上游下载当前 AIRules commit 与 capability 固定的供应商 commit,安装 `common`、`productivity` 和 `frontend` 的菱形组合,比较 skills 与可视化资源内容,并校验三种宿主的 MCP 配置。该检查需要联网,由 Linux CI 执行;它验证安装与配置,不启动 MCP 服务或调用模型。
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { parseDocument } from 'yaml';
|
|
5
|
+
import { setupIdentity } from './mcp-catalog.js';
|
|
5
6
|
/**
|
|
6
7
|
* Capability content is data owned by each directory. This loader is the
|
|
7
8
|
* shared runtime that turns capability.yaml into the existing vendor model.
|
|
@@ -204,40 +205,70 @@ export function composeCapabilityDefinitions(selections, options) {
|
|
|
204
205
|
const seenCapabilities = new Set();
|
|
205
206
|
const roleVendor = cloneVendor(options.roleVendor);
|
|
206
207
|
const capabilityVendors = [];
|
|
208
|
+
const roleOrigin = options.roleOrigin ?? 'selected role installation';
|
|
209
|
+
const roleProjections = new Map();
|
|
207
210
|
for (const selection of selections) {
|
|
208
211
|
if (seenCapabilities.has(selection.name))
|
|
209
212
|
throw new Error(`Capability "${selection.name}" is declared more than once`);
|
|
210
213
|
seenCapabilities.add(selection.name);
|
|
211
|
-
|
|
214
|
+
const origin = selection.origin ?? options.capabilityOrigins?.[selection.name] ?? `capability ${selection.name}`;
|
|
215
|
+
for (const projection of selection.definition.roleProjections ?? []) {
|
|
212
216
|
appendProjection(roleVendor, projection);
|
|
217
|
+
const key = projectionKey(projection);
|
|
218
|
+
const origins = roleProjections.get(key) ?? [];
|
|
219
|
+
origins.push(origin);
|
|
220
|
+
roleProjections.set(key, origins);
|
|
221
|
+
}
|
|
213
222
|
for (const vendor of selection.definition.vendors ?? [])
|
|
214
|
-
capabilityVendors.push(cloneVendor(vendor));
|
|
223
|
+
capabilityVendors.push({ vendor: cloneVendor(vendor), origin });
|
|
215
224
|
}
|
|
225
|
+
const roleProjectionOrigins = roleVendor.projections.map((projection) => {
|
|
226
|
+
const origins = roleProjections.get(projectionKey(projection));
|
|
227
|
+
return origins?.length ? [...new Set(origins)] : [roleOrigin];
|
|
228
|
+
});
|
|
216
229
|
const sequence = options.roleVendorPosition === 'after'
|
|
217
|
-
? [...capabilityVendors, roleVendor]
|
|
218
|
-
: [roleVendor, ...capabilityVendors];
|
|
230
|
+
? [...capabilityVendors, { vendor: roleVendor, origin: roleOrigin, projectionOrigins: roleProjectionOrigins }]
|
|
231
|
+
: [{ vendor: roleVendor, origin: roleOrigin, projectionOrigins: roleProjectionOrigins }, ...capabilityVendors];
|
|
219
232
|
return mergeVendors(sequence);
|
|
220
233
|
}
|
|
221
234
|
function mergeVendors(sequence) {
|
|
222
235
|
const result = [];
|
|
223
236
|
const byName = new Map();
|
|
224
|
-
for (const
|
|
225
|
-
const
|
|
237
|
+
for (const entry of sequence) {
|
|
238
|
+
const candidate = entry.vendor;
|
|
239
|
+
const caseAlias = [...byName.keys()].find(name => name.toLowerCase() === candidate.name.toLowerCase() && name !== candidate.name);
|
|
240
|
+
if (caseAlias)
|
|
241
|
+
throw new Error(`Vendor names differ only by case: ${caseAlias} (${byName.get(caseAlias).origin}), ${candidate.name} (${entry.origin})`);
|
|
242
|
+
const existingEntry = byName.get(candidate.name);
|
|
243
|
+
const existing = existingEntry?.vendor;
|
|
226
244
|
if (!existing) {
|
|
227
245
|
const cloned = cloneVendor(candidate);
|
|
228
|
-
byName.set(cloned.name,
|
|
246
|
+
byName.set(cloned.name, {
|
|
247
|
+
vendor: cloned,
|
|
248
|
+
origin: entry.origin,
|
|
249
|
+
projectionOrigins: entry.projectionOrigins ?? cloned.projections.map(() => [entry.origin]),
|
|
250
|
+
});
|
|
229
251
|
result.push(cloned);
|
|
230
252
|
continue;
|
|
231
253
|
}
|
|
232
254
|
if (existing.source !== candidate.source
|
|
233
255
|
|| existing.revision !== candidate.revision
|
|
234
256
|
|| setupKey(existing.setup) !== setupKey(candidate.setup)) {
|
|
235
|
-
throw new Error(`Vendor "${candidate.name}" has conflicting source, revision, or setup definitions`);
|
|
257
|
+
throw new Error(`Vendor "${candidate.name}" has conflicting source, revision, or setup definitions between ${existingEntry?.origin} and ${entry.origin}`);
|
|
236
258
|
}
|
|
237
|
-
for (const projection of candidate.projections)
|
|
259
|
+
for (const [index, projection] of candidate.projections.entries()) {
|
|
260
|
+
const projectionOwner = existing.projections.findIndex(existingProjection => projectionKey(existingProjection) === projectionKey(projection));
|
|
261
|
+
if (projectionOwner >= 0) {
|
|
262
|
+
const origins = entry.projectionOrigins?.[index] ?? [entry.origin];
|
|
263
|
+
existingEntry.projectionOrigins[projectionOwner] = [...new Set([...existingEntry.projectionOrigins[projectionOwner], ...origins])];
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
238
266
|
appendProjection(existing, projection);
|
|
267
|
+
existingEntry.projectionOrigins.push(entry.projectionOrigins?.[index] ?? [entry.origin]);
|
|
268
|
+
}
|
|
269
|
+
existingEntry.origin = `${existingEntry.origin}, ${entry.origin}`;
|
|
239
270
|
}
|
|
240
|
-
validateProjectionTargets(
|
|
271
|
+
validateProjectionTargets([...byName.values()]);
|
|
241
272
|
return result;
|
|
242
273
|
}
|
|
243
274
|
function appendProjection(vendor, projection) {
|
|
@@ -248,32 +279,41 @@ function appendProjection(vendor, projection) {
|
|
|
248
279
|
}
|
|
249
280
|
function validateProjectionTargets(vendors) {
|
|
250
281
|
const owners = new Map();
|
|
251
|
-
for (const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
for (const target of
|
|
255
|
-
const owner = owners.get(target);
|
|
256
|
-
if (owner) {
|
|
257
|
-
throw new Error(`Projection target "${target}" conflicts between vendor "${owner.vendor}" and vendor "${vendor.name}"`);
|
|
282
|
+
for (const entry of vendors) {
|
|
283
|
+
const vendor = entry.vendor;
|
|
284
|
+
for (const [index, projection] of vendor.projections.entries()) {
|
|
285
|
+
for (const { target, identity } of projectionClaims(projection)) {
|
|
286
|
+
const owner = owners.get(target.toLowerCase());
|
|
287
|
+
if (owner && (owner.vendor !== vendor.name || owner.projection !== identity)) {
|
|
288
|
+
throw new Error(`Projection target "${target}" conflicts between vendor "${owner.vendor}" (${owner.origin}) and vendor "${vendor.name}" (${entry.projectionOrigins[index]?.join(', ') ?? entry.origin})`);
|
|
258
289
|
}
|
|
259
|
-
owners.set(target, { projection:
|
|
290
|
+
owners.set(target.toLowerCase(), { projection: identity, vendor: vendor.name, origin: entry.projectionOrigins[index]?.join(', ') ?? entry.origin });
|
|
260
291
|
}
|
|
261
292
|
}
|
|
262
293
|
}
|
|
263
294
|
}
|
|
264
|
-
function
|
|
295
|
+
function projectionClaims(projection) {
|
|
296
|
+
const identity = projectionKey(projection);
|
|
265
297
|
if (projection.kind === 'namespace')
|
|
266
|
-
return [`skill-namespace:${projection.output}
|
|
298
|
+
return [{ target: `skill-namespace:${leafName(projection.output)}`, identity }];
|
|
267
299
|
if (projection.kind === 'skills') {
|
|
268
300
|
return projection.skills.map((skill) => {
|
|
269
|
-
const
|
|
270
|
-
|
|
301
|
+
const source = typeof skill === 'string' ? skill : skill.name;
|
|
302
|
+
const name = leafName(typeof skill === 'string' ? skill : skill.output ?? skill.name);
|
|
303
|
+
return {
|
|
304
|
+
target: `skill:${name}`,
|
|
305
|
+
identity: JSON.stringify([
|
|
306
|
+
path.posix.join(projection.sourceBaseDir.replaceAll('\\', '/'), source.replaceAll('\\', '/')),
|
|
307
|
+
name,
|
|
308
|
+
setupKey(typeof skill === 'string' ? undefined : skill.setup),
|
|
309
|
+
]),
|
|
310
|
+
};
|
|
271
311
|
});
|
|
272
312
|
}
|
|
273
313
|
if (projection.kind === 'mcp')
|
|
274
|
-
return [`mcp:${projection.output}
|
|
314
|
+
return [{ target: `mcp:${projection.output}`, identity }];
|
|
275
315
|
if (projection.kind === 'role-assets')
|
|
276
|
-
return ['role-assets'];
|
|
316
|
+
return [{ target: 'role-assets', identity }];
|
|
277
317
|
return assertNever(projection);
|
|
278
318
|
}
|
|
279
319
|
function projectionKey(projection) {
|
|
@@ -300,16 +340,11 @@ function projectionKey(projection) {
|
|
|
300
340
|
}
|
|
301
341
|
function skillKey(skill) {
|
|
302
342
|
if (typeof skill === 'string')
|
|
303
|
-
return skill;
|
|
304
|
-
return [skill.name, skill.output, setupKey(skill.setup)];
|
|
343
|
+
return [skill, leafName(skill), setupKey(undefined)];
|
|
344
|
+
return [skill.name, leafName(skill.output ?? skill.name), setupKey(skill.setup)];
|
|
305
345
|
}
|
|
306
346
|
function setupKey(setup) {
|
|
307
|
-
return
|
|
308
|
-
command.command,
|
|
309
|
-
command.args ?? [],
|
|
310
|
-
command.windowsCommandShim,
|
|
311
|
-
command.skipIfCommandAvailable,
|
|
312
|
-
]));
|
|
347
|
+
return setupIdentity(setup);
|
|
313
348
|
}
|
|
314
349
|
function cloneVendor(vendor) {
|
|
315
350
|
return {
|
|
@@ -32,7 +32,7 @@ interface SyncFlattenedSkillsOptions {
|
|
|
32
32
|
}
|
|
33
33
|
export declare function syncFlattenedSkills(sourceDir: string, targetDir: string, moluoHome: string, options?: SyncFlattenedSkillsOptions): void;
|
|
34
34
|
/**
|
|
35
|
-
* 将第一方 skills 源目录投影到 vendor/skills
|
|
35
|
+
* 将第一方 skills 源目录投影到 vendor/skills;v2 仅投影所选角色并拒绝共享目标冲突。
|
|
36
36
|
* 该函数只清理曾经指向同一 source skills 根目录的过时链接,不会删除第三方 vendor 技能。
|
|
37
37
|
*/
|
|
38
38
|
export declare function syncFirstPartySkillsToVendor(sourceRoot: string, moluoHome: string, role?: string): Promise<void>;
|
|
@@ -5,9 +5,10 @@ import path from 'node:path';
|
|
|
5
5
|
import { areSamePaths, canonicalPath, canonicalPathKey, isPathInside } from './canonical-path.js';
|
|
6
6
|
import { findHostConfig, resolveGlobalAgentSkillsPath, resolveHostPaths } from './hosts.js';
|
|
7
7
|
import { buildLinkPlan } from './links.js';
|
|
8
|
-
import { loadMcpCatalog,
|
|
8
|
+
import { loadMcpCatalog, mergeMcpServer, readMcpServerFile, setupIdentity } from './mcp-catalog.js';
|
|
9
9
|
import { requireRoleName } from './role-assets.js';
|
|
10
|
-
import {
|
|
10
|
+
import { readRoleContract } from './role-contract.js';
|
|
11
|
+
import { DEFAULT_ROLE, resolveRoleInheritance } from './roles.js';
|
|
11
12
|
import { collectFlattenedSkillSources, discoverSkillDirectories, flattenedSkillName } from './skill-projection.js';
|
|
12
13
|
import { loadVendorManifest, rolePackageSetupCommands } from './vendors.js';
|
|
13
14
|
/** 获取 vendor 技能目录的绝对路径 */
|
|
@@ -82,18 +83,13 @@ function isSetupCommandAvailable(command) {
|
|
|
82
83
|
* @param manifest 已解析的 VendorManifest
|
|
83
84
|
*/
|
|
84
85
|
export function runSkillSetupCommands(manifest, homeDir) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
runSetupCommandGroup('role packages', rolePackageCommands);
|
|
89
|
-
}
|
|
86
|
+
// Build and validate the whole command plan before executing its first command.
|
|
87
|
+
const groups = [{ owner: 'role packages', commands: rolePackageSetupCommands(manifest.packages) }];
|
|
88
|
+
const mcpOwners = new Map();
|
|
90
89
|
for (const [vendorName, vendor] of Object.entries(manifest.vendors)) {
|
|
91
|
-
|
|
92
|
-
console.log(`\n[setup] 执行 ${vendorName} 的安装前置命令...`);
|
|
93
|
-
runSetupCommandGroup(vendorName, vendor.setup);
|
|
94
|
-
}
|
|
90
|
+
groups.push({ owner: vendorName, commands: vendor.setup ?? [] });
|
|
95
91
|
for (const link of vendor.links) {
|
|
96
|
-
const
|
|
92
|
+
const owner = `${vendorName}/${link.kind === 'mcp-file' ? 'mcp' : path.basename(link.target)}`;
|
|
97
93
|
if (link.kind === 'mcp-file') {
|
|
98
94
|
if (!homeDir) {
|
|
99
95
|
throw new Error(`[setup] ${vendorName} MCP setup requires the AIRules home directory`);
|
|
@@ -107,15 +103,34 @@ export function runSkillSetupCommands(manifest, homeDir) {
|
|
|
107
103
|
if (!stats?.isFile() || stats.isSymbolicLink() || !isPathInside(checkoutRoot, realpathSync(sourceFile))) {
|
|
108
104
|
throw new Error(`[setup] ${vendorName} MCP catalog must be a plain file inside its checkout: ${link.source}`);
|
|
109
105
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
const catalog = loadMcpCatalog(sourceFile);
|
|
107
|
+
const serverGroups = [];
|
|
108
|
+
for (const [name, server] of Object.entries(catalog.servers)) {
|
|
109
|
+
const setup = catalog.serverSetup[name] ?? [];
|
|
110
|
+
if (mergeMcpServer(mcpOwners, name, {
|
|
111
|
+
server,
|
|
112
|
+
setup: [...(vendor.setup ?? []), ...(link.setup ?? []), ...setup],
|
|
113
|
+
}, `${vendorName} (${sourceFile})`)) {
|
|
114
|
+
serverGroups.push({ owner: `${owner}:${name}`, commands: setup });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (serverGroups.length > 0) {
|
|
118
|
+
groups.push({ owner, commands: link.setup ?? [] }, ...serverGroups);
|
|
119
|
+
}
|
|
113
120
|
continue;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
runSetupCommandGroup(`${vendorName}/${assetName}`, setupCommands);
|
|
121
|
+
}
|
|
122
|
+
groups.push({ owner, commands: link.setup ?? [] });
|
|
117
123
|
}
|
|
118
124
|
}
|
|
125
|
+
const executed = new Set();
|
|
126
|
+
for (const { owner, commands } of groups) {
|
|
127
|
+
const identity = setupIdentity(commands);
|
|
128
|
+
if (commands.length === 0 || executed.has(identity))
|
|
129
|
+
continue;
|
|
130
|
+
executed.add(identity);
|
|
131
|
+
console.log(`\n[setup] 执行 ${owner} 的安装前置命令...`);
|
|
132
|
+
runSetupCommandGroup(owner, commands);
|
|
133
|
+
}
|
|
119
134
|
}
|
|
120
135
|
function runSetupCommandGroup(owner, commands) {
|
|
121
136
|
for (const command of commands) {
|
|
@@ -249,18 +264,19 @@ export function syncFlattenedSkills(sourceDir, targetDir, moluoHome, options = {
|
|
|
249
264
|
}
|
|
250
265
|
}
|
|
251
266
|
/**
|
|
252
|
-
* 将第一方 skills 源目录投影到 vendor/skills
|
|
267
|
+
* 将第一方 skills 源目录投影到 vendor/skills;v2 仅投影所选角色并拒绝共享目标冲突。
|
|
253
268
|
* 该函数只清理曾经指向同一 source skills 根目录的过时链接,不会删除第三方 vendor 技能。
|
|
254
269
|
*/
|
|
255
270
|
export async function syncFirstPartySkillsToVendor(sourceRoot, moluoHome, role = DEFAULT_ROLE) {
|
|
256
271
|
const legacySkillsDir = path.join(sourceRoot, 'skills');
|
|
257
272
|
const rolesRoot = path.join(sourceRoot, 'roles');
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
273
|
+
const lineage = existsSync(rolesRoot) ? await resolveRoleInheritance(sourceRoot, role) : [];
|
|
274
|
+
const isV2 = lineage.at(-1)?.contract?.schemaVersion === 2;
|
|
275
|
+
const selectedRoles = isV2 ? lineage.slice(-1) : lineage;
|
|
276
|
+
const sourceSkillRoots = selectedRoles
|
|
277
|
+
.map(entry => path.join(sourceRoot, 'roles', entry.role, entry.contract?.assets?.skills ?? 'skills'))
|
|
278
|
+
.filter(existsSync);
|
|
279
|
+
if (!isV2 && sourceSkillRoots.length === 0 && existsSync(legacySkillsDir)) {
|
|
264
280
|
sourceSkillRoots.push(legacySkillsDir);
|
|
265
281
|
}
|
|
266
282
|
if (sourceSkillRoots.length === 0) {
|
|
@@ -283,6 +299,13 @@ export async function syncFirstPartySkillsToVendor(sourceRoot, moluoHome, role =
|
|
|
283
299
|
}
|
|
284
300
|
}
|
|
285
301
|
const skillSources = [...seenSkillNames.values()].map(({ name, source }) => ({ name, source }));
|
|
302
|
+
if (isV2) {
|
|
303
|
+
for (const skill of skillSources) {
|
|
304
|
+
const target = path.join(vendorSkillsDir, skill.name);
|
|
305
|
+
if (existsSync(target) && !isSamePath(realpathSync(target), skill.source))
|
|
306
|
+
throw new Error(`Role/shared skill target conflict "${skill.name}": ${target} conflicts with ${skill.source}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
286
309
|
const currentSkillNames = new Set(skillSources.map(skill => skill.name));
|
|
287
310
|
const normalizedSourceSkillRoots = sourceSkillRoots.map(canonicalPath);
|
|
288
311
|
const normalizedManagedSkillRoots = existsSync(rolesRoot)
|
|
@@ -400,37 +423,12 @@ function escapeTomlString(value) {
|
|
|
400
423
|
function tomlKey(key) {
|
|
401
424
|
return /^[\w-]+$/u.test(key) ? key : `"${escapeTomlString(key)}"`;
|
|
402
425
|
}
|
|
403
|
-
function readMcpServerFile(sourceFile) {
|
|
404
|
-
const stats = lstatSync(sourceFile);
|
|
405
|
-
if (!stats.isFile() || stats.isSymbolicLink())
|
|
406
|
-
throw new Error(`MCP source must be a plain file: ${sourceFile}`);
|
|
407
|
-
const raw = readFileSync(sourceFile, 'utf8').trim();
|
|
408
|
-
if (!raw)
|
|
409
|
-
return {};
|
|
410
|
-
let parsed;
|
|
411
|
-
try {
|
|
412
|
-
parsed = JSON.parse(raw);
|
|
413
|
-
}
|
|
414
|
-
catch (error) {
|
|
415
|
-
throw new Error(`MCP source is invalid JSON: ${sourceFile}`, { cause: error });
|
|
416
|
-
}
|
|
417
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
418
|
-
throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
|
|
419
|
-
const servers = parsed.mcpServers;
|
|
420
|
-
if (servers === undefined)
|
|
421
|
-
return {};
|
|
422
|
-
if (servers === null || typeof servers !== 'object' || Array.isArray(servers))
|
|
423
|
-
throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
|
|
424
|
-
const serverRecord = servers;
|
|
425
|
-
validateMcpServerNames(serverRecord, sourceFile);
|
|
426
|
-
return serverRecord;
|
|
427
|
-
}
|
|
428
426
|
function readVendorMcpServers(moluoHome) {
|
|
429
427
|
const root = path.join(moluoHome, 'vendor', 'mcps');
|
|
430
428
|
if (!existsSync(root))
|
|
431
429
|
return {};
|
|
432
430
|
const servers = Object.create(null);
|
|
433
|
-
const
|
|
431
|
+
const owners = new Map();
|
|
434
432
|
function visit(directory) {
|
|
435
433
|
const stats = lstatSync(directory);
|
|
436
434
|
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
@@ -445,12 +443,8 @@ function readVendorMcpServers(moluoHome) {
|
|
|
445
443
|
else if (entry.isFile() && entry.name === 'mcp.json') {
|
|
446
444
|
const fileServers = readMcpServerFile(entryPath);
|
|
447
445
|
for (const [name, server] of Object.entries(fileServers)) {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
throw new Error(`Duplicate shared MCP server "${name}": ${previous} conflicts with ${entryPath}`);
|
|
451
|
-
}
|
|
452
|
-
servers[name] = server;
|
|
453
|
-
sources.set(name, entryPath);
|
|
446
|
+
if (mergeMcpServer(owners, name, { server, setup: [] }, entryPath))
|
|
447
|
+
servers[name] = server;
|
|
454
448
|
}
|
|
455
449
|
}
|
|
456
450
|
}
|
|
@@ -461,9 +455,19 @@ function readVendorMcpServers(moluoHome) {
|
|
|
461
455
|
export function readInstalledMcpServers(moluoHome, role) {
|
|
462
456
|
const servers = readVendorMcpServers(moluoHome);
|
|
463
457
|
if (role) {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
458
|
+
const roleRoot = path.join(moluoHome, 'roles', requireRoleName(role));
|
|
459
|
+
const contractPath = path.join(roleRoot, 'role.yaml');
|
|
460
|
+
const assets = existsSync(contractPath) ? readRoleContract(contractPath).assets : undefined;
|
|
461
|
+
const roleSource = path.resolve(roleRoot, assets?.mcp ?? 'mcp', 'mcp.json');
|
|
462
|
+
if (!isPathInside(roleRoot, roleSource))
|
|
463
|
+
throw new Error(`Role MCP source must stay inside the selected role: ${roleSource}`);
|
|
464
|
+
if (existsSync(roleSource)) {
|
|
465
|
+
const owners = new Map(Object.entries(servers).map(([name, server]) => [name, { server, setup: [], owner: 'shared capabilities' }]));
|
|
466
|
+
for (const [name, server] of Object.entries(readMcpServerFile(roleSource))) {
|
|
467
|
+
if (mergeMcpServer(owners, name, { server, setup: [] }, `${role} (${roleSource})`))
|
|
468
|
+
servers[name] = server;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
467
471
|
}
|
|
468
472
|
return Object.keys(servers).length > 0 ? servers : undefined;
|
|
469
473
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { SetupCommand } from './
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
setup: SetupCommand[];
|
|
5
|
-
}
|
|
1
|
+
import type { SetupCommand } from './types/manifest.js';
|
|
2
|
+
import type { McpCatalog, McpServerDefinition, McpServerOwner } from './types/mcp.js';
|
|
3
|
+
export type { McpCatalog } from './types/mcp.js';
|
|
6
4
|
export declare function validateMcpServerNames(servers: Record<string, unknown>, sourceFile: string): void;
|
|
5
|
+
export declare function readMcpServerFile(sourceFile: string): Record<string, unknown>;
|
|
7
6
|
export declare function loadMcpCatalog(sourceFile: string): McpCatalog;
|
|
7
|
+
export declare function setupIdentity(setup?: readonly SetupCommand[]): string;
|
|
8
|
+
/** Returns false for a completely identical declaration; incompatible names fail closed. */
|
|
9
|
+
export declare function mergeMcpServer(owners: Map<string, McpServerOwner>, name: string, definition: McpServerDefinition, owner: string): boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { canonicalJson } from './core/canonical-json.js';
|
|
2
3
|
const reservedServerNames = new Set(['__proto__', 'constructor', 'prototype']);
|
|
3
4
|
function isRecord(value) {
|
|
4
5
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -33,6 +34,29 @@ export function validateMcpServerNames(servers, sourceFile) {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
}
|
|
37
|
+
export function readMcpServerFile(sourceFile) {
|
|
38
|
+
const stats = fs.lstatSync(sourceFile);
|
|
39
|
+
if (!stats.isFile() || stats.isSymbolicLink())
|
|
40
|
+
throw new Error(`MCP source must be a plain file: ${sourceFile}`);
|
|
41
|
+
const raw = fs.readFileSync(sourceFile, 'utf8').trim();
|
|
42
|
+
if (!raw)
|
|
43
|
+
return {};
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`MCP source is invalid JSON: ${sourceFile}`, { cause: error });
|
|
50
|
+
}
|
|
51
|
+
if (!isRecord(parsed))
|
|
52
|
+
throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
|
|
53
|
+
if (parsed.mcpServers === undefined)
|
|
54
|
+
return {};
|
|
55
|
+
if (!isRecord(parsed.mcpServers))
|
|
56
|
+
throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
|
|
57
|
+
validateMcpServerNames(parsed.mcpServers, sourceFile);
|
|
58
|
+
return parsed.mcpServers;
|
|
59
|
+
}
|
|
36
60
|
export function loadMcpCatalog(sourceFile) {
|
|
37
61
|
let parsed;
|
|
38
62
|
try {
|
|
@@ -47,6 +71,7 @@ export function loadMcpCatalog(sourceFile) {
|
|
|
47
71
|
validateMcpServerNames(parsed.mcps, sourceFile);
|
|
48
72
|
const servers = Object.create(null);
|
|
49
73
|
const setup = [];
|
|
74
|
+
const serverSetup = Object.create(null);
|
|
50
75
|
for (const [name, value] of Object.entries(parsed.mcps)) {
|
|
51
76
|
if (!name || !isRecord(value) || !isRecord(value.mcp)) {
|
|
52
77
|
throw new Error(`MCP catalog entry must contain an "mcp" object: ${sourceFile}#${name}`);
|
|
@@ -55,9 +80,29 @@ export function loadMcpCatalog(sourceFile) {
|
|
|
55
80
|
throw new Error(`MCP catalog entry setup must be an array: ${sourceFile}#${name}`);
|
|
56
81
|
}
|
|
57
82
|
servers[name] = value.mcp;
|
|
58
|
-
|
|
59
|
-
|
|
83
|
+
serverSetup[name] = (value.setup ?? []).map((command, index) => parseSetupCommand(command, `${sourceFile}#${name}.setup[${index}]`));
|
|
84
|
+
setup.push(...serverSetup[name]);
|
|
85
|
+
}
|
|
86
|
+
return { servers, serverSetup, setup };
|
|
87
|
+
}
|
|
88
|
+
export function setupIdentity(setup = []) {
|
|
89
|
+
return canonicalJson(setup.map(command => ({
|
|
90
|
+
command: command.command,
|
|
91
|
+
args: command.args ?? [],
|
|
92
|
+
windowsCommandShim: command.windowsCommandShim ?? false,
|
|
93
|
+
skipIfCommandAvailable: command.skipIfCommandAvailable ?? null,
|
|
94
|
+
})));
|
|
95
|
+
}
|
|
96
|
+
/** Returns false for a completely identical declaration; incompatible names fail closed. */
|
|
97
|
+
export function mergeMcpServer(owners, name, definition, owner) {
|
|
98
|
+
const previous = owners.get(name);
|
|
99
|
+
if (previous) {
|
|
100
|
+
if (canonicalJson(previous.server) !== canonicalJson(definition.server)
|
|
101
|
+
|| setupIdentity(previous.setup) !== setupIdentity(definition.setup)) {
|
|
102
|
+
throw new Error(`Shared MCP server "${name}" has conflicting connection or setup definitions: ${previous.owner} conflicts with ${owner}`);
|
|
60
103
|
}
|
|
104
|
+
return false;
|
|
61
105
|
}
|
|
62
|
-
|
|
106
|
+
owners.set(name, { ...definition, owner });
|
|
107
|
+
return true;
|
|
63
108
|
}
|