@xfe-repo/cli-plugin-ai-rules 2.0.8 → 2.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +13 -0
- package/dist/formats/mcp-json.d.ts +1 -0
- package/dist/formats/mcp-json.js +61 -11
- package/dist/formats/mcp-toml.d.ts +1 -0
- package/dist/formats/mcp-toml.js +61 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +100 -78
- package/dist/pipeline.js +16 -3
- package/dist/types.d.ts +8 -0
- package/dist/types.js +11 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -7,9 +7,22 @@ AI 编码规则同步插件,从远程仓库拉取 AI 编码规则并注入到
|
|
|
7
7
|
- 声明式 AI 配置管理,从 `xfe.json` 读取 `ai` 配置
|
|
8
8
|
- 远程仓库规则同步,支持本地缓存
|
|
9
9
|
- 多目标格式分发:Markdown、MCP JSON/TOML、技能目录
|
|
10
|
+
- 按 Agent、Sub-Agent、Rules、Skills、MCP 类型独立选择,避免混合列表
|
|
10
11
|
- 智能冲突检测与解决
|
|
11
12
|
- 增量更新规划
|
|
12
13
|
|
|
14
|
+
## 快速选择
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
xfe rules add --type agent
|
|
18
|
+
xfe rules add --type subagent
|
|
19
|
+
xfe rules add --type rule
|
|
20
|
+
xfe rules add --type skill
|
|
21
|
+
xfe rules add --type mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
不传 `--type` 时先选择条目类型;传入后直接进入对应条目列表,其他类型的现有配置保持不变。
|
|
25
|
+
|
|
13
26
|
## 依赖插件
|
|
14
27
|
|
|
15
28
|
- `plugin-git`
|
|
@@ -21,5 +21,6 @@ export interface PatchResult {
|
|
|
21
21
|
hashes: Record<string, string>;
|
|
22
22
|
}
|
|
23
23
|
export declare function patchMcpJsonInjections(config: AiConfig, projectRoot: string, injections: InjectionRegistry): Promise<PatchResult>;
|
|
24
|
+
export declare function stripMcpJsonInjections(content: string): string;
|
|
24
25
|
export declare function getMcpJsonGitignore(target: TargetConfig): string[];
|
|
25
26
|
//# sourceMappingURL=mcp-json.d.ts.map
|
package/dist/formats/mcp-json.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getTarget } from '../targets.js';
|
|
|
14
14
|
import { computeHash } from './shared.js';
|
|
15
15
|
// ─── Constants ──────
|
|
16
16
|
const MCP_SERVER_PREFIX = 'xfe:';
|
|
17
|
+
const getInjectedServerPrefix = (source) => `${MCP_SERVER_PREFIX}${source}:`;
|
|
17
18
|
// ─── Plan ──────
|
|
18
19
|
/**
|
|
19
20
|
* 将 MCP server 项按 target 转换为 PlannedFile
|
|
@@ -23,8 +24,8 @@ const MCP_SERVER_PREFIX = 'xfe:';
|
|
|
23
24
|
export function planMcpJsonFiles(target, items, injections) {
|
|
24
25
|
if (!isMcpJsonTarget(target))
|
|
25
26
|
return [];
|
|
26
|
-
const syncServers =
|
|
27
|
-
const injectionServers = injections ?
|
|
27
|
+
const syncServers = buildSyncServerEntries(items.filter((i) => i.type === SyncItemType.McpServer));
|
|
28
|
+
const injectionServers = injections ? buildInjectedServerEntries(injections) : {};
|
|
28
29
|
const servers = { ...syncServers, ...injectionServers };
|
|
29
30
|
if (Object.keys(servers).length === 0)
|
|
30
31
|
return [];
|
|
@@ -44,10 +45,11 @@ export async function writeMcpJsonFile(file, projectRoot) {
|
|
|
44
45
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
45
46
|
const merged = await mergeWithExisting(absPath, file.content);
|
|
46
47
|
await writeFile(absPath, merged, 'utf-8');
|
|
47
|
-
return computeHash(merged);
|
|
48
|
+
return computeHash(stripMcpJsonInjections(merged));
|
|
48
49
|
}
|
|
49
50
|
export async function patchMcpJsonInjections(config, projectRoot, injections) {
|
|
50
|
-
const servers =
|
|
51
|
+
const servers = buildInjectedServerEntries(injections);
|
|
52
|
+
const injectedPrefixes = collectInjectedServerPrefixes(injections);
|
|
51
53
|
const isEmpty = Object.keys(servers).length === 0;
|
|
52
54
|
let written = 0;
|
|
53
55
|
let removed = 0;
|
|
@@ -68,28 +70,48 @@ export async function patchMcpJsonInjections(config, projectRoot, injections) {
|
|
|
68
70
|
const rootKey = target.mcpRootKey ?? 'mcpServers';
|
|
69
71
|
const parsed = JSON.parse(content);
|
|
70
72
|
const existing = parsed[rootKey] ?? {};
|
|
71
|
-
const
|
|
73
|
+
const keptServers = filterInjectedServers(existing, injectedPrefixes);
|
|
72
74
|
if (isEmpty) {
|
|
73
|
-
if (Object.keys(existing).
|
|
74
|
-
parsed[rootKey] =
|
|
75
|
+
if (Object.keys(existing).length !== Object.keys(keptServers).length) {
|
|
76
|
+
parsed[rootKey] = keptServers;
|
|
75
77
|
await writeFile(absPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
|
|
76
78
|
removed++;
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
else {
|
|
80
|
-
parsed[rootKey] = { ...
|
|
82
|
+
parsed[rootKey] = { ...keptServers, ...servers };
|
|
81
83
|
await writeFile(absPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
|
|
82
84
|
written++;
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
return { written, removed, hashes: {} };
|
|
86
88
|
}
|
|
89
|
+
export function stripMcpJsonInjections(content) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(content);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return content;
|
|
96
|
+
}
|
|
97
|
+
let changed = false;
|
|
98
|
+
for (const [rootKey, value] of Object.entries(parsed)) {
|
|
99
|
+
if (!isRecord(value))
|
|
100
|
+
continue;
|
|
101
|
+
const keptServers = filterInjectedServers(value, []);
|
|
102
|
+
if (Object.keys(keptServers).length === Object.keys(value).length)
|
|
103
|
+
continue;
|
|
104
|
+
parsed[rootKey] = keptServers;
|
|
105
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
return changed ? JSON.stringify(parsed, null, 2) + '\n' : content;
|
|
108
|
+
}
|
|
87
109
|
// ─── Gitignore ──────
|
|
88
110
|
export function getMcpJsonGitignore(target) {
|
|
89
111
|
return isMcpJsonTarget(target) ? [target.mcp] : [];
|
|
90
112
|
}
|
|
91
113
|
// ─── Helpers ──────
|
|
92
|
-
function
|
|
114
|
+
function buildSyncServerEntries(items) {
|
|
93
115
|
const servers = {};
|
|
94
116
|
for (const item of items) {
|
|
95
117
|
if (!item.serverConfig)
|
|
@@ -98,12 +120,23 @@ function buildServerEntries(items) {
|
|
|
98
120
|
}
|
|
99
121
|
return servers;
|
|
100
122
|
}
|
|
123
|
+
function buildInjectedServerEntries(injections) {
|
|
124
|
+
const servers = {};
|
|
125
|
+
for (const [source, items] of injections) {
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
if (item.type !== SyncItemType.McpServer || !item.serverConfig)
|
|
128
|
+
continue;
|
|
129
|
+
servers[`${getInjectedServerPrefix(source)}${item.id}`] = item.serverConfig;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return servers;
|
|
133
|
+
}
|
|
101
134
|
function buildMcpJson(target, servers) {
|
|
102
135
|
const rootKey = target.mcpRootKey ?? 'mcpServers';
|
|
103
136
|
return JSON.stringify({ [rootKey]: servers }, null, 2) + '\n';
|
|
104
137
|
}
|
|
105
|
-
function
|
|
106
|
-
return [...injections.
|
|
138
|
+
function collectInjectedServerPrefixes(injections) {
|
|
139
|
+
return [...injections.keys()].map(getInjectedServerPrefix);
|
|
107
140
|
}
|
|
108
141
|
function filterUserServers(servers) {
|
|
109
142
|
const user = {};
|
|
@@ -113,6 +146,23 @@ function filterUserServers(servers) {
|
|
|
113
146
|
}
|
|
114
147
|
return user;
|
|
115
148
|
}
|
|
149
|
+
function filterInjectedServers(servers, injectedPrefixes) {
|
|
150
|
+
const kept = {};
|
|
151
|
+
for (const [key, value] of Object.entries(servers)) {
|
|
152
|
+
if (isInjectedServerKey(key, injectedPrefixes))
|
|
153
|
+
continue;
|
|
154
|
+
kept[key] = value;
|
|
155
|
+
}
|
|
156
|
+
return kept;
|
|
157
|
+
}
|
|
158
|
+
function isInjectedServerKey(key, injectedPrefixes) {
|
|
159
|
+
if (injectedPrefixes.some((prefix) => key.startsWith(prefix)))
|
|
160
|
+
return true;
|
|
161
|
+
return key.startsWith(MCP_SERVER_PREFIX) && key.slice(MCP_SERVER_PREFIX.length).includes(':');
|
|
162
|
+
}
|
|
163
|
+
function isRecord(value) {
|
|
164
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
165
|
+
}
|
|
116
166
|
async function mergeWithExisting(absPath, newContent) {
|
|
117
167
|
if (!existsSync(absPath))
|
|
118
168
|
return newContent;
|
|
@@ -17,5 +17,6 @@ export interface PatchResult {
|
|
|
17
17
|
hashes: Record<string, string>;
|
|
18
18
|
}
|
|
19
19
|
export declare function patchMcpTomlInjections(config: AiConfig, projectRoot: string, injections: InjectionRegistry): Promise<PatchResult>;
|
|
20
|
+
export declare function stripMcpTomlInjections(content: string): string;
|
|
20
21
|
export declare function getMcpTomlGitignore(target: TargetConfig): string[];
|
|
21
22
|
//# sourceMappingURL=mcp-toml.d.ts.map
|
package/dist/formats/mcp-toml.js
CHANGED
|
@@ -19,6 +19,8 @@ const DEFAULT_SERVER_KEY = 'mcp_server';
|
|
|
19
19
|
const SERVER_KEY_HASH_LENGTH = 8;
|
|
20
20
|
const MANAGED_BLOCK_BEGIN = '# xfe-ai-rules:mcp:begin';
|
|
21
21
|
const MANAGED_BLOCK_END = '# xfe-ai-rules:mcp:end';
|
|
22
|
+
const INJECTED_BLOCK_BEGIN = '# xfe-ai-rules:mcp-injected:begin';
|
|
23
|
+
const INJECTED_BLOCK_END = '# xfe-ai-rules:mcp-injected:end';
|
|
22
24
|
const TOML_BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
23
25
|
const SERVER_KEY_INVALID_CHARS_PATTERN = /[^a-z0-9]+/g;
|
|
24
26
|
const SERVER_KEY_EDGE_UNDERSCORES_PATTERN = /^_+|_+$/g;
|
|
@@ -28,16 +30,17 @@ export function planMcpTomlFiles(target, items, injections) {
|
|
|
28
30
|
if (!isMcpTomlTarget(target))
|
|
29
31
|
return [];
|
|
30
32
|
const syncServers = buildServerEntries(items.filter((i) => i.type === SyncItemType.McpServer));
|
|
31
|
-
const injectionServers = injections ?
|
|
32
|
-
const
|
|
33
|
-
|
|
33
|
+
const injectionServers = injections ? buildInjectedServerEntries(injections) : {};
|
|
34
|
+
const hasSyncServers = Object.keys(syncServers).length > 0;
|
|
35
|
+
const hasInjectionServers = Object.keys(injectionServers).length > 0;
|
|
36
|
+
if (!hasSyncServers && !hasInjectionServers)
|
|
34
37
|
return [];
|
|
35
38
|
return [
|
|
36
39
|
{
|
|
37
40
|
relativePath: target.mcp,
|
|
38
|
-
content: buildMcpToml(target,
|
|
41
|
+
content: buildMcpToml(target, { syncServers, injectionServers }),
|
|
39
42
|
action: 'write',
|
|
40
|
-
source:
|
|
43
|
+
source: hasSyncServers ? 'sync' : 'injection',
|
|
41
44
|
format: 'mcp-toml',
|
|
42
45
|
},
|
|
43
46
|
];
|
|
@@ -48,10 +51,10 @@ export async function writeMcpTomlFile(file, projectRoot) {
|
|
|
48
51
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
49
52
|
const merged = await mergeWithExisting(absPath, file.content);
|
|
50
53
|
await writeFile(absPath, merged, 'utf-8');
|
|
51
|
-
return computeHash(merged);
|
|
54
|
+
return computeHash(stripMcpTomlInjections(merged));
|
|
52
55
|
}
|
|
53
56
|
export async function patchMcpTomlInjections(config, projectRoot, injections) {
|
|
54
|
-
const servers =
|
|
57
|
+
const servers = buildInjectedServerEntries(injections);
|
|
55
58
|
const isEmpty = Object.keys(servers).length === 0;
|
|
56
59
|
let written = 0;
|
|
57
60
|
let removed = 0;
|
|
@@ -63,15 +66,13 @@ export async function patchMcpTomlInjections(config, projectRoot, injections) {
|
|
|
63
66
|
if (!existsSync(absPath)) {
|
|
64
67
|
if (!isEmpty) {
|
|
65
68
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
66
|
-
await writeFile(absPath, buildMcpToml(target,
|
|
69
|
+
await writeFile(absPath, buildMcpToml(target, { syncServers: {}, injectionServers: servers }), 'utf-8');
|
|
67
70
|
written++;
|
|
68
71
|
}
|
|
69
72
|
continue;
|
|
70
73
|
}
|
|
71
74
|
const content = await readFile(absPath, 'utf-8');
|
|
72
|
-
const updated =
|
|
73
|
-
? removeManagedBlock(content)
|
|
74
|
-
: mergeTomlContent(content, buildMcpToml(target, servers, true), getRootKey(target));
|
|
75
|
+
const updated = mergeInjectedTomlContent(content, buildMcpToml(target, { syncServers: {}, injectionServers: servers }), getRootKey(target));
|
|
75
76
|
if (updated === content)
|
|
76
77
|
continue;
|
|
77
78
|
await writeFile(absPath, updated, 'utf-8');
|
|
@@ -79,6 +80,10 @@ export async function patchMcpTomlInjections(config, projectRoot, injections) {
|
|
|
79
80
|
}
|
|
80
81
|
return { written, removed, hashes: {} };
|
|
81
82
|
}
|
|
83
|
+
export function stripMcpTomlInjections(content) {
|
|
84
|
+
const stripped = removeInjectedBlock(content).trimEnd();
|
|
85
|
+
return stripped ? stripped + '\n' : '';
|
|
86
|
+
}
|
|
82
87
|
// ─── Gitignore ──────
|
|
83
88
|
export function getMcpTomlGitignore(target) {
|
|
84
89
|
return isMcpTomlTarget(target) ? [target.mcp] : [];
|
|
@@ -94,10 +99,22 @@ function buildServerEntries(items) {
|
|
|
94
99
|
}
|
|
95
100
|
return servers;
|
|
96
101
|
}
|
|
97
|
-
function buildMcpToml(target,
|
|
102
|
+
function buildMcpToml(target, options) {
|
|
98
103
|
const rootKey = getRootKey(target);
|
|
99
|
-
const
|
|
100
|
-
const
|
|
104
|
+
const blocks = [];
|
|
105
|
+
const syncEntries = Object.entries(options.syncServers);
|
|
106
|
+
const injectionEntries = Object.entries(options.injectionServers);
|
|
107
|
+
if (syncEntries.length > 0) {
|
|
108
|
+
blocks.push(buildMcpTomlBlock(MANAGED_BLOCK_BEGIN, MANAGED_BLOCK_END, rootKey, syncEntries, true));
|
|
109
|
+
}
|
|
110
|
+
if (injectionEntries.length > 0) {
|
|
111
|
+
blocks.push(buildMcpTomlBlock(INJECTED_BLOCK_BEGIN, INJECTED_BLOCK_END, rootKey, injectionEntries, blocks.length === 0));
|
|
112
|
+
}
|
|
113
|
+
return blocks.join('\n');
|
|
114
|
+
}
|
|
115
|
+
function buildMcpTomlBlock(beginMarker, endMarker, rootKey, serverEntries, includeRootTable) {
|
|
116
|
+
const serverBlocks = serverEntries.map(([name, serverConfig]) => buildServerBlock(rootKey, name, serverConfig));
|
|
117
|
+
const lines = [beginMarker, includeRootTable ? `[${formatTomlKey(rootKey)}]` : '', ...serverBlocks, endMarker];
|
|
101
118
|
return normalizeTomlLines(lines);
|
|
102
119
|
}
|
|
103
120
|
function buildServerBlock(rootKey, name, serverConfig) {
|
|
@@ -115,13 +132,30 @@ async function mergeWithExisting(absPath, newContent) {
|
|
|
115
132
|
return mergeTomlContent(content, newContent, DEFAULT_ROOT_KEY);
|
|
116
133
|
}
|
|
117
134
|
function mergeTomlContent(content, newContent, rootKey) {
|
|
118
|
-
const stripped = removeManagedBlock(content);
|
|
135
|
+
const stripped = removeInjectedBlock(removeManagedBlock(content));
|
|
136
|
+
const replacement = hasRootScope(stripped, rootKey) ? removeRootTable(newContent, rootKey) : newContent;
|
|
137
|
+
return appendTomlContent(stripped, replacement);
|
|
138
|
+
}
|
|
139
|
+
function mergeInjectedTomlContent(content, newContent, rootKey) {
|
|
140
|
+
const stripped = removeInjectedBlock(content);
|
|
119
141
|
const replacement = hasRootScope(stripped, rootKey) ? removeRootTable(newContent, rootKey) : newContent;
|
|
120
|
-
return
|
|
142
|
+
return appendTomlContent(stripped, replacement);
|
|
121
143
|
}
|
|
122
144
|
function removeManagedBlock(content) {
|
|
123
145
|
return spliceMarkerBlock(content, MANAGED_BLOCK_BEGIN, MANAGED_BLOCK_END);
|
|
124
146
|
}
|
|
147
|
+
function removeInjectedBlock(content) {
|
|
148
|
+
return spliceMarkerBlock(content, INJECTED_BLOCK_BEGIN, INJECTED_BLOCK_END);
|
|
149
|
+
}
|
|
150
|
+
function appendTomlContent(content, replacement) {
|
|
151
|
+
const base = content.trimEnd();
|
|
152
|
+
const next = replacement.trimEnd();
|
|
153
|
+
if (!next)
|
|
154
|
+
return base ? base + '\n' : '';
|
|
155
|
+
if (!base)
|
|
156
|
+
return next + '\n';
|
|
157
|
+
return `${base}\n\n${next}\n`;
|
|
158
|
+
}
|
|
125
159
|
function removeRootTable(content, rootKey) {
|
|
126
160
|
const lines = content.split('\n');
|
|
127
161
|
const rootTableLine = `[${formatTomlKey(rootKey)}]`;
|
|
@@ -133,8 +167,17 @@ function hasRootScope(content, rootKey) {
|
|
|
133
167
|
const rootScopePattern = new RegExp(`^\\s*\\[\\s*${formattedRootKey}(?:\\s*\\]|\\s*\\.)`, 'm');
|
|
134
168
|
return rootScopePattern.test(content);
|
|
135
169
|
}
|
|
136
|
-
function
|
|
137
|
-
|
|
170
|
+
function buildInjectedServerEntries(injections) {
|
|
171
|
+
const servers = {};
|
|
172
|
+
for (const [source, items] of injections) {
|
|
173
|
+
for (const item of items) {
|
|
174
|
+
if (item.type !== SyncItemType.McpServer || !item.serverConfig)
|
|
175
|
+
continue;
|
|
176
|
+
const serverKey = createUniqueServerKey(`${source}-${item.id}`, servers);
|
|
177
|
+
servers[serverKey] = item.serverConfig;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return servers;
|
|
138
181
|
}
|
|
139
182
|
function createUniqueServerKey(id, servers) {
|
|
140
183
|
const normalizedKey = normalizeServerKey(id);
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { XfePlugin } from '@xfe-repo/cli-core';
|
|
8
8
|
import type { AiRulesCapability, AiRulesStoreState } from './types.js';
|
|
9
|
-
export { SyncItemType } from './types.js';
|
|
9
|
+
export { RulesAddType, SyncItemType } from './types.js';
|
|
10
10
|
export type { AiRulesCapability, SyncEntry } from './types.js';
|
|
11
11
|
declare const AI_RULES_STORE_INITIAL: AiRulesStoreState;
|
|
12
12
|
declare module '@xfe-repo/cli-core' {
|
package/dist/index.js
CHANGED
|
@@ -4,20 +4,39 @@
|
|
|
4
4
|
* 声明式 AI 配置同步:从 xfe.json 读取 ai 配置,
|
|
5
5
|
* 拉取远程仓库内容,按 target 注册表分发到项目 IDE 配置
|
|
6
6
|
*/
|
|
7
|
-
import { aiConfigSchema, rulesAddParameters, rulesSyncParameters, rulesTargetsParameters } from './types.js';
|
|
8
|
-
import { SyncItemType } from './types.js';
|
|
7
|
+
import { aiConfigSchema, rulesAddParameters, rulesSyncParameters, rulesTargetsParameters, RulesAddType, SyncItemType } from './types.js';
|
|
9
8
|
import { planSync, detectConflicts, resolveConflicts, executePlan, checkSyncStatus, applyInjections as applyInjectionsToFiles, } from './pipeline.js';
|
|
10
9
|
import { scanRemoteEntries, loadSelectedItems } from './scanner.js';
|
|
11
10
|
import { fetchCachedRepo } from './repo-cache.js';
|
|
12
11
|
import { getTargetChoices } from './targets.js';
|
|
13
12
|
import { removeStaleSkillDirs } from './formats/skill-dir.js';
|
|
14
|
-
export { SyncItemType } from './types.js';
|
|
13
|
+
export { RulesAddType, SyncItemType } from './types.js';
|
|
15
14
|
// ─── Store ──────
|
|
16
15
|
const AI_RULES_STORE_INITIAL = {
|
|
17
16
|
lastSyncHash: {},
|
|
18
17
|
lastRemoteHash: '',
|
|
19
18
|
lastSyncTime: 0,
|
|
20
19
|
};
|
|
20
|
+
const RULES_ADD_TYPE_CHOICES = [
|
|
21
|
+
{ name: '主 Agent', value: RulesAddType.Agent, description: '选择写入 AGENTS.md 或 CLAUDE.md 的主助手' },
|
|
22
|
+
{ name: 'Sub-Agent', value: RulesAddType.Subagent, description: '选择可按需调用的子助手' },
|
|
23
|
+
{ name: '编码规则 (Rules)', value: RulesAddType.Rule, description: '选择项目编码规范' },
|
|
24
|
+
{ name: 'AI 技能 (Skills)', value: RulesAddType.Skill, description: '选择可复用的 AI 工作流' },
|
|
25
|
+
{ name: 'MCP 服务', value: RulesAddType.Mcp, description: '选择外部工具连接' },
|
|
26
|
+
];
|
|
27
|
+
const ENTRY_TYPE_BY_RULES_ADD_TYPE = {
|
|
28
|
+
agent: SyncItemType.Agent,
|
|
29
|
+
subagent: SyncItemType.Agent,
|
|
30
|
+
rule: SyncItemType.Rule,
|
|
31
|
+
skill: SyncItemType.Skill,
|
|
32
|
+
mcp: SyncItemType.McpServer,
|
|
33
|
+
};
|
|
34
|
+
const CONFIG_KEY_BY_RULES_ADD_TYPE = {
|
|
35
|
+
subagent: 'subagents',
|
|
36
|
+
rule: 'rules',
|
|
37
|
+
skill: 'skills',
|
|
38
|
+
mcp: 'mcpServers',
|
|
39
|
+
};
|
|
21
40
|
// ─── 插件工厂 ──────
|
|
22
41
|
export function aiRulesPlugin() {
|
|
23
42
|
const injectionRegistry = new Map();
|
|
@@ -30,7 +49,8 @@ export function aiRulesPlugin() {
|
|
|
30
49
|
injectionRegistry.set(source, items);
|
|
31
50
|
},
|
|
32
51
|
removeInjectedRules(source) {
|
|
33
|
-
|
|
52
|
+
// 保留 source tombstone,MCP 热清理需要知道要删除哪个动态前缀。
|
|
53
|
+
injectionRegistry.set(source, []);
|
|
34
54
|
},
|
|
35
55
|
async applyInjections(ctx) {
|
|
36
56
|
const config = parseAiConfig(ctx);
|
|
@@ -68,8 +88,8 @@ export function aiRulesPlugin() {
|
|
|
68
88
|
});
|
|
69
89
|
hooks.registerCommand({
|
|
70
90
|
name: 'rules:add',
|
|
71
|
-
simpleDescription: '
|
|
72
|
-
description: '
|
|
91
|
+
simpleDescription: '按类型选择远程 AI 条目',
|
|
92
|
+
description: '按 agent、subagent、rule、skill 或 mcp 类型选择远程条目并更新 xfe.json;传入 type 可跳过类型选择,其他类型的现有配置保持不变。',
|
|
73
93
|
parameters: rulesAddParameters,
|
|
74
94
|
execute: (ctx) => executeAddCommand(ctx, injectionRegistry),
|
|
75
95
|
});
|
|
@@ -146,7 +166,7 @@ async function executeSyncCommand(ctx, injections) {
|
|
|
146
166
|
return;
|
|
147
167
|
}
|
|
148
168
|
const hasItems = config.agent || config.subagents.length > 0 || config.rules.length > 0 || config.skills.length > 0 || config.mcpServers.length > 0;
|
|
149
|
-
if (!hasItems && injections
|
|
169
|
+
if (!hasItems && !hasInjectedItems(injections)) {
|
|
150
170
|
ctx.logger.warn('未配置任何 agent/subagents/rules/skills/mcpServers,请先运行 xfe rules add');
|
|
151
171
|
return;
|
|
152
172
|
}
|
|
@@ -195,40 +215,17 @@ async function executeAddCommand(ctx, injections) {
|
|
|
195
215
|
const cachePath = await fetchCachedRepo(ctx, git, config);
|
|
196
216
|
const entries = await scanRemoteEntries(cachePath);
|
|
197
217
|
spinner.stop();
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
let agentId;
|
|
203
|
-
if (selectedAgents.length === 1) {
|
|
204
|
-
agentId = selectedAgents[0].id;
|
|
205
|
-
}
|
|
206
|
-
else if (selectedAgents.length > 1) {
|
|
207
|
-
agentId = await promptMainAgentSelection(ctx, selectedAgents, config.agent);
|
|
208
|
-
}
|
|
209
|
-
const { rules, skills, mcpServers, subagents } = classifySelectedIds(entries, selectedIds, agentId);
|
|
218
|
+
const selectionType = await promptRulesAddType(ctx);
|
|
219
|
+
const selectionPatch = await promptTypeSelection(ctx, entries, config, selectionType);
|
|
220
|
+
if (!selectionPatch)
|
|
221
|
+
return;
|
|
210
222
|
ctx.store.setPluginState('xfe', {
|
|
211
223
|
aiRules: {
|
|
212
224
|
...ctx.store.getState().xfe?.aiRules,
|
|
213
|
-
|
|
214
|
-
subagents,
|
|
215
|
-
rules,
|
|
216
|
-
skills,
|
|
217
|
-
mcpServers,
|
|
225
|
+
...selectionPatch,
|
|
218
226
|
},
|
|
219
227
|
});
|
|
220
|
-
|
|
221
|
-
agentId && `agent: ${agentId}`,
|
|
222
|
-
subagents.length > 0 && `${subagents.length} subagents`,
|
|
223
|
-
rules.length > 0 && `${rules.length} rules`,
|
|
224
|
-
skills.length > 0 && `${skills.length} skills`,
|
|
225
|
-
mcpServers.length > 0 && `${mcpServers.length} mcp-servers`,
|
|
226
|
-
].filter(Boolean);
|
|
227
|
-
ctx.logger.success(`已写入 xfe.json: ${parts.join(', ') || '无选择'}`);
|
|
228
|
-
if (parts.length === 0) {
|
|
229
|
-
ctx.logger.warn('未选择任何条目,配置已清空');
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
228
|
+
logSelectionResult(ctx, selectionType, selectionPatch);
|
|
232
229
|
const { sync } = await ctx.prompt.ask({
|
|
233
230
|
type: 'confirm',
|
|
234
231
|
name: 'sync',
|
|
@@ -270,34 +267,68 @@ async function executeTargetsCommand(ctx) {
|
|
|
270
267
|
}
|
|
271
268
|
}
|
|
272
269
|
// ─── 交互提示 ──────
|
|
273
|
-
async function
|
|
274
|
-
const
|
|
275
|
-
name: entry.id,
|
|
276
|
-
value: entry.id,
|
|
277
|
-
description: entry.description,
|
|
278
|
-
}));
|
|
279
|
-
const { primaryAgent } = await ctx.prompt.ask({
|
|
270
|
+
async function promptRulesAddType(ctx) {
|
|
271
|
+
const { type } = await ctx.prompt.ask({
|
|
280
272
|
type: 'list',
|
|
273
|
+
name: 'type',
|
|
274
|
+
message: '选择要配置的 AI 条目类型:',
|
|
275
|
+
choices: RULES_ADD_TYPE_CHOICES,
|
|
276
|
+
});
|
|
277
|
+
if (Object.values(RulesAddType).includes(type))
|
|
278
|
+
return type;
|
|
279
|
+
throw new Error(`不支持的 AI 条目类型: ${String(type)}`);
|
|
280
|
+
}
|
|
281
|
+
async function promptTypeSelection(ctx, entries, config, selectionType) {
|
|
282
|
+
const selectableEntries = getSelectableEntries(entries, selectionType, config.agent);
|
|
283
|
+
if (selectableEntries.length === 0) {
|
|
284
|
+
ctx.logger.warn(`${getRulesAddTypeLabel(selectionType)} 暂无可选项`);
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
if (selectionType === RulesAddType.Agent) {
|
|
288
|
+
const agent = await promptPrimaryAgentSelection(ctx, selectableEntries, config.agent);
|
|
289
|
+
return {
|
|
290
|
+
agent,
|
|
291
|
+
subagents: config.subagents.filter((id) => id !== agent),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const configKey = CONFIG_KEY_BY_RULES_ADD_TYPE[selectionType];
|
|
295
|
+
if (!configKey)
|
|
296
|
+
return undefined;
|
|
297
|
+
const selectedIds = await promptItemSelection(ctx, selectableEntries, new Set(config[configKey]), selectionType);
|
|
298
|
+
const selectionPatch = {};
|
|
299
|
+
selectionPatch[configKey] = [...selectedIds];
|
|
300
|
+
return selectionPatch;
|
|
301
|
+
}
|
|
302
|
+
async function promptPrimaryAgentSelection(ctx, agents, currentAgent) {
|
|
303
|
+
const choices = [
|
|
304
|
+
{ name: '不配置主 Agent', value: '', description: '清除当前主 Agent' },
|
|
305
|
+
...agents.map((entry) => ({
|
|
306
|
+
name: entry.id,
|
|
307
|
+
value: entry.id,
|
|
308
|
+
description: getEntryChoiceDescription(entry),
|
|
309
|
+
})),
|
|
310
|
+
];
|
|
311
|
+
const { primaryAgent } = await ctx.prompt.ask({
|
|
312
|
+
type: 'search-list',
|
|
281
313
|
name: 'primaryAgent',
|
|
282
|
-
message: '
|
|
314
|
+
message: '选择主 Agent:',
|
|
283
315
|
choices,
|
|
284
|
-
default: currentAgent
|
|
316
|
+
default: currentAgent ?? '',
|
|
285
317
|
});
|
|
286
318
|
return primaryAgent || undefined;
|
|
287
319
|
}
|
|
288
|
-
async function promptItemSelection(ctx, entries, currentIds) {
|
|
289
|
-
if (entries.length === 0)
|
|
290
|
-
return new Set();
|
|
320
|
+
async function promptItemSelection(ctx, entries, currentIds, selectionType) {
|
|
291
321
|
const choices = entries.map((entry) => {
|
|
292
322
|
const description = getEntryChoiceDescription(entry);
|
|
293
323
|
return {
|
|
294
|
-
name:
|
|
324
|
+
name: entry.id,
|
|
295
325
|
value: entry.id,
|
|
296
326
|
description,
|
|
297
327
|
short: entry.id,
|
|
298
328
|
};
|
|
299
329
|
});
|
|
300
|
-
const
|
|
330
|
+
const label = getRulesAddTypeLabel(selectionType);
|
|
331
|
+
const message = currentIds.size > 0 ? `选择 ${label} (当前已选 ${currentIds.size} 项):` : `选择 ${label}:`;
|
|
301
332
|
const { selectedIds = [] } = await ctx.prompt.ask({
|
|
302
333
|
type: 'search-checkbox',
|
|
303
334
|
name: 'selectedIds',
|
|
@@ -308,31 +339,22 @@ async function promptItemSelection(ctx, entries, currentIds) {
|
|
|
308
339
|
return new Set(selectedIds);
|
|
309
340
|
}
|
|
310
341
|
// ─── Helpers ──────
|
|
311
|
-
function
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
case SyncItemType.Skill:
|
|
324
|
-
skills.push(entry.id);
|
|
325
|
-
break;
|
|
326
|
-
case SyncItemType.McpServer:
|
|
327
|
-
mcpServers.push(entry.id);
|
|
328
|
-
break;
|
|
329
|
-
case SyncItemType.Agent:
|
|
330
|
-
if (entry.id !== agentId)
|
|
331
|
-
subagents.push(entry.id);
|
|
332
|
-
break;
|
|
333
|
-
}
|
|
342
|
+
function getSelectableEntries(entries, selectionType, currentAgent) {
|
|
343
|
+
const entryType = ENTRY_TYPE_BY_RULES_ADD_TYPE[selectionType];
|
|
344
|
+
return entries.filter((entry) => entry.type === entryType && !(selectionType === RulesAddType.Subagent && entry.id === currentAgent));
|
|
345
|
+
}
|
|
346
|
+
function getRulesAddTypeLabel(selectionType) {
|
|
347
|
+
return RULES_ADD_TYPE_CHOICES.find(({ value }) => value === selectionType)?.name ?? selectionType;
|
|
348
|
+
}
|
|
349
|
+
function logSelectionResult(ctx, selectionType, selectionPatch) {
|
|
350
|
+
const label = getRulesAddTypeLabel(selectionType);
|
|
351
|
+
if (selectionType === RulesAddType.Agent) {
|
|
352
|
+
ctx.logger.success(selectionPatch.agent ? `已更新主 Agent: ${selectionPatch.agent}` : '已清除主 Agent');
|
|
353
|
+
return;
|
|
334
354
|
}
|
|
335
|
-
|
|
355
|
+
const configKey = CONFIG_KEY_BY_RULES_ADD_TYPE[selectionType];
|
|
356
|
+
const selectedCount = configKey ? (selectionPatch[configKey]?.length ?? 0) : 0;
|
|
357
|
+
ctx.logger.success(`已更新 ${label}: ${selectedCount} 项`);
|
|
336
358
|
}
|
|
337
359
|
function collectSelectedIds(config) {
|
|
338
360
|
const ids = [...config.subagents, ...config.rules, ...config.skills, ...config.mcpServers];
|
|
@@ -340,11 +362,11 @@ function collectSelectedIds(config) {
|
|
|
340
362
|
ids.push(config.agent);
|
|
341
363
|
return new Set(ids);
|
|
342
364
|
}
|
|
365
|
+
function hasInjectedItems(injections) {
|
|
366
|
+
return [...injections.values()].some((items) => items.length > 0);
|
|
367
|
+
}
|
|
343
368
|
function getEntryChoiceDescription(entry) {
|
|
344
|
-
const descriptions = [
|
|
345
|
-
entry.description,
|
|
346
|
-
entry.appliesTo?.length ? `适用: ${entry.appliesTo.join(', ')}` : undefined,
|
|
347
|
-
].filter(Boolean);
|
|
369
|
+
const descriptions = [entry.description, entry.appliesTo?.length ? `适用: ${entry.appliesTo.join(', ')}` : undefined].filter(Boolean);
|
|
348
370
|
if (descriptions.length === 0)
|
|
349
371
|
return undefined;
|
|
350
372
|
return descriptions.join(' · ');
|
package/dist/pipeline.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { McpConfigFormat } from './types.js';
|
|
11
12
|
import { getTarget } from './targets.js';
|
|
12
13
|
import { getCachePath, hasCachedRepo } from './repo-cache.js';
|
|
13
14
|
import { computeHash, spliceMarkerBlock } from './formats/shared.js';
|
|
14
15
|
import { planMarkdownFiles, detectMarkdownConflict, writeMarkdownFile, patchMarkdownInjections, getMarkdownGitignore, stripInjectedSections, removeStaleInjectedFiles, } from './formats/agent-md.js';
|
|
15
|
-
import { planMcpJsonFiles, writeMcpJsonFile, patchMcpJsonInjections, getMcpJsonGitignore } from './formats/mcp-json.js';
|
|
16
|
-
import { planMcpTomlFiles, writeMcpTomlFile, patchMcpTomlInjections, getMcpTomlGitignore } from './formats/mcp-toml.js';
|
|
16
|
+
import { planMcpJsonFiles, writeMcpJsonFile, patchMcpJsonInjections, getMcpJsonGitignore, stripMcpJsonInjections } from './formats/mcp-json.js';
|
|
17
|
+
import { planMcpTomlFiles, writeMcpTomlFile, patchMcpTomlInjections, getMcpTomlGitignore, stripMcpTomlInjections } from './formats/mcp-toml.js';
|
|
17
18
|
import { planSkillDirs, writeSkillDir, patchSkillDirInjections } from './formats/skill-dir.js';
|
|
18
19
|
// ─── Constants ──────
|
|
19
20
|
const GITIGNORE_BLOCK_START = '# xfe-ai-rules (auto-generated, do not edit)';
|
|
@@ -138,7 +139,7 @@ export async function checkSyncStatus(ctx, config) {
|
|
|
138
139
|
if (!existsSync(absPath))
|
|
139
140
|
return false;
|
|
140
141
|
const content = await readFile(absPath, 'utf-8');
|
|
141
|
-
if (computeHash(
|
|
142
|
+
if (computeHash(stripRuntimeInjections(config, relativePath, content)) !== expectedHash)
|
|
142
143
|
return false;
|
|
143
144
|
}
|
|
144
145
|
return true;
|
|
@@ -174,4 +175,16 @@ async function updateGitignore(projectRoot, entries) {
|
|
|
174
175
|
const updated = spliceMarkerBlock(content, GITIGNORE_BLOCK_START, GITIGNORE_BLOCK_END, newBlock);
|
|
175
176
|
await writeFile(gitignorePath, updated, 'utf-8');
|
|
176
177
|
}
|
|
178
|
+
function stripRuntimeInjections(config, relativePath, content) {
|
|
179
|
+
for (const targetId of config.targets) {
|
|
180
|
+
const target = getTarget(targetId);
|
|
181
|
+
if (!target || target.mcp !== relativePath)
|
|
182
|
+
continue;
|
|
183
|
+
if ((target.mcpFormat ?? McpConfigFormat.Json) === McpConfigFormat.Json)
|
|
184
|
+
return stripMcpJsonInjections(content);
|
|
185
|
+
if (target.mcpFormat === McpConfigFormat.Toml)
|
|
186
|
+
return stripMcpTomlInjections(content);
|
|
187
|
+
}
|
|
188
|
+
return stripInjectedSections(content);
|
|
189
|
+
}
|
|
177
190
|
//# sourceMappingURL=pipeline.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -29,7 +29,15 @@ export declare enum McpConfigFormat {
|
|
|
29
29
|
export declare const rulesTargetsParameters: z.ZodObject<{
|
|
30
30
|
targets: z.ZodOptional<z.ZodArray<z.ZodEnum<typeof SyncTarget>>>;
|
|
31
31
|
}, z.core.$strip>;
|
|
32
|
+
export declare enum RulesAddType {
|
|
33
|
+
Agent = "agent",
|
|
34
|
+
Subagent = "subagent",
|
|
35
|
+
Rule = "rule",
|
|
36
|
+
Skill = "skill",
|
|
37
|
+
Mcp = "mcp"
|
|
38
|
+
}
|
|
32
39
|
export declare const rulesAddParameters: z.ZodObject<{
|
|
40
|
+
type: z.ZodOptional<z.ZodEnum<typeof RulesAddType>>;
|
|
33
41
|
selectedIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
34
42
|
primaryAgent: z.ZodOptional<z.ZodString>;
|
|
35
43
|
sync: z.ZodOptional<z.ZodBoolean>;
|
package/dist/types.js
CHANGED
|
@@ -32,12 +32,18 @@ const syncTargetSchema = z.enum(SyncTarget);
|
|
|
32
32
|
export const rulesTargetsParameters = z.object({
|
|
33
33
|
targets: z.array(syncTargetSchema).optional().describe('需要同步注入的 IDE 目标 ID,可选值: copilot、codex、cursor、claude-code'),
|
|
34
34
|
});
|
|
35
|
+
export var RulesAddType;
|
|
36
|
+
(function (RulesAddType) {
|
|
37
|
+
RulesAddType["Agent"] = "agent";
|
|
38
|
+
RulesAddType["Subagent"] = "subagent";
|
|
39
|
+
RulesAddType["Rule"] = "rule";
|
|
40
|
+
RulesAddType["Skill"] = "skill";
|
|
41
|
+
RulesAddType["Mcp"] = "mcp";
|
|
42
|
+
})(RulesAddType || (RulesAddType = {}));
|
|
35
43
|
export const rulesAddParameters = z.object({
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.describe('需要写入 aiRules 的远程条目 ID 列表,可混合 agent、rule、skill;MCP 调用时应直接传入'),
|
|
40
|
-
primaryAgent: z.string().optional().describe('当 selectedIds 中包含多个 agent 时,指定主 Agent ID;其余 agent 会写入 subagents'),
|
|
44
|
+
type: z.enum(RulesAddType).optional().describe('需要配置的条目类型,可选值: agent、subagent、rule、skill、mcp'),
|
|
45
|
+
selectedIds: z.array(z.string()).optional().describe('type 为 subagent、rule、skill 或 mcp 时,需要写入 aiRules 的远程条目 ID 列表'),
|
|
46
|
+
primaryAgent: z.string().optional().describe('type 为 agent 时,需要写入 aiRules.agent 的主 Agent ID'),
|
|
41
47
|
sync: z.boolean().optional().describe('写入配置后立即执行同步'),
|
|
42
48
|
});
|
|
43
49
|
export const rulesSyncParameters = z.object({
|