@playcraft/cli 0.0.19 → 0.0.21

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.
@@ -0,0 +1,447 @@
1
+ import { writeFileSync, mkdirSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { tmpdir } from 'os';
4
+ import { AgentApiClient } from '../utils/agent-api-client.js';
5
+ const TMP_DIR = join(tmpdir(), 'playcraft');
6
+ function ensureTmpDir() {
7
+ mkdirSync(TMP_DIR, { recursive: true });
8
+ }
9
+ function tmpPath(prefix) {
10
+ return join(TMP_DIR, `${prefix}-${Date.now()}.json`);
11
+ }
12
+ function writeResult(prefix, data) {
13
+ ensureTmpDir();
14
+ const path = tmpPath(prefix);
15
+ writeFileSync(path, JSON.stringify(data, null, 2));
16
+ return path;
17
+ }
18
+ function handleError(err) {
19
+ const msg = err instanceof Error ? err.message : String(err);
20
+ console.error(`Error: ${msg}`);
21
+ process.exit(1);
22
+ }
23
+ export function registerToolsCommands(program) {
24
+ const tools = program
25
+ .command('tools')
26
+ .description('调用后端 /api/agent/tools:素材 AI(generate-*)与平台能力(Git/沙箱构建/发布/查询/Prefab 等)');
27
+ // ─── Generation ─────────────────────────────────────────────
28
+ tools.command('generate-image')
29
+ .description('AI 生成图片')
30
+ .requiredOption('--prompt <text>', '图片描述')
31
+ .option('--aspect-ratio <ratio>', '宽高比 (1:1|16:9|9:16|3:4|4:3)', '1:1')
32
+ .requiredOption('--output <path>', '保存路径')
33
+ .option('--image-size <size>', '图片尺寸 (1K|2K|4K)')
34
+ .action(async (opts) => {
35
+ try {
36
+ const client = new AgentApiClient();
37
+ const result = await client.post('/generate-image', {
38
+ prompt: opts.prompt,
39
+ aspectRatio: opts.aspectRatio,
40
+ imageSize: opts.imageSize,
41
+ });
42
+ mkdirSync(dirname(opts.output), { recursive: true });
43
+ const buf = Buffer.from(result.base64, 'base64');
44
+ writeFileSync(opts.output, buf);
45
+ const sizeKB = Math.round(buf.length / 1024);
46
+ console.log(`Image saved to ${opts.output} (${sizeKB}KB, ${result.mimeType})`);
47
+ }
48
+ catch (e) {
49
+ handleError(e);
50
+ }
51
+ });
52
+ tools.command('generate-sfx')
53
+ .description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
54
+ .requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
55
+ .option('--duration <seconds>', '时长(秒,0.5-30)', parseFloat)
56
+ .option('--loop', '生成可循环的音效', false)
57
+ .requiredOption('--output <path>', '保存路径,如 ./assets/audio/click.mp3')
58
+ .action(async (opts) => {
59
+ try {
60
+ const client = new AgentApiClient();
61
+ const result = await client.post('/generate-sfx', {
62
+ prompt: opts.prompt,
63
+ duration: opts.duration,
64
+ loop: opts.loop,
65
+ });
66
+ mkdirSync(dirname(opts.output), { recursive: true });
67
+ const buf = Buffer.from(result.audioBase64, 'base64');
68
+ writeFileSync(opts.output, buf);
69
+ const sizeKB = Math.round(buf.length / 1024);
70
+ console.log(`SFX saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
71
+ }
72
+ catch (e) {
73
+ handleError(e);
74
+ }
75
+ });
76
+ tools.command('generate-bgm')
77
+ .description('AI 生成 BGM(30s 循环),使用 Google Lyria 3')
78
+ .requiredOption('--prompt <text>', 'BGM 描述,如 "轻快休闲游戏配乐"')
79
+ .option('--style <style>', '音乐风格,如 casual / epic / sci-fi / retro')
80
+ .option('--bpm <bpm>', '目标 BPM', parseInt)
81
+ .requiredOption('--output <path>', '保存路径,如 ./assets/audio/bgm.mp3')
82
+ .action(async (opts) => {
83
+ try {
84
+ const client = new AgentApiClient();
85
+ const result = await client.post('/generate-bgm', {
86
+ prompt: opts.prompt,
87
+ style: opts.style,
88
+ bpm: opts.bpm,
89
+ });
90
+ mkdirSync(dirname(opts.output), { recursive: true });
91
+ const buf = Buffer.from(result.audioBase64, 'base64');
92
+ writeFileSync(opts.output, buf);
93
+ const sizeKB = Math.round(buf.length / 1024);
94
+ console.log(`BGM saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
95
+ }
96
+ catch (e) {
97
+ handleError(e);
98
+ }
99
+ });
100
+ // ─── Project Pipeline ───────────────────────────────────────
101
+ tools.command('save-to-git')
102
+ .description('同步沙箱内容到 Git 仓库')
103
+ .requiredOption('--project-id <id>', '项目数字 ID', parseInt)
104
+ .requiredOption('--message <text>', '提交信息')
105
+ .option('--branch <name>', '分支名', 'main')
106
+ .action(async (opts) => {
107
+ try {
108
+ const client = new AgentApiClient();
109
+ const result = await client.post('/save-to-git', {
110
+ projectId: opts.projectId,
111
+ branch: opts.branch,
112
+ commitMessage: opts.message,
113
+ });
114
+ if (result.success) {
115
+ console.log(`Saved to git: commit ${result.commitHash ?? 'unknown'}`);
116
+ }
117
+ else if (result.conflict) {
118
+ console.log(`Conflict detected in ${result.conflictFiles?.length ?? 0} file(s)`);
119
+ if (result.conflictFiles?.length) {
120
+ for (const f of result.conflictFiles)
121
+ console.log(` - ${f}`);
122
+ }
123
+ process.exit(1);
124
+ }
125
+ else {
126
+ console.error(`Error: ${result.error ?? 'Save failed'}`);
127
+ process.exit(1);
128
+ }
129
+ }
130
+ catch (e) {
131
+ handleError(e);
132
+ }
133
+ });
134
+ tools.command('build-project')
135
+ .description('在沙箱中执行项目构建')
136
+ .requiredOption('--project-id <id>', '项目数字 ID', parseInt)
137
+ .option('--branch <name>', '分支名', 'main')
138
+ .action(async (opts) => {
139
+ try {
140
+ const client = new AgentApiClient();
141
+ const result = await client.post('/build-project', {
142
+ projectId: opts.projectId,
143
+ branch: opts.branch,
144
+ });
145
+ if (result.success === false) {
146
+ if (result.output) {
147
+ const logPath = writeResult('build', { output: result.output, error: result.error });
148
+ console.error(`Build failed. Log: ${logPath}`);
149
+ }
150
+ else {
151
+ console.error(`Error: ${result.error ?? 'Build failed'}`);
152
+ }
153
+ process.exit(1);
154
+ }
155
+ if (result.output) {
156
+ const logPath = writeResult('build', result);
157
+ console.log(`Build succeeded`);
158
+ console.log(`Log: ${logPath}`);
159
+ }
160
+ else {
161
+ console.log('Build succeeded');
162
+ }
163
+ }
164
+ catch (e) {
165
+ handleError(e);
166
+ }
167
+ });
168
+ tools.command('publish')
169
+ .description('将构建产物上传到 COS,获取预览 URL')
170
+ .requiredOption('--project-id <id>', '项目数字 ID', parseInt)
171
+ .option('--branch <name>', '分支名', 'main')
172
+ .action(async (opts) => {
173
+ try {
174
+ const client = new AgentApiClient();
175
+ const result = await client.post('/publish-to-cos', {
176
+ projectId: opts.projectId,
177
+ branch: opts.branch,
178
+ });
179
+ if (result.success === false) {
180
+ console.error(`Error: ${result.error ?? 'Publish failed'}`);
181
+ process.exit(1);
182
+ }
183
+ if (result.previewUrl) {
184
+ console.log(`Published: ${result.previewUrl}`);
185
+ }
186
+ else {
187
+ console.log('Published successfully');
188
+ }
189
+ }
190
+ catch (e) {
191
+ handleError(e);
192
+ }
193
+ });
194
+ tools.command('create-remix')
195
+ .description('基于项目创建 Remix 分支')
196
+ .requiredOption('--project-id <id>', '项目数字 ID')
197
+ .requiredOption('--name <name>', 'Remix 显示名称')
198
+ .option('--source-branch <name>', '源分支', 'main')
199
+ .option('--description <text>', '描述')
200
+ .action(async (opts) => {
201
+ try {
202
+ const client = new AgentApiClient();
203
+ const result = await client.post('/create-remix', {
204
+ projectId: opts.projectId,
205
+ sourceBranch: opts.sourceBranch,
206
+ name: opts.name,
207
+ description: opts.description,
208
+ });
209
+ if (result.success === false) {
210
+ console.error(`Error: ${result.error ?? 'Create remix failed'}`);
211
+ process.exit(1);
212
+ }
213
+ console.log(`Remix created: ${opts.name} (project ${result.projectId}, branch ${result.branchName})`);
214
+ if (result.editorUrl)
215
+ console.log(`Editor: ${result.editorUrl}`);
216
+ }
217
+ catch (e) {
218
+ handleError(e);
219
+ }
220
+ });
221
+ // ─── Queries ────────────────────────────────────────────────
222
+ tools.command('list-templates')
223
+ .description('列出可用项目模板')
224
+ .option('--tag <filter>', '按标签过滤')
225
+ .action(async (opts) => {
226
+ try {
227
+ const client = new AgentApiClient();
228
+ const params = {};
229
+ if (opts.tag)
230
+ params.tagFilter = opts.tag;
231
+ const result = await client.get('/list-templates', params);
232
+ const detailsPath = writeResult('list-templates', result);
233
+ if (opts.tag) {
234
+ console.log(`Found ${result.templateCount} templates matching "${opts.tag}" (${result.totalCount} total)`);
235
+ }
236
+ else {
237
+ console.log(`Found ${result.templateCount} templates`);
238
+ }
239
+ console.log(`Details: ${detailsPath}`);
240
+ }
241
+ catch (e) {
242
+ handleError(e);
243
+ }
244
+ });
245
+ tools.command('list-assets')
246
+ .description('列出项目资产')
247
+ .requiredOption('--project-id <id>', '项目数字 ID')
248
+ .option('--branch <name>', '分支名')
249
+ .option('--types <list>', '资产类型,逗号分隔(texture,script,audio,model...)')
250
+ .option('--search <keyword>', '名称搜索')
251
+ .option('--limit <n>', '最大数量', '30')
252
+ .option('--skip <n>', '偏移', '0')
253
+ .action(async (opts) => {
254
+ try {
255
+ const client = new AgentApiClient();
256
+ const params = { projectId: opts.projectId };
257
+ if (opts.branch)
258
+ params.branch = opts.branch;
259
+ if (opts.types)
260
+ params.types = opts.types;
261
+ if (opts.search)
262
+ params.search = opts.search;
263
+ if (opts.limit)
264
+ params.limit = opts.limit;
265
+ if (opts.skip)
266
+ params.skip = opts.skip;
267
+ const result = await client.get('/list-project-assets', params);
268
+ const detailsPath = writeResult('list-assets', result);
269
+ const typeSummary = Object.entries(result.byTypeCount)
270
+ .map(([k, v]) => `${k}: ${v}`)
271
+ .join(', ');
272
+ console.log(`Found ${result.total} assets (${typeSummary})`);
273
+ console.log(`Details: ${detailsPath}`);
274
+ }
275
+ catch (e) {
276
+ handleError(e);
277
+ }
278
+ });
279
+ tools.command('list-remixes')
280
+ .description('列出可访问的 Remix 项目')
281
+ .option('--scope <scope>', 'my 或 gallery', 'my')
282
+ .option('--search <keyword>', '搜索')
283
+ .option('--limit <n>', '最大数量', '20')
284
+ .option('--page <n>', '页码', '1')
285
+ .action(async (opts) => {
286
+ try {
287
+ const client = new AgentApiClient();
288
+ const params = {};
289
+ if (opts.scope)
290
+ params.scope = opts.scope;
291
+ if (opts.search)
292
+ params.search = opts.search;
293
+ if (opts.limit)
294
+ params.limit = opts.limit;
295
+ if (opts.page)
296
+ params.page = opts.page;
297
+ const result = await client.get('/list-remixes', params);
298
+ const detailsPath = writeResult('list-remixes', result);
299
+ console.log(`Found ${result.total} remixes (page ${result.page}/${result.totalPages})`);
300
+ console.log(`Details: ${detailsPath}`);
301
+ }
302
+ catch (e) {
303
+ handleError(e);
304
+ }
305
+ });
306
+ // ─── Publish ────────────────────────────────────────────────
307
+ tools.command('publish-prefab')
308
+ .description('发布 Remixable Prefab 到资产库')
309
+ .requiredOption('--remix-project-id <id>', 'Remix 项目 ID', parseInt)
310
+ .requiredOption('--template <name>', '模板名')
311
+ .requiredOption('--variant <name>', '变体名')
312
+ .requiredOption('--prefab <name>', '预制件名')
313
+ .requiredOption('--staging-root <path>', 'Staging 目录路径')
314
+ .option('--visibility <v>', 'public 或 team', 'team')
315
+ .option('--team-id <id>', '团队 ID')
316
+ .action(async (opts) => {
317
+ try {
318
+ const client = new AgentApiClient();
319
+ const result = await client.post('/publish-prefab', {
320
+ remixProjectId: opts.remixProjectId,
321
+ templateName: opts.template,
322
+ variantName: opts.variant,
323
+ prefabName: opts.prefab,
324
+ stagingRoot: opts.stagingRoot,
325
+ visibility: opts.visibility,
326
+ teamId: opts.teamId,
327
+ });
328
+ if (result.success === false) {
329
+ console.error(`Error: ${result.error ?? 'Publish prefab failed'}`);
330
+ process.exit(1);
331
+ }
332
+ console.log(`Prefab published: ${result.bundlePath} (${result.uploaded} files)`);
333
+ }
334
+ catch (e) {
335
+ handleError(e);
336
+ }
337
+ });
338
+ // ─── Builds ─────────────────────────────────────────────────
339
+ tools.command('list-builds')
340
+ .description('查看云端构建列表')
341
+ .option('--project-id <id>', '项目数字 ID(不填则查看当前用户所有项目)')
342
+ .option('--branch <name>', '按分支过滤')
343
+ .option('--status <status>', '按状态过滤 (queued|running|success|failed)')
344
+ .option('--platform <platform>', '按平台过滤 (facebook|snapchat|playcraft...)')
345
+ .option('--search <keyword>', '按名称/提交信息搜索')
346
+ .option('--limit <n>', '最大数量', '20')
347
+ .option('--skip <n>', '偏移', '0')
348
+ .action(async (opts) => {
349
+ try {
350
+ const client = new AgentApiClient();
351
+ const params = {};
352
+ if (opts.projectId)
353
+ params.projectId = opts.projectId;
354
+ if (opts.branch)
355
+ params.branchName = opts.branch;
356
+ if (opts.status)
357
+ params.status = opts.status;
358
+ if (opts.platform)
359
+ params.platform = opts.platform;
360
+ if (opts.search)
361
+ params.search = opts.search;
362
+ if (opts.limit)
363
+ params.limit = opts.limit;
364
+ if (opts.skip)
365
+ params.skip = opts.skip;
366
+ const result = await client.get('/list-builds', params);
367
+ const detailsPath = writeResult('list-builds', result);
368
+ const statusCounts = {};
369
+ for (const item of result.items) {
370
+ statusCounts[item.status] = (statusCounts[item.status] ?? 0) + 1;
371
+ }
372
+ const statusSummary = Object.entries(statusCounts).map(([s, n]) => `${s}: ${n}`).join(', ');
373
+ console.log(`Found ${result.total} builds (${statusSummary || 'none'})`);
374
+ console.log(`Details: ${detailsPath}`);
375
+ }
376
+ catch (e) {
377
+ handleError(e);
378
+ }
379
+ });
380
+ tools.command('get-build')
381
+ .description('查看单次构建详情(状态、产物、校验结果等)')
382
+ .requiredOption('--id <buildId>', '构建 ID')
383
+ .action(async (opts) => {
384
+ try {
385
+ const client = new AgentApiClient();
386
+ const result = await client.get('/get-build', { id: opts.id });
387
+ if ('error' in result && result.error) {
388
+ console.error(`Error: ${result.error}`);
389
+ process.exit(1);
390
+ }
391
+ const detailsPath = writeResult('get-build', result);
392
+ const statusLine = result.status === 'running' && result.progress != null
393
+ ? `${result.status} (${result.progress}%)`
394
+ : result.status;
395
+ const sizeMB = result.outputSize ? `${(result.outputSize / 1024 / 1024).toFixed(2)}MB` : 'unknown size';
396
+ const nameLabel = result.name ? ` "${result.name}"` : '';
397
+ console.log(`Build ${result.id}${nameLabel}: ${statusLine} [${result.platform}/${result.format}] ${sizeMB}`);
398
+ if (result.error)
399
+ console.log(`Error: ${result.error}`);
400
+ if (result.downloadUrl)
401
+ console.log(`Download: ${result.downloadUrl}`);
402
+ if (result.previewUrl)
403
+ console.log(`Preview: ${result.previewUrl}`);
404
+ if (result.validationPassed === false) {
405
+ const errors = (result.validationWarnings ?? []).filter((w) => w.severity === 'error');
406
+ console.log(`Validation: FAILED (${errors.length} error(s))`);
407
+ }
408
+ else if (result.validationPassed === true) {
409
+ console.log(`Validation: PASSED`);
410
+ }
411
+ console.log(`Details: ${detailsPath}`);
412
+ }
413
+ catch (e) {
414
+ handleError(e);
415
+ }
416
+ });
417
+ tools.command('get-build-download')
418
+ .description('获取构建产物下载地址')
419
+ .requiredOption('--id <buildId>', '构建 ID')
420
+ .action(async (opts) => {
421
+ try {
422
+ const client = new AgentApiClient();
423
+ const result = await client.get('/get-build-download', { id: opts.id });
424
+ if (result.error) {
425
+ console.error(`Error: ${result.error}`);
426
+ process.exit(1);
427
+ }
428
+ console.log(`Download URL: ${result.downloadUrl}`);
429
+ if (result.previewUrl)
430
+ console.log(`Preview URL: ${result.previewUrl}`);
431
+ const sizeMB = result.size ? ` (${(result.size / 1024 / 1024).toFixed(2)}MB)` : '';
432
+ console.log(`Platform: ${result.platform}/${result.format}${sizeMB}`);
433
+ }
434
+ catch (e) {
435
+ handleError(e);
436
+ }
437
+ });
438
+ tools.addHelpText('before', () => [
439
+ '子命令分区(均需 PLAYCRAFT_API_URL + PLAYCRAFT_SANDBOX_TOKEN 或 .playcraft.json):',
440
+ '',
441
+ ' 素材 AI generate-image, generate-sfx, generate-bgm',
442
+ ' 流水线 save-to-git, build-project, publish, create-remix',
443
+ ' 查询 list-templates, list-assets, list-remixes, list-builds, get-build, get-build-download',
444
+ ' 资产库 publish-prefab',
445
+ '',
446
+ ].join('\n'));
447
+ }
package/dist/index.js CHANGED
@@ -15,14 +15,19 @@ import { upgradeCommand } from './commands/upgrade.js';
15
15
  import { checkForUpdates } from './utils/version-checker.js';
16
16
  import { syncCommand } from './commands/sync.js';
17
17
  import { fixIdsCommand } from './commands/fix-ids.js';
18
+ import { registerToolsCommands } from './commands/tools.js';
19
+ import { registerImageCommands } from './commands/image.js';
20
+ import { registerAudioCommands } from './commands/audio.js';
21
+ import { CLI_ROOT_DESCRIPTION, getCliTopicsHelpText, registerRootProgramHelp, } from './cli-root-help.js';
18
22
  const __filename = fileURLToPath(import.meta.url);
19
23
  const __dirname = dirname(__filename);
20
24
  const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
21
25
  const program = new Command();
22
26
  program
23
27
  .name('playcraft')
24
- .description('PlayCraft Local Dev Agent - 本地开发助手')
28
+ .description(CLI_ROOT_DESCRIPTION)
25
29
  .version(packageJson.version);
30
+ registerRootProgramHelp(program);
26
31
  // 版本检查(非阻塞,后台执行)
27
32
  // 只在非 upgrade 命令时检查,避免重复提示
28
33
  if (!process.argv.includes('upgrade')) {
@@ -156,6 +161,7 @@ program
156
161
  .option('--compress-models', '压缩 3D 模型(默认根据平台配置,仅 PlayCanvas/Three.js/Babylon.js)')
157
162
  .option('--no-compress-models', '禁用模型压缩')
158
163
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
164
+ .option('--use-playable-scripts', '使用 @playcraft/devkit 构建工具打包(可玩广告项目专用)', false)
159
165
  .action(async (projectPath, options) => {
160
166
  await buildCommand(projectPath, options);
161
167
  });
@@ -281,6 +287,8 @@ program
281
287
  .option('--no-compress-models', '禁用模型压缩')
282
288
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
283
289
  .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
290
+ .option('--use-playable-scripts', '使用 @playcraft/devkit 构建工具打包(可玩广告项目专用)', false)
291
+ .option('--theme <theme>', '指定要构建的主题(devkit 项目专用)')
284
292
  .action(async (projectPath, options) => {
285
293
  await buildAllCommand(projectPath, options);
286
294
  });
@@ -301,4 +309,17 @@ program
301
309
  .action(async (options) => {
302
310
  await upgradeCommand(options);
303
311
  });
312
+ // topics — 与根 --help 顶部一致的命令分区说明(便于脚本或文档引用)
313
+ program
314
+ .command('topics')
315
+ .description('打印命令分区说明(本地开发 / 素材 / 平台)')
316
+ .action(() => {
317
+ process.stdout.write(getCliTopicsHelpText());
318
+ });
319
+ // Agent domain tools (playcraft tools <command>)
320
+ registerToolsCommands(program);
321
+ // Local image processing (playcraft image <command>)
322
+ registerImageCommands(program);
323
+ // Local audio processing (playcraft audio <command>)
324
+ registerAudioCommands(program);
304
325
  program.parse(process.argv);