@playcraft/cli 0.1.85-beta → 0.1.87

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.
@@ -3,8 +3,8 @@ import { writeFileSync, mkdirSync } from 'fs';
3
3
  import { dirname, parse, join } from 'path';
4
4
  import sharp from 'sharp';
5
5
  import { captureGeneratedArtifact } from '../workspace-runtime/workspaces/artifact-receipts.js';
6
- import { resolveImageBackgroundRequirement, validateImageDeliveryOptions } from '@playcraft/common/prefab';
7
- import { assertImageAssetOutputPaths, readImageAssetContext } from '../utils/image-asset-context.js';
6
+ import { resolveImageBackgroundRequirement, validateImageDeliveryOptions, } from '@playcraft/common/prefab';
7
+ import { assertImageAssetOutputPaths, readImageAssetContext, } from '../utils/image-asset-context.js';
8
8
  import { AgentApiClient } from '../utils/agent-api-client.js';
9
9
  import { isUnsafeImageRetryError } from '../utils/image-generation-retry.js';
10
10
  import { MAX_REFERENCE_IMAGES, collectReferenceImagePaths, parseImageTargetFieldPath, collectReferenceImagePayloads, sniffImageExtension, extensionForImageMime, resolveImageOutputPath, handleError, raceWithTimeout, } from './tools-utils.js';
@@ -73,7 +73,8 @@ async function verifyMasterCompositeStripSize(imgBuf, target) {
73
73
  }
74
74
  export function registerGenerationCommands(tools) {
75
75
  // ─── Image Models ────────────────────────────────────────────
76
- tools.command('list-image-models')
76
+ tools
77
+ .command('list-image-models')
77
78
  .description('列出后端已配置的可用生图模型,按模型名分组展示所有 provider(多个 provider 时按优先级自动 fallback)')
78
79
  .option('--json', '以 JSON 格式输出(默认为人类可读表格)')
79
80
  .action(async (opts) => {
@@ -150,7 +151,8 @@ export function registerGenerationCommands(tools) {
150
151
  }
151
152
  });
152
153
  // ─── Generation ─────────────────────────────────────────────
153
- tools.command('generate-image')
154
+ tools
155
+ .command('generate-image')
154
156
  .description('AI 生成图片(支持多张参考图图生图;gpt-image-2 支持 --count N 批量出图)')
155
157
  .requiredOption('--prompt <text>', '图片描述')
156
158
  .option('--aspect-ratio <ratio>', '宽高比 (1:1|45:16|16:9|9:16|3:4|4:3|2:3|3:2|21:9)', '1:1')
@@ -174,8 +176,12 @@ export function registerGenerationCommands(tools) {
174
176
  .option('--count <n>', '一次 API 调用生成多少张图片(默认 1,最多 10;仅 gpt-image-2 实际支持批量)', (v) => parseInt(v, 10), 1)
175
177
  .action(async (opts) => {
176
178
  try {
177
- const explicitBackground = opts.background === undefined ? opts.removeBackground : opts.background === 'transparent';
178
- if (opts.background !== undefined && opts.removeBackground !== undefined && opts.removeBackground !== explicitBackground) {
179
+ const explicitBackground = opts.background === undefined
180
+ ? opts.removeBackground
181
+ : opts.background === 'transparent';
182
+ if (opts.background !== undefined &&
183
+ opts.removeBackground !== undefined &&
184
+ opts.removeBackground !== explicitBackground) {
179
185
  throw new Error('Conflicting background options. Use --background transparent or --background opaque; no image request was sent.');
180
186
  }
181
187
  opts = { ...opts, removeBackground: explicitBackground };
@@ -184,28 +190,46 @@ export function registerGenerationCommands(tools) {
184
190
  }
185
191
  const explicitOutputFormat = opts.outputFormat;
186
192
  const { dir: outputDir, name: outputName, ext: outputExt } = parse(opts.output);
187
- const outputCandidates = [opts.output, ...Array.from({ length: opts.count > 1 ? Math.min(opts.count, 10) : 0 }, (_, i) => join(outputDir, `${outputName}_${i + 1}${outputExt}`))];
188
- const assetContext = opts.targetAsset ? await readImageAssetContext(opts.targetAsset, process.cwd(), {
189
- referenceImagePaths: opts.referenceImage, useTargetReference: opts.targetReference,
190
- ...(opts.targetFieldPath ? { targetFieldPath: opts.targetFieldPath } : {}),
191
- outputPaths: outputCandidates,
192
- prompt: opts.prompt, removeBackground: opts.removeBackground,
193
- }) : undefined;
194
- opts = { ...opts,
193
+ const outputCandidates = [
194
+ opts.output,
195
+ ...Array.from({ length: opts.count > 1 ? Math.min(opts.count, 10) : 0 }, (_, i) => join(outputDir, `${outputName}_${i + 1}${outputExt}`)),
196
+ ];
197
+ const assetContext = opts.targetAsset
198
+ ? await readImageAssetContext(opts.targetAsset, process.cwd(), {
199
+ referenceImagePaths: opts.referenceImage,
200
+ useTargetReference: opts.targetReference,
201
+ ...(opts.targetFieldPath ? { targetFieldPath: opts.targetFieldPath } : {}),
202
+ outputPaths: outputCandidates,
203
+ prompt: opts.prompt,
204
+ removeBackground: opts.removeBackground,
205
+ })
206
+ : undefined;
207
+ opts = {
208
+ ...opts,
195
209
  aspectRatio: assetContext?.aspectRatio ?? opts.aspectRatio,
196
- width: assetContext?.width ?? opts.width, height: assetContext?.height ?? opts.height,
197
- outputFormat: assetContext?.outputFormatFromSchema ? assetContext.outputFormat : opts.outputFormat ?? assetContext?.outputFormat,
210
+ width: assetContext?.width ?? opts.width,
211
+ height: assetContext?.height ?? opts.height,
212
+ outputFormat: assetContext?.outputFormatFromSchema
213
+ ? assetContext.outputFormat
214
+ : (opts.outputFormat ?? assetContext?.outputFormat),
198
215
  fitMode: assetContext?.fitMode ?? opts.fitMode,
199
- removeBackground: resolveImageBackgroundRequirement({ prompt: opts.prompt,
200
- schemaRemoveBackground: assetContext?.removeBackgroundFromSchema ? assetContext.removeBackground : undefined,
201
- removeBackground: opts.removeBackground, fallbackRemoveBackground: assetContext?.removeBackground }),
216
+ removeBackground: resolveImageBackgroundRequirement({
217
+ prompt: opts.prompt,
218
+ schemaRemoveBackground: assetContext?.removeBackgroundFromSchema
219
+ ? assetContext.removeBackground
220
+ : undefined,
221
+ removeBackground: opts.removeBackground,
222
+ fallbackRemoveBackground: assetContext?.removeBackground,
223
+ }),
202
224
  paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
203
225
  imageModel: opts.imageModel || assetContext?.imageModelRef,
204
226
  };
205
227
  const invalidDelivery = validateImageDeliveryOptions(opts);
206
228
  if (invalidDelivery)
207
229
  throw new Error(invalidDelivery);
208
- const paths = opts.referenceImage?.length ? opts.referenceImage : assetContext?.referenceImagePaths ?? [];
230
+ const paths = opts.referenceImage?.length
231
+ ? opts.referenceImage
232
+ : (assetContext?.referenceImagePaths ?? []);
209
233
  if (paths.length > MAX_REFERENCE_IMAGES) {
210
234
  console.error(`Error: Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
211
235
  process.exit(1);
@@ -230,14 +254,26 @@ export function registerGenerationCommands(tools) {
230
254
  width: assetContext?.width ?? opts.width,
231
255
  height: assetContext?.height ?? opts.height,
232
256
  outputFormat: opts.outputFormat,
233
- outputFormatSource: assetContext?.outputFormatFromSchema ? 'schema'
234
- : explicitOutputFormat ? 'explicit' : assetContext ? 'inferred' : undefined,
257
+ outputFormatSource: assetContext?.outputFormatFromSchema
258
+ ? 'schema'
259
+ : explicitOutputFormat
260
+ ? 'explicit'
261
+ : assetContext
262
+ ? 'inferred'
263
+ : undefined,
235
264
  fitMode: assetContext?.fitMode ?? opts.fitMode,
236
265
  paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
237
- background: opts.background === undefined ? undefined : opts.removeBackground ? 'transparent' : 'opaque',
266
+ background: opts.background === undefined
267
+ ? undefined
268
+ : opts.removeBackground
269
+ ? 'transparent'
270
+ : 'opaque',
238
271
  removeBackground: opts.removeBackground,
239
- backgroundRequirementSource: assetContext?.removeBackgroundFromSchema ? 'schema'
240
- : explicitBackground !== undefined ? 'explicit' : 'inferred',
272
+ backgroundRequirementSource: assetContext?.removeBackgroundFromSchema
273
+ ? 'schema'
274
+ : explicitBackground !== undefined
275
+ ? 'explicit'
276
+ : 'inferred',
241
277
  referenceImages,
242
278
  ...(modelRef ? { imageModelRef: modelRef } : {}),
243
279
  ...(imageCount > 1 ? { imageCount } : {}),
@@ -265,7 +301,8 @@ export function registerGenerationCommands(tools) {
265
301
  lines.push(' → Try --image-size 1K to reduce generation time');
266
302
  lines.push(' → Or increase timeout with --timeout <seconds> (current default: 1800s)');
267
303
  }
268
- else if (hasReferenceImages && (msg.includes('reference') || msg.includes('image input'))) {
304
+ else if (hasReferenceImages &&
305
+ (msg.includes('reference') || msg.includes('image input'))) {
269
306
  lines.push('');
270
307
  lines.push('Hint: Reference image error — the selected model may not support --reference-image.');
271
308
  lines.push(' → Run: playcraft tools list-image-models');
@@ -313,7 +350,11 @@ export function registerGenerationCommands(tools) {
313
350
  }
314
351
  // 收集所有待保存图片(第 1 张 + 批量额外图)
315
352
  const allImages = [
316
- { imageBase64: result.imageBase64, mimeType: result.mimeType, previewUrl: result.previewUrl },
353
+ {
354
+ imageBase64: result.imageBase64,
355
+ mimeType: result.mimeType,
356
+ previewUrl: result.previewUrl,
357
+ },
317
358
  ...(result.additionalImages ?? []),
318
359
  ];
319
360
  const isBatch = allImages.length > 1;
@@ -324,12 +365,14 @@ export function registerGenerationCommands(tools) {
324
365
  const savedPaths = [];
325
366
  if (result.transparencySource)
326
367
  console.log(`Transparency source: ${result.transparencySource}`);
327
- const stripTarget = opts.targetAsset || result.transparentBackground ? null : resolveStripTargetDimensions({
328
- aspectRatio: opts.aspectRatio,
329
- width: opts.width,
330
- height: opts.height,
331
- imageSize: opts.imageSize,
332
- });
368
+ const stripTarget = opts.targetAsset || result.transparentBackground
369
+ ? null
370
+ : resolveStripTargetDimensions({
371
+ aspectRatio: opts.aspectRatio,
372
+ width: opts.width,
373
+ height: opts.height,
374
+ imageSize: opts.imageSize,
375
+ });
333
376
  for (let i = 0; i < allImages.length; i++) {
334
377
  const img = allImages[i];
335
378
  let imgBuf = Buffer.from(img.imageBase64, 'base64');
@@ -347,8 +390,7 @@ export function registerGenerationCommands(tools) {
347
390
  generatedHeight = metaBefore.height;
348
391
  if (metaBefore.width != null &&
349
392
  metaBefore.height != null &&
350
- (metaBefore.width !== stripTarget.width ||
351
- metaBefore.height !== stripTarget.height)) {
393
+ (metaBefore.width !== stripTarget.width || metaBefore.height !== stripTarget.height)) {
352
394
  imgBuf = Buffer.from(await letterboxToTarget(imgBuf, stripTarget.width, stripTarget.height));
353
395
  console.log(`↑ Letterbox ${generatedWidth}×${generatedHeight} → ${stripTarget.width}×${stripTarget.height}(等比 + 白边,不拉伸)`);
354
396
  }
@@ -404,10 +446,11 @@ export function registerGenerationCommands(tools) {
404
446
  referenceImageStatus: result.referenceImageStatus ?? null,
405
447
  generatedAt: new Date().toISOString(),
406
448
  };
407
- const sidecarPath = resolvedPath.replace(/\.[^.]+$/, '.json');
449
+ const sidecarPath = `${resolvedPath}.generation.json`;
408
450
  writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2));
409
451
  await captureGeneratedArtifact(resolvedPath, 'generate-image', {
410
- ...(actualWidth ? { width: actualWidth } : {}), ...(actualHeight ? { height: actualHeight } : {}),
452
+ ...(actualWidth ? { width: actualWidth } : {}),
453
+ ...(actualHeight ? { height: actualHeight } : {}),
411
454
  });
412
455
  console.log(` └─ Generation metadata: ${sidecarPath}`);
413
456
  }
@@ -431,7 +474,8 @@ export function registerGenerationCommands(tools) {
431
474
  handleError(e);
432
475
  }
433
476
  });
434
- tools.command('generate-sfx')
477
+ tools
478
+ .command('generate-sfx')
435
479
  .description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
436
480
  .requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
437
481
  .option('--duration <seconds>', '时长(秒,0.5-22;省略时由供应商自动决定)', parseFloat)
@@ -451,9 +495,14 @@ export function registerGenerationCommands(tools) {
451
495
  const sizeKB = Math.round(buf.length / 1024);
452
496
  console.log(`SFX saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
453
497
  const sfxSidecar = {
454
- type: 'sfx', prompt: opts.prompt, duration: opts.duration ?? null,
455
- loop: opts.loop ?? false, provider: result.provider, actualDuration: result.duration,
456
- output: opts.output, generatedAt: new Date().toISOString(),
498
+ type: 'sfx',
499
+ prompt: opts.prompt,
500
+ duration: opts.duration ?? null,
501
+ loop: opts.loop ?? false,
502
+ provider: result.provider,
503
+ actualDuration: result.duration,
504
+ output: opts.output,
505
+ generatedAt: new Date().toISOString(),
457
506
  };
458
507
  writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(sfxSidecar, null, 2));
459
508
  await captureGeneratedArtifact(opts.output, 'generate-sfx', { duration: result.duration });
@@ -463,7 +512,8 @@ export function registerGenerationCommands(tools) {
463
512
  handleError(e);
464
513
  }
465
514
  });
466
- tools.command('generate-bgm')
515
+ tools
516
+ .command('generate-bgm')
467
517
  .description('AI 生成 BGM(30s 循环),使用 Google Lyria 3')
468
518
  .requiredOption('--prompt <text>', 'BGM 描述,如 "轻快休闲游戏配乐"')
469
519
  .option('--style <style>', '音乐风格,如 casual / epic / sci-fi / retro')
@@ -484,9 +534,14 @@ export function registerGenerationCommands(tools) {
484
534
  const sizeKB = Math.round(buf.length / 1024);
485
535
  console.log(`BGM saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
486
536
  const bgmSidecar = {
487
- type: 'bgm', prompt: opts.prompt, style: opts.style ?? null,
488
- bpm: opts.bpm ?? null, provider: result.provider, actualDuration: result.duration,
489
- output: opts.output, generatedAt: new Date().toISOString(),
537
+ type: 'bgm',
538
+ prompt: opts.prompt,
539
+ style: opts.style ?? null,
540
+ bpm: opts.bpm ?? null,
541
+ provider: result.provider,
542
+ actualDuration: result.duration,
543
+ output: opts.output,
544
+ generatedAt: new Date().toISOString(),
490
545
  };
491
546
  writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(bgmSidecar, null, 2));
492
547
  await captureGeneratedArtifact(opts.output, 'generate-bgm', { duration: result.duration });
@@ -517,7 +572,8 @@ export function registerGenerationCommands(tools) {
517
572
  }
518
573
  });
519
574
  // ─── Search Image ──────────────────────────────────────────
520
- tools.command('search-image')
575
+ tools
576
+ .command('search-image')
521
577
  .description('搜索参考图片(Unsplash / Pexels),结果 URL 可直接用于 --reference-image')
522
578
  .requiredOption('--query <query>', '搜索关键词(建议英文)')
523
579
  .option('--source <source>', '图源:unsplash | pexels | auto(默认 auto)', 'auto')
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { AgentApiClient } from '../utils/agent-api-client.js';
4
+ export async function toolRequestCommand(operation, serializedBody) {
5
+ if (!['generate-image', 'generate-bgm', 'generate-sfx'].includes(operation))
6
+ throw new Error('Unsupported generation operation');
7
+ const body = JSON.parse(serializedBody);
8
+ if (!body || typeof body !== 'object' || Array.isArray(body))
9
+ throw new Error('Generation request must be a JSON object');
10
+ return new AgentApiClient({
11
+ configFile: process.env.PLAYCRAFT_SANDBOX_CONFIG_PATH || join(process.cwd(), '.playcraft.json'),
12
+ operationNamespace: 'opencode',
13
+ }).post(`/${operation}`, body);
14
+ }
15
+ /** Static sandbox plugins cannot import command-line modules; use this client for transport. */
16
+ export function registerToolRequestCommand(tools) {
17
+ tools
18
+ .command('request <operation>', { hidden: true })
19
+ .description('Send a generation request from JSON on stdin and print its durable result as JSON')
20
+ .action(async (operation) => {
21
+ const result = await toolRequestCommand(operation, readFileSync(0, 'utf8'));
22
+ process.stdout.write(`${JSON.stringify(result)}\n`);
23
+ });
24
+ }
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync, mkdirSync, readFileSync } from 'fs';
2
- import { join, parse } from 'path';
2
+ import { join, parse, resolve } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { InvalidArgumentError } from 'commander';
5
5
  export const TMP_DIR = join(tmpdir(), 'playcraft');
@@ -9,8 +9,12 @@ export function parseImageTargetFieldPath(value) {
9
9
  try {
10
10
  parsed = JSON.parse(value);
11
11
  }
12
- catch { /* Report the expected shape below. */ }
13
- if (!Array.isArray(parsed) || !parsed.length || parsed.some(key => typeof key !== 'string' || !key.length)) {
12
+ catch {
13
+ /* Report the expected shape below. */
14
+ }
15
+ if (!Array.isArray(parsed) ||
16
+ !parsed.length ||
17
+ parsed.some((key) => typeof key !== 'string' || !key.length)) {
14
18
  throw new InvalidArgumentError('Expected an array of field names, e.g. ["endUI","playButton"].');
15
19
  }
16
20
  return parsed;
@@ -74,11 +78,7 @@ export function extensionForImageMime(mimeType) {
74
78
  * Detect container from decoded bytes (magic). Prefer this over MIME alone.
75
79
  */
76
80
  export function sniffImageExtension(buf) {
77
- if (buf.length >= 8 &&
78
- buf[0] === 0x89 &&
79
- buf[1] === 0x50 &&
80
- buf[2] === 0x4e &&
81
- buf[3] === 0x47) {
81
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
82
82
  return '.png';
83
83
  }
84
84
  if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
@@ -133,12 +133,14 @@ export function mimeTypeForImagePath(filePath) {
133
133
  return 'image/png';
134
134
  if (ext === 'webp')
135
135
  return 'image/webp';
136
+ if (ext === 'gif')
137
+ return 'image/gif';
136
138
  if (ext === 'jpg' || ext === 'jpeg')
137
139
  return 'image/jpeg';
138
140
  if (ext === undefined || ext === '') {
139
141
  throw new Error(`Reference image path has no file extension: ${filePath}. Use .png, .webp, .jpg, or .jpeg.`);
140
142
  }
141
- throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, and jpeg are allowed.`);
143
+ throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, jpeg, and gif are allowed.`);
142
144
  }
143
145
  async function downloadImageFromUrl(url) {
144
146
  let res;
@@ -155,7 +157,8 @@ async function downloadImageFromUrl(url) {
155
157
  const contentType = res.headers.get('content-type') ?? '';
156
158
  const mimeType = contentType.split(';')[0].trim() || 'image/png';
157
159
  const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
158
- if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) || !mimeType.startsWith('image/')) {
160
+ if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) ||
161
+ !mimeType.startsWith('image/')) {
159
162
  throw new Error(`参考图 URL 返回的 Content-Type 不是图片(${mimeType}):${url}\n 只支持 PNG / JPG / WEBP / GIF。`);
160
163
  }
161
164
  const buf = await res.arrayBuffer();
@@ -178,18 +181,13 @@ export async function collectReferenceImagePayloads(paths) {
178
181
  }
179
182
  else {
180
183
  const mimeType = mimeTypeForImagePath(p);
181
- out.push({ base64: readFileSync(p).toString('base64'), mimeType });
184
+ out.push({ base64: readFileSync(resolve(process.cwd(), p)).toString('base64'), mimeType });
182
185
  }
183
186
  }
184
187
  catch (e) {
185
188
  const detail = e instanceof Error ? e.message : String(e);
186
- const code = !isUrl && e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
187
- const hint = code ? ` (${code})` : '';
188
- console.warn(`Skipping reference image (load failed): ${p}${hint}\n ${detail}`);
189
+ throw new Error(`Could not load reference image "${p}": ${detail}. No image request was sent.`);
189
190
  }
190
191
  }
191
- if (paths.length > 0 && out.length === 0) {
192
- console.warn('All reference images failed to load; continuing with text-only generation.');
193
- }
194
192
  return out.length > 0 ? out : undefined;
195
193
  }
@@ -6,11 +6,13 @@ import { registerDetectCommand } from './tools-detect.js';
6
6
  import { registerVideoReviewCommand } from './tools-video-review.js';
7
7
  import { registerImageContextCommand } from './tools-image-context.js';
8
8
  import { registerContextCommands } from './tools-context.js';
9
+ import { registerToolRequestCommand } from './tools-request.js';
9
10
  export function registerToolsCommands(program) {
10
11
  const tools = program
11
12
  .command('tools')
12
13
  .description('后端 /api/agent/tools;运行 playcraft login 后即可直接使用,也支持 .playcraft.json 或 PLAYCRAFT_SANDBOX_TOKEN 环境变量');
13
14
  registerGenerationCommands(tools);
15
+ registerToolRequestCommand(tools);
14
16
  registerImageContextCommand(tools);
15
17
  registerProjectCommands(tools);
16
18
  registerBatchCommands(tools);
@@ -1,10 +1,10 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
2
  import { createWriteStream } from 'node:fs';
3
3
  import { link, mkdir, stat, unlink } from 'node:fs/promises';
4
- import { randomUUID } from 'node:crypto';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
- import { dirname, join } from 'path';
7
+ import { dirname, join, resolve } from 'path';
8
8
  import { homedir } from 'os';
9
9
  import { loadGlobalConfig } from '../config.js';
10
10
  import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
@@ -41,20 +41,28 @@ export class AgentApiClient {
41
41
  credentialFile;
42
42
  playcraftEnv;
43
43
  cloudRuntimeCredential;
44
- constructor() {
45
- const localConfig = AgentApiClient.loadLocalConfig();
46
- const globalConfig = loadGlobalConfig();
44
+ explicitConfig;
45
+ operationDirectory;
46
+ constructor(options = {}) {
47
+ const localConfig = options.configFile
48
+ ? AgentApiClient.requireLocalConfig(options.configFile)
49
+ : AgentApiClient.loadLocalConfig();
50
+ const globalConfig = options.configFile ? {} : loadGlobalConfig();
47
51
  // URL: 环境变量 → 本地 .playcraft.json → 全局 login 配置 → 公有云默认地址
48
- const apiUrl = (process.env.PLAYCRAFT_API_URL ||
49
- localConfig.apiUrl ||
50
- globalConfig.backendUrl ||
51
- globalConfig.url ||
52
- DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
52
+ const apiUrl = (options.configFile
53
+ ? localConfig.apiUrl
54
+ : process.env.PLAYCRAFT_API_URL ||
55
+ localConfig.apiUrl ||
56
+ globalConfig.backendUrl ||
57
+ globalConfig.url ||
58
+ DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
53
59
  // Token: 环境变量 → 沙箱全局配置 → 项目配置 → 用户登录配置
54
- const token = process.env.PLAYCRAFT_SANDBOX_TOKEN ||
55
- localConfig.token ||
56
- globalConfig.token ||
57
- '';
60
+ const token = options.configFile
61
+ ? localConfig.token
62
+ : process.env.PLAYCRAFT_SANDBOX_TOKEN ||
63
+ localConfig.token ||
64
+ globalConfig.token ||
65
+ '';
58
66
  if (!token) {
59
67
  throw new Error('Missing PlayCraft API credentials. ' +
60
68
  'Run `playcraft login` to authenticate, or set PLAYCRAFT_SANDBOX_TOKEN / create a .playcraft.json file.');
@@ -62,14 +70,29 @@ export class AgentApiClient {
62
70
  this.apiUrl = apiUrl;
63
71
  this.token = token;
64
72
  this.credentialFile = localConfig.credentialFile;
65
- this.playcraftEnv = (process.env.PLAYCRAFT_ENV || localConfig.env || '').trim();
73
+ this.explicitConfig = Boolean(options.configFile);
74
+ this.playcraftEnv = (options.configFile
75
+ ? localConfig.env || process.env.PLAYCRAFT_ENV || ''
76
+ : process.env.PLAYCRAFT_ENV || localConfig.env || '').trim();
66
77
  this.cloudRuntimeCredential = cloudRuntimeCredentialHint(token, token === localConfig.token ? localConfig : undefined);
78
+ if (options.operationNamespace) {
79
+ if (!options.configFile)
80
+ throw new Error('Operation namespace requires an explicit sandbox configuration');
81
+ const scope = JSON.stringify([
82
+ resolve(options.configFile),
83
+ apiUrl,
84
+ this.playcraftEnv,
85
+ localConfig.projectId,
86
+ localConfig.branch,
87
+ ]);
88
+ this.operationDirectory = join(process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests'), options.operationNamespace, createHash('sha256').update(scope).digest('hex'));
89
+ }
67
90
  }
68
91
  /** 根据 token 类型构造鉴权 headers */
69
92
  authHeaders() {
70
93
  let token = this.token;
71
94
  // Polling commands can outlive a credential: read the atomically refreshed file per request.
72
- if (this.credentialFile && !process.env.PLAYCRAFT_SANDBOX_TOKEN) {
95
+ if (this.credentialFile && (this.explicitConfig || !process.env.PLAYCRAFT_SANDBOX_TOKEN)) {
73
96
  try {
74
97
  token = JSON.parse(readFileSync(this.credentialFile, 'utf8')).token || token;
75
98
  }
@@ -96,12 +119,12 @@ export class AgentApiClient {
96
119
  const recoverId = options?.requestId ?? process.env.PLAYCRAFT_TOOL_REQUEST_ID;
97
120
  const newRequest = options?.newRequest ?? process.env.PLAYCRAFT_TOOL_NEW_REQUEST === '1';
98
121
  const originalReceipt = recoverId !== undefined && !newRequest && isWorkspaceGenerationPath(path)
99
- ? findToolOperation(url.pathname, originalBody, recoverId) : undefined;
122
+ ? findToolOperation(url.pathname, originalBody, recoverId, this.operationDirectory) : undefined;
100
123
  let serializedBody = originalReceipt ? originalBody : JSON.stringify(await withGenerationWorkspaceScope(path, body, this.cloudRuntimeCredential));
101
124
  if (serializedBody === undefined)
102
125
  throw new Error('Tool request body is required');
103
126
  if (serializedBody !== originalBody && !newRequest) {
104
- const previous = findToolOperation(url.pathname, originalBody, recoverId);
127
+ const previous = findToolOperation(url.pathname, originalBody, recoverId, this.operationDirectory);
105
128
  if (previous && recoverId !== undefined)
106
129
  serializedBody = originalBody;
107
130
  else if (previous)
@@ -110,6 +133,7 @@ export class AgentApiClient {
110
133
  }
111
134
  const operation = beginToolOperation(url.pathname, serializedBody, recoverId, {
112
135
  newRequest,
136
+ directory: this.operationDirectory,
113
137
  });
114
138
  const unknownOutcome = `Tool request ${operation.requestId}: the outcome is unknown. Rerun the same command to ` +
115
139
  `continue this request: it returns the result if the request finished, or finishes it if ` +
@@ -384,6 +408,21 @@ export class AgentApiClient {
384
408
  }
385
409
  return { apiUrl: '', token: '' };
386
410
  }
411
+ /** A static plugin's slot must never fall back to another workspace or a global login. */
412
+ static requireLocalConfig(file) {
413
+ try {
414
+ const config = JSON.parse(readFileSync(file, 'utf8'));
415
+ if (typeof config?.apiUrl !== 'string' ||
416
+ !config.apiUrl.trim() ||
417
+ typeof config.token !== 'string' ||
418
+ !config.token.trim())
419
+ throw new Error('invalid');
420
+ return { ...config, credentialFile: file };
421
+ }
422
+ catch {
423
+ throw new Error('A valid project .playcraft.json with apiUrl and token is required; no generation request was sent');
424
+ }
425
+ }
387
426
  }
388
427
  /** A failure whose meaning is settled; no recovery hint applies. */
389
428
  class ToolRequestError extends Error {