moluoxixi-ai-rules 0.2.2 → 0.2.3

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.
@@ -0,0 +1,180 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isPathInside } from './canonical-path.js';
4
+ import { requireRoleName } from './role-assets.js';
5
+ import { resolveRoleManifestPath } from './roles.js';
6
+ import { loadVendorManifest } from './vendors.js';
7
+ const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
8
+ const roleReleaseTagPattern = /^([a-z0-9][a-z0-9-]{0,62})-v(.+)$/u;
9
+ export function parseRoleReleaseTag(tagValue) {
10
+ const tag = tagValue.replace(/^refs\/tags\//u, '');
11
+ const match = tag.match(roleReleaseTagPattern);
12
+ if (!match || !semverPattern.test(match[2]))
13
+ throw new Error(`Role release tag must match <role>-v<semver>: ${tagValue}`);
14
+ return {
15
+ role: requireRoleName(match[1]),
16
+ version: match[2],
17
+ };
18
+ }
19
+ export function npmDistTag(version) {
20
+ if (!semverPattern.test(version))
21
+ throw new Error(`Invalid package semver: ${version}`);
22
+ const prerelease = version.split('-', 2)[1]?.split('.', 1)[0];
23
+ return prerelease && /[A-Za-z]/u.test(prerelease)
24
+ ? prerelease.toLowerCase()
25
+ : prerelease
26
+ ? 'next'
27
+ : 'latest';
28
+ }
29
+ function comparePrerelease(left, right) {
30
+ if (left === right)
31
+ return 0;
32
+ if (left === undefined)
33
+ return 1;
34
+ if (right === undefined)
35
+ return -1;
36
+ const leftParts = left.split('.');
37
+ const rightParts = right.split('.');
38
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
39
+ const leftPart = leftParts[index];
40
+ const rightPart = rightParts[index];
41
+ if (leftPart === rightPart)
42
+ continue;
43
+ if (leftPart === undefined)
44
+ return -1;
45
+ if (rightPart === undefined)
46
+ return 1;
47
+ const leftNumeric = /^\d+$/u.test(leftPart);
48
+ const rightNumeric = /^\d+$/u.test(rightPart);
49
+ if (leftNumeric && rightNumeric)
50
+ return Number(leftPart) - Number(rightPart);
51
+ if (leftNumeric !== rightNumeric)
52
+ return leftNumeric ? -1 : 1;
53
+ return leftPart.localeCompare(rightPart);
54
+ }
55
+ return 0;
56
+ }
57
+ export function compareSemver(left, right) {
58
+ if (!semverPattern.test(left) || !semverPattern.test(right))
59
+ throw new Error(`Cannot compare invalid semver values: ${left}, ${right}`);
60
+ const [leftCore, leftPrerelease] = left.split('+', 1)[0].split('-', 2);
61
+ const [rightCore, rightPrerelease] = right.split('+', 1)[0].split('-', 2);
62
+ const leftNumbers = leftCore.split('.').map(Number);
63
+ const rightNumbers = rightCore.split('.').map(Number);
64
+ for (let index = 0; index < 3; index += 1) {
65
+ if (leftNumbers[index] !== rightNumbers[index])
66
+ return leftNumbers[index] - rightNumbers[index];
67
+ }
68
+ return comparePrerelease(leftPrerelease, rightPrerelease);
69
+ }
70
+ function readPackageJson(packageJsonPath) {
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
74
+ }
75
+ catch (error) {
76
+ throw new Error(`Cannot read role package manifest ${packageJsonPath}: ${String(error)}`);
77
+ }
78
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
79
+ throw new TypeError(`Role package manifest must contain a JSON object: ${packageJsonPath}`);
80
+ return parsed;
81
+ }
82
+ function validatePublicationOrder(packages) {
83
+ const order = new Map(packages.map((rolePackage, index) => [rolePackage.name, index]));
84
+ for (const [index, rolePackage] of packages.entries()) {
85
+ const dependencies = {
86
+ ...rolePackage.packageJson.peerDependencies,
87
+ ...rolePackage.packageJson.optionalDependencies,
88
+ ...rolePackage.packageJson.dependencies,
89
+ };
90
+ for (const dependencyName of Object.keys(dependencies)) {
91
+ const dependencyIndex = order.get(dependencyName);
92
+ if (dependencyIndex !== undefined && dependencyIndex >= index) {
93
+ throw new Error(`Role package publication order is invalid: ${dependencyName} must appear before ${rolePackage.name}`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ export async function loadRolePackageWorkspace(repoRoot, roleValue) {
99
+ const role = requireRoleName(roleValue);
100
+ const manifestPath = resolveRoleManifestPath(repoRoot, role);
101
+ const roleRoot = fs.realpathSync(path.resolve(manifestPath, '..', '..'));
102
+ const manifest = await loadVendorManifest(manifestPath);
103
+ if (!manifest.packages || manifest.packages.length === 0)
104
+ throw new Error(`AIRules role "${role}" does not declare publishable packages`);
105
+ const packages = manifest.packages.map((config) => {
106
+ const requestedDirectory = path.resolve(roleRoot, config.path);
107
+ if (!fs.existsSync(requestedDirectory))
108
+ throw new Error(`Role package directory does not exist: roles/${role}/${config.path}`);
109
+ const directory = fs.realpathSync(requestedDirectory);
110
+ if (!isPathInside(roleRoot, directory))
111
+ throw new Error(`Role package directory escapes roles/${role}: ${config.path}`);
112
+ const packageJsonPath = path.join(directory, 'package.json');
113
+ if (!fs.existsSync(packageJsonPath) || !fs.statSync(packageJsonPath).isFile())
114
+ throw new Error(`Role package is missing package.json: roles/${role}/${config.path}`);
115
+ const packageJson = readPackageJson(packageJsonPath);
116
+ if (packageJson.name !== config.name) {
117
+ throw new Error(`Role package name mismatch at roles/${role}/${config.path}: configured ${config.name}, found ${String(packageJson.name)}`);
118
+ }
119
+ if (typeof packageJson.version !== 'string' || !semverPattern.test(packageJson.version))
120
+ throw new Error(`Role package ${config.name} must declare a valid semver version`);
121
+ if (packageJson.private === true)
122
+ throw new Error(`Role package ${config.name} is private and cannot be published`);
123
+ if (packageJson.publishConfig?.access !== 'public')
124
+ throw new Error(`Role package ${config.name} must set publishConfig.access to "public"`);
125
+ return {
126
+ directory,
127
+ name: config.name,
128
+ packageJson,
129
+ relativePath: config.path,
130
+ version: packageJson.version,
131
+ };
132
+ });
133
+ const versions = new Set(packages.map(rolePackage => rolePackage.version));
134
+ if (versions.size !== 1)
135
+ throw new Error(`Role package versions must match: ${packages.map(item => `${item.name}@${item.version}`).join(', ')}`);
136
+ validatePublicationOrder(packages);
137
+ return {
138
+ packages,
139
+ role,
140
+ roleRoot,
141
+ version: packages[0].version,
142
+ };
143
+ }
144
+ export async function discoverRolePackageWorkspaces(repoRoot) {
145
+ const rolesRoot = path.join(fs.realpathSync(path.resolve(repoRoot)), 'roles');
146
+ if (!fs.existsSync(rolesRoot))
147
+ return [];
148
+ const workspaces = [];
149
+ const roleNames = fs.readdirSync(rolesRoot, { withFileTypes: true })
150
+ .filter(entry => entry.isDirectory())
151
+ .map(entry => entry.name)
152
+ .sort();
153
+ for (const roleName of roleNames) {
154
+ const manifestPath = path.join(rolesRoot, roleName, 'constants', 'skills.ts');
155
+ const javascriptManifestPath = path.join(rolesRoot, roleName, 'constants', 'skills.js');
156
+ if (!fs.existsSync(manifestPath) && !fs.existsSync(javascriptManifestPath))
157
+ continue;
158
+ const manifest = await loadVendorManifest(fs.existsSync(manifestPath) ? manifestPath : javascriptManifestPath);
159
+ if ((manifest.packages?.length ?? 0) > 0)
160
+ workspaces.push(await loadRolePackageWorkspace(repoRoot, roleName));
161
+ }
162
+ const packageOwners = new Map();
163
+ for (const workspace of workspaces) {
164
+ for (const rolePackage of workspace.packages) {
165
+ const owner = packageOwners.get(rolePackage.name);
166
+ if (owner) {
167
+ throw new Error(`Role package name "${rolePackage.name}" is declared by both "${owner}" and "${workspace.role}"`);
168
+ }
169
+ packageOwners.set(rolePackage.name, workspace.role);
170
+ }
171
+ }
172
+ return workspaces;
173
+ }
174
+ export function requireReleaseMatchesWorkspace(release, workspace) {
175
+ if (release.role !== workspace.role)
176
+ throw new Error(`Release tag role "${release.role}" does not match workspace role "${workspace.role}"`);
177
+ if (release.version !== workspace.version) {
178
+ throw new Error(`Release tag version "${release.version}" does not match ${workspace.role} package version "${workspace.version}"`);
179
+ }
180
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
+ import { isPathInside } from './canonical-path.js';
4
5
  import { requireRoleName } from './role-assets.js';
5
6
  export const DEFAULT_ROLE = '';
6
7
  export const COMMON_ROLE = '';
@@ -118,8 +119,7 @@ function buildRolePaths(role, roleRoot) {
118
119
  };
119
120
  }
120
121
  function requireInsideRoot(root, target, field, rootLabel) {
121
- const relative = path.relative(root, target);
122
- if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
122
+ if (!isPathInside(root, target)) {
123
123
  throw new Error(`AIRules ${field} resolves outside ${rootLabel}: ${target}`);
124
124
  }
125
125
  }
@@ -53,7 +53,7 @@ async function syncVendorsIfNeeded(paths, skipVendors, manifest) {
53
53
  for (const vendor of Object.values(manifest.vendors)) {
54
54
  ensureVendorRepo(paths.moluoHome, vendor);
55
55
  }
56
- runSkillSetupCommands(manifest);
56
+ runSkillSetupCommands(manifest, paths.moluoHome);
57
57
  }
58
58
  else {
59
59
  for (const vendor of Object.values(manifest.vendors)) {
@@ -99,7 +99,7 @@ export async function syncToHosts(options) {
99
99
  }
100
100
  projectedHosts.push(host);
101
101
  if (options.verify) {
102
- const verified = await verifyHost(host, paths.moluoHome, paths.userHome);
102
+ const verified = await verifyHost(host, paths.moluoHome, paths.userHome, paths.role);
103
103
  if (!verified) {
104
104
  failedHosts.push(host);
105
105
  }
@@ -123,7 +123,7 @@ export async function verifyHosts(options) {
123
123
  if (!await verifyGlobalAgentSkills(paths.moluoHome, paths.userHome))
124
124
  throw new Error('Mandatory global Agent skills verification failed');
125
125
  for (const host of targets) {
126
- const verified = await verifyHost(host, paths.moluoHome, paths.userHome);
126
+ const verified = await verifyHost(host, paths.moluoHome, paths.userHome, paths.role);
127
127
  if (!verified) {
128
128
  failedHosts.push(host);
129
129
  }
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
+ import { isPathInside } from './canonical-path.js';
6
+ import { loadMcpCatalog } from './mcp-catalog.js';
5
7
  import { requireRoleName } from './role-assets.js';
6
8
  import { collectFlattenedSkillSources } from './skill-projection.js';
7
9
  import { loadVendorManifest } from './vendors.js';
@@ -11,20 +13,16 @@ const defaultCanonicalRoleAssetRoots = {
11
13
  function portablePath(value) {
12
14
  return value.replace(/[\\/]+/gu, path.sep);
13
15
  }
14
- function isInsideRoot(root, target) {
15
- const relative = path.relative(root, target);
16
- return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
17
- }
18
16
  function requireSource(checkoutRoot, configuredPath, kind, vendorId) {
19
17
  const requested = path.resolve(checkoutRoot, portablePath(configuredPath));
20
- if (!isInsideRoot(checkoutRoot, requested)) {
18
+ if (!isPathInside(checkoutRoot, requested)) {
21
19
  throw new Error(`Vendor "${vendorId}" source resolves outside its checkout: ${configuredPath}`);
22
20
  }
23
21
  if (!fs.existsSync(requested)) {
24
22
  throw new Error(`Vendor "${vendorId}" is missing configured source: ${configuredPath}`);
25
23
  }
26
24
  const resolved = fs.realpathSync(requested);
27
- if (!isInsideRoot(checkoutRoot, resolved)) {
25
+ if (!isPathInside(checkoutRoot, resolved)) {
28
26
  throw new Error(`Vendor "${vendorId}" source resolves outside its checkout: ${configuredPath}`);
29
27
  }
30
28
  const stats = fs.statSync(resolved);
@@ -36,7 +34,7 @@ function requireSource(checkoutRoot, configuredPath, kind, vendorId) {
36
34
  }
37
35
  function validateSourceTree(source, checkoutRoot, vendorId, ancestors) {
38
36
  const resolved = fs.realpathSync(source);
39
- if (!isInsideRoot(checkoutRoot, resolved)) {
37
+ if (!isPathInside(checkoutRoot, resolved)) {
40
38
  throw new Error(`Vendor "${vendorId}" source symlink escapes outside its checkout: ${source}`);
41
39
  }
42
40
  const stats = fs.statSync(resolved);
@@ -87,7 +85,7 @@ function validateRoleSourceTree(source, roleRoot, vendorId) {
87
85
  throw new Error(`Vendor "${vendorId}" role source must not contain symbolic links: ${source}`);
88
86
  }
89
87
  const resolved = fs.realpathSync(source);
90
- if (!isInsideRoot(roleRoot, resolved)) {
88
+ if (!isPathInside(roleRoot, resolved)) {
91
89
  throw new Error(`Vendor "${vendorId}" role source resolves outside the selected role: ${source}`);
92
90
  }
93
91
  if (stats.isFile()) {
@@ -163,9 +161,12 @@ function requireManagedTarget(homeDir, kind, configuredTarget) {
163
161
  const vendorRoot = path.resolve(homeDir, 'vendor');
164
162
  const target = path.resolve(homeDir, portablePath(configuredTarget));
165
163
  const skillsRoot = path.join(vendorRoot, 'skills');
166
- const valid = (kind === 'skill' || kind === 'namespace-dir')
167
- && isInsideRoot(skillsRoot, target)
168
- && target !== skillsRoot;
164
+ const mcpsRoot = path.join(vendorRoot, 'mcps');
165
+ const validSkillTarget = (kind === 'skill' || kind === 'namespace-dir')
166
+ && isPathInside(skillsRoot, target) && target !== skillsRoot;
167
+ const validMcpTarget = kind === 'mcp-file'
168
+ && isPathInside(mcpsRoot, target) && target !== mcpsRoot && path.basename(target) === 'mcp.json';
169
+ const valid = validSkillTarget || validMcpTarget;
169
170
  if (!valid) {
170
171
  throw new Error(`Vendor target resolves outside its managed staging root: ${configuredTarget}`);
171
172
  }
@@ -207,6 +208,14 @@ function expandOrdinaryLink(homeDir, vendorId, link) {
207
208
  if (link.kind === 'role-assets-dir') {
208
209
  throw new Error('role-assets must be expanded through the canonical role staging path');
209
210
  }
211
+ if (link.kind === 'mcp-file') {
212
+ return [{
213
+ vendorId,
214
+ kind: link.kind,
215
+ source: requireSource(checkoutRoot, link.source, 'file', vendorId),
216
+ target: requireManagedTarget(homeDir, link.kind, link.target),
217
+ }];
218
+ }
210
219
  const source = requireSource(checkoutRoot, link.source, 'directory', vendorId);
211
220
  requireSkill(source, checkoutRoot, vendorId);
212
221
  return [{
@@ -239,7 +248,7 @@ function resolveRoleChild(roleRoot, relativePath, kind) {
239
248
  return undefined;
240
249
  }
241
250
  const resolved = fs.realpathSync(requested);
242
- if (!isInsideRoot(roleRoot, resolved)) {
251
+ if (!isPathInside(roleRoot, resolved)) {
243
252
  throw new Error(`Role asset resolves outside the selected role: ${relativePath}`);
244
253
  }
245
254
  const stats = fs.statSync(resolved);
@@ -303,12 +312,17 @@ function buildStagingPlan(manifest, homeDir, role) {
303
312
  roleVendorId: roleDeclaration?.vendorId,
304
313
  };
305
314
  }
306
- function copyPlannedAsset(stagingRoot, asset, replace) {
315
+ function copyPlannedAsset(stagingRoot, asset, replace, mcpCatalog) {
307
316
  const target = path.join(stagingRoot, asset.target);
308
317
  if (replace) {
309
318
  fs.rmSync(target, { recursive: true, force: true });
310
319
  }
311
320
  fs.mkdirSync(path.dirname(target), { recursive: true });
321
+ if (asset.kind === 'mcp-file') {
322
+ const catalog = mcpCatalog ?? loadMcpCatalog(asset.source);
323
+ fs.writeFileSync(target, `${JSON.stringify({ mcpServers: catalog.servers }, null, 2)}\n`, 'utf8');
324
+ return;
325
+ }
312
326
  fs.cpSync(asset.source, target, { recursive: true, dereference: true });
313
327
  }
314
328
  function materializePlan(plan, role) {
@@ -316,8 +330,17 @@ function materializePlan(plan, role) {
316
330
  const stagingRoot = path.join(buildRoot, 'vendor');
317
331
  fs.mkdirSync(stagingRoot);
318
332
  try {
333
+ const mcpServerOwners = new Map();
319
334
  for (const asset of plan.ordinary) {
320
- copyPlannedAsset(stagingRoot, asset, false);
335
+ const mcpCatalog = asset.kind === 'mcp-file' ? loadMcpCatalog(asset.source) : undefined;
336
+ for (const serverName of Object.keys(mcpCatalog?.servers ?? {})) {
337
+ const previous = mcpServerOwners.get(serverName);
338
+ if (previous) {
339
+ throw new Error(`Shared MCP server "${serverName}" is declared by both ${previous.vendorId} (${previous.source}) and ${asset.vendorId} (${asset.source})`);
340
+ }
341
+ mcpServerOwners.set(serverName, asset);
342
+ }
343
+ copyPlannedAsset(stagingRoot, asset, false, mcpCatalog);
321
344
  }
322
345
  let roleStagingRoot;
323
346
  if (plan.roleSource !== undefined && plan.roleVendorId !== undefined) {
@@ -380,7 +403,7 @@ function validateInventory(stagingRoot, finalVendorRoot, role, roleStagingRoot)
380
403
  skills,
381
404
  };
382
405
  }
383
- const managedEntryNames = ['skills'];
406
+ const managedEntryNames = ['skills', 'mcps'];
384
407
  function requirePlainDirectoryOrMissing(target, label) {
385
408
  const stats = fs.lstatSync(target, { throwIfNoEntry: false });
386
409
  if (stats && (!stats.isDirectory() || stats.isSymbolicLink())) {
@@ -12,6 +12,20 @@ export interface SetupCommand {
12
12
  */
13
13
  skipIfCommandAvailable?: string;
14
14
  }
15
+ /** A published role package may optionally provide a global CLI for role setup. */
16
+ export interface RolePackageInstall {
17
+ kind: 'npm-global';
18
+ /** npm version or dist-tag. Defaults to `latest`. */
19
+ version?: string;
20
+ }
21
+ /** Role-owned npm package declaration. Array order is the publication order. */
22
+ export interface RolePackageConfig {
23
+ name: string;
24
+ /** Package directory relative to `roles/<role>`. */
25
+ path: string;
26
+ /** Packages without this field are published but not globally installed. */
27
+ install?: RolePackageInstall;
28
+ }
15
29
  /**
16
30
  * 单个 skill 的详细配置(适用于需要重命名或前置安装命令的场景)。
17
31
  */
@@ -51,6 +65,12 @@ export type VendorProjection = {
51
65
  kind: 'role-assets';
52
66
  /** 远程仓库内所选角色根目录,如 roles/example-development。 */
53
67
  sourceDir: string;
68
+ } | {
69
+ kind: 'mcp';
70
+ /** 仓库内单个 MCP 清单文件路径,如 mcps/code/mcps.json。文件格式包含 mcp 配置和 setup 命令。 */
71
+ sourceFile: string;
72
+ /** 投影到 vendor/ 下的目标路径,如 mcps/code/mcp.json。会从 sourceFile 提取 mcp 配置并生成标准 MCP 格式。 */
73
+ output: string;
54
74
  };
55
75
  /**
56
76
  * 代表一个必须通过 Git remote checkout 获取的供应商仓库。
@@ -81,10 +101,10 @@ export type VendorNode = VendorRepo | {
81
101
  export type VendorsConfig = VendorNode[];
82
102
  export declare function normalizePath(value: string): string;
83
103
  export interface VendorLink {
84
- kind: 'namespace-dir' | 'skill' | 'role-assets-dir';
104
+ kind: 'namespace-dir' | 'skill' | 'role-assets-dir' | 'mcp-file';
85
105
  source: string;
86
106
  target: string;
87
- /** 该 skill 的安装前置命令(来自 SkillConfig.setup) */
107
+ /** 该 skill 或 MCP 的安装前置命令 */
88
108
  setup?: SetupCommand[];
89
109
  }
90
110
  export interface Vendor {
@@ -96,9 +116,11 @@ export interface Vendor {
96
116
  }
97
117
  export interface VendorManifest {
98
118
  hosts?: string[];
119
+ packages?: RolePackageConfig[];
99
120
  version: number;
100
121
  vendors: Record<string, Vendor>;
101
122
  }
123
+ export declare function rolePackageSetupCommands(packages?: RolePackageConfig[]): SetupCommand[];
102
124
  /**
103
125
  * 递归遍历供应商定义树,支持混合数组和对象结构
104
126
  * @param node 当前处理的节点 (VendorRepo | Record | Array)
@@ -3,12 +3,25 @@ import { fileURLToPath, pathToFileURL, URL } from 'node:url';
3
3
  import { HOST_IDS } from '../../constants/hosts.js';
4
4
  import { flattenedSkillName, flattenedVendorSkillTarget } from './skill-projection.js';
5
5
  const vendorNamePattern = /^[A-Za-z0-9][\w-]*$/u;
6
+ const npmPackageNamePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
7
+ const npmInstallVersionPattern = /^(?:(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?|[a-z][a-z0-9._-]*)$/u;
6
8
  const gitCommitPattern = /^[a-f0-9]{40}$/u;
7
9
  const remoteGitProtocols = new Set(['https:', 'http:', 'ssh:', 'git:', 'git+ssh:']);
8
10
  const scpStyleRemotePattern = /^[^@\s/:]+@[^@\s/:]+:\S+$/u;
9
11
  export function normalizePath(value) {
10
12
  return value.replace(/\\/g, '/');
11
13
  }
14
+ export function rolePackageSetupCommands(packages = []) {
15
+ return packages.flatMap((rolePackage) => {
16
+ if (!rolePackage.install)
17
+ return [];
18
+ const version = rolePackage.install.version ?? 'latest';
19
+ return [{
20
+ command: 'npm',
21
+ args: ['install', '--global', `${rolePackage.name}@${version}`],
22
+ }];
23
+ });
24
+ }
12
25
  /**
13
26
  * 判断是否为有效的 VendorRepo 定义
14
27
  */
@@ -115,6 +128,13 @@ function buildLinksForEntry(entry) {
115
128
  target: 'vendor',
116
129
  }];
117
130
  }
131
+ if (projection.kind === 'mcp') {
132
+ return [{
133
+ kind: 'mcp-file',
134
+ source: projection.sourceFile,
135
+ target: path.posix.join('vendor', projection.output),
136
+ }];
137
+ }
118
138
  throw new Error(`供应商 "${entry.name}" 存在未知 projection 类型: ${projection.kind}`);
119
139
  });
120
140
  }
@@ -191,12 +211,62 @@ export async function loadVendorManifest(manifestPath) {
191
211
  const vendors = {};
192
212
  walkVendorTree(vendorTree, [], vendors);
193
213
  const hosts = normalizeRoleHosts(module.hosts ?? module.default?.hosts, manifestPath);
214
+ const packages = normalizeRolePackages(module.packages ?? module.default?.packages, manifestPath);
194
215
  return {
195
216
  ...(hosts === undefined ? {} : { hosts }),
217
+ packages,
196
218
  version: 1,
197
219
  vendors,
198
220
  };
199
221
  }
222
+ function normalizeRolePackages(value, manifestPath) {
223
+ if (value === undefined)
224
+ return [];
225
+ if (!Array.isArray(value))
226
+ throw new TypeError(`Vendor manifest "${manifestPath}" export "packages" must be an array`);
227
+ const packageNames = new Set();
228
+ const packagePaths = new Set();
229
+ return value.map((entry, index) => {
230
+ const location = `Vendor manifest "${manifestPath}" package at index ${index}`;
231
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
232
+ throw new TypeError(`${location} must be an object`);
233
+ const candidate = entry;
234
+ if (typeof candidate.name !== 'string' || !npmPackageNamePattern.test(candidate.name))
235
+ throw new Error(`${location} has invalid npm package name "${String(candidate.name)}"`);
236
+ if (packageNames.has(candidate.name))
237
+ throw new Error(`Vendor manifest "${manifestPath}" contains duplicate package "${candidate.name}"`);
238
+ packageNames.add(candidate.name);
239
+ if (typeof candidate.path !== 'string' || candidate.path.length === 0)
240
+ throw new Error(`${location} must declare a non-empty relative path`);
241
+ const packagePath = normalizePath(candidate.path);
242
+ const pathParts = packagePath.split('/');
243
+ if (path.posix.isAbsolute(packagePath)
244
+ || path.win32.isAbsolute(packagePath)
245
+ || pathParts.some(part => part === '' || part === '.' || part === '..')) {
246
+ throw new Error(`${location} path must stay inside the role directory: ${candidate.path}`);
247
+ }
248
+ if (packagePaths.has(packagePath))
249
+ throw new Error(`Vendor manifest "${manifestPath}" contains duplicate package path "${packagePath}"`);
250
+ packagePaths.add(packagePath);
251
+ if (candidate.install === undefined)
252
+ return { name: candidate.name, path: packagePath };
253
+ if (!candidate.install || typeof candidate.install !== 'object' || Array.isArray(candidate.install))
254
+ throw new TypeError(`${location} install must be an object`);
255
+ const install = candidate.install;
256
+ if (install.kind !== 'npm-global')
257
+ throw new Error(`${location} install.kind must be "npm-global"`);
258
+ if (install.version !== undefined && (typeof install.version !== 'string' || !npmInstallVersionPattern.test(install.version)))
259
+ throw new Error(`${location} install.version must be an exact semver or safe npm dist-tag`);
260
+ return {
261
+ name: candidate.name,
262
+ path: packagePath,
263
+ install: {
264
+ kind: 'npm-global',
265
+ ...(install.version === undefined ? {} : { version: install.version }),
266
+ },
267
+ };
268
+ });
269
+ }
200
270
  function normalizeRoleHosts(value, manifestPath) {
201
271
  if (value === undefined)
202
272
  return undefined;
@@ -1,4 +1,4 @@
1
1
  /** Verify the mandatory canonical skills layer shared by every role and host. */
2
2
  export declare function verifyGlobalAgentSkills(moluoHome: string, userHome?: string): Promise<boolean>;
3
3
  /** Verify only the skills managed by AIRules. Host-native assets belong to project initializers. */
4
- export declare function verifyHost(host: string, moluoHome: string, userHome?: string): Promise<boolean>;
4
+ export declare function verifyHost(host: string, moluoHome: string, userHome?: string, role?: string): Promise<boolean>;
@@ -1,8 +1,10 @@
1
- import { existsSync, lstatSync, readdirSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import kleur from 'kleur';
5
5
  import { findHostConfig, resolveGlobalAgentSkillsPath, resolveHostPaths } from '../../constants/hosts.js';
6
+ import { applyMcpServerProjection, readInstalledMcpServers, readTomlMcpServerNames } from './install.js';
7
+ import { DEFAULT_ROLE } from './roles.js';
6
8
  /** Verify the mandatory canonical skills layer shared by every role and host. */
7
9
  export async function verifyGlobalAgentSkills(moluoHome, userHome = os.homedir()) {
8
10
  console.log('\n--- 正在验证公共 Agent skills 层 ---');
@@ -14,27 +16,74 @@ export async function verifyGlobalAgentSkills(moluoHome, userHome = os.homedir()
14
16
  return verifyProjectedSkills('公共 Agent skills 层', moluoHome, targetSkillsDir, []);
15
17
  }
16
18
  /** Verify only the skills managed by AIRules. Host-native assets belong to project initializers. */
17
- export async function verifyHost(host, moluoHome, userHome = os.homedir()) {
19
+ export async function verifyHost(host, moluoHome, userHome = os.homedir(), role = DEFAULT_ROLE) {
18
20
  console.log(`\n--- 正在验证宿主: ${host} ---`);
19
21
  const config = findHostConfig(host);
20
22
  if (!config)
21
23
  return false;
22
- const { hostHome, skillsDirName, excludedSkills, projectSkills } = resolveHostPaths(config, userHome);
24
+ const { hostHome, skillsDirName, excludedSkills, projectSkills, mcpHome, mcp } = resolveHostPaths(config, userHome);
23
25
  const resolvedHostHome = path.resolve(hostHome);
24
- if (!existsSync(resolvedHostHome)) {
26
+ const hasHostHome = existsSync(resolvedHostHome);
27
+ const hasMcpHome = Boolean(mcp
28
+ && existsSync(path.resolve(mcpHome))
29
+ && (mcp.requireHostHome !== true || hasHostHome));
30
+ if (!hasHostHome && !hasMcpHome) {
25
31
  console.warn(`[SKIP] 宿主目录不存在: ${resolvedHostHome}`);
26
32
  return true;
27
33
  }
28
- if (!projectSkills) {
34
+ let skillsValid = true;
35
+ if (projectSkills && hasHostHome) {
36
+ const targetSkillsDir = path.join(resolvedHostHome, skillsDirName);
37
+ if (!existsSync(targetSkillsDir)) {
38
+ console.error(`[FAIL] 技能目录缺失: ${targetSkillsDir}`);
39
+ skillsValid = false;
40
+ }
41
+ else {
42
+ skillsValid = verifyProjectedSkills(host, moluoHome, targetSkillsDir, excludedSkills);
43
+ }
44
+ }
45
+ else {
29
46
  console.log('[info] 宿主未启用 skills 投影,跳过 skills 链接校验');
47
+ }
48
+ const mcpValid = hasMcpHome && mcp ? verifyProjectedMcp(host, moluoHome, role, mcpHome, mcp) : true;
49
+ return skillsValid && mcpValid;
50
+ }
51
+ function verifyProjectedMcp(host, moluoHome, role, mcpHome, mcp) {
52
+ const installed = readInstalledMcpServers(moluoHome, role);
53
+ if (!installed)
54
+ return true;
55
+ const expected = applyMcpServerProjection(installed, mcp);
56
+ const targetDir = mcp.relDir === '.' ? mcpHome : path.join(mcpHome, mcp.relDir);
57
+ const targetFile = path.join(targetDir, mcp.fileName);
58
+ if (!existsSync(targetFile)) {
59
+ console.error(`[FAIL] MCP 配置缺失: ${targetFile}`);
60
+ return false;
61
+ }
62
+ try {
63
+ const content = readFileSync(targetFile, 'utf8').replace(/^\uFEFF/u, '');
64
+ let actualNames;
65
+ if (mcp.format === 'json') {
66
+ const parsed = JSON.parse(content);
67
+ const servers = parsed[mcp.serversKey];
68
+ if (servers === null || typeof servers !== 'object' || Array.isArray(servers))
69
+ throw new Error(`missing "${mcp.serversKey}" object`);
70
+ actualNames = new Set(Object.keys(servers));
71
+ }
72
+ else {
73
+ actualNames = readTomlMcpServerNames(content, mcp.serversKey);
74
+ }
75
+ const missing = Object.keys(expected).filter(name => !actualNames.has(name));
76
+ if (missing.length > 0) {
77
+ console.error(`[FAIL] 缺失 MCP server: ${missing.join(', ')}`);
78
+ return false;
79
+ }
80
+ console.log(`[info] ${host} MCP servers 验证通过: ${Object.keys(expected).length}`);
30
81
  return true;
31
82
  }
32
- const targetSkillsDir = path.join(resolvedHostHome, skillsDirName);
33
- if (!existsSync(targetSkillsDir)) {
34
- console.error(`[FAIL] 技能目录缺失: ${targetSkillsDir}`);
83
+ catch (error) {
84
+ console.error(`[FAIL] MCP 配置无效: ${targetFile}\n${String(error)}`);
35
85
  return false;
36
86
  }
37
- return verifyProjectedSkills(host, moluoHome, targetSkillsDir, excludedSkills);
38
87
  }
39
88
  function verifyProjectedSkills(label, moluoHome, targetSkillsDir, excludedSkills) {
40
89
  const vendorSkillsDir = path.join(moluoHome, 'vendor', 'skills');
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import type { ResolvedRolePackage, RolePackageWorkspace } from './lib/role-packages.js';
3
+ export interface RolePackagePublishOperations {
4
+ npmViewVersion: (specifier: string) => string | undefined;
5
+ run: (command: 'npm' | 'pnpm', args: string[], cwd?: string) => string;
6
+ waitForRegistry: (rolePackage: ResolvedRolePackage) => Promise<void>;
7
+ }
8
+ export declare function publishWorkspace(workspace: RolePackageWorkspace, operations?: RolePackagePublishOperations): Promise<void>;