moluoxixi-ai-rules 0.2.1 → 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.
- package/dist/roles/moluoxixi/constants/skills.d.ts +2 -1
- package/dist/roles/moluoxixi/constants/skills.js +23 -11
- package/dist/roles/trellis/constants/skills.js +10 -10
- package/dist/scripts/lib/canonical-path.d.ts +9 -0
- package/dist/scripts/lib/canonical-path.js +65 -0
- package/dist/scripts/lib/install.d.ts +4 -1
- package/dist/scripts/lib/install.js +153 -72
- package/dist/scripts/lib/mcp-catalog.d.ts +7 -0
- package/dist/scripts/lib/mcp-catalog.js +63 -0
- package/dist/scripts/lib/role-packages.d.ts +36 -0
- package/dist/scripts/lib/role-packages.js +180 -0
- package/dist/scripts/lib/roles.js +2 -2
- package/dist/scripts/lib/tool.js +5 -4
- package/dist/scripts/lib/vendor-staging.d.ts +1 -0
- package/dist/scripts/lib/vendor-staging.js +101 -33
- package/dist/scripts/lib/vendors.d.ts +24 -2
- package/dist/scripts/lib/vendors.js +70 -0
- package/dist/scripts/lib/verify.d.ts +1 -1
- package/dist/scripts/lib/verify.js +58 -9
- package/dist/scripts/role-packages.d.ts +8 -0
- package/dist/scripts/role-packages.js +192 -0
- package/package.json +3 -2
- package/roles/moluoxixi/constants/skills.ts +25 -12
- package/roles/moluoxixi/role.yaml +3 -4
- package/roles/trellis/constants/skills.ts +10 -10
|
@@ -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
|
-
|
|
21
|
+
projections: [
|
|
8
22
|
{
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
skipIfCommandAvailable: 'codegraph',
|
|
23
|
+
kind: 'role-assets',
|
|
24
|
+
sourceDir: 'roles/moluoxixi',
|
|
12
25
|
},
|
|
13
26
|
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
kind: 'namespace',
|
|
28
|
+
sourceDir: 'skills/common',
|
|
29
|
+
output: 'common',
|
|
17
30
|
},
|
|
18
|
-
],
|
|
19
|
-
projections: [
|
|
20
31
|
{
|
|
21
|
-
kind: '
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
225
|
-
|
|
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
|
-
&&
|
|
271
|
-
&&
|
|
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(
|
|
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(
|
|
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 =
|
|
297
|
-
if (normalizedManagedSkillRoots.some(sourceSkillsDir =>
|
|
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
|
|
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(`
|
|
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
|
|
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(`
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 {};
|