@playcraft/cli 0.0.30 → 0.0.32

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.
@@ -2,7 +2,7 @@ import { inspect } from 'node:util';
2
2
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
  import JSON5 from 'json5';
5
- import { THEME_SCHEMA_PATH, THEME_INDEX_PATH, THEME_DIR, DEFAULT_GAME_PATH, MANIFEST_PATH, ASSETS_JSON_PATH, LITE_CREATOR_EXTENSIONS_DIR, themeDataPath, scenePath, defaultValueFromJsonSchemaProperty, buildThemeIndexTs, parseThemeIndexImportPath, parseThemePrefabs, mergeThemeDataKey, deleteThemeDataKey, parseExtensionMetaData, parseGameConfig, buildExtensionPrefabs, updateGameConfigValue, updateGameConfigExtensions, findConfigKeyCaseInsensitive, parseConfigKey, listScenesFromManifest, listScenesWithGameConfig, resolveGameConfigPath, validateThemeValue, validateConfigValue, describeJsonSchemaFields, describeConfigSchemaFields, getPrefabFieldDescriptors, getPrefabFieldDiffs, prefabHasSchemaDiff, } from '@playcraft/common/prefab';
5
+ import { THEME_SCHEMA_PATH, THEME_INDEX_PATH, THEME_DIR, DEFAULT_GAME_PATH, MANIFEST_PATH, ASSETS_JSON_PATH, LITE_CREATOR_EXTENSIONS_DIR, themeDataPath, scenePath, defaultValueFromJsonSchemaProperty, buildThemeIndexTs, parseThemeIndexImportPath, parseThemePrefabs, mergeThemeDataKey, deleteThemeDataKey, parseExtensionMetaData, parseGameConfig, buildExtensionPrefabs, updateGameConfigValue, updateGameConfigExtensions, findConfigKeyCaseInsensitive, parseConfigKey, listScenesFromManifest, resolveGameConfigPath, validateThemeValue, validateConfigValue, describeJsonSchemaFields, describeConfigSchemaFields, getPrefabFieldDescriptors, getPrefabFieldDiffs, prefabHasSchemaDiff, } from '@playcraft/common/prefab';
6
6
  const DEFAULT_PAGE_LIMIT = 50;
7
7
  /** 无法识别 External / PlayCanvas 项目类型(供测试在 mock process.exit 后通过 instanceof 识别)。 */
8
8
  export class ProjectDetectError extends Error {
@@ -266,58 +266,22 @@ function scanExtensionMetaData(projectDir) {
266
266
  }
267
267
  return map;
268
268
  }
269
- /** Write a single field's value into MetaData.json configSchema.{field}.default */
270
- function writeMetaDataDefault(projectDir, extensionName, field, value) {
271
- const metaPath = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
272
- const raw = readFileSync(metaPath, 'utf-8');
273
- const metaData = JSON.parse(raw);
274
- const configSchema = metaData.configSchema;
275
- if (!configSchema?.[field]) {
276
- throw new Error(`MetaData.json for "${extensionName}" has no configSchema.${field}`);
277
- }
278
- configSchema[field].default = value;
279
- writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
280
- }
281
- /** Write multiple fields' values into MetaData.json configSchema.*.default */
282
- function writeMetaDataDefaults(projectDir, extensionName, values) {
283
- const metaPath = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
284
- const raw = readFileSync(metaPath, 'utf-8');
285
- const metaData = JSON.parse(raw);
286
- const configSchema = metaData.configSchema;
287
- if (!configSchema) {
288
- throw new Error(`MetaData.json for "${extensionName}" has no configSchema`);
289
- }
290
- for (const [field, value] of Object.entries(values)) {
291
- if (configSchema[field]) {
292
- configSchema[field].default = value;
293
- }
294
- }
295
- writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
296
- }
297
269
  function resolveGameConfigPathForVariant(projectDir, variantId) {
298
270
  if (!variantId)
299
- return null;
271
+ return DEFAULT_GAME_PATH;
300
272
  if (!fileExists(projectDir, scenePath(variantId)))
301
- return null;
273
+ return DEFAULT_GAME_PATH;
302
274
  const sceneJson = JSON.parse(readLocalFile(projectDir, scenePath(variantId)));
303
275
  if (!fileExists(projectDir, ASSETS_JSON_PATH))
304
- return null;
276
+ return DEFAULT_GAME_PATH;
305
277
  const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
306
- return resolveGameConfigPath(sceneJson, assetsJson);
278
+ const resolved = resolveGameConfigPath(sceneJson, assetsJson);
279
+ return resolved ?? DEFAULT_GAME_PATH;
307
280
  }
308
281
  function listLocalScenes(projectDir) {
309
282
  if (!fileExists(projectDir, MANIFEST_PATH))
310
283
  return [];
311
284
  const manifestJson = JSON.parse(readLocalFile(projectDir, MANIFEST_PATH));
312
- if (fileExists(projectDir, ASSETS_JSON_PATH)) {
313
- const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
314
- return listScenesWithGameConfig(manifestJson, (sceneId) => {
315
- const sp = scenePath(sceneId);
316
- if (!fileExists(projectDir, sp))
317
- return null;
318
- return JSON.parse(readLocalFile(projectDir, sp));
319
- }, assetsJson);
320
- }
321
285
  return listScenesFromManifest(manifestJson);
322
286
  }
323
287
  function loadPrefabs(projectDir, systemType, opts) {
@@ -327,22 +291,11 @@ function loadPrefabs(projectDir, systemType, opts) {
327
291
  const data = loadThemeData(projectDir, themeId);
328
292
  return parseThemePrefabs(schema, data, themeId);
329
293
  }
330
- const metaMap = scanExtensionMetaData(projectDir);
331
294
  const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
332
- if (configPath) {
333
- // 有场景级 GameConfig 从中读取 extensions 和 config
334
- const gameConfigJson = loadGameConfig(projectDir, configPath);
335
- const { extensions, config } = parseGameConfig(gameConfigJson);
336
- return buildExtensionPrefabs(metaMap, { extensions, config }, configPath);
337
- }
338
- // 无场景级 GameConfig → 从 DefaultGame.json 只读 Extensions 列表(不读 Config 值)
339
- if (fileExists(projectDir, DEFAULT_GAME_PATH)) {
340
- const defaultGameJson = loadGameConfig(projectDir, DEFAULT_GAME_PATH);
341
- const { extensions } = parseGameConfig(defaultGameJson);
342
- return buildExtensionPrefabs(metaMap, { extensions, config: {} }, DEFAULT_GAME_PATH);
343
- }
344
- // 完全无 GameConfig → 仅根据 MetaData 构建(全部标记为未启用)
345
- return buildExtensionPrefabs(metaMap, { extensions: [], config: {} }, '');
295
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
296
+ const { extensions, config } = parseGameConfig(gameConfigJson);
297
+ const metaMap = scanExtensionMetaData(projectDir);
298
+ return buildExtensionPrefabs(metaMap, { extensions, config }, configPath);
346
299
  }
347
300
  // ─── dotpath helper ──────────────────────────────────────────────────────────
348
301
  function getByDotPath(obj, path) {
@@ -479,17 +432,8 @@ export function registerPrefabCommands(program) {
479
432
  return;
480
433
  }
481
434
  for (const s of scenes) {
482
- const mark = s.isActive ? ' *' : '';
483
435
  const gc = s.gameConfigPath ? ` [${s.gameConfigPath}]` : '';
484
- console.log(` ${s.name} (${s.id})${mark}${gc}`);
485
- }
486
- if (scenes.some((s) => s.isActive)) {
487
- console.log('');
488
- console.log('标 * 为 manifest 中的主场景(isMain)。其他场景请用 --variant <sceneId> 指定上下文。');
489
- }
490
- else {
491
- console.log('');
492
- console.log('(manifest 未标记主场景;操作其他场景请用 --variant <sceneId>)');
436
+ console.log(` ${s.name} (${s.id})${gc}`);
493
437
  }
494
438
  });
495
439
  }
@@ -874,12 +818,18 @@ export function registerPrefabCommands(program) {
874
818
  });
875
819
  }
876
820
  else {
821
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
822
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
823
+ const { config } = parseGameConfig(gameConfigJson);
877
824
  const metaMap = scanExtensionMetaData(projectDir);
878
825
  const meta = metaMap.get(key);
879
826
  if (!meta) {
880
827
  console.error(`Error: extension "${key}" 不存在。`);
881
828
  process.exit(1);
882
829
  }
830
+ const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
831
+ const existingKey = findConfigKeyCaseInsensitive(config, configKey);
832
+ const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
883
833
  const fieldDef = meta.configSchema?.[field];
884
834
  const fieldType = fieldDef?.type ?? 'string';
885
835
  const coerced = coerceValue(rawValue, fieldType);
@@ -890,22 +840,9 @@ export function registerPrefabCommands(program) {
890
840
  process.exit(1);
891
841
  }
892
842
  }
893
- const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
894
- if (configPath) {
895
- // 有场景级 GameConfig → 写入 GameConfig 文件
896
- const gameConfigJson = loadGameConfig(projectDir, configPath);
897
- const { config } = parseGameConfig(gameConfigJson);
898
- const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
899
- const existingKey = findConfigKeyCaseInsensitive(config, configKey);
900
- const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
901
- const newValues = { ...currentValues, [field]: coerced };
902
- const updated = updateGameConfigValue(gameConfigJson, configKey, newValues);
903
- writeGameConfig(projectDir, configPath, updated);
904
- }
905
- else {
906
- // 无场景级 GameConfig → 写 MetaData.json 的 configSchema.{field}.default
907
- writeMetaDataDefault(projectDir, key, field, coerced);
908
- }
843
+ const newValues = { ...currentValues, [field]: coerced };
844
+ const updated = updateGameConfigValue(gameConfigJson, configKey, newValues);
845
+ writeGameConfig(projectDir, configPath, updated);
909
846
  const payload = { success: true, key, field, value: coerced };
910
847
  outputResult(asJson, payload, () => {
911
848
  console.log(`已更新 ${key}.${field} = ${formatHumanValue(coerced)}`);
@@ -1003,67 +940,38 @@ export function registerPrefabCommands(program) {
1003
940
  }
1004
941
  else {
1005
942
  const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
943
+ let gameConfigJson = loadGameConfig(projectDir, configPath);
1006
944
  const metaMap = scanExtensionMetaData(projectDir);
1007
- if (configPath) {
1008
- // 有场景级 GameConfig 写入 GameConfig 文件
1009
- let gameConfigJson = loadGameConfig(projectDir, configPath);
1010
- for (const [extKey, fieldMap] of Object.entries(batch)) {
1011
- if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
1012
- throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
1013
- }
1014
- const meta = metaMap.get(extKey);
1015
- if (!meta) {
1016
- throw new Error(`extension "${extKey}" 不存在`);
1017
- }
1018
- const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
1019
- const { config } = parseGameConfig(gameConfigJson);
1020
- const existingKey = findConfigKeyCaseInsensitive(config, configKey);
1021
- let currentValues = (existingKey ? config[existingKey] : {}) ?? {};
1022
- currentValues = typeof currentValues === 'object' && !Array.isArray(currentValues)
1023
- ? { ...currentValues }
1024
- : {};
1025
- for (const [field, rawVal] of Object.entries(fieldMap)) {
1026
- const fieldDef = meta.configSchema?.[field];
1027
- const fieldType = fieldDef?.type ?? 'string';
1028
- const coerced = coerceBatchLeaf(rawVal, fieldType);
1029
- if (fieldDef) {
1030
- const result = validateConfigValue(fieldDef, coerced);
1031
- if (!result.valid) {
1032
- throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
1033
- }
1034
- }
1035
- currentValues = setByDotPath(currentValues, field, coerced);
1036
- }
1037
- gameConfigJson = updateGameConfigValue(gameConfigJson, configKey, currentValues);
945
+ for (const [extKey, fieldMap] of Object.entries(batch)) {
946
+ if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
947
+ throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
1038
948
  }
1039
- writeGameConfig(projectDir, configPath, gameConfigJson);
1040
- }
1041
- else {
1042
- // 无场景级 GameConfig → 逐个写 MetaData.json defaults
1043
- for (const [extKey, fieldMap] of Object.entries(batch)) {
1044
- if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
1045
- throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
1046
- }
1047
- const meta = metaMap.get(extKey);
1048
- if (!meta) {
1049
- throw new Error(`extension "${extKey}" 不存在`);
1050
- }
1051
- const coercedValues = {};
1052
- for (const [field, rawVal] of Object.entries(fieldMap)) {
1053
- const fieldDef = meta.configSchema?.[field];
1054
- const fieldType = fieldDef?.type ?? 'string';
1055
- const coerced = coerceBatchLeaf(rawVal, fieldType);
1056
- if (fieldDef) {
1057
- const result = validateConfigValue(fieldDef, coerced);
1058
- if (!result.valid) {
1059
- throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
1060
- }
949
+ const meta = metaMap.get(extKey);
950
+ if (!meta) {
951
+ throw new Error(`extension "${extKey}" 不存在`);
952
+ }
953
+ const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
954
+ const { config } = parseGameConfig(gameConfigJson);
955
+ const existingKey = findConfigKeyCaseInsensitive(config, configKey);
956
+ let currentValues = (existingKey ? config[existingKey] : {}) ?? {};
957
+ currentValues = typeof currentValues === 'object' && !Array.isArray(currentValues)
958
+ ? { ...currentValues }
959
+ : {};
960
+ for (const [field, rawVal] of Object.entries(fieldMap)) {
961
+ const fieldDef = meta.configSchema?.[field];
962
+ const fieldType = fieldDef?.type ?? 'string';
963
+ const coerced = coerceBatchLeaf(rawVal, fieldType);
964
+ if (fieldDef) {
965
+ const result = validateConfigValue(fieldDef, coerced);
966
+ if (!result.valid) {
967
+ throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
1061
968
  }
1062
- coercedValues[field] = coerced;
1063
969
  }
1064
- writeMetaDataDefaults(projectDir, extKey, coercedValues);
970
+ currentValues = setByDotPath(currentValues, field, coerced);
1065
971
  }
972
+ gameConfigJson = updateGameConfigValue(gameConfigJson, configKey, currentValues);
1066
973
  }
974
+ writeGameConfig(projectDir, configPath, gameConfigJson);
1067
975
  }
1068
976
  const payload = { success: true, updatedKeys: Object.keys(batch) };
1069
977
  outputResult(asJson, payload, () => {
@@ -1103,7 +1011,7 @@ export function registerPrefabCommands(program) {
1103
1011
  });
1104
1012
  }
1105
1013
  else {
1106
- const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
1014
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
1107
1015
  const gameConfigJson = loadGameConfig(projectDir, configPath);
1108
1016
  const { extensions } = parseGameConfig(gameConfigJson);
1109
1017
  if (extensions.includes(key)) {
@@ -1147,7 +1055,7 @@ export function registerPrefabCommands(program) {
1147
1055
  });
1148
1056
  }
1149
1057
  else {
1150
- const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
1058
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
1151
1059
  const gameConfigJson = loadGameConfig(projectDir, configPath);
1152
1060
  const { extensions } = parseGameConfig(gameConfigJson);
1153
1061
  const filtered = extensions.filter((n) => n !== key);
@@ -0,0 +1,227 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { AgentApiClient } from '../utils/agent-api-client.js';
3
+ function handleError(err) {
4
+ const msg = err instanceof Error ? err.message : String(err);
5
+ console.error(`Error: ${msg}`);
6
+ process.exit(1);
7
+ }
8
+ function output(data, opts) {
9
+ const text = JSON.stringify(data, null, opts.pretty ? 2 : 0);
10
+ if (opts.output) {
11
+ writeFileSync(opts.output, text);
12
+ console.error(`Wrote ${opts.output}`);
13
+ }
14
+ else {
15
+ process.stdout.write(text + '\n');
16
+ }
17
+ }
18
+ function parseTagsArg(raw) {
19
+ if (!raw)
20
+ return undefined;
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
24
+ throw new Error('tags must be a JSON object');
25
+ }
26
+ return parsed;
27
+ }
28
+ catch (e) {
29
+ throw new Error(`Invalid --tags JSON: ${e instanceof Error ? e.message : String(e)}`);
30
+ }
31
+ }
32
+ function parseIntOpt(label, raw) {
33
+ if (raw === undefined || raw === '')
34
+ return undefined;
35
+ const n = Number.parseInt(raw, 10);
36
+ if (!Number.isFinite(n))
37
+ throw new Error(`Invalid ${label}: "${raw}" is not a valid integer`);
38
+ return n;
39
+ }
40
+ /**
41
+ * 注册 `playcraft recommend <subcommand>` 命令组。
42
+ *
43
+ * Subcommands(全部调 /api/agent/tools/recommend-*):
44
+ * meta 打印当前数据快照的渠道 / 指标 / 维度元数据
45
+ * data 下载完整 RecommendData 快照
46
+ * similar 策略一: Top-N 相似 Creative + KNN 预估
47
+ * improvements 策略二: R1(可选 R2) 的 Tag 改进建议 + diff
48
+ * random 策略三: 批量随机生成 Tag 组合
49
+ * optimal 辅助: 理论最优 Tag 组合(perDim ω² 排序)
50
+ * tag-to-atom 辅助: Tag → Atom slot 映射
51
+ */
52
+ export function registerRecommendCommands(program) {
53
+ const rec = program
54
+ .command('recommend')
55
+ .description('Tag → Atom 推荐(后端 /api/agent/tools/recommend-*);需 .playcraft.json 或环境变量');
56
+ // ─── meta / data ───────────────────────────────────────────
57
+ rec
58
+ .command('meta')
59
+ .description('打印当前数据快照的元数据(渠道 / 指标 / 维度)')
60
+ .option('--pretty', '美化 JSON 输出')
61
+ .option('--output <file>', '写入文件而非 stdout')
62
+ .action(async (opts) => {
63
+ try {
64
+ const client = new AgentApiClient();
65
+ const result = await client.get('/recommend-meta');
66
+ output(result, opts);
67
+ }
68
+ catch (e) {
69
+ handleError(e);
70
+ }
71
+ });
72
+ rec
73
+ .command('data')
74
+ .description('下载完整 RecommendData 快照(含 creatives/channelStats/dimValueStats)')
75
+ .option('--pretty', '美化 JSON 输出')
76
+ .option('--output <file>', '写入文件而非 stdout', '')
77
+ .action(async (opts) => {
78
+ try {
79
+ const client = new AgentApiClient();
80
+ const result = await client.get('/recommend-data');
81
+ output(result, opts);
82
+ }
83
+ catch (e) {
84
+ handleError(e);
85
+ }
86
+ });
87
+ // ─── 策略一: similar ───────────────────────────────────────
88
+ rec
89
+ .command('similar')
90
+ .description('策略一: 相似 Creative + KNN 预估')
91
+ .requiredOption('--channel <name>', '投放渠道(Applovin|Facebook|Google|Moloco|Unity)')
92
+ .option('--metric <name>', '指标(ctr|cvr|cpi|ipm|cost|installs)', 'ctr')
93
+ .option('--creative <id>', '种子素材 ID(与 --tags 二选一)')
94
+ .option('--tags <json>', '种子 tags JSON(与 --creative 二选一)')
95
+ .option('--top <n>', 'Top-N 相似数量', '5')
96
+ .option('--k <n>', 'KNN 邻居数', '5')
97
+ .option('--no-knn', '不返回 KNN 预估')
98
+ .option('--include-atoms', '为每个相似项返回 atoms 配置')
99
+ .option('--pretty', '美化 JSON 输出')
100
+ .option('--output <file>', '写入文件而非 stdout')
101
+ .action(async (opts) => {
102
+ try {
103
+ if (!opts.creative && !opts.tags) {
104
+ throw new Error('Either --creative <id> or --tags <json> must be provided');
105
+ }
106
+ const body = {
107
+ channel: opts.channel,
108
+ metric: opts.metric,
109
+ creativeId: opts.creative,
110
+ tags: parseTagsArg(opts.tags),
111
+ topN: parseIntOpt('--top', opts.top),
112
+ k: parseIntOpt('--k', opts.k),
113
+ includeKnn: opts.knn,
114
+ includeAtoms: !!opts.includeAtoms,
115
+ };
116
+ const client = new AgentApiClient();
117
+ const result = await client.post('/recommend-similar', body);
118
+ output(result, opts);
119
+ }
120
+ catch (e) {
121
+ handleError(e);
122
+ }
123
+ });
124
+ // ─── 策略二: improvements ──────────────────────────────────
125
+ rec
126
+ .command('improvements')
127
+ .description('策略二: R1 的 Tag 改进建议 + 可选 R2 diff')
128
+ .requiredOption('--channel <name>', '投放渠道')
129
+ .option('--metric <name>', '指标(ctr|cvr|cpi|ipm|cost|installs)', 'ctr')
130
+ .requiredOption('--r1 <id>', '当前素材 ID')
131
+ .option('--r2 <id>', '对照标杆素材 ID(可选)')
132
+ .option('--pretty', '美化 JSON 输出')
133
+ .option('--output <file>', '写入文件而非 stdout')
134
+ .action(async (opts) => {
135
+ try {
136
+ const body = {
137
+ channel: opts.channel,
138
+ metric: opts.metric,
139
+ r1Id: opts.r1,
140
+ r2Id: opts.r2,
141
+ };
142
+ const client = new AgentApiClient();
143
+ const result = await client.post('/recommend-improvements', body);
144
+ output(result, opts);
145
+ }
146
+ catch (e) {
147
+ handleError(e);
148
+ }
149
+ });
150
+ // ─── 策略三: random ────────────────────────────────────────
151
+ rec
152
+ .command('random')
153
+ .description('策略三: 批量随机生成 Tag 组合,按预估指标排序')
154
+ .requiredOption('--channel <name>', '投放渠道')
155
+ .option('--metric <name>', '指标(ctr|cvr|cpi|ipm|cost|installs)', 'ctr')
156
+ .option('--batch <n>', '生成数量', '10')
157
+ .option('--similar-top <n>', '每条结果附带的相似 Creative 数量', '5')
158
+ .option('--no-atoms', '不为每条结果返回 atoms')
159
+ .option('--seed <n>', '随机种子(可选;提供时结果可复现)')
160
+ .option('--pretty', '美化 JSON 输出')
161
+ .option('--output <file>', '写入文件而非 stdout')
162
+ .action(async (opts) => {
163
+ try {
164
+ const body = {
165
+ channel: opts.channel,
166
+ metric: opts.metric,
167
+ batchSize: parseIntOpt('--batch', opts.batch),
168
+ similarTopN: parseIntOpt('--similar-top', opts.similarTop),
169
+ includeAtoms: opts.atoms,
170
+ seed: opts.seed === undefined ? undefined : parseIntOpt('--seed', opts.seed),
171
+ };
172
+ const client = new AgentApiClient();
173
+ const result = await client.post('/recommend-random', body);
174
+ output(result, opts);
175
+ }
176
+ catch (e) {
177
+ handleError(e);
178
+ }
179
+ });
180
+ // ─── 辅助: optimal ────────────────────────────────────────
181
+ rec
182
+ .command('optimal')
183
+ .description('辅助: 理论最优 Tag 组合(perDim ω² 排序)')
184
+ .requiredOption('--channel <name>', '投放渠道')
185
+ .option('--metric <name>', '指标(ctr|cvr|cpi|ipm|cost|installs)', 'ctr')
186
+ .option('--top <n>', 'Top-K 数量', '5')
187
+ .option('--include-atoms', '为每条结果附带 atoms 配置')
188
+ .option('--pretty', '美化 JSON 输出')
189
+ .option('--output <file>', '写入文件而非 stdout')
190
+ .action(async (opts) => {
191
+ try {
192
+ const body = {
193
+ channel: opts.channel,
194
+ metric: opts.metric,
195
+ topK: parseIntOpt('--top', opts.top),
196
+ includeAtoms: !!opts.includeAtoms,
197
+ };
198
+ const client = new AgentApiClient();
199
+ const result = await client.post('/recommend-optimal', body);
200
+ output(result, opts);
201
+ }
202
+ catch (e) {
203
+ handleError(e);
204
+ }
205
+ });
206
+ // ─── 辅助: tag-to-atom ────────────────────────────────────
207
+ rec
208
+ .command('tag-to-atom')
209
+ .description('辅助: Tag → Atom slot 映射')
210
+ .requiredOption('--tags <json>', 'Tag JSON,如 \'{"玩法类型":"三消复刻","背景场景":"公园"}\'')
211
+ .option('--pretty', '美化 JSON 输出')
212
+ .option('--output <file>', '写入文件而非 stdout')
213
+ .action(async (opts) => {
214
+ try {
215
+ const tags = parseTagsArg(opts.tags);
216
+ if (!tags)
217
+ throw new Error('--tags is required');
218
+ const body = { tags };
219
+ const client = new AgentApiClient();
220
+ const result = await client.post('/recommend-tag-to-atom', body);
221
+ output(result, opts);
222
+ }
223
+ catch (e) {
224
+ handleError(e);
225
+ }
226
+ });
227
+ }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { registerToolsCommands } from './commands/tools.js';
19
19
  import { registerImageCommands } from './commands/image.js';
20
20
  import { registerAudioCommands } from './commands/audio.js';
21
21
  import { registerPrefabCommands } from './commands/prefab.js';
22
+ import { registerRecommendCommands } from './commands/recommend.js';
22
23
  import { CLI_ROOT_DESCRIPTION, getCliTopicsHelpText, registerRootProgramHelp, } from './cli-root-help.js';
23
24
  const __filename = fileURLToPath(import.meta.url);
24
25
  const __dirname = dirname(__filename);
@@ -329,4 +330,6 @@ registerImageCommands(program);
329
330
  registerAudioCommands(program);
330
331
  // Remix prefab management (playcraft prefab <command>)
331
332
  registerPrefabCommands(program);
333
+ // Tag → Atom recommend (playcraft recommend <command>)
334
+ registerRecommendCommands(program);
332
335
  program.parse(process.argv);
@@ -62,24 +62,12 @@ export class AgentApiClient {
62
62
  return res.json();
63
63
  }
64
64
  static loadConfig() {
65
- // 兜底覆盖三类常见沙箱运行用户:
66
- // - AGS 镜像(ccr.ccs.tencentyun.com/ags-image/sandbox-code)默认运行用户为 `user`,HOME=/home/user
67
- // - 自建 docker/Dockerfile 沙箱 runtime stage 用户为 `opencode`,HOME=/home/opencode
68
- // - 以 root 启动 CLI 的旁路场景,HOME=/root
69
- // 第一条 cwd 与第二条 /project 是与运行用户无关的硬编码命中点。
70
65
  const searchPaths = [
71
66
  join(process.cwd(), '.playcraft.json'),
72
67
  '/project/.playcraft.json',
73
- '/home/user/.playcraft.json',
74
- '/home/opencode/.playcraft.json',
75
- '/root/.playcraft.json',
76
68
  join(homedir(), '.playcraft.json'),
77
69
  ];
78
- const seen = new Set();
79
70
  for (const p of searchPaths) {
80
- if (seen.has(p))
81
- continue;
82
- seen.add(p);
83
71
  if (existsSync(p)) {
84
72
  try {
85
73
  return JSON.parse(readFileSync(p, 'utf-8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,8 +23,8 @@
23
23
  "release": "node scripts/release.js"
24
24
  },
25
25
  "dependencies": {
26
- "@playcraft/build": "^0.0.31",
27
- "@playcraft/common": "^0.0.19",
26
+ "@playcraft/build": "^0.0.33",
27
+ "@playcraft/common": "^0.0.20",
28
28
  "chokidar": "^4.0.3",
29
29
  "commander": "^13.1.0",
30
30
  "cors": "^2.8.6",