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.
@@ -1,4 +1,5 @@
1
- import type { VendorRepo } from '../../../scripts/lib/vendors.js';
1
+ import type { RolePackageConfig, VendorRepo } from '../../../scripts/lib/vendors.js';
2
2
  export declare const extendsRoles: string[];
3
3
  export declare const hosts = "all";
4
+ export declare const packages: RolePackageConfig[];
4
5
  export declare const vendors: VendorRepo[];
@@ -1,25 +1,37 @@
1
1
  export const extendsRoles = [];
2
2
  export const hosts = 'all';
3
+ export const packages = [
4
+ {
5
+ name: '@moluoxixi/airules-moluoxixi-core',
6
+ path: 'packages/core',
7
+ },
8
+ {
9
+ name: '@moluoxixi/airules-moluoxixi-cli',
10
+ path: 'packages/cli',
11
+ install: {
12
+ kind: 'npm-global',
13
+ version: 'latest',
14
+ },
15
+ },
16
+ ];
3
17
  export const vendors = [
4
18
  {
5
19
  name: 'moluoxixi',
6
20
  source: 'https://github.com/moluoxixi/AIRules.git',
7
- setup: [
21
+ projections: [
8
22
  {
9
- command: 'npm',
10
- args: ['install', '--global', '@colbymchenry/codegraph'],
11
- skipIfCommandAvailable: 'codegraph',
23
+ kind: 'role-assets',
24
+ sourceDir: 'roles/moluoxixi',
12
25
  },
13
26
  {
14
- command: 'codegraph',
15
- args: ['install', '--yes'],
16
- windowsCommandShim: true,
27
+ kind: 'namespace',
28
+ sourceDir: 'skills/common',
29
+ output: 'common',
17
30
  },
18
- ],
19
- projections: [
20
31
  {
21
- kind: 'role-assets',
22
- sourceDir: 'roles/moluoxixi',
32
+ kind: 'mcp',
33
+ sourceFile: 'mcps/code/mcps.json',
34
+ output: 'mcps/code/mcp.json',
23
35
  },
24
36
  ],
25
37
  },
@@ -9,22 +9,22 @@ export const vendors = [
9
9
  command: 'npm',
10
10
  args: ['install', '--global', '@mindfoldhq/trellis@latest'],
11
11
  },
12
- {
13
- command: 'npm',
14
- args: ['install', '--global', '@colbymchenry/codegraph'],
15
- skipIfCommandAvailable: 'codegraph',
16
- },
17
- {
18
- command: 'codegraph',
19
- args: ['install', '--yes'],
20
- windowsCommandShim: true,
21
- },
22
12
  ],
23
13
  projections: [
24
14
  {
25
15
  kind: 'role-assets',
26
16
  sourceDir: 'roles/trellis',
27
17
  },
18
+ {
19
+ kind: 'namespace',
20
+ sourceDir: 'skills/common',
21
+ output: 'common',
22
+ },
23
+ {
24
+ kind: 'mcp',
25
+ sourceFile: 'mcps/code/mcps.json',
26
+ output: 'mcps/code/mcp.json',
27
+ },
28
28
  ],
29
29
  },
30
30
  {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolve filesystem aliases for every existing ancestor while preserving a
3
+ * not-yet-created suffix. macOS exposes paths such as /var through a symlink to
4
+ * /private/var, so lexical path.resolve() alone is not a stable identity.
5
+ */
6
+ export declare function canonicalPath(input: string): string;
7
+ export declare function canonicalPathKey(input: string): string;
8
+ export declare function areSamePaths(left: string, right: string): boolean;
9
+ export declare function isPathInside(root: string, target: string): boolean;
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ function isMissingPathError(error) {
4
+ return error.code === 'ENOENT';
5
+ }
6
+ function isFilesystemError(error, code) {
7
+ return error.code === code;
8
+ }
9
+ function lexicalPathKey(input) {
10
+ const resolved = path.resolve(input).replaceAll('\\', '/');
11
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
12
+ }
13
+ /**
14
+ * Resolve filesystem aliases for every existing ancestor while preserving a
15
+ * not-yet-created suffix. macOS exposes paths such as /var through a symlink to
16
+ * /private/var, so lexical path.resolve() alone is not a stable identity.
17
+ */
18
+ export function canonicalPath(input) {
19
+ let current = path.resolve(input);
20
+ const missingSegments = [];
21
+ while (true) {
22
+ try {
23
+ const existingRoot = fs.realpathSync.native(current);
24
+ return path.resolve(existingRoot, ...missingSegments.reverse());
25
+ }
26
+ catch (error) {
27
+ if (!isMissingPathError(error)) {
28
+ throw error;
29
+ }
30
+ const parent = path.dirname(current);
31
+ if (parent === current) {
32
+ throw error;
33
+ }
34
+ missingSegments.push(path.basename(current));
35
+ current = parent;
36
+ }
37
+ }
38
+ }
39
+ export function canonicalPathKey(input) {
40
+ const resolved = canonicalPath(input).replaceAll('\\', '/');
41
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
42
+ }
43
+ export function areSamePaths(left, right) {
44
+ if (!left || !right) {
45
+ return false;
46
+ }
47
+ try {
48
+ return canonicalPathKey(left) === canonicalPathKey(right);
49
+ }
50
+ catch (error) {
51
+ // Equality is used while repairing cyclic links. Containment checks must
52
+ // remain strict and continue to surface ELOOP through canonicalPath().
53
+ if (!isFilesystemError(error, 'ELOOP')) {
54
+ throw error;
55
+ }
56
+ return lexicalPathKey(left) === lexicalPathKey(right);
57
+ }
58
+ }
59
+ export function isPathInside(root, target) {
60
+ const canonicalRoot = canonicalPath(root);
61
+ const canonicalTarget = canonicalPath(target);
62
+ const relative = path.relative(canonicalRoot, canonicalTarget);
63
+ return relative === ''
64
+ || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
65
+ }
@@ -8,7 +8,7 @@ export declare function shouldUseShellForSetupCommand(command: string, windowsCo
8
8
  * 任一命令失败都会抛出错误,避免安装流程伪装成功。
9
9
  * @param manifest 已解析的 VendorManifest
10
10
  */
11
- export declare function runSkillSetupCommands(manifest: VendorManifest): void;
11
+ export declare function runSkillSetupCommands(manifest: VendorManifest, homeDir?: string): void;
12
12
  export declare function isSamePath(p1: string, p2: string): boolean;
13
13
  export declare function replaceWithSymlink(source: string, target: string, type: 'junction' | 'dir' | 'file'): void;
14
14
  export interface InstallPaths {
@@ -52,6 +52,9 @@ export declare function rebuildVendorSkillLinks({ homeDir, manifestPath }: {
52
52
  * ~/.agents 是行业标准共享层,始终存在(不存在则创建)。
53
53
  */
54
54
  export declare function projectSkillsToHost(userHome: string, moluoHome: string, hostSkillsHome: string, options?: SyncFlattenedSkillsOptions): void;
55
+ export declare function readInstalledMcpServers(moluoHome: string, role: string): Record<string, unknown> | undefined;
56
+ export declare function applyMcpServerProjection(servers: Record<string, unknown>, mcp: Pick<McpProjection, 'serverCommandFormat' | 'serverDefaults' | 'serverOverrides'>): Record<string, unknown>;
57
+ export declare function readTomlMcpServerNames(content: string, serversKey: string): Set<string>;
55
58
  export declare function projectToHost({ userHome, moluoHome, hostHome, customSkillsDirName, excludedSkills, projectSkills, role, mcpHome, mcp, }: {
56
59
  userHome: string;
57
60
  moluoHome: string;
@@ -3,11 +3,13 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, re
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { findHostConfig, resolveGlobalAgentSkillsPath, resolveHostPaths } from '../../constants/hosts.js';
6
+ import { areSamePaths, canonicalPath, canonicalPathKey, isPathInside } from './canonical-path.js';
6
7
  import { buildLinkPlan } from './links.js';
8
+ import { loadMcpCatalog, validateMcpServerNames } from './mcp-catalog.js';
7
9
  import { requireRoleName } from './role-assets.js';
8
10
  import { DEFAULT_ROLE, roleOverlayOrder } from './roles.js';
9
11
  import { collectFlattenedSkillSources, discoverSkillDirectories, flattenedSkillName } from './skill-projection.js';
10
- import { loadVendorManifest } from './vendors.js';
12
+ import { loadVendorManifest, rolePackageSetupCommands } from './vendors.js';
11
13
  /** 获取 vendor 技能目录的绝对路径 */
12
14
  function vendorSkillsPath(homeDir) {
13
15
  return path.join(homeDir, 'vendor', 'skills');
@@ -79,50 +81,58 @@ function isSetupCommandAvailable(command) {
79
81
  * 任一命令失败都会抛出错误,避免安装流程伪装成功。
80
82
  * @param manifest 已解析的 VendorManifest
81
83
  */
82
- export function runSkillSetupCommands(manifest) {
84
+ export function runSkillSetupCommands(manifest, homeDir) {
85
+ const rolePackageCommands = rolePackageSetupCommands(manifest.packages);
86
+ if (rolePackageCommands.length > 0) {
87
+ console.log('\n[setup] 安装角色声明的 npm packages...');
88
+ runSetupCommandGroup('role packages', rolePackageCommands);
89
+ }
83
90
  for (const [vendorName, vendor] of Object.entries(manifest.vendors)) {
84
91
  if (vendor.setup && vendor.setup.length > 0) {
85
92
  console.log(`\n[setup] 执行 ${vendorName} 的安装前置命令...`);
86
- for (const command of vendor.setup) {
87
- const commandText = setupCommandText(command);
88
- if (command.skipIfCommandAvailable && isSetupCommandAvailable(command.skipIfCommandAvailable)) {
89
- console.log(`[setup] 跳过 ${commandText},已检测到 ${command.skipIfCommandAvailable}`);
90
- continue;
91
- }
92
- console.log(`[setup] > ${commandText}`);
93
- try {
94
- execFileSync(resolveSetupCommandExecutable(command.command, command.windowsCommandShim), command.args ?? [], {
95
- shell: shouldUseShellForSetupCommand(command.command, command.windowsCommandShim),
96
- stdio: 'inherit',
97
- });
98
- }
99
- catch (error) {
100
- throw new Error(`[setup] ${vendorName} 安装前置命令失败: ${commandText}\n${String(error)}`);
101
- }
102
- }
93
+ runSetupCommandGroup(vendorName, vendor.setup);
103
94
  }
104
95
  for (const link of vendor.links) {
105
- if (!link.setup || link.setup.length === 0)
106
- continue;
107
- const skillName = path.basename(link.target);
108
- console.log(`\n[setup] 执行 ${vendorName}/${skillName} 的安装前置命令...`);
109
- for (const command of link.setup) {
110
- const commandText = setupCommandText(command);
111
- if (command.skipIfCommandAvailable && isSetupCommandAvailable(command.skipIfCommandAvailable)) {
112
- console.log(`[setup] 跳过 ${commandText},已检测到 ${command.skipIfCommandAvailable}`);
113
- continue;
96
+ const setupCommands = [...(link.setup ?? [])];
97
+ if (link.kind === 'mcp-file') {
98
+ if (!homeDir) {
99
+ throw new Error(`[setup] ${vendorName} MCP setup requires the AIRules home directory`);
114
100
  }
115
- console.log(`[setup] > ${commandText}`);
116
- try {
117
- execFileSync(resolveSetupCommandExecutable(command.command, command.windowsCommandShim), command.args ?? [], {
118
- shell: shouldUseShellForSetupCommand(command.command, command.windowsCommandShim),
119
- stdio: 'inherit',
120
- });
101
+ const checkoutRoot = path.resolve(homeDir, vendor.cloneDir);
102
+ const sourceFile = path.resolve(checkoutRoot, link.source);
103
+ if (!isPathInside(checkoutRoot, sourceFile)) {
104
+ throw new Error(`[setup] ${vendorName} MCP catalog resolves outside its checkout: ${link.source}`);
121
105
  }
122
- catch (error) {
123
- throw new Error(`[setup] ${vendorName}/${skillName} 安装前置命令失败: ${commandText}\n${String(error)}`);
106
+ const stats = lstatSync(sourceFile, { throwIfNoEntry: false });
107
+ if (!stats?.isFile() || stats.isSymbolicLink() || !isPathInside(checkoutRoot, realpathSync(sourceFile))) {
108
+ throw new Error(`[setup] ${vendorName} MCP catalog must be a plain file inside its checkout: ${link.source}`);
124
109
  }
110
+ setupCommands.push(...loadMcpCatalog(sourceFile).setup);
125
111
  }
112
+ if (setupCommands.length === 0)
113
+ continue;
114
+ const assetName = link.kind === 'mcp-file' ? 'mcp' : path.basename(link.target);
115
+ console.log(`\n[setup] 执行 ${vendorName}/${assetName} 的安装前置命令...`);
116
+ runSetupCommandGroup(`${vendorName}/${assetName}`, setupCommands);
117
+ }
118
+ }
119
+ }
120
+ function runSetupCommandGroup(owner, commands) {
121
+ for (const command of commands) {
122
+ const commandText = setupCommandText(command);
123
+ if (command.skipIfCommandAvailable && isSetupCommandAvailable(command.skipIfCommandAvailable)) {
124
+ console.log(`[setup] 跳过 ${commandText},已检测到 ${command.skipIfCommandAvailable}`);
125
+ continue;
126
+ }
127
+ console.log(`[setup] > ${commandText}`);
128
+ try {
129
+ execFileSync(resolveSetupCommandExecutable(command.command, command.windowsCommandShim), command.args ?? [], {
130
+ shell: shouldUseShellForSetupCommand(command.command, command.windowsCommandShim),
131
+ stdio: 'inherit',
132
+ });
133
+ }
134
+ catch (error) {
135
+ throw new Error(`[setup] ${owner} 安装前置命令失败: ${commandText}\n${String(error)}`);
126
136
  }
127
137
  }
128
138
  }
@@ -130,7 +140,7 @@ function setupCommandText(command) {
130
140
  return [command.command, ...(command.args ?? [])].join(' ');
131
141
  }
132
142
  function rememberFlattenedTarget(targets, target, source) {
133
- const targetKey = path.resolve(target).toLowerCase();
143
+ const targetKey = canonicalPathKey(target);
134
144
  const previousSource = targets.get(targetKey);
135
145
  if (previousSource && !isSamePath(previousSource, source)) {
136
146
  throw new Error(`Flattened skill target collision "${target}": ${previousSource} conflicts with ${source}`);
@@ -138,11 +148,7 @@ function rememberFlattenedTarget(targets, target, source) {
138
148
  targets.set(targetKey, source);
139
149
  }
140
150
  export function isSamePath(p1, p2) {
141
- if (!p1 || !p2)
142
- return false;
143
- const n1 = path.resolve(p1).toLowerCase().replace(/\\/g, '/').replace(/\/$/, '');
144
- const n2 = path.resolve(p2).toLowerCase().replace(/\\/g, '/').replace(/\/$/, '');
145
- return n1 === n2;
151
+ return areSamePaths(p1, p2);
146
152
  }
147
153
  function linkTypeForCurrentPlatform() {
148
154
  return process.platform === 'win32' ? 'junction' : 'dir';
@@ -221,11 +227,8 @@ export function syncFlattenedSkills(sourceDir, targetDir, moluoHome, options = {
221
227
  continue;
222
228
  }
223
229
  const resolvedPath = realpathSync(targetPath);
224
- const normalizedResolved = path.resolve(resolvedPath);
225
- const normalizedMoluo = path.resolve(moluoHome);
226
- const normalizedRepo = path.resolve(process.cwd()); // 仓库根目录
227
- const isInternal = normalizedResolved.startsWith(normalizedMoluo)
228
- || normalizedResolved.startsWith(normalizedRepo);
230
+ const isInternal = isPathInside(moluoHome, resolvedPath)
231
+ || isPathInside(process.cwd(), resolvedPath);
229
232
  // 如果该链接指向我们的项目,但不在当前技能集合中,则视为过时并移除
230
233
  if (isInternal && !currentSkills.has(entry.name)) {
231
234
  removePath(targetPath);
@@ -267,8 +270,8 @@ export async function syncFirstPartySkillsToVendor(sourceRoot, moluoHome, role =
267
270
  const nameKey = name.toLowerCase();
268
271
  const previousSource = seenSkillNames.get(nameKey);
269
272
  if (previousSource
270
- && path.resolve(previousSource.source) !== path.resolve(source)
271
- && path.resolve(previousSource.root) === path.resolve(sourceSkillsDir)) {
273
+ && !isSamePath(previousSource.source, source)
274
+ && isSamePath(previousSource.root, sourceSkillsDir)) {
272
275
  throw new Error(`First-party skill name collision "${name}": ${previousSource.source} conflicts with ${source}`);
273
276
  }
274
277
  seenSkillNames.set(nameKey, { name, source, root: sourceSkillsDir });
@@ -276,13 +279,13 @@ export async function syncFirstPartySkillsToVendor(sourceRoot, moluoHome, role =
276
279
  }
277
280
  const skillSources = [...seenSkillNames.values()].map(({ name, source }) => ({ name, source }));
278
281
  const currentSkillNames = new Set(skillSources.map(skill => skill.name));
279
- const normalizedSourceSkillRoots = sourceSkillRoots.map(sourceSkillsDir => path.resolve(sourceSkillsDir));
282
+ const normalizedSourceSkillRoots = sourceSkillRoots.map(canonicalPath);
280
283
  const normalizedManagedSkillRoots = existsSync(rolesRoot)
281
284
  ? readdirSync(rolesRoot, { withFileTypes: true })
282
285
  .filter(entry => entry.isDirectory())
283
286
  .map(entry => path.join(rolesRoot, entry.name, 'skills'))
284
287
  .filter(existsSync)
285
- .map(skillRoot => path.resolve(skillRoot))
288
+ .map(canonicalPath)
286
289
  : normalizedSourceSkillRoots;
287
290
  for (const entry of readdirSync(vendorSkillsDir, { withFileTypes: true })) {
288
291
  const targetPath = path.join(vendorSkillsDir, entry.name);
@@ -293,8 +296,8 @@ export async function syncFirstPartySkillsToVendor(sourceRoot, moluoHome, role =
293
296
  removePath(targetPath);
294
297
  continue;
295
298
  }
296
- const resolvedPath = path.resolve(realpathSync(targetPath));
297
- if (normalizedManagedSkillRoots.some(sourceSkillsDir => resolvedPath.startsWith(sourceSkillsDir)) && !currentSkillNames.has(entry.name)) {
299
+ const resolvedPath = realpathSync(targetPath);
300
+ if (normalizedManagedSkillRoots.some(sourceSkillsDir => isPathInside(sourceSkillsDir, resolvedPath)) && !currentSkillNames.has(entry.name)) {
298
301
  removePath(targetPath);
299
302
  }
300
303
  }
@@ -370,26 +373,72 @@ function escapeTomlString(value) {
370
373
  function tomlKey(key) {
371
374
  return /^[\w-]+$/u.test(key) ? key : `"${escapeTomlString(key)}"`;
372
375
  }
373
- function readRoleMcpServers(moluoHome, role) {
374
- if (!role)
375
- return undefined;
376
- const sourceFile = path.join(moluoHome, 'roles', requireRoleName(role), 'mcp', 'mcp.json');
377
- if (!existsSync(sourceFile))
378
- return undefined;
376
+ function readMcpServerFile(sourceFile) {
379
377
  const stats = lstatSync(sourceFile);
380
378
  if (!stats.isFile() || stats.isSymbolicLink())
381
- throw new Error(`Role MCP source must be a plain file: ${sourceFile}`);
379
+ throw new Error(`MCP source must be a plain file: ${sourceFile}`);
382
380
  const raw = readFileSync(sourceFile, 'utf8').trim();
383
381
  if (!raw)
384
- return undefined;
382
+ return {};
385
383
  let parsed;
386
384
  try {
387
385
  parsed = JSON.parse(raw);
388
386
  }
389
387
  catch (error) {
390
- throw new Error(`Role MCP source is invalid JSON: ${sourceFile}`, { cause: error });
388
+ throw new Error(`MCP source is invalid JSON: ${sourceFile}`, { cause: error });
389
+ }
390
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
391
+ throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
392
+ const servers = parsed.mcpServers;
393
+ if (servers === undefined)
394
+ return {};
395
+ if (servers === null || typeof servers !== 'object' || Array.isArray(servers))
396
+ throw new Error(`MCP source must contain an "mcpServers" object: ${sourceFile}`);
397
+ const serverRecord = servers;
398
+ validateMcpServerNames(serverRecord, sourceFile);
399
+ return serverRecord;
400
+ }
401
+ function readVendorMcpServers(moluoHome) {
402
+ const root = path.join(moluoHome, 'vendor', 'mcps');
403
+ if (!existsSync(root))
404
+ return {};
405
+ const servers = Object.create(null);
406
+ const sources = new Map();
407
+ function visit(directory) {
408
+ const stats = lstatSync(directory);
409
+ if (!stats.isDirectory() || stats.isSymbolicLink())
410
+ throw new Error(`Managed MCP path must be a plain directory: ${directory}`);
411
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
412
+ const entryPath = path.join(directory, entry.name);
413
+ if (entry.isSymbolicLink())
414
+ throw new Error(`Managed MCP path must not be a symbolic link: ${entryPath}`);
415
+ if (entry.isDirectory()) {
416
+ visit(entryPath);
417
+ }
418
+ else if (entry.isFile() && entry.name === 'mcp.json') {
419
+ const fileServers = readMcpServerFile(entryPath);
420
+ for (const [name, server] of Object.entries(fileServers)) {
421
+ const previous = sources.get(name);
422
+ if (previous) {
423
+ throw new Error(`Duplicate shared MCP server "${name}": ${previous} conflicts with ${entryPath}`);
424
+ }
425
+ servers[name] = server;
426
+ sources.set(name, entryPath);
427
+ }
428
+ }
429
+ }
391
430
  }
392
- return parsed.mcpServers && Object.keys(parsed.mcpServers).length > 0 ? parsed.mcpServers : undefined;
431
+ visit(root);
432
+ return servers;
433
+ }
434
+ export function readInstalledMcpServers(moluoHome, role) {
435
+ const servers = readVendorMcpServers(moluoHome);
436
+ if (role) {
437
+ const roleSource = path.join(moluoHome, 'roles', requireRoleName(role), 'mcp', 'mcp.json');
438
+ if (existsSync(roleSource))
439
+ Object.assign(servers, readMcpServerFile(roleSource));
440
+ }
441
+ return Object.keys(servers).length > 0 ? servers : undefined;
393
442
  }
394
443
  function readHostConfigForMerge(targetFile) {
395
444
  if (existsSync(targetFile) && lstatSync(targetFile).isSymbolicLink()) {
@@ -398,7 +447,7 @@ function readHostConfigForMerge(targetFile) {
398
447
  }
399
448
  return existsSync(targetFile) ? readFileSync(targetFile, 'utf8').replace(/^\uFEFF/u, '') : '';
400
449
  }
401
- function applyMcpServerProjection(servers, mcp) {
450
+ export function applyMcpServerProjection(servers, mcp) {
402
451
  const { serverCommandFormat, serverDefaults, serverOverrides } = mcp;
403
452
  if (!serverDefaults && !serverOverrides && serverCommandFormat !== 'command-array')
404
453
  return servers;
@@ -416,7 +465,7 @@ function applyMcpServerProjection(servers, mcp) {
416
465
  }));
417
466
  }
418
467
  function projectMcpToHost(moluoHome, role, mcpHome, mcp) {
419
- const servers = readRoleMcpServers(moluoHome, role);
468
+ const servers = readInstalledMcpServers(moluoHome, role);
420
469
  if (!servers)
421
470
  return;
422
471
  const projectedServers = applyMcpServerProjection(servers, mcp);
@@ -444,11 +493,7 @@ function projectMcpToHost(moluoHome, role, mcpHome, mcp) {
444
493
  }
445
494
  const previous = readHostConfigForMerge(targetFile);
446
495
  const cleaned = previous.replace(/\n*# >>> AIRULES MCP >>>[\s\S]*?(?:# <<< AIRULES MCP <<<|$)\n*/gu, '\n').trimEnd();
447
- const serversKeyPattern = mcp.serversKey.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
448
- const userDeclared = new Set();
449
- const tablePattern = new RegExp(`^\\s*\\[${serversKeyPattern}\\.(?:"([^"]+)"|([\\w-]+))\\]`, 'gmu');
450
- for (const match of cleaned.matchAll(tablePattern))
451
- userDeclared.add(match[1] ?? match[2]);
496
+ const userDeclared = readTomlMcpServerNames(cleaned, mcp.serversKey);
452
497
  const lines = [];
453
498
  for (const [name, value] of Object.entries(projectedServers)) {
454
499
  if (userDeclared.has(name))
@@ -475,6 +520,43 @@ function projectMcpToHost(moluoHome, role, mcpHome, mcp) {
475
520
  const block = `# >>> AIRULES MCP >>>\n${lines.join('\n')}# <<< AIRULES MCP <<<\n`;
476
521
  writeFileSync(targetFile, cleaned ? `${cleaned}\n\n${block}` : block, 'utf8');
477
522
  }
523
+ function decodeTomlBasicKey(value) {
524
+ const escapePattern = /\\(?:([btnfr"\\])|u([\dA-Fa-f]{4})|U([\dA-Fa-f]{8}))/gu;
525
+ let valid = true;
526
+ const decoded = value.replace(escapePattern, (_match, simple, shortHex, longHex) => {
527
+ if (simple) {
528
+ return {
529
+ 'b': '\b',
530
+ 't': '\t',
531
+ 'n': '\n',
532
+ 'f': '\f',
533
+ 'r': '\r',
534
+ '"': '"',
535
+ '\\': '\\',
536
+ }[simple] ?? simple;
537
+ }
538
+ const codePoint = Number.parseInt(shortHex ?? longHex ?? '', 16);
539
+ if (codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
540
+ valid = false;
541
+ return '';
542
+ }
543
+ return String.fromCodePoint(codePoint);
544
+ });
545
+ return valid ? decoded : undefined;
546
+ }
547
+ export function readTomlMcpServerNames(content, serversKey) {
548
+ const names = new Set();
549
+ const escapedServersKey = serversKey.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
550
+ const basicKey = '"((?:[^"\\\\\\r\\n]|\\\\(?:[btnfr"\\\\]|u[\\dA-Fa-f]{4}|U[\\dA-Fa-f]{8}))*)"';
551
+ const literalKey = `'([^'\\r\\n]*)'`;
552
+ const tablePattern = new RegExp(`^\\s*\\[${escapedServersKey}\\.(?:${basicKey}|${literalKey}|([\\w-]+))\\]\\s*(?:#.*)?$`, 'gmu');
553
+ for (const match of content.matchAll(tablePattern)) {
554
+ const name = match[1] === undefined ? (match[2] ?? match[3]) : decodeTomlBasicKey(match[1]);
555
+ if (name !== undefined)
556
+ names.add(name);
557
+ }
558
+ return names;
559
+ }
478
560
  export function projectToHost({ userHome, moluoHome, hostHome, customSkillsDirName = 'skills', excludedSkills = [], projectSkills = true, role = DEFAULT_ROLE, mcpHome = hostHome, mcp, }) {
479
561
  if (projectSkills) {
480
562
  projectSkillsToHost(userHome, moluoHome, path.join(hostHome, customSkillsDirName), { excludedSkills });
@@ -493,8 +575,7 @@ export function projectHostById(host, userHome, moluoHome, role = DEFAULT_ROLE)
493
575
  const { hostHome, projectSkills, skillsDirName, excludedSkills, mcpHome, mcp } = resolveHostPaths(config, userHome);
494
576
  const hostHomePath = path.resolve(hostHome);
495
577
  const hasHostHome = existsSync(hostHomePath);
496
- const hasMcpHome = Boolean(role
497
- && mcp
578
+ const hasMcpHome = Boolean(mcp
498
579
  && existsSync(path.resolve(mcpHome))
499
580
  && (mcp.requireHostHome !== true || hasHostHome));
500
581
  if (!hasHostHome && !hasMcpHome) {
@@ -0,0 +1,7 @@
1
+ import type { SetupCommand } from './vendors.js';
2
+ export interface McpCatalog {
3
+ servers: Record<string, Record<string, unknown>>;
4
+ setup: SetupCommand[];
5
+ }
6
+ export declare function validateMcpServerNames(servers: Record<string, unknown>, sourceFile: string): void;
7
+ export declare function loadMcpCatalog(sourceFile: string): McpCatalog;
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ const reservedServerNames = new Set(['__proto__', 'constructor', 'prototype']);
3
+ function isRecord(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ }
6
+ function parseSetupCommand(value, location) {
7
+ if (!isRecord(value) || typeof value.command !== 'string' || value.command.length === 0) {
8
+ throw new Error(`MCP setup command must declare a non-empty command: ${location}`);
9
+ }
10
+ if (value.args !== undefined && (!Array.isArray(value.args) || !value.args.every(argument => typeof argument === 'string'))) {
11
+ throw new Error(`MCP setup command args must be a string array: ${location}`);
12
+ }
13
+ if (value.windowsCommandShim !== undefined && typeof value.windowsCommandShim !== 'boolean') {
14
+ throw new Error(`MCP setup windowsCommandShim must be boolean: ${location}`);
15
+ }
16
+ if (value.skipIfCommandAvailable !== undefined && typeof value.skipIfCommandAvailable !== 'string') {
17
+ throw new Error(`MCP setup skipIfCommandAvailable must be a string: ${location}`);
18
+ }
19
+ return {
20
+ command: value.command,
21
+ ...(value.args === undefined ? {} : { args: value.args }),
22
+ ...(value.windowsCommandShim === undefined ? {} : { windowsCommandShim: value.windowsCommandShim }),
23
+ ...(value.skipIfCommandAvailable === undefined ? {} : { skipIfCommandAvailable: value.skipIfCommandAvailable }),
24
+ };
25
+ }
26
+ export function validateMcpServerNames(servers, sourceFile) {
27
+ for (const name of Object.keys(servers)) {
28
+ if (name.length === 0) {
29
+ throw new Error(`MCP server name must be non-empty: ${sourceFile}#${name}`);
30
+ }
31
+ if (reservedServerNames.has(name)) {
32
+ throw new Error(`MCP server name is reserved: ${sourceFile}#${name}`);
33
+ }
34
+ }
35
+ }
36
+ export function loadMcpCatalog(sourceFile) {
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
40
+ }
41
+ catch (error) {
42
+ throw new Error(`MCP catalog is invalid JSON: ${sourceFile}`, { cause: error });
43
+ }
44
+ if (!isRecord(parsed) || !isRecord(parsed.mcps)) {
45
+ throw new Error(`MCP catalog must contain an "mcps" object: ${sourceFile}`);
46
+ }
47
+ validateMcpServerNames(parsed.mcps, sourceFile);
48
+ const servers = Object.create(null);
49
+ const setup = [];
50
+ for (const [name, value] of Object.entries(parsed.mcps)) {
51
+ if (!name || !isRecord(value) || !isRecord(value.mcp)) {
52
+ throw new Error(`MCP catalog entry must contain an "mcp" object: ${sourceFile}#${name}`);
53
+ }
54
+ if (value.setup !== undefined && !Array.isArray(value.setup)) {
55
+ throw new Error(`MCP catalog entry setup must be an array: ${sourceFile}#${name}`);
56
+ }
57
+ servers[name] = value.mcp;
58
+ for (const [index, command] of (value.setup ?? []).entries()) {
59
+ setup.push(parseSetupCommand(command, `${sourceFile}#${name}.setup[${index}]`));
60
+ }
61
+ }
62
+ return { servers, setup };
63
+ }
@@ -0,0 +1,36 @@
1
+ interface PackageJson {
2
+ dependencies?: Record<string, string>;
3
+ name?: string;
4
+ optionalDependencies?: Record<string, string>;
5
+ peerDependencies?: Record<string, string>;
6
+ private?: boolean;
7
+ publishConfig?: {
8
+ access?: string;
9
+ };
10
+ scripts?: Record<string, string>;
11
+ version?: string;
12
+ }
13
+ export interface ResolvedRolePackage {
14
+ directory: string;
15
+ name: string;
16
+ packageJson: PackageJson;
17
+ relativePath: string;
18
+ version: string;
19
+ }
20
+ export interface RolePackageWorkspace {
21
+ packages: ResolvedRolePackage[];
22
+ role: string;
23
+ roleRoot: string;
24
+ version: string;
25
+ }
26
+ export interface RoleReleaseTag {
27
+ role: string;
28
+ version: string;
29
+ }
30
+ export declare function parseRoleReleaseTag(tagValue: string): RoleReleaseTag;
31
+ export declare function npmDistTag(version: string): string;
32
+ export declare function compareSemver(left: string, right: string): number;
33
+ export declare function loadRolePackageWorkspace(repoRoot: string, roleValue: unknown): Promise<RolePackageWorkspace>;
34
+ export declare function discoverRolePackageWorkspaces(repoRoot: string): Promise<RolePackageWorkspace[]>;
35
+ export declare function requireReleaseMatchesWorkspace(release: RoleReleaseTag, workspace: RolePackageWorkspace): void;
36
+ export {};