@xfe-repo/cli-plugin-ai-rules 2.0.8 → 2.0.9

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @xfe-repo/cli-plugin-ai-rules
2
2
 
3
+ ## 2.0.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 修复deploy 优化mcp
8
+
3
9
  ## 2.0.8
4
10
 
5
11
  ### Patch Changes
@@ -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
@@ -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 = buildServerEntries(items.filter((i) => i.type === SyncItemType.McpServer));
27
- const injectionServers = injections ? buildServerEntries(collectMcpItems(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 = buildServerEntries(collectMcpItems(injections));
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 userServers = filterUserServers(existing);
73
+ const keptServers = filterInjectedServers(existing, injectedPrefixes);
72
74
  if (isEmpty) {
73
- if (Object.keys(existing).some((k) => k.startsWith(MCP_SERVER_PREFIX))) {
74
- parsed[rootKey] = userServers;
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] = { ...userServers, ...servers };
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 buildServerEntries(items) {
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 collectMcpItems(injections) {
106
- return [...injections.values()].flat().filter((i) => i.type === SyncItemType.McpServer);
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
@@ -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 ? buildServerEntries(collectMcpItems(injections)) : {};
32
- const servers = { ...syncServers, ...injectionServers };
33
- if (Object.keys(servers).length === 0)
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, servers, true),
41
+ content: buildMcpToml(target, { syncServers, injectionServers }),
39
42
  action: 'write',
40
- source: Object.keys(syncServers).length > 0 ? 'sync' : 'injection',
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 = buildServerEntries(collectMcpItems(injections));
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, servers, true), 'utf-8');
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 = isEmpty
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, servers, includeRootTable) {
102
+ function buildMcpToml(target, options) {
98
103
  const rootKey = getRootKey(target);
99
- const serverBlocks = Object.entries(servers).map(([name, serverConfig]) => buildServerBlock(rootKey, name, serverConfig));
100
- const lines = [MANAGED_BLOCK_BEGIN, includeRootTable ? `[${formatTomlKey(rootKey)}]` : '', ...serverBlocks, MANAGED_BLOCK_END];
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 spliceMarkerBlock(stripped, MANAGED_BLOCK_BEGIN, MANAGED_BLOCK_END, replacement);
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 collectMcpItems(injections) {
137
- return [...injections.values()].flat().filter((i) => i.type === SyncItemType.McpServer);
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.js CHANGED
@@ -30,7 +30,8 @@ export function aiRulesPlugin() {
30
30
  injectionRegistry.set(source, items);
31
31
  },
32
32
  removeInjectedRules(source) {
33
- injectionRegistry.delete(source);
33
+ // 保留 source tombstone,MCP 热清理需要知道要删除哪个动态前缀。
34
+ injectionRegistry.set(source, []);
34
35
  },
35
36
  async applyInjections(ctx) {
36
37
  const config = parseAiConfig(ctx);
@@ -146,7 +147,7 @@ async function executeSyncCommand(ctx, injections) {
146
147
  return;
147
148
  }
148
149
  const hasItems = config.agent || config.subagents.length > 0 || config.rules.length > 0 || config.skills.length > 0 || config.mcpServers.length > 0;
149
- if (!hasItems && injections.size === 0) {
150
+ if (!hasItems && !hasInjectedItems(injections)) {
150
151
  ctx.logger.warn('未配置任何 agent/subagents/rules/skills/mcpServers,请先运行 xfe rules add');
151
152
  return;
152
153
  }
@@ -340,6 +341,9 @@ function collectSelectedIds(config) {
340
341
  ids.push(config.agent);
341
342
  return new Set(ids);
342
343
  }
344
+ function hasInjectedItems(injections) {
345
+ return [...injections.values()].some((items) => items.length > 0);
346
+ }
343
347
  function getEntryChoiceDescription(entry) {
344
348
  const descriptions = [
345
349
  entry.description,
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(stripInjectedSections(content)) !== expectedHash)
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfe-repo/cli-plugin-ai-rules",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
4
4
  "description": "XFE AI Rules plugin - sync AI coding rules from remote repo to IDE configs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",