@playcraft/cli 0.0.31 → 0.0.33
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/dist/commands/prefab.js +119 -75
- package/dist/commands/recommend.js +227 -0
- package/dist/index.js +3 -0
- package/dist/utils/agent-api-client.js +0 -12
- package/package.json +3 -3
package/dist/commands/prefab.js
CHANGED
|
@@ -4,6 +4,8 @@ import { join, resolve } from 'path';
|
|
|
4
4
|
import JSON5 from 'json5';
|
|
5
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';
|
|
6
6
|
const DEFAULT_PAGE_LIMIT = 50;
|
|
7
|
+
/** 场景在 manifest / CLI 中列出但解析不到 GameConfig 时的人读标签 */
|
|
8
|
+
const NO_SCENE_GAMECONFIG_LABEL = '(无场景级 GameConfig)';
|
|
7
9
|
/** 无法识别 External / PlayCanvas 项目类型(供测试在 mock process.exit 后通过 instanceof 识别)。 */
|
|
8
10
|
export class ProjectDetectError extends Error {
|
|
9
11
|
name = 'ProjectDetectError';
|
|
@@ -186,7 +188,7 @@ function resolveVariantResolved(projectDir, systemType, opts) {
|
|
|
186
188
|
if (systemType === 'external-theme') {
|
|
187
189
|
return resolveThemeId(projectDir, opts);
|
|
188
190
|
}
|
|
189
|
-
return
|
|
191
|
+
return getMainSceneId(projectDir);
|
|
190
192
|
}
|
|
191
193
|
// ─── External Theme helpers ──────────────────────────────────────────────────
|
|
192
194
|
function loadThemeSchema(projectDir) {
|
|
@@ -240,6 +242,39 @@ function resolveThemeId(projectDir, opts) {
|
|
|
240
242
|
throw new Error('unreachable');
|
|
241
243
|
}
|
|
242
244
|
// ─── PlayCanvas / LiteCreator helpers ────────────────────────────────────────
|
|
245
|
+
/**
|
|
246
|
+
* manifest 中的主场景 ID(与编辑器「当前入口场景」对齐)。
|
|
247
|
+
*
|
|
248
|
+
* 优先级:1) isMain: true 2) 仅有一个场景时自动视为当前活跃场景 3) 无法确定则 null
|
|
249
|
+
*/
|
|
250
|
+
function getMainSceneId(projectDir) {
|
|
251
|
+
if (!fileExists(projectDir, MANIFEST_PATH))
|
|
252
|
+
return null;
|
|
253
|
+
try {
|
|
254
|
+
const manifestJson = JSON.parse(readLocalFile(projectDir, MANIFEST_PATH));
|
|
255
|
+
const scenes = manifestJson.scenes;
|
|
256
|
+
if (!Array.isArray(scenes) || scenes.length === 0)
|
|
257
|
+
return null;
|
|
258
|
+
const mainScene = scenes.find((s) => !!s && typeof s === 'object' && s.isMain === true);
|
|
259
|
+
if (mainScene != null && (mainScene.id != null || mainScene.uniqueId != null)) {
|
|
260
|
+
return String(mainScene.id ?? mainScene.uniqueId);
|
|
261
|
+
}
|
|
262
|
+
if (scenes.length === 1) {
|
|
263
|
+
const only = scenes[0];
|
|
264
|
+
if (only && typeof only === 'object') {
|
|
265
|
+
const o = only;
|
|
266
|
+
if (o.id != null)
|
|
267
|
+
return String(o.id);
|
|
268
|
+
if (o.uniqueId != null)
|
|
269
|
+
return String(o.uniqueId);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
243
278
|
function loadGameConfig(projectDir, configPath) {
|
|
244
279
|
const raw = readLocalFile(projectDir, configPath);
|
|
245
280
|
return JSON.parse(raw);
|
|
@@ -266,85 +301,71 @@ function scanExtensionMetaData(projectDir) {
|
|
|
266
301
|
}
|
|
267
302
|
return map;
|
|
268
303
|
}
|
|
269
|
-
|
|
270
|
-
|
|
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');
|
|
304
|
+
function extensionMetaDataFile(projectDir, extensionName) {
|
|
305
|
+
return join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
|
|
280
306
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const metaPath = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
|
|
307
|
+
function mutateExtensionMetaData(projectDir, extensionName, apply) {
|
|
308
|
+
const metaPath = extensionMetaDataFile(projectDir, extensionName);
|
|
284
309
|
const raw = readFileSync(metaPath, 'utf-8');
|
|
285
310
|
const metaData = JSON.parse(raw);
|
|
286
311
|
const configSchema = metaData.configSchema;
|
|
287
312
|
if (!configSchema) {
|
|
288
313
|
throw new Error(`MetaData.json for "${extensionName}" has no configSchema`);
|
|
289
314
|
}
|
|
290
|
-
|
|
291
|
-
if (configSchema[field]) {
|
|
292
|
-
configSchema[field].default = value;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
315
|
+
apply(configSchema);
|
|
295
316
|
writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
|
|
296
317
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
* 1. Scene with isMain: true
|
|
302
|
-
* 2. If only one scene exists, use that scene
|
|
303
|
-
* 3. If multiple scenes without isMain, return null (caller should fallback)
|
|
304
|
-
*/
|
|
305
|
-
function getMainSceneId(projectDir) {
|
|
306
|
-
if (!fileExists(projectDir, MANIFEST_PATH))
|
|
307
|
-
return null;
|
|
308
|
-
try {
|
|
309
|
-
const manifestJson = JSON.parse(readLocalFile(projectDir, MANIFEST_PATH));
|
|
310
|
-
const scenes = manifestJson?.scenes;
|
|
311
|
-
if (!Array.isArray(scenes) || scenes.length === 0)
|
|
312
|
-
return null;
|
|
313
|
-
// Priority 1: Find scene with isMain: true
|
|
314
|
-
const mainScene = scenes.find((s) => s.isMain === true);
|
|
315
|
-
if (mainScene?.id)
|
|
316
|
-
return String(mainScene.id);
|
|
317
|
-
// Priority 2: If only one scene, use it
|
|
318
|
-
if (scenes.length === 1 && scenes[0]?.id) {
|
|
319
|
-
return String(scenes[0].id);
|
|
318
|
+
function writeMetaDataDefault(projectDir, extensionName, field, value) {
|
|
319
|
+
mutateExtensionMetaData(projectDir, extensionName, (configSchema) => {
|
|
320
|
+
if (!configSchema[field]) {
|
|
321
|
+
throw new Error(`MetaData.json for "${extensionName}" has no configSchema.${field}`);
|
|
320
322
|
}
|
|
321
|
-
|
|
323
|
+
configSchema[field].default = value;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function writeMetaDataDefaults(projectDir, extensionName, values) {
|
|
327
|
+
mutateExtensionMetaData(projectDir, extensionName, (configSchema) => {
|
|
328
|
+
for (const [field, value] of Object.entries(values)) {
|
|
329
|
+
if (configSchema[field]) {
|
|
330
|
+
configSchema[field].default = value;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/** 仅按场景 ID 解析 GameConfig(不查主场景)。 */
|
|
336
|
+
function resolveGameConfigPathForSceneId(projectDir, sceneId) {
|
|
337
|
+
if (!fileExists(projectDir, scenePath(sceneId)))
|
|
322
338
|
return null;
|
|
323
|
-
|
|
324
|
-
|
|
339
|
+
const sceneJson = JSON.parse(readLocalFile(projectDir, scenePath(sceneId)));
|
|
340
|
+
if (!fileExists(projectDir, ASSETS_JSON_PATH))
|
|
325
341
|
return null;
|
|
326
|
-
|
|
342
|
+
const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
|
|
343
|
+
return resolveGameConfigPath(sceneJson, assetsJson);
|
|
327
344
|
}
|
|
328
345
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* If variantId is not provided, automatically detect the current scene:
|
|
332
|
-
* 1. Scene with isMain: true in manifest.json
|
|
333
|
-
* 2. If only one scene exists, use that scene
|
|
334
|
-
* 3. If multiple scenes without isMain, return null (fallback to MetaData.json)
|
|
346
|
+
* 解析当前变体下的 GameConfig 路径。无法解析时返回 null(list 回退 DefaultGame 扩展;set 可写 MetaData)。
|
|
347
|
+
* 未传 variantId 时用 manifest 主场景(isMain / 单场景)。
|
|
335
348
|
*/
|
|
336
349
|
function resolveGameConfigPathForVariant(projectDir, variantId) {
|
|
337
|
-
// Auto-detect scene if not specified
|
|
338
350
|
const sceneId = variantId ?? getMainSceneId(projectDir);
|
|
339
351
|
if (!sceneId)
|
|
340
352
|
return null;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
353
|
+
return resolveGameConfigPathForSceneId(projectDir, sceneId);
|
|
354
|
+
}
|
|
355
|
+
/** PlayCanvas:`prefab list` 等人读「变体上下文」一行话。 */
|
|
356
|
+
function liteCreatorVariantHumanNote(projectDir, explicitVariant) {
|
|
357
|
+
if (explicitVariant) {
|
|
358
|
+
const p = resolveGameConfigPathForSceneId(projectDir, explicitVariant);
|
|
359
|
+
return p != null ? `场景 ${explicitVariant} → ${p}` : `场景 ${explicitVariant}(${NO_SCENE_GAMECONFIG_LABEL};set 可走 MetaData)`;
|
|
360
|
+
}
|
|
361
|
+
const mainId = getMainSceneId(projectDir);
|
|
362
|
+
const gc = resolveGameConfigPathForVariant(projectDir, undefined);
|
|
363
|
+
if (mainId != null) {
|
|
364
|
+
return gc != null
|
|
365
|
+
? `主场景 ${mainId} → ${gc}`
|
|
366
|
+
: `主场景 ${mainId}(未解析到 GameConfig;list 读 ${DEFAULT_GAME_PATH} 扩展,set 写 MetaData)`;
|
|
367
|
+
}
|
|
368
|
+
return `无主场景标记 → 扩展列表:${DEFAULT_GAME_PATH};set / set-batch:各扩展 MetaData.json`;
|
|
348
369
|
}
|
|
349
370
|
function listLocalScenes(projectDir) {
|
|
350
371
|
if (!fileExists(projectDir, MANIFEST_PATH))
|
|
@@ -361,6 +382,21 @@ function listLocalScenes(projectDir) {
|
|
|
361
382
|
}
|
|
362
383
|
return listScenesFromManifest(manifestJson);
|
|
363
384
|
}
|
|
385
|
+
/** 列表展示:补全 GameConfig 路径,并用主场景 ID 标记 isActive(与未指定 --variant 的 prefab 上下文一致)。 */
|
|
386
|
+
function annotatePlaycanvasScenesForMainScene(projectDir, scenes) {
|
|
387
|
+
const mainSceneId = getMainSceneId(projectDir);
|
|
388
|
+
const annotated = scenes.map((s) => {
|
|
389
|
+
const resolved = s.gameConfigPath ?? resolveGameConfigPathForSceneId(projectDir, s.id);
|
|
390
|
+
const out = {
|
|
391
|
+
...s,
|
|
392
|
+
isActive: mainSceneId !== null && s.id === mainSceneId,
|
|
393
|
+
};
|
|
394
|
+
if (resolved != null)
|
|
395
|
+
out.gameConfigPath = resolved;
|
|
396
|
+
return out;
|
|
397
|
+
});
|
|
398
|
+
return { scenes: annotated, mainSceneId };
|
|
399
|
+
}
|
|
364
400
|
function loadPrefabs(projectDir, systemType, opts) {
|
|
365
401
|
if (systemType === 'external-theme') {
|
|
366
402
|
const themeId = resolveThemeId(projectDir, opts);
|
|
@@ -371,18 +407,15 @@ function loadPrefabs(projectDir, systemType, opts) {
|
|
|
371
407
|
const metaMap = scanExtensionMetaData(projectDir);
|
|
372
408
|
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
373
409
|
if (configPath) {
|
|
374
|
-
// 有场景级 GameConfig → 从中读取 extensions 和 config
|
|
375
410
|
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
376
411
|
const { extensions, config } = parseGameConfig(gameConfigJson);
|
|
377
412
|
return buildExtensionPrefabs(metaMap, { extensions, config }, configPath);
|
|
378
413
|
}
|
|
379
|
-
// 无场景级 GameConfig → 从 DefaultGame.json 只读 Extensions 列表(不读 Config 值)
|
|
380
414
|
if (fileExists(projectDir, DEFAULT_GAME_PATH)) {
|
|
381
415
|
const defaultGameJson = loadGameConfig(projectDir, DEFAULT_GAME_PATH);
|
|
382
416
|
const { extensions } = parseGameConfig(defaultGameJson);
|
|
383
417
|
return buildExtensionPrefabs(metaMap, { extensions, config: {} }, DEFAULT_GAME_PATH);
|
|
384
418
|
}
|
|
385
|
-
// 完全无 GameConfig → 仅根据 MetaData 构建(全部标记为未启用)
|
|
386
419
|
return buildExtensionPrefabs(metaMap, { extensions: [], config: {} }, '');
|
|
387
420
|
}
|
|
388
421
|
// ─── dotpath helper ──────────────────────────────────────────────────────────
|
|
@@ -510,8 +543,18 @@ export function registerPrefabCommands(program) {
|
|
|
510
543
|
});
|
|
511
544
|
}
|
|
512
545
|
else {
|
|
513
|
-
const
|
|
514
|
-
const
|
|
546
|
+
const scenesRaw = listLocalScenes(projectDir);
|
|
547
|
+
const { scenes, mainSceneId } = annotatePlaycanvasScenesForMainScene(projectDir, scenesRaw);
|
|
548
|
+
const gameConfigPath = resolveGameConfigPathForVariant(projectDir, undefined);
|
|
549
|
+
const payload = {
|
|
550
|
+
projectType: 'playcanvas',
|
|
551
|
+
variants: scenes,
|
|
552
|
+
context: {
|
|
553
|
+
mainSceneId,
|
|
554
|
+
gameConfigPath,
|
|
555
|
+
usesMetaDataForSet: gameConfigPath == null,
|
|
556
|
+
},
|
|
557
|
+
};
|
|
515
558
|
outputResult(asJson, payload, () => {
|
|
516
559
|
console.log(`项目类型: ${projectTypeLabel('lite-creator')}`);
|
|
517
560
|
console.log('');
|
|
@@ -521,16 +564,18 @@ export function registerPrefabCommands(program) {
|
|
|
521
564
|
}
|
|
522
565
|
for (const s of scenes) {
|
|
523
566
|
const mark = s.isActive ? ' *' : '';
|
|
524
|
-
const
|
|
525
|
-
console.log(` ${s.name} (${s.id})${
|
|
567
|
+
const pathLabel = s.gameConfigPath ?? NO_SCENE_GAMECONFIG_LABEL;
|
|
568
|
+
console.log(` ${s.name} (${s.id}) [${pathLabel}]${mark}`);
|
|
526
569
|
}
|
|
527
|
-
if (scenes.some((
|
|
570
|
+
if (scenes.some((v) => v.isActive)) {
|
|
528
571
|
console.log('');
|
|
529
|
-
console.log('标
|
|
572
|
+
console.log('标 *:manifest 主场景(isMain,或仅 1 个场景时自动认定)。' +
|
|
573
|
+
' 未带 --variant:优先该场景 GameConfig;若无路径则 list 读 DefaultGame 扩展、set 写 MetaData。');
|
|
530
574
|
}
|
|
531
575
|
else {
|
|
532
576
|
console.log('');
|
|
533
|
-
console.log('
|
|
577
|
+
console.log('无主场景:多场景请在 manifest 设 isMain。未带 --variant 时扩展来自 ' +
|
|
578
|
+
`${DEFAULT_GAME_PATH},set / set-batch 写入各扩展 MetaData.json。`);
|
|
534
579
|
}
|
|
535
580
|
});
|
|
536
581
|
}
|
|
@@ -563,6 +608,9 @@ export function registerPrefabCommands(program) {
|
|
|
563
608
|
if (systemType === 'external-theme' && !opts.variant) {
|
|
564
609
|
variantNote = resolveThemeId(projectDir, opts);
|
|
565
610
|
}
|
|
611
|
+
else if (systemType === 'lite-creator') {
|
|
612
|
+
variantNote = liteCreatorVariantHumanNote(projectDir, opts.variant);
|
|
613
|
+
}
|
|
566
614
|
const summary = buildSummary(systemType, variantResolved, all);
|
|
567
615
|
const filtered = filterPrefabsList(all, {
|
|
568
616
|
match: opts.match,
|
|
@@ -933,7 +981,6 @@ export function registerPrefabCommands(program) {
|
|
|
933
981
|
}
|
|
934
982
|
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
935
983
|
if (configPath) {
|
|
936
|
-
// 有场景级 GameConfig → 写入 GameConfig 文件
|
|
937
984
|
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
938
985
|
const { config } = parseGameConfig(gameConfigJson);
|
|
939
986
|
const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
|
|
@@ -944,7 +991,6 @@ export function registerPrefabCommands(program) {
|
|
|
944
991
|
writeGameConfig(projectDir, configPath, updated);
|
|
945
992
|
}
|
|
946
993
|
else {
|
|
947
|
-
// 无场景级 GameConfig → 写 MetaData.json 的 configSchema.{field}.default
|
|
948
994
|
writeMetaDataDefault(projectDir, key, field, coerced);
|
|
949
995
|
}
|
|
950
996
|
const payload = { success: true, key, field, value: coerced };
|
|
@@ -1046,7 +1092,6 @@ export function registerPrefabCommands(program) {
|
|
|
1046
1092
|
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
1047
1093
|
const metaMap = scanExtensionMetaData(projectDir);
|
|
1048
1094
|
if (configPath) {
|
|
1049
|
-
// 有场景级 GameConfig → 写入 GameConfig 文件
|
|
1050
1095
|
let gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
1051
1096
|
for (const [extKey, fieldMap] of Object.entries(batch)) {
|
|
1052
1097
|
if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
|
|
@@ -1080,7 +1125,6 @@ export function registerPrefabCommands(program) {
|
|
|
1080
1125
|
writeGameConfig(projectDir, configPath, gameConfigJson);
|
|
1081
1126
|
}
|
|
1082
1127
|
else {
|
|
1083
|
-
// 无场景级 GameConfig → 逐个写 MetaData.json defaults
|
|
1084
1128
|
for (const [extKey, fieldMap] of Object.entries(batch)) {
|
|
1085
1129
|
if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
|
|
1086
1130
|
throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
|
|
@@ -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.
|
|
3
|
+
"version": "0.0.33",
|
|
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.
|
|
27
|
-
"@playcraft/common": "^0.0.
|
|
26
|
+
"@playcraft/build": "^0.0.34",
|
|
27
|
+
"@playcraft/common": "^0.0.22",
|
|
28
28
|
"chokidar": "^4.0.3",
|
|
29
29
|
"commander": "^13.1.0",
|
|
30
30
|
"cors": "^2.8.6",
|