@playcraft/cli 0.0.39 → 0.0.41

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.
Files changed (121) hide show
  1. package/README.md +66 -3
  2. package/dist/atom-plan/validate-atom-plan.js +298 -0
  3. package/dist/cli-root-help.js +1 -1
  4. package/dist/commands/3d.js +363 -0
  5. package/dist/commands/create.js +337 -0
  6. package/dist/commands/fix-ids.js +17 -3
  7. package/dist/commands/fix-ids.test.js +264 -0
  8. package/dist/commands/image.js +1337 -43
  9. package/dist/commands/login.js +60 -2
  10. package/dist/commands/recommend.js +1 -1
  11. package/dist/commands/remix.js +213 -0
  12. package/dist/commands/skills.js +1379 -0
  13. package/dist/commands/tools-3d.js +473 -0
  14. package/dist/commands/tools-generation.js +454 -0
  15. package/dist/commands/tools-project.js +400 -0
  16. package/dist/commands/tools-research.js +37 -0
  17. package/dist/commands/tools-research.test.js +216 -0
  18. package/dist/commands/tools-utils.js +164 -0
  19. package/dist/commands/tools.js +7 -616
  20. package/dist/config.js +2 -0
  21. package/dist/index.js +20 -2
  22. package/dist/utils/agent-api-client.js +52 -16
  23. package/package.json +9 -3
  24. package/project-template/.claude/agents/designer.md +116 -0
  25. package/project-template/.claude/agents/developer.md +133 -0
  26. package/project-template/.claude/agents/pm.md +164 -0
  27. package/project-template/.claude/agents/refs/README.md +67 -0
  28. package/project-template/.claude/agents/refs/designer-art-style-catalog.md +533 -0
  29. package/project-template/.claude/agents/refs/designer-color-audio-recipes.md +153 -0
  30. package/project-template/.claude/agents/refs/designer-deliverable-spec.md +167 -0
  31. package/project-template/.claude/agents/refs/designer-dimension-axis.md +27 -0
  32. package/project-template/.claude/agents/refs/designer-handoff-v2-checklist.md +68 -0
  33. package/project-template/.claude/agents/refs/designer-master-composite-recipes.md +216 -0
  34. package/project-template/.claude/agents/refs/designer-style-exploration-flow.md +37 -0
  35. package/project-template/.claude/agents/refs/developer-dev-handoff.md +109 -0
  36. package/project-template/.claude/agents/refs/developer-impl-cookbook.md +134 -0
  37. package/project-template/.claude/agents/refs/developer-phase1-flow.md +211 -0
  38. package/project-template/.claude/agents/refs/pm-workflow-detail.md +545 -0
  39. package/project-template/.claude/agents/refs/reviewer-six-dimension-eval.md +286 -0
  40. package/project-template/.claude/agents/refs/ta-3d-flip-recipe.md +85 -0
  41. package/project-template/.claude/agents/refs/ta-atlas-deliverable-standard.md +46 -0
  42. package/project-template/.claude/agents/refs/ta-batch-pipeline-recipes.md +120 -0
  43. package/project-template/.claude/agents/refs/ta-image-generation-detail.md +356 -0
  44. package/project-template/.claude/agents/refs/ta-image-ops-reference.md +495 -0
  45. package/project-template/.claude/agents/refs/ta-pipeline-cookbook.md +699 -0
  46. package/project-template/.claude/agents/refs/ta-tools-reference.md +111 -0
  47. package/project-template/.claude/agents/refs/ta-vfx-preset-catalog.md +365 -0
  48. package/project-template/.claude/agents/reviewer.md +103 -0
  49. package/project-template/.claude/agents/technical-artist.md +111 -0
  50. package/project-template/.claude/hooks/README.md +36 -0
  51. package/project-template/.claude/hooks/validate-atom-plan.mjs +224 -0
  52. package/project-template/.claude/hooks/validate-workflow-stop.mjs +258 -0
  53. package/project-template/.claude/settings.json +32 -0
  54. package/project-template/.claude/settings.local.json +4 -0
  55. package/project-template/.claude/skills/playcraft-ad-psychology/SKILL.md +182 -0
  56. package/project-template/.claude/skills/playcraft-art-style-guide/SKILL.md +123 -0
  57. package/project-template/.claude/skills/playcraft-asset-state-sheet/SKILL.md +141 -0
  58. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +280 -0
  59. package/project-template/.claude/skills/playcraft-batch-pipeline/SKILL.md +184 -0
  60. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +306 -0
  61. package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +229 -0
  62. package/project-template/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +123 -0
  63. package/project-template/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +254 -0
  64. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +235 -0
  65. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +97 -0
  66. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +118 -0
  67. package/project-template/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +137 -0
  68. package/project-template/.claude/skills/playcraft-image-generation/reference/prompt-cookbook.md +397 -0
  69. package/project-template/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +296 -0
  70. package/project-template/.claude/skills/playcraft-image-ops/SKILL.md +122 -0
  71. package/project-template/.claude/skills/playcraft-masking/SKILL.md +373 -0
  72. package/project-template/.claude/skills/playcraft-research/SKILL.md +212 -0
  73. package/project-template/.claude/skills/playcraft-sprite-generation/SKILL.md +423 -0
  74. package/project-template/.claude/skills/playcraft-storyboard/SKILL.md +148 -0
  75. package/project-template/.claude/skills/playcraft-style-qa/SKILL.md +270 -0
  76. package/project-template/.claude/skills/playcraft-text-rendering/SKILL.md +236 -0
  77. package/project-template/.claude/skills/playcraft-vfx-animation/SKILL.md +130 -0
  78. package/project-template/.claude/skills/playcraft-workflow/SKILL.md +396 -0
  79. package/project-template/.cursor/hooks.json +17 -0
  80. package/project-template/.cursor/rules/playcraft-orchestrator.mdc +87 -0
  81. package/project-template/.cursor/rules/playcraft-subagent-boundary.mdc +18 -0
  82. package/project-template/CLAUDE.md +240 -0
  83. package/project-template/assets/audio/bgm/.gitkeep +0 -0
  84. package/project-template/assets/audio/sfx/.gitkeep +0 -0
  85. package/project-template/assets/bundles/.gitkeep +0 -0
  86. package/project-template/assets/images/bg/.gitkeep +0 -0
  87. package/project-template/assets/images/reference/.gitkeep +0 -0
  88. package/project-template/assets/images/storyboard/.gitkeep +0 -0
  89. package/project-template/assets/images/tiles/.gitkeep +0 -0
  90. package/project-template/assets/images/ui/.gitkeep +0 -0
  91. package/project-template/assets/images/vfx/.gitkeep +0 -0
  92. package/project-template/assets/models/.gitkeep +0 -0
  93. package/project-template/docs/team/agent-conduct.md +105 -0
  94. package/project-template/docs/team/agent-runtime-matrix.md +62 -0
  95. package/project-template/docs/team/atom-plan-format.md +74 -0
  96. package/project-template/docs/team/collaboration.md +288 -0
  97. package/project-template/docs/team/core-model.md +50 -0
  98. package/project-template/docs/team/platform-capabilities.md +15 -0
  99. package/project-template/docs/team/workflow-changelog.md +51 -0
  100. package/project-template/docs/team/workflow-consistency-checklist.md +128 -0
  101. package/project-template/game/config/.gitkeep +0 -0
  102. package/project-template/game/gameplay/.gitkeep +0 -0
  103. package/project-template/game/scenes/.gitkeep +0 -0
  104. package/project-template/logs/.gitkeep +0 -0
  105. package/project-template/ta-workspace/logs/.gitkeep +0 -0
  106. package/project-template/ta-workspace/scripts/.gitkeep +0 -0
  107. package/project-template/ta-workspace/tmp/.gitkeep +0 -0
  108. package/project-template/templates/atom-plan.template.json +26 -0
  109. package/project-template/templates/atom-plan.template.md +76 -0
  110. package/project-template/templates/design-brief.template.md +195 -0
  111. package/project-template/templates/design-lens-checklist.reference.md +117 -0
  112. package/project-template/templates/design-methodology.md +99 -0
  113. package/project-template/templates/designer-log.template.md +98 -0
  114. package/project-template/templates/developer-log.template.md +140 -0
  115. package/project-template/templates/five-axis-framework.md +186 -0
  116. package/project-template/templates/intent-clarifications.template.md +58 -0
  117. package/project-template/templates/layout-spec.template.md +132 -0
  118. package/project-template/templates/project-state.template.md +219 -0
  119. package/project-template/templates/review-report.template.md +166 -0
  120. package/project-template/templates/style-exploration.template.md +93 -0
  121. package/project-template/templates/ta-log.template.md +205 -0
@@ -0,0 +1,1379 @@
1
+ /**
2
+ * playcraft skills <command>
3
+ *
4
+ * 开发时 Skill 发现与脚手架工具。
5
+ * 解决问题:Agent 在构建特定类型项目(如 Three.js 3D 棋盘游戏)时,
6
+ * 不知道应该用哪些 skill 文件,导致重复造轮子或遗漏已有实现。
7
+ *
8
+ * 子命令:
9
+ * list 列出所有可用 skill(支持按 engine/category/tag 过滤)
10
+ * match 按引擎+意图推荐完整 skill 集合(含依赖图遍历)
11
+ * scaffold 将指定 skill 的 ref 文件复制到目标项目目录
12
+ *
13
+ * Skill 目录发现优先级:
14
+ * 1. --skills-dir 参数
15
+ * 2. AGENT_SKILLS_PATHS 环境变量(逗号分隔)
16
+ * 3. <cwd>/node_modules/@playcraft/skills/skills
17
+ * 4. <CLI 自身>/../../skills/skills(monorepo 开发时)
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { parseSkillRefsFromAtomPlanProject, validateAtomPlanProject } from '../atom-plan/validate-atom-plan.js';
23
+ import { loadConfig } from '../config.js';
24
+ const KNOWN_ENGINES = ['phaser', 'threejs'];
25
+ const SCORE_THRESHOLD_FULL = 8;
26
+ const SCORE_THRESHOLD_DISCOVERY = 6;
27
+ /** 引擎等非媒体类备选最多展示条数 */
28
+ export const MAX_ALTERNATIVES_PER_CLASS = 3;
29
+ /** 媒体角色(BGM / 背景 / 面板等)每类备选最多展示条数 */
30
+ export const MAX_MEDIA_ALTERNATIVES_PER_CLASS = 10;
31
+ export function capAlternatives(ranked, limit = MAX_ALTERNATIVES_PER_CLASS) {
32
+ return { items: ranked.slice(0, limit), total: ranked.length };
33
+ }
34
+ /** 泛化 intent 词:单独命中时不应拉起大量无关 gameplay / 组件 */
35
+ const GENERIC_INTENT_TAGS = new Set([
36
+ 'grid',
37
+ 'tile',
38
+ 'tiles',
39
+ 'casual',
40
+ 'puzzle',
41
+ 'gameplay',
42
+ 'core',
43
+ 'game',
44
+ 'rules',
45
+ 'algorithm',
46
+ 'logic',
47
+ 'ui',
48
+ 'component',
49
+ 'sprite',
50
+ 'image',
51
+ 'audio',
52
+ 'validator',
53
+ 'balance',
54
+ 'quality',
55
+ 'compliance',
56
+ 'ux',
57
+ 'processing',
58
+ 'pipeline',
59
+ 'sheet',
60
+ 'atlas',
61
+ 'animation',
62
+ 'anim',
63
+ 'render',
64
+ '2d',
65
+ '3d',
66
+ ]);
67
+ function isSpecificIntentTag(tag) {
68
+ return !GENERIC_INTENT_TAGS.has(tag.toLowerCase());
69
+ }
70
+ /** 用户 intent 词 → 技能库常用 tags / 角色(如 mahjong → tile 贴图) */
71
+ const INTENT_TAG_ALIASES = {
72
+ mahjong: ['tile', 'tiles', '牌面'],
73
+ 麻将: ['tile', 'tiles', 'mahjong', '牌面'],
74
+ solitaire: ['solitaire', 'klondike', 'card'],
75
+ };
76
+ const ANCHOR_MEDIA_ROLE_LABELS = {
77
+ tileTextureSet: { label: '牌面/瓦片贴图组', layer: '视觉资产层' },
78
+ backgroundTexture: { label: '场景背景贴图', layer: '背景图层' },
79
+ levelDataPack: { label: '关卡数据包', layer: '配置层' },
80
+ };
81
+ export function expandIntentTags(intentTags) {
82
+ const out = new Set();
83
+ for (const raw of intentTags) {
84
+ const t = raw.trim();
85
+ if (!t)
86
+ continue;
87
+ out.add(t);
88
+ out.add(t.toLowerCase());
89
+ for (const alias of INTENT_TAG_ALIASES[t.toLowerCase()] ?? []) {
90
+ out.add(alias);
91
+ }
92
+ }
93
+ return [...out];
94
+ }
95
+ export function entryMatchesIntentTag(entry, tag) {
96
+ const { manifest } = entry;
97
+ const t = tag.toLowerCase();
98
+ if (manifest.tags?.some((x) => x.toLowerCase() === t || x.toLowerCase().includes(t)))
99
+ return true;
100
+ if (manifest.atomId.toLowerCase().includes(t))
101
+ return true;
102
+ // 中文短词不做 label 子串匹配,避免「牌」命中「翻牌」等误伤
103
+ if (tag.length >= 2 && /[\u4e00-\u9fff]/.test(tag) && manifest.label.zh.includes(tag))
104
+ return true;
105
+ if (manifest.label.en.toLowerCase().includes(t))
106
+ return true;
107
+ const gt = getGameplayType(manifest);
108
+ if (gt && (gt.toLowerCase() === t || gt.toLowerCase().includes(t)))
109
+ return true;
110
+ return false;
111
+ }
112
+ function countIntentMatches(entry, intentTags) {
113
+ let n = 0;
114
+ for (const tag of intentTags) {
115
+ if (entryMatchesIntentTag(entry, tag))
116
+ n++;
117
+ }
118
+ return n;
119
+ }
120
+ function passesGameplayIntentFilter(entry, intentTags) {
121
+ if (entry.manifest.category !== 'gameplay')
122
+ return true;
123
+ const gt = getGameplayType(entry.manifest);
124
+ if (gt && intentTags.some((t) => t.toLowerCase() === gt.toLowerCase()))
125
+ return true;
126
+ return intentTags.some((t) => isSpecificIntentTag(t) && entryMatchesIntentTag(entry, t));
127
+ }
128
+ /** 无直接 atom 命中、但通过 INTENT_TAG_ALIASES 关联到技能的 intent 词 */
129
+ export function findIntentAliasOnlyTags(entries, intentTags) {
130
+ const aliasOnly = [];
131
+ for (const raw of intentTags) {
132
+ const t = raw.trim();
133
+ if (!t)
134
+ continue;
135
+ if (entries.some((e) => entryMatchesIntentTag(e, t)))
136
+ continue;
137
+ const aliases = INTENT_TAG_ALIASES[t.toLowerCase()] ?? [];
138
+ if (aliases.some((a) => entries.some((e) => entryMatchesIntentTag(e, a))))
139
+ aliasOnly.push(t);
140
+ }
141
+ return aliasOnly;
142
+ }
143
+ /** 用户写了但未命中任何 atom 的 intent 词(直连与别名均无匹配) */
144
+ export function findUnresolvedIntentTags(entries, intentTags) {
145
+ const unresolved = [];
146
+ for (const raw of intentTags) {
147
+ const t = raw.trim();
148
+ if (!t)
149
+ continue;
150
+ if (entries.some((e) => entryMatchesIntentTag(e, t)))
151
+ continue;
152
+ const aliases = INTENT_TAG_ALIASES[t.toLowerCase()] ?? [];
153
+ if (aliases.some((a) => entries.some((e) => entryMatchesIntentTag(e, a))))
154
+ continue;
155
+ unresolved.push(t);
156
+ }
157
+ return unresolved;
158
+ }
159
+ // ─── 媒体/配置通用角色(引擎无关,每个可玩广告都需要)──────────────────────
160
+ /**
161
+ * 任何游戏都需要这些 bindingRole 对应的媒体资产。
162
+ * 格式:role → { label, layer }
163
+ */
164
+ const UNIVERSAL_MEDIA_ROLES = {
165
+ // 音频
166
+ bgMusic: { label: '背景音乐(BGM)', layer: '音频层' },
167
+ sfxClick: { label: '点击/消除音效', layer: '音频层' },
168
+ sfxAppear: { label: '路径出现音效', layer: '音频层' },
169
+ sfxSuccess: { label: '成功音效', layer: '音频层' },
170
+ sfxFail: { label: '关卡失败音效', layer: '音频层' },
171
+ sfxLose: { label: '扣命音效', layer: '音频层' },
172
+ sfxMistake: { label: '错误/被阻挡音效', layer: '音频层' },
173
+ sfxArrowMoveError: { label: '箭头无效移动音效', layer: '音频层' },
174
+ sfxButtonClick: { label: 'UI 按钮点击音效', layer: '音频层' },
175
+ sfxWinAnim: { label: '胜利动画音效', layer: '音频层' },
176
+ // 图像:结果面板 & UI 元素
177
+ successFeedbackPanel: { label: '胜利结果面板', layer: '视觉资产层' },
178
+ failFeedbackPanel: { label: '失败结果面板', layer: '视觉资产层' },
179
+ dangerFlashOverlay: { label: '危险警示闪光遮罩', layer: '视觉资产层' },
180
+ normalParticleAsset: { label: '普通粒子素材', layer: '视觉资产层' },
181
+ winParticleAsset: { label: '胜利粒子素材', layer: '视觉资产层' },
182
+ appLogo: { label: '应用 Logo', layer: '视觉资产层' },
183
+ tutorialPointer: { label: '教程手指指针', layer: '视觉资产层' },
184
+ clockIcon: { label: '计时器图标', layer: '视觉资产层' },
185
+ boardEntitySprite: { label: '棋盘实体精灵', layer: '视觉资产层' },
186
+ directionalIndicator: { label: '方向指示器', layer: '视觉资产层' },
187
+ };
188
+ // ─── 层级定义(按构建顺序排序)──────────────────────────────────────────────
189
+ const LAYER_ORDER = {
190
+ '引擎层': 0,
191
+ '构建层': 1,
192
+ '布局层': 2,
193
+ '场景层': 3,
194
+ '状态层': 4,
195
+ '输入层': 5,
196
+ '渲染层': 6,
197
+ '动画层': 7,
198
+ '玩法层': 8,
199
+ '实体层': 9,
200
+ 'UI层': 10,
201
+ '音频层': 11,
202
+ '视觉资产层': 12,
203
+ '背景图层': 13,
204
+ '配置层': 14,
205
+ '工具层': 15,
206
+ '其他': 99,
207
+ };
208
+ function getCategoryLayer(category, atomId) {
209
+ const id = atomId.toLowerCase();
210
+ if (category === 'engine')
211
+ return '引擎层';
212
+ if (category === 'build')
213
+ return '构建层';
214
+ if (category === 'layout')
215
+ return '布局层';
216
+ if (category === 'scene')
217
+ return '场景层';
218
+ if (category === 'state')
219
+ return '状态层';
220
+ if (category === 'gameplay')
221
+ return '玩法层';
222
+ if (category === 'visual' || id.includes('sprite') || id.includes('image') || id.includes('aiimage'))
223
+ return '实体层';
224
+ if (category === 'audio')
225
+ return '音频层';
226
+ if (category === 'config')
227
+ return '配置层';
228
+ if (category === 'util')
229
+ return '工具层';
230
+ // component 需要按子类型细分
231
+ if (id.includes('input') || id.includes('handler'))
232
+ return '输入层';
233
+ if (id.includes('render') || id.includes('renderer'))
234
+ return '渲染层';
235
+ if (id.includes('animation') || id.includes('anim'))
236
+ return '动画层';
237
+ if (id.includes('camera'))
238
+ return '场景层';
239
+ if (id.includes('tray') || id.includes('ui') || id.includes('bar') || id.includes('panel'))
240
+ return 'UI层';
241
+ return 'UI层'; // component 默认 UI 层
242
+ }
243
+ // ─── Skill 目录发现 ──────────────────────────────────────────────────────────
244
+ /**
245
+ * 解析 skills 目录,优先级:
246
+ * 1. --skills-dir CLI 参数
247
+ * 2. playcraft.config.json 中的 skillsDir
248
+ * 3. AGENT_SKILLS_PATHS 环境变量(逗号分隔)
249
+ * 4. node_modules/@playcraft/skills/skills
250
+ * 5. monorepo 开发时的相对路径
251
+ */
252
+ function resolveSkillsDirs(customDir, configDir) {
253
+ if (customDir) {
254
+ return [path.resolve(customDir)];
255
+ }
256
+ const dirs = [];
257
+ // 来自 playcraft.config.json
258
+ if (configDir) {
259
+ dirs.push(path.resolve(configDir));
260
+ }
261
+ // 来自环境变量
262
+ const envPaths = process.env.AGENT_SKILLS_PATHS?.split(',').map((p) => p.trim()).filter(Boolean) ?? [];
263
+ dirs.push(...envPaths);
264
+ // node_modules 中的 @playcraft/skills
265
+ const cwdModulesPath = path.join(process.cwd(), 'node_modules', '@playcraft', 'skills', 'skills');
266
+ if (fs.existsSync(cwdModulesPath)) {
267
+ dirs.push(cwdModulesPath);
268
+ }
269
+ // monorepo 开发时:CLI 包相对于 skills 包的路径
270
+ const __filename = fileURLToPath(import.meta.url);
271
+ const __dirname = path.dirname(__filename);
272
+ const monoSkillsPath = path.resolve(__dirname, '..', '..', '..', '..', 'skills', 'skills');
273
+ if (fs.existsSync(monoSkillsPath)) {
274
+ dirs.push(monoSkillsPath);
275
+ }
276
+ // 编译后的 dist 路径
277
+ const distSkillsPath = path.resolve(__dirname, '..', '..', '..', 'skills', 'skills');
278
+ if (fs.existsSync(distSkillsPath) && distSkillsPath !== monoSkillsPath) {
279
+ dirs.push(distSkillsPath);
280
+ }
281
+ return [...new Set(dirs)];
282
+ }
283
+ // ─── Manifest 索引器 ─────────────────────────────────────────────────────────
284
+ function loadSkillIndex(skillsDirs) {
285
+ const entries = [];
286
+ const seen = new Set();
287
+ for (const dir of skillsDirs) {
288
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
289
+ continue;
290
+ const subdirs = fs.readdirSync(dir, { withFileTypes: true });
291
+ for (const dirent of subdirs) {
292
+ if (!dirent.isDirectory())
293
+ continue;
294
+ const skillDir = path.join(dir, dirent.name);
295
+ const manifestPath = path.join(skillDir, 'manifest.json');
296
+ if (!fs.existsSync(manifestPath))
297
+ continue;
298
+ try {
299
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
300
+ if (!manifest.atomId || seen.has(manifest.atomId))
301
+ continue;
302
+ seen.add(manifest.atomId);
303
+ entries.push({ atomId: manifest.atomId, dir: skillDir, manifest });
304
+ }
305
+ catch {
306
+ // 跳过无法解析的 manifest
307
+ }
308
+ }
309
+ }
310
+ return entries;
311
+ }
312
+ // ─── 推荐算法 ────────────────────────────────────────────────────────────────
313
+ function getGameplayType(manifest) {
314
+ const gen = manifest['generation'];
315
+ return gen?.gameplayType;
316
+ }
317
+ function pinAtomIdToEngine(pinAtomId) {
318
+ if (!pinAtomId.endsWith('.aicomponent'))
319
+ return null;
320
+ const name = pinAtomId.slice(0, -'.aicomponent'.length);
321
+ return KNOWN_ENGINES.includes(name) ? name : null;
322
+ }
323
+ /**
324
+ * 仅按 intent 打分(discovery 相位)。不假设目标引擎。
325
+ */
326
+ export function scoreSkillIntentOnly(entry, intentTags) {
327
+ if (intentTags.length === 0)
328
+ return 0;
329
+ const { manifest } = entry;
330
+ let score = 0;
331
+ for (const tag of intentTags) {
332
+ if (entryMatchesIntentTag(entry, tag))
333
+ score += 3;
334
+ }
335
+ const gameplayType = getGameplayType(manifest);
336
+ if (gameplayType && intentTags.some((t) => t.toLowerCase() === gameplayType.toLowerCase()))
337
+ score += 12;
338
+ for (const tag of intentTags) {
339
+ if (manifest.atomId.toLowerCase().includes(tag.toLowerCase()))
340
+ score += 2;
341
+ }
342
+ if (manifest.category === 'engine')
343
+ score += 1;
344
+ return score;
345
+ }
346
+ /**
347
+ * 从 discovery 相位已匹配的 skill 聚合候选引擎。
348
+ */
349
+ export function inferSuggestedEngines(scored, intentTags) {
350
+ const totals = new Map();
351
+ const bump = (engine, delta, reason) => {
352
+ if (!KNOWN_ENGINES.includes(engine))
353
+ return;
354
+ const cur = totals.get(engine) ?? { score: 0, reasons: new Set() };
355
+ cur.score += delta;
356
+ cur.reasons.add(reason);
357
+ totals.set(engine, cur);
358
+ };
359
+ if (intentTags.some((t) => ['3d', 'threejs', 'three', 'board', 'path', 'webgl'].includes(t))) {
360
+ bump('threejs', 10, '意图标签偏 3D / Three.js');
361
+ }
362
+ if (intentTags.some((t) => ['2d', 'phaser', 'match3', 'grid', 'tile', 'canvas'].includes(t))) {
363
+ bump('phaser', 10, '意图标签偏 2D / Phaser');
364
+ }
365
+ for (const { entry, score } of scored) {
366
+ const { manifest } = entry;
367
+ const renderBackend = manifest['renderBackend'];
368
+ for (const e of renderBackend ?? []) {
369
+ bump(e, score * 0.5, `${entry.atomId} renderBackend`);
370
+ }
371
+ const engineVariants = manifest['engineVariants'];
372
+ for (const e of Object.keys(engineVariants ?? {})) {
373
+ bump(e, score * 0.4, `${entry.atomId} 含 ${e} 变体`);
374
+ }
375
+ for (const imp of manifest.imports ?? []) {
376
+ if (!imp.pinAtomId)
377
+ continue;
378
+ const eng = pinAtomIdToEngine(imp.pinAtomId);
379
+ if (eng)
380
+ bump(eng, score, `${entry.atomId} → ${imp.pinAtomId}`);
381
+ }
382
+ if (manifest.category === 'engine') {
383
+ const eng = pinAtomIdToEngine(manifest.atomId);
384
+ if (eng)
385
+ bump(eng, score + 5, `引擎节点 ${manifest.atomId}`);
386
+ }
387
+ }
388
+ const ranked = [...totals.entries()]
389
+ .map(([engine, { score, reasons }]) => ({
390
+ engine,
391
+ score: Math.round(score * 10) / 10,
392
+ reasons: [...reasons],
393
+ }))
394
+ .sort((a, b) => b.score - a.score)
395
+ .filter((s) => s.score > 0);
396
+ return capAlternatives(ranked).items;
397
+ }
398
+ /**
399
+ * 计算一个 skill 对给定 engine + intent 的匹配分数。
400
+ * 得分越高,越应该被推荐。
401
+ */
402
+ export function scoreSkill(entry, engine, intentTags) {
403
+ const { manifest } = entry;
404
+ let score = 0;
405
+ // 1. 引擎精确匹配:renderBackend 或 engineVariants 中有指定引擎
406
+ const renderBackend = manifest['renderBackend'];
407
+ if (renderBackend?.includes(engine))
408
+ score += 15;
409
+ const engineVariants = manifest['engineVariants'];
410
+ if (engineVariants?.[engine])
411
+ score += 15;
412
+ // 2. 标签包含引擎名
413
+ if (manifest.tags?.includes(engine))
414
+ score += 8;
415
+ // 3. intent 标签重叠(含别名、label、atomId)
416
+ for (const tag of intentTags) {
417
+ if (entryMatchesIntentTag(entry, tag))
418
+ score += 3;
419
+ }
420
+ // 4. imports 中依赖该引擎的 skill
421
+ const importsEngine = manifest.imports?.some((imp) => imp.pinAtomId === `${engine}.aicomponent`);
422
+ if (importsEngine)
423
+ score += 5;
424
+ return score;
425
+ }
426
+ /**
427
+ * 按 imports 图遍历,收集 skill 的所有传递依赖(BFS)。
428
+ * engine-aware:当 skill 有指定引擎的 engineVariant 时,优先使用
429
+ * engineVariants[engine].imports 而非 manifest 顶层 imports,
430
+ * 避免将另一引擎的依赖(如 phaser.aicomponent)错误地拉进来。
431
+ */
432
+ function collectDependencies(atomId, index, engine, visited = new Set()) {
433
+ if (visited.has(atomId))
434
+ return [];
435
+ visited.add(atomId);
436
+ const entry = index.get(atomId);
437
+ if (!entry)
438
+ return [];
439
+ // 如果 engineVariants[engine] 存在,使用其 imports 列表(字符串数组形式)
440
+ const engineVariants = entry.manifest['engineVariants'];
441
+ const variantImports = engineVariants?.[engine]?.imports;
442
+ const deps = [];
443
+ if (variantImports && variantImports.length > 0) {
444
+ // variant 的 imports 是 atomId 字符串数组
445
+ for (const pinId of variantImports) {
446
+ if (!visited.has(pinId)) {
447
+ deps.push(pinId);
448
+ deps.push(...collectDependencies(pinId, index, engine, visited));
449
+ }
450
+ }
451
+ }
452
+ else {
453
+ // 无 variant,使用顶层 imports
454
+ for (const imp of entry.manifest.imports ?? []) {
455
+ if (imp.pinAtomId && !visited.has(imp.pinAtomId)) {
456
+ deps.push(imp.pinAtomId);
457
+ deps.push(...collectDependencies(imp.pinAtomId, index, engine, visited));
458
+ }
459
+ }
460
+ }
461
+ return deps;
462
+ }
463
+ /**
464
+ * 从 manifest 中提取指定 engine 的 scaffold 文件列表。
465
+ * 优先使用 engineVariants[engine].scaffoldFiles,其次 scaffold.files。
466
+ */
467
+ function getScaffoldFiles(manifest, engine) {
468
+ const engineVariants = manifest['engineVariants'];
469
+ const variantFiles = engineVariants?.[engine]?.scaffoldFiles;
470
+ if (variantFiles && Object.keys(variantFiles).length > 0) {
471
+ return Object.fromEntries(Object.entries(variantFiles).map(([k, v]) => [k, { source: v.source, mode: v.mode ?? 'full', weight: v.weight ?? 10 }]));
472
+ }
473
+ const sharedFiles = manifest['sharedFiles'];
474
+ const scaffoldFiles = {};
475
+ if (manifest.scaffold?.files) {
476
+ for (const [k, v] of Object.entries(manifest.scaffold.files)) {
477
+ const contribution = v;
478
+ if (contribution.source) {
479
+ scaffoldFiles[k] = { source: contribution.source, mode: contribution.mode ?? 'full', weight: contribution.weight ?? 10 };
480
+ }
481
+ }
482
+ }
483
+ if (sharedFiles) {
484
+ for (const [k, v] of Object.entries(sharedFiles)) {
485
+ scaffoldFiles[k] = { source: v.source, mode: v.mode ?? 'full', weight: v.weight ?? 10 };
486
+ }
487
+ }
488
+ return scaffoldFiles;
489
+ }
490
+ /** 无法推断引擎时的兜底:仅 intent 命中项,无依赖展开与 scaffold */
491
+ function buildIntentOnlyMatchResult(entries, intentTags, suggestedEngines) {
492
+ const scored = entries
493
+ .map((entry) => ({ entry, score: scoreSkillIntentOnly(entry, intentTags) }))
494
+ .filter(({ score }) => score >= SCORE_THRESHOLD_DISCOVERY)
495
+ .sort((a, b) => b.score - a.score);
496
+ const items = scored.map(({ entry, score }) => {
497
+ const { manifest } = entry;
498
+ const layer = getCategoryLayer(manifest.category, manifest.atomId);
499
+ const gameplayType = getGameplayType(manifest);
500
+ const matchedTags = intentTags.filter((t) => manifest.tags?.includes(t));
501
+ let reason = matchedTags.length > 0 ? `标签匹配:${matchedTags.join(', ')}` : `score=${score}`;
502
+ if (gameplayType && intentTags.includes(gameplayType)) {
503
+ reason = `玩法类型 ${gameplayType} 匹配`;
504
+ }
505
+ const supportedEngines = [];
506
+ const renderBackend = manifest['renderBackend'];
507
+ if (renderBackend?.length)
508
+ supportedEngines.push(...renderBackend);
509
+ const engineVariants = manifest['engineVariants'];
510
+ if (engineVariants)
511
+ supportedEngines.push(...Object.keys(engineVariants));
512
+ for (const imp of manifest.imports ?? []) {
513
+ const eng = imp.pinAtomId ? pinAtomIdToEngine(imp.pinAtomId) : null;
514
+ if (eng)
515
+ supportedEngines.push(eng);
516
+ }
517
+ const enginesHint = [...new Set(supportedEngines.filter((e) => KNOWN_ENGINES.includes(e)))];
518
+ if (enginesHint.length > 0) {
519
+ reason += `;候选引擎:${enginesHint.join('/')}`;
520
+ }
521
+ return {
522
+ atomId: manifest.atomId,
523
+ label: manifest.label,
524
+ layer,
525
+ reason,
526
+ scaffoldFiles: {},
527
+ hasEngineVariant: false,
528
+ skillDir: entry.dir,
529
+ };
530
+ });
531
+ items.sort((a, b) => (LAYER_ORDER[a.layer] ?? 99) - (LAYER_ORDER[b.layer] ?? 99));
532
+ const mediaGroups = buildMediaGroups(entries, intentTags);
533
+ return {
534
+ engine: null,
535
+ engineSource: 'none',
536
+ intent: intentTags,
537
+ suggestedEngines,
538
+ items,
539
+ mediaGroups,
540
+ missingSlots: [],
541
+ };
542
+ }
543
+ /**
544
+ * 仅 --intent:先推断引擎,再对首选引擎做完整 match(一轮输出全部内容)。
545
+ */
546
+ export function buildAutoMatchResult(entries, intentTags) {
547
+ const scored = entries
548
+ .map((entry) => ({ entry, score: scoreSkillIntentOnly(entry, intentTags) }))
549
+ .filter(({ score }) => score >= SCORE_THRESHOLD_DISCOVERY);
550
+ const suggestedEngines = inferSuggestedEngines(scored, intentTags);
551
+ const engine = suggestedEngines[0]?.engine;
552
+ if (!engine) {
553
+ return buildIntentOnlyMatchResult(entries, intentTags, suggestedEngines);
554
+ }
555
+ const full = buildMatchResult(entries, engine, intentTags);
556
+ const gap = suggestedEngines.length > 1 ? suggestedEngines[0].score - suggestedEngines[1].score : 99;
557
+ return {
558
+ ...full,
559
+ engineSource: 'inferred',
560
+ suggestedEngines,
561
+ engineAmbiguous: suggestedEngines.length > 1 && gap < 5,
562
+ };
563
+ }
564
+ function selectMatchSeedAtomIds(scored, engine, intentTags) {
565
+ const seeds = new Set([`${engine}.aicomponent`]);
566
+ for (const { entry, score } of scored) {
567
+ const { manifest } = entry;
568
+ if (manifest.category === 'gameplay') {
569
+ if (passesGameplayIntentFilter(entry, intentTags))
570
+ seeds.add(entry.atomId);
571
+ continue;
572
+ }
573
+ if (manifest.category === 'engine')
574
+ continue;
575
+ if (['aiimage', 'aiaudio', 'aiconfig'].includes(manifest.bundleType))
576
+ continue;
577
+ const specificHits = intentTags.filter((t) => isSpecificIntentTag(t) && entryMatchesIntentTag(entry, t));
578
+ if (specificHits.length > 0 && score >= SCORE_THRESHOLD_FULL)
579
+ seeds.add(entry.atomId);
580
+ }
581
+ return seeds;
582
+ }
583
+ function expandSeedClosure(seedIds, indexMap, engine) {
584
+ const closed = new Set();
585
+ for (const seedId of seedIds) {
586
+ closed.add(seedId);
587
+ for (const depId of collectDependencies(seedId, indexMap, engine)) {
588
+ closed.add(depId);
589
+ }
590
+ }
591
+ return closed;
592
+ }
593
+ function buildItemReason(entry, engine, intentTags, score, via) {
594
+ const { manifest } = entry;
595
+ if (via === 'dependency')
596
+ return '传递依赖';
597
+ if (manifest.category === 'engine')
598
+ return '引擎根节点';
599
+ const gt = getGameplayType(manifest);
600
+ if (gt && intentTags.includes(gt))
601
+ return `玩法 ${gt}`;
602
+ const hasEngineVariant = !!manifest['engineVariants']?.[engine];
603
+ if (hasEngineVariant)
604
+ return `${engine} 变体`;
605
+ const matchedTags = intentTags.filter((t) => manifest.tags?.includes(t));
606
+ if (matchedTags.length > 0)
607
+ return `标签:${matchedTags.join(', ')}`;
608
+ return score !== null ? `score=${score}` : '推荐';
609
+ }
610
+ export function buildMatchResult(entries, engine, intentTags) {
611
+ const indexMap = new Map(entries.map((e) => [e.atomId, e]));
612
+ const scoreById = new Map();
613
+ const expandedIntent = expandIntentTags(intentTags);
614
+ const scored = entries
615
+ .map((entry) => {
616
+ const score = scoreSkill(entry, engine, expandedIntent);
617
+ scoreById.set(entry.atomId, score);
618
+ return { entry, score };
619
+ })
620
+ .filter(({ score }) => score >= SCORE_THRESHOLD_FULL)
621
+ .sort((a, b) => b.score - a.score);
622
+ const seedIds = selectMatchSeedAtomIds(scored, engine, expandedIntent);
623
+ const requiredIds = expandSeedClosure(seedIds, indexMap, engine);
624
+ const seedIdSet = seedIds;
625
+ const anchorEntries = [];
626
+ for (const id of seedIds) {
627
+ if (id.endsWith('.aigameplay')) {
628
+ const e = indexMap.get(id);
629
+ if (e)
630
+ anchorEntries.push(e);
631
+ }
632
+ }
633
+ const allItems = [];
634
+ for (const atomId of requiredIds) {
635
+ const entry = indexMap.get(atomId);
636
+ if (!entry)
637
+ continue;
638
+ const { manifest } = entry;
639
+ const layer = getCategoryLayer(manifest.category, manifest.atomId);
640
+ const scaffoldFiles = getScaffoldFiles(manifest, engine);
641
+ const hasEngineVariant = !!manifest['engineVariants']?.[engine];
642
+ const via = seedIdSet.has(atomId) ? 'seed' : 'dependency';
643
+ const reason = buildItemReason(entry, engine, expandedIntent, scoreById.get(atomId) ?? null, via);
644
+ allItems.push({
645
+ atomId: manifest.atomId,
646
+ label: manifest.label,
647
+ layer,
648
+ reason,
649
+ scaffoldFiles,
650
+ hasEngineVariant,
651
+ skillDir: entry.dir,
652
+ });
653
+ }
654
+ allItems.sort((a, b) => (LAYER_ORDER[a.layer] ?? 99) - (LAYER_ORDER[b.layer] ?? 99));
655
+ const mediaGroups = buildMediaGroupsForAnchors(entries, expandedIntent, anchorEntries);
656
+ const mediaRoles = new Set(mediaGroups.map((g) => g.role));
657
+ const missingSlots = [];
658
+ for (const entry of anchorEntries) {
659
+ for (const imp of entry.manifest.imports ?? []) {
660
+ if (!imp.matchBindingRoles?.length || imp.pinAtomId)
661
+ continue;
662
+ for (const role of imp.matchBindingRoles) {
663
+ const group = mediaGroups.find((g) => g.role === role);
664
+ if (!group?.candidates.length) {
665
+ missingSlots.push(`${entry.atomId} → ${role}`);
666
+ }
667
+ }
668
+ }
669
+ }
670
+ return {
671
+ engine,
672
+ engineSource: 'explicit',
673
+ intent: intentTags,
674
+ items: allItems,
675
+ mediaGroups,
676
+ missingSlots,
677
+ };
678
+ }
679
+ // ─── 媒体/配置资产匹配(第二遍:引擎无关,按 bindingRole 分组)────────────
680
+ /**
681
+ * 媒体 skill(aiimage / aiaudio / aiconfig)是引擎无关的,
682
+ * 通过 bindingRoles 和语义标签连接到游戏。
683
+ *
684
+ * 策略:
685
+ * 1. Role-based:收集所有声明了 bindingRoles 的媒体 skill,按 role 分组(同 role = 候选项)
686
+ * 2. Tag-based:背景图(tags: background)、通用 BGM 备选无 bindingRole → 按 tag 伪分组
687
+ * 3. 只输出 UNIVERSAL_MEDIA_ROLES 定义的角色,避免噪音
688
+ */
689
+ function collectAnchorBindingRoles(anchorEntries) {
690
+ const roles = new Set(['background']);
691
+ for (const entry of anchorEntries) {
692
+ for (const imp of entry.manifest.imports ?? []) {
693
+ for (const role of imp.matchBindingRoles ?? [])
694
+ roles.add(role);
695
+ }
696
+ }
697
+ return roles;
698
+ }
699
+ function collectMediaRoleMap(entries) {
700
+ const mediaEntries = entries.filter((e) => ['aiimage', 'aiaudio', 'aiconfig'].includes(e.manifest.bundleType));
701
+ const roleMap = new Map();
702
+ for (const entry of mediaEntries) {
703
+ for (const role of entry.manifest.bindingRoles ?? []) {
704
+ if (!roleMap.has(role))
705
+ roleMap.set(role, []);
706
+ roleMap.get(role).push({
707
+ atomId: entry.atomId,
708
+ labelZh: entry.manifest.label.zh,
709
+ labelEn: entry.manifest.label.en,
710
+ });
711
+ }
712
+ }
713
+ const backgrounds = mediaEntries.filter((e) => !e.manifest.bindingRoles?.length && e.manifest.tags?.includes('background'));
714
+ if (backgrounds.length > 0) {
715
+ roleMap.set('background', backgrounds.map((e) => ({
716
+ atomId: e.atomId,
717
+ labelZh: e.manifest.label.zh,
718
+ labelEn: e.manifest.label.en,
719
+ })));
720
+ }
721
+ const bgmAlts = mediaEntries.filter((e) => !e.manifest.bindingRoles?.length && e.manifest.tags?.includes('bgm'));
722
+ if (bgmAlts.length > 0) {
723
+ const existing = roleMap.get('bgMusic') ?? [];
724
+ roleMap.set('bgMusic', [
725
+ ...existing,
726
+ ...bgmAlts.map((e) => ({
727
+ atomId: e.atomId,
728
+ labelZh: e.manifest.label.zh,
729
+ labelEn: e.manifest.label.en,
730
+ })),
731
+ ]);
732
+ }
733
+ return roleMap;
734
+ }
735
+ /** 锚定玩法 imports 声明的媒体角色 + 通用音频/面板;含 tileTextureSet 等非常规角色 */
736
+ function buildMediaGroupsForAnchors(entries, intentTags, anchorEntries) {
737
+ const neededRoles = collectAnchorBindingRoles(anchorEntries);
738
+ const roleMap = collectMediaRoleMap(entries);
739
+ const groups = [];
740
+ const emitted = new Set();
741
+ const pushRole = (role, label, layer) => {
742
+ if (emitted.has(role))
743
+ return;
744
+ const candidates = roleMap.get(role);
745
+ if (!candidates?.length)
746
+ return;
747
+ groups.push(finalizeMediaGroup({ role, label, layer, candidates }, entries, intentTags));
748
+ emitted.add(role);
749
+ };
750
+ if (neededRoles.has('background') || neededRoles.has('backgroundTexture')) {
751
+ pushRole('background', '背景图(任选其一)', '背景图层');
752
+ pushRole('backgroundTexture', ANCHOR_MEDIA_ROLE_LABELS.backgroundTexture.label, ANCHOR_MEDIA_ROLE_LABELS.backgroundTexture.layer);
753
+ }
754
+ for (const [role, meta] of Object.entries(UNIVERSAL_MEDIA_ROLES)) {
755
+ if (!neededRoles.has(role))
756
+ continue;
757
+ pushRole(role, meta.label, meta.layer);
758
+ }
759
+ for (const role of neededRoles) {
760
+ if (emitted.has(role))
761
+ continue;
762
+ const meta = ANCHOR_MEDIA_ROLE_LABELS[role] ?? { label: role, layer: '视觉资产层' };
763
+ pushRole(role, meta.label, meta.layer);
764
+ }
765
+ return groups;
766
+ }
767
+ function rankMediaCandidates(candidates, entries, intentTags) {
768
+ return [...candidates].sort((a, b) => {
769
+ const entryA = entries.find((e) => e.atomId === a.atomId);
770
+ const entryB = entries.find((e) => e.atomId === b.atomId);
771
+ const scoreA = entryA ? countIntentMatches(entryA, intentTags) : 0;
772
+ const scoreB = entryB ? countIntentMatches(entryB, intentTags) : 0;
773
+ return scoreB - scoreA;
774
+ });
775
+ }
776
+ function finalizeMediaGroup(group, entries, intentTags) {
777
+ const ranked = rankMediaCandidates(group.candidates, entries, intentTags);
778
+ const { items, total } = capAlternatives(ranked, MAX_MEDIA_ALTERNATIVES_PER_CLASS);
779
+ return total > items.length ? { ...group, candidates: items, totalCandidates: total } : { ...group, candidates: items };
780
+ }
781
+ function buildMediaGroups(entries, intentTags) {
782
+ const roleMap = collectMediaRoleMap(entries);
783
+ const groups = [];
784
+ // 先加背景图(放最前面)
785
+ const bgCandidates = roleMap.get('background');
786
+ if (bgCandidates?.length) {
787
+ groups.push(finalizeMediaGroup({
788
+ role: 'background',
789
+ layer: '背景图层',
790
+ label: '背景图(任选其一)',
791
+ candidates: bgCandidates,
792
+ }, entries, intentTags));
793
+ }
794
+ // 再按 UNIVERSAL_MEDIA_ROLES 顺序输出
795
+ for (const [role, meta] of Object.entries(UNIVERSAL_MEDIA_ROLES)) {
796
+ if (role === 'background')
797
+ continue; // 已处理
798
+ const candidates = roleMap.get(role);
799
+ if (!candidates?.length)
800
+ continue;
801
+ groups.push(finalizeMediaGroup({ role, layer: meta.layer, label: meta.label, candidates }, entries, intentTags));
802
+ }
803
+ return groups;
804
+ }
805
+ // ─── 输出格式化 ──────────────────────────────────────────────────────────────
806
+ function printMatchResult(result, verbose, entries) {
807
+ const { engine, intent, items, mediaGroups, engineSource } = result;
808
+ const unresolved = findUnresolvedIntentTags(entries, intent);
809
+ const aliasOnly = findIntentAliasOnlyTags(entries, intent);
810
+ const intentStr = intent.length > 0 ? `意图[${intent.join(', ')}]` : '';
811
+ const totalMedia = mediaGroups.reduce((s, g) => s + g.candidates.length, 0);
812
+ const codeItems = items.filter((i) => /\.(aigameplay|aicomponent|aiconfig)$/.test(i.atomId));
813
+ const engineLine = engineSource === 'inferred'
814
+ ? `engine=${engine}(自动)`
815
+ : engineSource === 'none'
816
+ ? '未选定引擎'
817
+ : `engine=${engine}`;
818
+ console.log(`\n${engineLine}${intentStr ? ` ${intentStr}` : ''}`);
819
+ console.log(`代码 skill ${codeItems.length} 个 · 媒体 ${mediaGroups.length} 类(${totalMedia} 候选)\n`);
820
+ if (result.suggestedEngines?.length && engineSource !== 'explicit') {
821
+ const top = result.suggestedEngines.map((s) => `${s.engine}${s.engine === engine ? '*' : ''}`).join(', ');
822
+ console.log(`候选引擎:${top}${result.engineAmbiguous ? ' ⚠️ 分差小,可用 --engine 覆盖' : ''}\n`);
823
+ }
824
+ for (const word of aliasOnly) {
825
+ const tileGroup = mediaGroups.find((g) => g.role === 'tileTextureSet');
826
+ if (tileGroup?.candidates.length) {
827
+ console.log(`ℹ️ intent「${word}」无专用 .aigameplay,已按牌面贴图(tileTextureSet)推荐\n`);
828
+ }
829
+ else {
830
+ console.log(`ℹ️ intent「${word}」已通过关联标签匹配相关 skill\n`);
831
+ }
832
+ }
833
+ for (const word of unresolved) {
834
+ console.log(`ℹ️ intent「${word}」未匹配到 DAG atom skill(可 --json 查看或补充仓库 skill)\n`);
835
+ }
836
+ if (engineSource === 'none') {
837
+ console.log(`指定 --engine 后可展开依赖与 scaffold。\n`);
838
+ return;
839
+ }
840
+ if (!verbose) {
841
+ let currentLayer = '';
842
+ for (const item of items) {
843
+ if (item.layer !== currentLayer) {
844
+ currentLayer = item.layer;
845
+ console.log(`${currentLayer}:`);
846
+ }
847
+ const sc = Object.keys(item.scaffoldFiles).length;
848
+ const scHint = sc > 0 ? ` · scaffold×${sc}` : '';
849
+ console.log(` ${item.atomId} ${item.label.zh}(${item.reason})${scHint}`);
850
+ }
851
+ if (mediaGroups.length > 0) {
852
+ console.log('\n媒体(玩法 imports 相关):');
853
+ for (const g of mediaGroups) {
854
+ const names = g.candidates.map((c) => c.atomId).join(' | ');
855
+ const more = g.totalCandidates && g.totalCandidates > g.candidates.length
856
+ ? ` (+${g.totalCandidates - g.candidates.length})`
857
+ : '';
858
+ const pick = (g.totalCandidates ?? g.candidates.length) > 1 ? '任选' : '';
859
+ console.log(` ${g.label}${pick ? `(${pick})` : ''}:${names}${more}`);
860
+ }
861
+ }
862
+ if (result.missingSlots.length > 0) {
863
+ console.log('\n⚠️ 未绑定软槽:');
864
+ for (const s of result.missingSlots)
865
+ console.log(` - ${s}`);
866
+ }
867
+ console.log('\n加 --json 供 atom-plan;加 --verbose 查看 scaffold 明细与命令。\n');
868
+ return;
869
+ }
870
+ // ── verbose:完整分层列表 ──
871
+ let currentLayer = '';
872
+ for (const item of items) {
873
+ if (item.layer !== currentLayer) {
874
+ currentLayer = item.layer;
875
+ console.log(` ── ${currentLayer} ──────────────────────`);
876
+ }
877
+ const variantMark = engine && item.hasEngineVariant ? ` [${engine} 变体]` : '';
878
+ console.log(` • ${item.atomId}${variantMark} ${item.label.zh}`);
879
+ console.log(` ${item.reason}`);
880
+ const files = Object.entries(item.scaffoldFiles);
881
+ if (files.length > 0) {
882
+ for (const [dest, { source }] of files) {
883
+ console.log(` ${source} → ${dest}`);
884
+ }
885
+ }
886
+ console.log();
887
+ }
888
+ if (mediaGroups.length > 0) {
889
+ console.log(` ── 媒体资产 ──────────────────────`);
890
+ for (const g of mediaGroups) {
891
+ const names = g.candidates.map((c) => `${c.atomId}(${c.labelZh})`).join(' / ');
892
+ const more = g.totalCandidates && g.totalCandidates > g.candidates.length
893
+ ? ` …共 ${g.totalCandidates} 个,仅列前 ${MAX_MEDIA_ALTERNATIVES_PER_CLASS}`
894
+ : '';
895
+ console.log(` ${g.label}:${names}${more}\n`);
896
+ }
897
+ }
898
+ if (result.missingSlots.length > 0) {
899
+ console.log(`⚠️ 未绑定软槽:`);
900
+ for (const s of result.missingSlots)
901
+ console.log(` - ${s}`);
902
+ console.log();
903
+ }
904
+ }
905
+ function printMatchFooters(result, verbose) {
906
+ if (!result.engine)
907
+ return;
908
+ const codeItems = result.items.filter((i) => /\.(aigameplay|aicomponent|aiconfig)$/.test(i.atomId));
909
+ if (!verbose) {
910
+ const ids = codeItems.map((i) => i.atomId).join(',');
911
+ console.log(`scaffold: playcraft skills scaffold --engine ${result.engine} --atoms ${ids} --out ./game`);
912
+ console.log(`link: playcraft skills link --from-atom-plan --prune # 或 --atoms <子集>`);
913
+ return;
914
+ }
915
+ const atomIdList = codeItems.map((i) => i.atomId).join(',');
916
+ console.log(`\n💡 Scaffold:playcraft skills scaffold --engine ${result.engine} --atoms ${atomIdList} --out ./game`);
917
+ console.log(`🔗 Link:playcraft skills link --atoms ${atomIdList}`);
918
+ }
919
+ // ─── Scaffold 辅助:merge-json 模式 ──────────────────────────────────────────
920
+ /**
921
+ * 深度合并两个 JSON 对象:incoming 中的新 key 追加到 existing,已有 key 保留 existing 的值。
922
+ * 对象类型字段(dependencies / devDependencies / compilerOptions 等)递归合并。
923
+ */
924
+ function mergeJson(existing, incoming) {
925
+ const result = { ...existing };
926
+ for (const [key, inVal] of Object.entries(incoming)) {
927
+ if (key in result) {
928
+ const exVal = result[key];
929
+ if (exVal !== null && inVal !== null &&
930
+ typeof exVal === 'object' && typeof inVal === 'object' &&
931
+ !Array.isArray(exVal) && !Array.isArray(inVal)) {
932
+ result[key] = mergeJson(exVal, inVal);
933
+ }
934
+ // 已有 key 且非对象:保留 existing 的值,不覆盖
935
+ }
936
+ else {
937
+ result[key] = inVal;
938
+ }
939
+ }
940
+ return result;
941
+ }
942
+ // ─── Scaffold 执行器 ─────────────────────────────────────────────────────────
943
+ function runScaffold(opts) {
944
+ const { entries, atomIds, engine, outDir, dryRun, force } = opts;
945
+ const indexMap = new Map(entries.map((e) => [e.atomId, e]));
946
+ let copied = 0;
947
+ let skipped = 0;
948
+ for (const atomId of atomIds) {
949
+ const entry = indexMap.get(atomId);
950
+ if (!entry) {
951
+ console.warn(`[warn] 未找到 skill: ${atomId}`);
952
+ continue;
953
+ }
954
+ const scaffoldFiles = getScaffoldFiles(entry.manifest, engine);
955
+ if (Object.keys(scaffoldFiles).length === 0) {
956
+ console.log(`[skip] ${atomId}:无 scaffold 文件`);
957
+ continue;
958
+ }
959
+ for (const [destRelPath, { source, mode }] of Object.entries(scaffoldFiles)) {
960
+ const srcPath = path.join(entry.dir, source);
961
+ const destPath = path.join(outDir, destRelPath);
962
+ if (!fs.existsSync(srcPath)) {
963
+ console.warn(`[warn] ${atomId}: 源文件不存在: ${srcPath}`);
964
+ continue;
965
+ }
966
+ const destExists = fs.existsSync(destPath);
967
+ // merge-json 模式:目标文件已存在时合并而非覆盖(不受 --force 影响)
968
+ if (mode === 'merge-json' && destExists) {
969
+ if (dryRun) {
970
+ console.log(`[dry-run] ${source} → ${destRelPath} (merge-json)`);
971
+ copied++;
972
+ continue;
973
+ }
974
+ try {
975
+ const existing = JSON.parse(fs.readFileSync(destPath, 'utf-8'));
976
+ const incoming = JSON.parse(fs.readFileSync(srcPath, 'utf-8'));
977
+ const merged = mergeJson(existing, incoming);
978
+ fs.writeFileSync(destPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
979
+ console.log(`[merged] ${source} → ${destRelPath}`);
980
+ copied++;
981
+ }
982
+ catch (e) {
983
+ console.warn(`[warn] ${atomId}: merge-json 失败,回退到跳过: ${e.message}`);
984
+ skipped++;
985
+ }
986
+ continue;
987
+ }
988
+ if (destExists && !force) {
989
+ console.log(`[skip] ${destRelPath} 已存在(使用 --force 覆盖)`);
990
+ skipped++;
991
+ continue;
992
+ }
993
+ if (dryRun) {
994
+ console.log(`[dry-run] ${source} → ${destRelPath}`);
995
+ copied++;
996
+ continue;
997
+ }
998
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
999
+ fs.copyFileSync(srcPath, destPath);
1000
+ console.log(`[copied] ${source} → ${destRelPath}`);
1001
+ copied++;
1002
+ }
1003
+ }
1004
+ console.log(`\n完成:${copied} 个文件${dryRun ? '(dry-run)' : ''},${skipped} 个跳过。`);
1005
+ }
1006
+ // ─── 项目内 DAG Skill 软连接(IDE 自动发现)────────────────────────────────────
1007
+ const ATOM_SKILL_LINK_SUFFIXES = [
1008
+ '.aigameplay',
1009
+ '.aicomponent',
1010
+ '.aiconfig',
1011
+ '.aiimage',
1012
+ '.aiaudio',
1013
+ '.aivalidator',
1014
+ ];
1015
+ /** 模板内静态 Skill 目录名前缀,禁止被 link/prune 覆盖 */
1016
+ const PROTECTED_SKILL_PREFIXES = ['playcraft-'];
1017
+ function isAtomSkillLinkName(name) {
1018
+ return ATOM_SKILL_LINK_SUFFIXES.some((suffix) => name.endsWith(suffix));
1019
+ }
1020
+ function isProtectedSkillDirName(name) {
1021
+ return PROTECTED_SKILL_PREFIXES.some((prefix) => name.startsWith(prefix));
1022
+ }
1023
+ /**
1024
+ * 从 atom-plan.md 的 Atom List 表解析非空的 skillRef 列。
1025
+ * 仅解析 `## Atom List` 与下一个 `##` 之间的 Markdown 表格。
1026
+ */
1027
+ export function parseSkillRefsFromAtomPlan(content) {
1028
+ const lines = content.split('\n');
1029
+ let inAtomList = false;
1030
+ let skillRefIdx = -1;
1031
+ const refs = new Set();
1032
+ for (const line of lines) {
1033
+ if (/^##\s+Atom List\b/i.test(line)) {
1034
+ inAtomList = true;
1035
+ skillRefIdx = -1;
1036
+ continue;
1037
+ }
1038
+ if (inAtomList && /^##\s+/.test(line))
1039
+ break;
1040
+ if (!inAtomList || !line.trim().startsWith('|'))
1041
+ continue;
1042
+ const cols = line
1043
+ .split('|')
1044
+ .slice(1, -1)
1045
+ .map((c) => c.trim());
1046
+ if (cols.length === 0)
1047
+ continue;
1048
+ if (skillRefIdx < 0) {
1049
+ if (cols.includes('skillRef')) {
1050
+ skillRefIdx = cols.indexOf('skillRef');
1051
+ }
1052
+ continue;
1053
+ }
1054
+ if (cols.every((c) => /^:?-+:?$/.test(c) || c === ''))
1055
+ continue;
1056
+ if (cols.includes('atomId') && cols.includes('assignTo'))
1057
+ continue;
1058
+ const ref = cols[skillRefIdx];
1059
+ if (!ref || ref === '—' || ref === '-' || ref.startsWith('{{'))
1060
+ continue;
1061
+ refs.add(ref);
1062
+ }
1063
+ return [...refs];
1064
+ }
1065
+ export async function linkAtomSkillsToProject(options) {
1066
+ const projectDir = path.resolve(options.projectDir);
1067
+ const skillsLinkDir = path.join(projectDir, '.claude', 'skills');
1068
+ const entryById = new Map(options.entries.map((e) => [e.atomId, e]));
1069
+ const linked = [];
1070
+ const skipped = [];
1071
+ const missing = [];
1072
+ const targetIds = new Set(options.atomIds.map((id) => id.trim()).filter(Boolean));
1073
+ if (!options.dryRun) {
1074
+ await fs.promises.mkdir(skillsLinkDir, { recursive: true });
1075
+ }
1076
+ for (const atomId of targetIds) {
1077
+ const entry = entryById.get(atomId);
1078
+ if (!entry) {
1079
+ missing.push(atomId);
1080
+ continue;
1081
+ }
1082
+ const linkPath = path.join(skillsLinkDir, atomId);
1083
+ if (isProtectedSkillDirName(atomId)) {
1084
+ skipped.push(atomId);
1085
+ continue;
1086
+ }
1087
+ const skillMd = path.join(entry.dir, 'SKILL.md');
1088
+ if (!fs.existsSync(skillMd)) {
1089
+ missing.push(`${atomId} (no SKILL.md)`);
1090
+ continue;
1091
+ }
1092
+ const relTarget = path.relative(path.dirname(linkPath), entry.dir);
1093
+ if (fs.existsSync(linkPath)) {
1094
+ const stat = fs.lstatSync(linkPath);
1095
+ if (stat.isSymbolicLink()) {
1096
+ const current = fs.readlinkSync(linkPath);
1097
+ const resolvedCurrent = path.isAbsolute(current)
1098
+ ? current
1099
+ : path.resolve(path.dirname(linkPath), current);
1100
+ if (path.resolve(resolvedCurrent) === path.resolve(entry.dir) && !options.force) {
1101
+ skipped.push(atomId);
1102
+ continue;
1103
+ }
1104
+ if (!options.dryRun && options.force) {
1105
+ await fs.promises.unlink(linkPath);
1106
+ }
1107
+ }
1108
+ else if (!options.force) {
1109
+ skipped.push(`${atomId} (exists, not symlink)`);
1110
+ continue;
1111
+ }
1112
+ else if (!options.dryRun) {
1113
+ await fs.promises.rm(linkPath, { recursive: true, force: true });
1114
+ }
1115
+ }
1116
+ if (options.dryRun) {
1117
+ console.log(`[dry-run] ${linkPath} → ${entry.dir}`);
1118
+ linked.push(atomId);
1119
+ continue;
1120
+ }
1121
+ await fs.promises.symlink(relTarget, linkPath, 'dir');
1122
+ linked.push(atomId);
1123
+ }
1124
+ const pruned = [];
1125
+ if (options.prune && fs.existsSync(skillsLinkDir)) {
1126
+ const children = await fs.promises.readdir(skillsLinkDir, { withFileTypes: true });
1127
+ for (const child of children) {
1128
+ if (!child.isSymbolicLink() || !isAtomSkillLinkName(child.name))
1129
+ continue;
1130
+ if (targetIds.has(child.name) || isProtectedSkillDirName(child.name))
1131
+ continue;
1132
+ const linkPath = path.join(skillsLinkDir, child.name);
1133
+ if (options.dryRun) {
1134
+ console.log(`[dry-run prune] ${linkPath}`);
1135
+ }
1136
+ else {
1137
+ await fs.promises.unlink(linkPath);
1138
+ }
1139
+ pruned.push(child.name);
1140
+ }
1141
+ }
1142
+ if (!options.dryRun) {
1143
+ const manifestPath = path.join(skillsLinkDir, '.playcraft-dag-links.json');
1144
+ await fs.promises.writeFile(manifestPath, JSON.stringify({
1145
+ linkedAt: new Date().toISOString(),
1146
+ atomIds: [...targetIds],
1147
+ linked,
1148
+ skillsDirs: [...new Set(options.entries.map((e) => path.dirname(e.dir)))],
1149
+ }, null, 2) + '\n', 'utf-8');
1150
+ }
1151
+ return { linked, skipped, pruned, missing };
1152
+ }
1153
+ // ─── CLI 注册 ────────────────────────────────────────────────────────────────
1154
+ export function registerSkillsCommands(program) {
1155
+ const skills = program
1156
+ .command('skills')
1157
+ .description('开发时 Skill 发现与脚手架工具(按 engine/intent 推荐 skill 集合,或将 ref 文件复制到项目)');
1158
+ // 公共选项:skills 目录
1159
+ const addSkillsDirOption = (cmd) => cmd.option('--skills-dir <path>', '指定 skills 目录路径(默认自动发现)');
1160
+ // ── list ──────────────────────────────────────────────────────────────────
1161
+ addSkillsDirOption(skills
1162
+ .command('list')
1163
+ .description('列出所有可用 skill(支持按 engine/category/tag 过滤)')
1164
+ .option('--engine <name>', '只显示支持该引擎的 skill(如 threejs/phaser)')
1165
+ .option('--category <name>', '按 category 过滤(engine/layout/component/gameplay/visual/audio/config...)')
1166
+ .option('--tag <tag>', '按标签过滤')
1167
+ .option('--json', '输出 JSON 格式')).action(async (opts) => {
1168
+ const config = await loadConfig({}).catch(() => ({ skillsDir: undefined }));
1169
+ const dirs = resolveSkillsDirs(opts.skillsDir, config.skillsDir);
1170
+ if (dirs.length === 0) {
1171
+ console.error('未找到 skills 目录。请通过 --skills-dir 指定、在 playcraft.config.json 中配置 skillsDir,或设置 AGENT_SKILLS_PATHS 环境变量。');
1172
+ process.exit(1);
1173
+ }
1174
+ let entries = loadSkillIndex(dirs);
1175
+ if (opts.engine) {
1176
+ entries = entries.filter((e) => {
1177
+ const renderBackend = e.manifest['renderBackend'];
1178
+ const engineVariants = e.manifest['engineVariants'];
1179
+ return renderBackend?.includes(opts.engine) || !!engineVariants?.[opts.engine] || e.manifest.tags?.includes(opts.engine);
1180
+ });
1181
+ }
1182
+ if (opts.category) {
1183
+ entries = entries.filter((e) => e.manifest.category === opts.category);
1184
+ }
1185
+ if (opts.tag) {
1186
+ entries = entries.filter((e) => e.manifest.tags?.includes(opts.tag));
1187
+ }
1188
+ if (opts.json) {
1189
+ process.stdout.write(JSON.stringify(entries.map((e) => ({
1190
+ atomId: e.atomId,
1191
+ category: e.manifest.category,
1192
+ label: e.manifest.label,
1193
+ tags: e.manifest.tags,
1194
+ bindingRoles: e.manifest.bindingRoles,
1195
+ dir: e.dir,
1196
+ })), null, 2) + '\n');
1197
+ return;
1198
+ }
1199
+ console.log(`\n共 ${entries.length} 个 skill:\n`);
1200
+ for (const e of entries) {
1201
+ const renderBackend = e.manifest['renderBackend'];
1202
+ const engines = renderBackend ? ` [${renderBackend.join('/')}]` : '';
1203
+ console.log(` ${e.atomId}${engines}`);
1204
+ console.log(` ${e.manifest.label.zh} | ${e.manifest.category} | tags: ${e.manifest.tags?.join(', ')}`);
1205
+ }
1206
+ console.log();
1207
+ });
1208
+ // ── match ─────────────────────────────────────────────────────────────────
1209
+ addSkillsDirOption(skills
1210
+ .command('match')
1211
+ .description('按 intent 推荐完整 skill 集合(含依赖与 scaffold);省略 --engine 时自动从 intent 推断引擎')
1212
+ .option('--engine <name>', '目标引擎(threejs / phaser);省略则由 --intent 自动推断')
1213
+ .option('--intent <tags>', '意图关键词,逗号分隔(如 board,3d,path,animation)', '')
1214
+ .option('--json', '输出 JSON 格式')
1215
+ .option('--verbose', '输出 scaffold 明细与完整媒体/命令提示')).action(async (opts) => {
1216
+ const config = await loadConfig({}).catch(() => ({ skillsDir: undefined }));
1217
+ const dirs = resolveSkillsDirs(opts.skillsDir, config.skillsDir);
1218
+ if (dirs.length === 0) {
1219
+ console.error('未找到 skills 目录。请通过 --skills-dir 指定、在 playcraft.config.json 中配置 skillsDir,或设置 AGENT_SKILLS_PATHS 环境变量。');
1220
+ process.exit(1);
1221
+ }
1222
+ const entries = loadSkillIndex(dirs);
1223
+ const intentTags = opts.intent
1224
+ ? opts.intent.split(',').map((t) => t.trim()).filter(Boolean)
1225
+ : [];
1226
+ if (!opts.engine && intentTags.length === 0) {
1227
+ console.error('请至少指定 --intent 或 --engine。\n' +
1228
+ ' 示例:playcraft skills match --intent "match3,grid" --json\n' +
1229
+ ' 示例:playcraft skills match --engine phaser --intent "match3,grid" --json');
1230
+ process.exit(1);
1231
+ }
1232
+ const result = opts.engine
1233
+ ? buildMatchResult(entries, opts.engine, intentTags)
1234
+ : buildAutoMatchResult(entries, intentTags);
1235
+ if (opts.json) {
1236
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1237
+ return;
1238
+ }
1239
+ printMatchResult(result, !!opts.verbose, entries);
1240
+ printMatchFooters(result, !!opts.verbose);
1241
+ if (opts.verbose)
1242
+ console.log();
1243
+ });
1244
+ // ── scaffold ──────────────────────────────────────────────────────────────
1245
+ addSkillsDirOption(skills
1246
+ .command('scaffold')
1247
+ .description('将指定 skill 的 ref 文件复制到目标项目目录')
1248
+ .requiredOption('--engine <name>', '目标引擎(决定使用哪个 engineVariant 的文件)')
1249
+ .requiredOption('--atoms <ids>', 'skill atomId 列表,逗号分隔(如 grid_board_layout.aicomponent,camera_controller_3d.aicomponent)')
1250
+ .option('--out <dir>', '输出目录(默认当前工作目录)', process.cwd())
1251
+ .option('--dry-run', '只打印要复制的文件,不实际执行')
1252
+ .option('--force', '覆盖已存在的文件')).action(async (opts) => {
1253
+ const config = await loadConfig({}).catch(() => ({ skillsDir: undefined }));
1254
+ const dirs = resolveSkillsDirs(opts.skillsDir, config.skillsDir);
1255
+ if (dirs.length === 0) {
1256
+ console.error('未找到 skills 目录。请通过 --skills-dir 指定、在 playcraft.config.json 中配置 skillsDir,或设置 AGENT_SKILLS_PATHS 环境变量。');
1257
+ process.exit(1);
1258
+ }
1259
+ const entries = loadSkillIndex(dirs);
1260
+ const atomIds = opts.atoms.split(',').map((s) => s.trim()).filter(Boolean);
1261
+ runScaffold({
1262
+ entries,
1263
+ atomIds,
1264
+ engine: opts.engine,
1265
+ outDir: path.resolve(opts.out),
1266
+ dryRun: !!opts.dryRun,
1267
+ force: !!opts.force,
1268
+ });
1269
+ });
1270
+ // ── link ──────────────────────────────────────────────────────────────────
1271
+ addSkillsDirOption(skills
1272
+ .command('link')
1273
+ .description('将 DAG Atom Skill 软连接到 .claude/skills/<atomId>/;下游优先读项目内路径,playcraft skills read 仅作断链兜底(不替代 scaffold)')
1274
+ .option('--atoms <ids>', 'atomId 列表,逗号分隔')
1275
+ .option('--from-atom-plan', '从 docs/atom-plan.md 的 Atom List 解析 skillRef 列')
1276
+ .option('--project-dir <path>', '项目根目录', process.cwd())
1277
+ .option('--prune', '移除 .claude/skills/ 下不在本次列表中的 DAG 软连接')
1278
+ .option('--dry-run', '只打印将创建/删除的软连接')
1279
+ .option('--force', '覆盖已存在的软连接或同名非链接目录')).action(async (opts) => {
1280
+ const config = await loadConfig({}).catch(() => ({ skillsDir: undefined }));
1281
+ const dirs = resolveSkillsDirs(opts.skillsDir, config.skillsDir);
1282
+ if (dirs.length === 0) {
1283
+ console.error('未找到 skills 目录。请通过 --skills-dir 指定、在 playcraft.config.json 中配置 skillsDir,或设置 AGENT_SKILLS_PATHS 环境变量。');
1284
+ process.exit(1);
1285
+ }
1286
+ const entries = loadSkillIndex(dirs);
1287
+ let atomIds = [];
1288
+ if (opts.fromAtomPlan) {
1289
+ atomIds = parseSkillRefsFromAtomPlanProject(opts.projectDir);
1290
+ if (atomIds.length === 0) {
1291
+ console.error('未找到 docs/atom-plan.json(或 atom-plan.md)中的非空 skillRef');
1292
+ process.exit(1);
1293
+ }
1294
+ }
1295
+ if (opts.atoms) {
1296
+ atomIds.push(...opts.atoms.split(',').map((s) => s.trim()).filter(Boolean));
1297
+ }
1298
+ atomIds = [...new Set(atomIds)];
1299
+ if (atomIds.length === 0) {
1300
+ console.error('请指定 --atoms <id1,id2> 或 --from-atom-plan(且 atom-plan 中含非空 skillRef)');
1301
+ process.exit(1);
1302
+ }
1303
+ const result = await linkAtomSkillsToProject({
1304
+ projectDir: opts.projectDir,
1305
+ atomIds,
1306
+ entries,
1307
+ dryRun: !!opts.dryRun,
1308
+ force: !!opts.force,
1309
+ prune: !!opts.prune,
1310
+ });
1311
+ console.log(`\n🔗 DAG Skill 软连接 → ${path.join(path.resolve(opts.projectDir), '.claude', 'skills')}`);
1312
+ if (result.linked.length)
1313
+ console.log(` 已链接 (${result.linked.length}):${result.linked.join(', ')}`);
1314
+ if (result.skipped.length)
1315
+ console.log(` 跳过 (${result.skipped.length}):${result.skipped.join(', ')}`);
1316
+ if (result.missing.length)
1317
+ console.log(` 未找到 (${result.missing.length}):${result.missing.join(', ')}`);
1318
+ if (result.pruned.length)
1319
+ console.log(` 已 prune (${result.pruned.length}):${result.pruned.join(', ')}`);
1320
+ if (result.missing.length > 0)
1321
+ process.exit(1);
1322
+ });
1323
+ // ── validate-atom-plan ────────────────────────────────────────────────────
1324
+ addSkillsDirOption(skills
1325
+ .command('validate-atom-plan')
1326
+ .description('校验 docs/atom-plan.json 中 skillRef 均存在于 skills 库,且落在 skillsMatch.items 内')
1327
+ .option('--project-dir <path>', '项目根目录', process.cwd())
1328
+ .option('--allow-markdown', '若无 JSON 则回退校验 atom-plan.md(不推荐)')
1329
+ .option('--json', '输出 JSON 格式')).action(async (opts) => {
1330
+ const result = await validateAtomPlanProject({
1331
+ projectDir: opts.projectDir,
1332
+ skillsDir: opts.skillsDir,
1333
+ preferJson: !opts.allowMarkdown,
1334
+ });
1335
+ if (opts.json) {
1336
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1337
+ }
1338
+ else if (result.ok) {
1339
+ console.log(`\n✓ atom-plan 校验通过(${result.source},${result.skillRefsChecked.length} 个 skillRef)`);
1340
+ for (const w of result.warnings)
1341
+ console.log(` ⚠ ${w}`);
1342
+ console.log();
1343
+ }
1344
+ else {
1345
+ console.error(`\n✗ atom-plan 校验失败(${result.source})\n`);
1346
+ for (const e of result.errors)
1347
+ console.error(` - ${e}`);
1348
+ for (const w of result.warnings)
1349
+ console.error(` ⚠ ${w}`);
1350
+ console.error();
1351
+ }
1352
+ if (!result.ok)
1353
+ process.exit(1);
1354
+ });
1355
+ // ── read ──────────────────────────────────────────────────────────────────
1356
+ addSkillsDirOption(skills
1357
+ .command('read <atomId>')
1358
+ .description('输出指定 skill 的 SKILL.md 内容(供 Agent 直接读取 Recipe 和使用说明)')).action(async (atomId, opts) => {
1359
+ const config = await loadConfig({}).catch(() => ({ skillsDir: undefined }));
1360
+ const dirs = resolveSkillsDirs(opts.skillsDir, config.skillsDir);
1361
+ if (dirs.length === 0) {
1362
+ console.error('未找到 skills 目录。请通过 --skills-dir 指定、在 playcraft.config.json 中配置 skillsDir,或设置 AGENT_SKILLS_PATHS 环境变量。');
1363
+ process.exit(1);
1364
+ }
1365
+ const entries = loadSkillIndex(dirs);
1366
+ const entry = entries.find((e) => e.atomId === atomId);
1367
+ if (!entry) {
1368
+ console.error(`未找到 skill: ${atomId}`);
1369
+ console.error(`可用 skill 列表:playcraft skills list --json`);
1370
+ process.exit(1);
1371
+ }
1372
+ const skillMdPath = path.join(entry.dir, 'SKILL.md');
1373
+ if (!fs.existsSync(skillMdPath)) {
1374
+ console.error(`${atomId} 没有 SKILL.md(skillDir: ${entry.dir})`);
1375
+ process.exit(1);
1376
+ }
1377
+ process.stdout.write(fs.readFileSync(skillMdPath, 'utf-8'));
1378
+ });
1379
+ }