@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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @xfe-repo/cli-plugin-ai-rules
2
2
 
3
+ ## 2.0.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 优化插件性能
8
+
9
+ ## 2.0.9
10
+
11
+ ### Patch Changes
12
+
13
+ - 修复deploy 优化mcp
14
+
3
15
  ## 2.0.8
4
16
 
5
17
  ### Patch Changes
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
@@ -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.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
- injectionRegistry.delete(source);
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: '选择远程 AI 条目并写入配置',
72
- description: ' aiRules.source 仓库扫描可用的 agent、rule、skill 条目,并把 selectedIds 写回 xfe.json。若 selectedIds 中包含多个 agent,可通过 primaryAgent 指定主 Agent,其余 agent 会写入 subagents。',
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.size === 0) {
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 currentSelectedIds = collectSelectedIds(config);
199
- const selectedIds = await promptItemSelection(ctx, entries, currentSelectedIds);
200
- // 分类选中项
201
- const selectedAgents = entries.filter((e) => e.type === SyncItemType.Agent && selectedIds.has(e.id));
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
- agent: agentId,
214
- subagents,
215
- rules,
216
- skills,
217
- mcpServers,
225
+ ...selectionPatch,
218
226
  },
219
227
  });
220
- const parts = [
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 promptMainAgentSelection(ctx, selectedAgents, currentAgent) {
274
- const choices = selectedAgents.map((entry) => ({
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: '已选择多个 Agent,请指定主 Agent(输出到 AGENTS.md/CLAUDE.md):',
314
+ message: '选择主 Agent:',
283
315
  choices,
284
- default: currentAgent || undefined,
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: `[${entry.type}] ${entry.id}`,
324
+ name: entry.id,
295
325
  value: entry.id,
296
326
  description,
297
327
  short: entry.id,
298
328
  };
299
329
  });
300
- const message = currentIds.size > 0 ? `选择 AI 规则 (当前已选 ${currentIds.size} 项):` : '选择 AI 规则:';
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 classifySelectedIds(entries, selectedIds, agentId) {
312
- const rules = [];
313
- const skills = [];
314
- const mcpServers = [];
315
- const subagents = [];
316
- for (const entry of entries) {
317
- if (!selectedIds.has(entry.id))
318
- continue;
319
- switch (entry.type) {
320
- case SyncItemType.Rule:
321
- rules.push(entry.id);
322
- break;
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
- return { rules, skills, mcpServers, subagents };
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(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/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
- selectedIds: z
37
- .array(z.string())
38
- .optional()
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({
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.10",
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",