@playcraft/cli 0.1.84-beta → 0.1.86

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,8 +73,7 @@ async function verifyMasterCompositeStripSize(imgBuf, target) {
73
73
  }
74
74
  export function registerGenerationCommands(tools) {
75
75
  // ─── Image Models ────────────────────────────────────────────
76
- tools
77
- .command('list-image-models')
76
+ tools.command('list-image-models')
78
77
  .description('列出后端已配置的可用生图模型,按模型名分组展示所有 provider(多个 provider 时按优先级自动 fallback)')
79
78
  .option('--json', '以 JSON 格式输出(默认为人类可读表格)')
80
79
  .action(async (opts) => {
@@ -151,8 +150,7 @@ export function registerGenerationCommands(tools) {
151
150
  }
152
151
  });
153
152
  // ─── Generation ─────────────────────────────────────────────
154
- tools
155
- .command('generate-image')
153
+ tools.command('generate-image')
156
154
  .description('AI 生成图片(支持多张参考图图生图;gpt-image-2 支持 --count N 批量出图)')
157
155
  .requiredOption('--prompt <text>', '图片描述')
158
156
  .option('--aspect-ratio <ratio>', '宽高比 (1:1|45:16|16:9|9:16|3:4|4:3|2:3|3:2|21:9)', '1:1')
@@ -176,12 +174,8 @@ export function registerGenerationCommands(tools) {
176
174
  .option('--count <n>', '一次 API 调用生成多少张图片(默认 1,最多 10;仅 gpt-image-2 实际支持批量)', (v) => parseInt(v, 10), 1)
177
175
  .action(async (opts) => {
178
176
  try {
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) {
177
+ const explicitBackground = opts.background === undefined ? opts.removeBackground : opts.background === 'transparent';
178
+ if (opts.background !== undefined && opts.removeBackground !== undefined && opts.removeBackground !== explicitBackground) {
185
179
  throw new Error('Conflicting background options. Use --background transparent or --background opaque; no image request was sent.');
186
180
  }
187
181
  opts = { ...opts, removeBackground: explicitBackground };
@@ -190,46 +184,28 @@ export function registerGenerationCommands(tools) {
190
184
  }
191
185
  const explicitOutputFormat = opts.outputFormat;
192
186
  const { dir: outputDir, name: outputName, ext: outputExt } = parse(opts.output);
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,
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,
209
195
  aspectRatio: assetContext?.aspectRatio ?? opts.aspectRatio,
210
- width: assetContext?.width ?? opts.width,
211
- height: assetContext?.height ?? opts.height,
212
- outputFormat: assetContext?.outputFormatFromSchema
213
- ? assetContext.outputFormat
214
- : (opts.outputFormat ?? assetContext?.outputFormat),
196
+ width: assetContext?.width ?? opts.width, height: assetContext?.height ?? opts.height,
197
+ outputFormat: assetContext?.outputFormatFromSchema ? assetContext.outputFormat : opts.outputFormat ?? assetContext?.outputFormat,
215
198
  fitMode: assetContext?.fitMode ?? opts.fitMode,
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
- }),
199
+ removeBackground: resolveImageBackgroundRequirement({ prompt: opts.prompt,
200
+ schemaRemoveBackground: assetContext?.removeBackgroundFromSchema ? assetContext.removeBackground : undefined,
201
+ removeBackground: opts.removeBackground, fallbackRemoveBackground: assetContext?.removeBackground }),
224
202
  paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
225
203
  imageModel: opts.imageModel || assetContext?.imageModelRef,
226
204
  };
227
205
  const invalidDelivery = validateImageDeliveryOptions(opts);
228
206
  if (invalidDelivery)
229
207
  throw new Error(invalidDelivery);
230
- const paths = opts.referenceImage?.length
231
- ? opts.referenceImage
232
- : (assetContext?.referenceImagePaths ?? []);
208
+ const paths = opts.referenceImage?.length ? opts.referenceImage : assetContext?.referenceImagePaths ?? [];
233
209
  if (paths.length > MAX_REFERENCE_IMAGES) {
234
210
  console.error(`Error: Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
235
211
  process.exit(1);
@@ -254,26 +230,14 @@ export function registerGenerationCommands(tools) {
254
230
  width: assetContext?.width ?? opts.width,
255
231
  height: assetContext?.height ?? opts.height,
256
232
  outputFormat: opts.outputFormat,
257
- outputFormatSource: assetContext?.outputFormatFromSchema
258
- ? 'schema'
259
- : explicitOutputFormat
260
- ? 'explicit'
261
- : assetContext
262
- ? 'inferred'
263
- : undefined,
233
+ outputFormatSource: assetContext?.outputFormatFromSchema ? 'schema'
234
+ : explicitOutputFormat ? 'explicit' : assetContext ? 'inferred' : undefined,
264
235
  fitMode: assetContext?.fitMode ?? opts.fitMode,
265
236
  paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
266
- background: opts.background === undefined
267
- ? undefined
268
- : opts.removeBackground
269
- ? 'transparent'
270
- : 'opaque',
237
+ background: opts.background === undefined ? undefined : opts.removeBackground ? 'transparent' : 'opaque',
271
238
  removeBackground: opts.removeBackground,
272
- backgroundRequirementSource: assetContext?.removeBackgroundFromSchema
273
- ? 'schema'
274
- : explicitBackground !== undefined
275
- ? 'explicit'
276
- : 'inferred',
239
+ backgroundRequirementSource: assetContext?.removeBackgroundFromSchema ? 'schema'
240
+ : explicitBackground !== undefined ? 'explicit' : 'inferred',
277
241
  referenceImages,
278
242
  ...(modelRef ? { imageModelRef: modelRef } : {}),
279
243
  ...(imageCount > 1 ? { imageCount } : {}),
@@ -301,8 +265,7 @@ export function registerGenerationCommands(tools) {
301
265
  lines.push(' → Try --image-size 1K to reduce generation time');
302
266
  lines.push(' → Or increase timeout with --timeout <seconds> (current default: 1800s)');
303
267
  }
304
- else if (hasReferenceImages &&
305
- (msg.includes('reference') || msg.includes('image input'))) {
268
+ else if (hasReferenceImages && (msg.includes('reference') || msg.includes('image input'))) {
306
269
  lines.push('');
307
270
  lines.push('Hint: Reference image error — the selected model may not support --reference-image.');
308
271
  lines.push(' → Run: playcraft tools list-image-models');
@@ -350,11 +313,7 @@ export function registerGenerationCommands(tools) {
350
313
  }
351
314
  // 收集所有待保存图片(第 1 张 + 批量额外图)
352
315
  const allImages = [
353
- {
354
- imageBase64: result.imageBase64,
355
- mimeType: result.mimeType,
356
- previewUrl: result.previewUrl,
357
- },
316
+ { imageBase64: result.imageBase64, mimeType: result.mimeType, previewUrl: result.previewUrl },
358
317
  ...(result.additionalImages ?? []),
359
318
  ];
360
319
  const isBatch = allImages.length > 1;
@@ -365,14 +324,12 @@ export function registerGenerationCommands(tools) {
365
324
  const savedPaths = [];
366
325
  if (result.transparencySource)
367
326
  console.log(`Transparency source: ${result.transparencySource}`);
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
- });
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
+ });
376
333
  for (let i = 0; i < allImages.length; i++) {
377
334
  const img = allImages[i];
378
335
  let imgBuf = Buffer.from(img.imageBase64, 'base64');
@@ -390,7 +347,8 @@ export function registerGenerationCommands(tools) {
390
347
  generatedHeight = metaBefore.height;
391
348
  if (metaBefore.width != null &&
392
349
  metaBefore.height != null &&
393
- (metaBefore.width !== stripTarget.width || metaBefore.height !== stripTarget.height)) {
350
+ (metaBefore.width !== stripTarget.width ||
351
+ metaBefore.height !== stripTarget.height)) {
394
352
  imgBuf = Buffer.from(await letterboxToTarget(imgBuf, stripTarget.width, stripTarget.height));
395
353
  console.log(`↑ Letterbox ${generatedWidth}×${generatedHeight} → ${stripTarget.width}×${stripTarget.height}(等比 + 白边,不拉伸)`);
396
354
  }
@@ -446,11 +404,10 @@ export function registerGenerationCommands(tools) {
446
404
  referenceImageStatus: result.referenceImageStatus ?? null,
447
405
  generatedAt: new Date().toISOString(),
448
406
  };
449
- const sidecarPath = `${resolvedPath}.generation.json`;
407
+ const sidecarPath = resolvedPath.replace(/\.[^.]+$/, '.json');
450
408
  writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2));
451
409
  await captureGeneratedArtifact(resolvedPath, 'generate-image', {
452
- ...(actualWidth ? { width: actualWidth } : {}),
453
- ...(actualHeight ? { height: actualHeight } : {}),
410
+ ...(actualWidth ? { width: actualWidth } : {}), ...(actualHeight ? { height: actualHeight } : {}),
454
411
  });
455
412
  console.log(` └─ Generation metadata: ${sidecarPath}`);
456
413
  }
@@ -474,8 +431,7 @@ export function registerGenerationCommands(tools) {
474
431
  handleError(e);
475
432
  }
476
433
  });
477
- tools
478
- .command('generate-sfx')
434
+ tools.command('generate-sfx')
479
435
  .description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
480
436
  .requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
481
437
  .option('--duration <seconds>', '时长(秒,0.5-22;省略时由供应商自动决定)', parseFloat)
@@ -495,14 +451,9 @@ export function registerGenerationCommands(tools) {
495
451
  const sizeKB = Math.round(buf.length / 1024);
496
452
  console.log(`SFX saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
497
453
  const sfxSidecar = {
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(),
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(),
506
457
  };
507
458
  writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(sfxSidecar, null, 2));
508
459
  await captureGeneratedArtifact(opts.output, 'generate-sfx', { duration: result.duration });
@@ -512,8 +463,7 @@ export function registerGenerationCommands(tools) {
512
463
  handleError(e);
513
464
  }
514
465
  });
515
- tools
516
- .command('generate-bgm')
466
+ tools.command('generate-bgm')
517
467
  .description('AI 生成 BGM(30s 循环),使用 Google Lyria 3')
518
468
  .requiredOption('--prompt <text>', 'BGM 描述,如 "轻快休闲游戏配乐"')
519
469
  .option('--style <style>', '音乐风格,如 casual / epic / sci-fi / retro')
@@ -534,14 +484,9 @@ export function registerGenerationCommands(tools) {
534
484
  const sizeKB = Math.round(buf.length / 1024);
535
485
  console.log(`BGM saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
536
486
  const bgmSidecar = {
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(),
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(),
545
490
  };
546
491
  writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(bgmSidecar, null, 2));
547
492
  await captureGeneratedArtifact(opts.output, 'generate-bgm', { duration: result.duration });
@@ -572,8 +517,7 @@ export function registerGenerationCommands(tools) {
572
517
  }
573
518
  });
574
519
  // ─── Search Image ──────────────────────────────────────────
575
- tools
576
- .command('search-image')
520
+ tools.command('search-image')
577
521
  .description('搜索参考图片(Unsplash / Pexels),结果 URL 可直接用于 --reference-image')
578
522
  .requiredOption('--query <query>', '搜索关键词(建议英文)')
579
523
  .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, resolve } from 'path';
2
+ import { join, parse } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { InvalidArgumentError } from 'commander';
5
5
  export const TMP_DIR = join(tmpdir(), 'playcraft');
@@ -9,12 +9,8 @@ export function parseImageTargetFieldPath(value) {
9
9
  try {
10
10
  parsed = JSON.parse(value);
11
11
  }
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)) {
12
+ catch { /* Report the expected shape below. */ }
13
+ if (!Array.isArray(parsed) || !parsed.length || parsed.some(key => typeof key !== 'string' || !key.length)) {
18
14
  throw new InvalidArgumentError('Expected an array of field names, e.g. ["endUI","playButton"].');
19
15
  }
20
16
  return parsed;
@@ -78,7 +74,11 @@ export function extensionForImageMime(mimeType) {
78
74
  * Detect container from decoded bytes (magic). Prefer this over MIME alone.
79
75
  */
80
76
  export function sniffImageExtension(buf) {
81
- if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
77
+ if (buf.length >= 8 &&
78
+ buf[0] === 0x89 &&
79
+ buf[1] === 0x50 &&
80
+ buf[2] === 0x4e &&
81
+ 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,14 +133,12 @@ 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';
138
136
  if (ext === 'jpg' || ext === 'jpeg')
139
137
  return 'image/jpeg';
140
138
  if (ext === undefined || ext === '') {
141
139
  throw new Error(`Reference image path has no file extension: ${filePath}. Use .png, .webp, .jpg, or .jpeg.`);
142
140
  }
143
- throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, jpeg, and gif are allowed.`);
141
+ throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, and jpeg are allowed.`);
144
142
  }
145
143
  async function downloadImageFromUrl(url) {
146
144
  let res;
@@ -157,8 +155,7 @@ async function downloadImageFromUrl(url) {
157
155
  const contentType = res.headers.get('content-type') ?? '';
158
156
  const mimeType = contentType.split(';')[0].trim() || 'image/png';
159
157
  const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
160
- if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) ||
161
- !mimeType.startsWith('image/')) {
158
+ if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) || !mimeType.startsWith('image/')) {
162
159
  throw new Error(`参考图 URL 返回的 Content-Type 不是图片(${mimeType}):${url}\n 只支持 PNG / JPG / WEBP / GIF。`);
163
160
  }
164
161
  const buf = await res.arrayBuffer();
@@ -181,13 +178,18 @@ export async function collectReferenceImagePayloads(paths) {
181
178
  }
182
179
  else {
183
180
  const mimeType = mimeTypeForImagePath(p);
184
- out.push({ base64: readFileSync(resolve(process.cwd(), p)).toString('base64'), mimeType });
181
+ out.push({ base64: readFileSync(p).toString('base64'), mimeType });
185
182
  }
186
183
  }
187
184
  catch (e) {
188
185
  const detail = e instanceof Error ? e.message : String(e);
189
- throw new Error(`Could not load reference image "${p}": ${detail}. No image request was sent.`);
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}`);
190
189
  }
191
190
  }
191
+ if (paths.length > 0 && out.length === 0) {
192
+ console.warn('All reference images failed to load; continuing with text-only generation.');
193
+ }
192
194
  return out.length > 0 ? out : undefined;
193
195
  }
@@ -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 {
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import JSON5 from 'json5';
4
4
  import sharp from 'sharp';
5
- import { extractAssetFields, getByPath, parseThemeIndexImportPath, resolveAiImageTargetConstraints, resolveImageBackgroundRequirement, THEME_INDEX_PATH, THEME_SCHEMA_PATH, themeDataPath, } from '@playcraft/common/prefab';
5
+ import { extractAssetFields, getByPath, resolveAiImageTargetConstraints, resolveImageBackgroundRequirement, THEME_SCHEMA_PATH, themeDataPath, } from '@playcraft/common/prefab';
6
6
  function isWithinDirectory(root, file) {
7
7
  const relative = path.relative(root, file);
8
8
  return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
@@ -38,29 +38,20 @@ export function assertImageAssetOutputPaths(context, outputPaths, projectDir = p
38
38
  const root = path.resolve(projectDir);
39
39
  const target = canonicalProjectPath(root, context.targetAssetPath);
40
40
  const targetStat = statSync(target);
41
- const outputRoots = [
42
- root,
43
- ...allowedOutputRoots.map((outputRoot) => path.resolve(root, outputRoot)),
44
- ];
41
+ const outputRoots = [root, ...allowedOutputRoots.map(outputRoot => path.resolve(root, outputRoot))];
45
42
  for (const output of outputPaths) {
46
43
  const mapped = output.startsWith('/project/') ? output.slice('/project/'.length) : output;
47
44
  const { dir, name } = path.parse(mapped);
48
45
  // The response encoding can change the requested extension.
49
- const candidates = [
50
- mapped,
51
- ...['.png', '.webp', '.jpg', '.gif', '.bmp'].map((ext) => path.join(dir, `${name}${ext}`)),
52
- ];
46
+ const candidates = [mapped, ...['.png', '.webp', '.jpg', '.gif', '.bmp'].map(ext => path.join(dir, `${name}${ext}`))];
53
47
  for (const candidate of candidates) {
54
48
  const candidatePath = path.resolve(root, candidate);
55
- const outputRoot = outputRoots.find((allowed) => isWithinDirectory(allowed, candidatePath));
49
+ const outputRoot = outputRoots.find(allowed => isWithinDirectory(allowed, candidatePath));
56
50
  if (!outputRoot)
57
51
  throw new Error(`Image output path is outside the project and allowed output roots: ${output}`);
58
52
  const file = canonicalProjectPath(outputRoot, candidatePath);
59
53
  const fileStat = existsSync(file) ? statSync(file) : undefined;
60
- if (!path.relative(target, file) ||
61
- (targetStat.ino !== 0 &&
62
- fileStat?.ino === targetStat.ino &&
63
- fileStat.dev === targetStat.dev)) {
54
+ if (!path.relative(target, file) || (targetStat.ino !== 0 && fileStat?.ino === targetStat.ino && fileStat.dev === targetStat.dev)) {
64
55
  throw new Error(`Cannot overwrite shared image asset ${context.targetAssetPath}: other fields have different requirements. Use a separate outputPath, then explicitly update the selected field reference; no shared asset was changed.`);
65
56
  }
66
57
  }
@@ -80,10 +71,8 @@ function projectFile(root, input) {
80
71
  /** Read the same per-field constraints used by the asset image dialog. */
81
72
  export async function readImageAssetContext(assetPath, projectDir = process.cwd(), options = {}) {
82
73
  const targetFieldPath = options.targetFieldPath;
83
- if (targetFieldPath !== undefined &&
84
- (!Array.isArray(targetFieldPath) ||
85
- !targetFieldPath.length ||
86
- targetFieldPath.some((key) => typeof key !== 'string' || !key.length))) {
74
+ if (targetFieldPath !== undefined && (!Array.isArray(targetFieldPath)
75
+ || !targetFieldPath.length || targetFieldPath.some(key => typeof key !== 'string' || !key.length))) {
87
76
  throw new Error('targetFieldPath must be a non-empty array of non-empty field names.');
88
77
  }
89
78
  const root = path.resolve(projectDir);
@@ -92,36 +81,24 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
92
81
  const targetStat = statSync(target);
93
82
  const relativeTarget = path.relative(root, target).replace(/\\/g, '/');
94
83
  const themeMatch = relativeTarget.match(/^src\/theme\/([^/]+)\/assets\//);
95
- const indexFile = path.join(root, THEME_INDEX_PATH);
96
- const themeId = themeMatch?.[1] ??
97
- (existsSync(indexFile)
98
- ? parseThemeIndexImportPath(readFileSync(projectFile(root, THEME_INDEX_PATH), 'utf8'))
99
- : null);
100
84
  let fields = [];
101
85
  const schemaFile = path.join(root, THEME_SCHEMA_PATH);
102
- if (themeId && existsSync(schemaFile)) {
86
+ if (themeMatch && existsSync(schemaFile)) {
103
87
  const schema = JSON5.parse(readFileSync(projectFile(root, THEME_SCHEMA_PATH), 'utf8'));
104
- const dataPath = themeDataPath(themeId);
88
+ const dataPath = themeDataPath(themeMatch[1]);
105
89
  const data = existsSync(path.join(root, dataPath))
106
- ? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8'))
107
- : {};
90
+ ? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8')) : {};
108
91
  const themeRoot = path.dirname(path.join(root, dataPath));
109
- const matches = extractAssetFields(schema, data).filter((candidate) => {
92
+ const matches = extractAssetFields(schema, data).filter(candidate => {
110
93
  let configured = getByPath(data, candidate.path);
111
94
  if (configured === undefined && ['gameplay', 'gamePlay'].includes(candidate.path[0])) {
112
- configured = getByPath(data, [
113
- candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay',
114
- ...candidate.path.slice(1),
115
- ]);
95
+ configured = getByPath(data, [candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay', ...candidate.path.slice(1)]);
116
96
  }
117
97
  const value = typeof configured === 'string' ? configured : candidate.schema.default;
118
98
  if (typeof value !== 'string' || /^https?:\/\//i.test(value))
119
99
  return false;
120
- const configuredPath = value.startsWith('/project/')
121
- ? path.resolve(root, value.slice('/project/'.length))
122
- : /^\/?src\//.test(value)
123
- ? path.resolve(root, value.replace(/^\//, ''))
124
- : path.resolve(themeRoot, value);
100
+ const configuredPath = value.startsWith('/project/') ? path.resolve(root, value.slice('/project/'.length))
101
+ : path.resolve(themeRoot, value);
125
102
  if (configuredPath === target)
126
103
  return true;
127
104
  try {
@@ -129,22 +106,19 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
129
106
  if (!path.relative(realTarget, realpathSync(candidateFile)))
130
107
  return true;
131
108
  const candidateStat = statSync(candidateFile);
132
- return (targetStat.ino !== 0 &&
133
- candidateStat.ino === targetStat.ino &&
134
- candidateStat.dev === targetStat.dev);
109
+ return targetStat.ino !== 0 && candidateStat.ino === targetStat.ino && candidateStat.dev === targetStat.dev;
135
110
  }
136
111
  catch {
137
112
  // Other fields may reference missing files or paths outside this project.
138
113
  return false;
139
114
  }
140
115
  });
141
- if (!matches.length && (themeMatch || targetFieldPath))
116
+ if (!matches.length)
142
117
  throw new Error(`No image schema field matches ${relativeTarget}; check theme.data.json5 before generating.`);
143
118
  fields = matches;
144
- if (targetFieldPath &&
145
- !fields.some((candidate) => candidate.path.length === targetFieldPath.length &&
146
- candidate.path.every((key, index) => key === targetFieldPath[index]))) {
147
- throw new Error(`targetFieldPath ${JSON.stringify(targetFieldPath)} does not reference ${relativeTarget}. Matching fields: ${matches.map((candidate) => JSON.stringify(candidate.path)).join(', ')}.`);
119
+ if (targetFieldPath && !fields.some(candidate => candidate.path.length === targetFieldPath.length
120
+ && candidate.path.every((key, index) => key === targetFieldPath[index]))) {
121
+ throw new Error(`targetFieldPath ${JSON.stringify(targetFieldPath)} does not reference ${relativeTarget}. Matching fields: ${matches.map(candidate => JSON.stringify(candidate.path)).join(', ')}.`);
148
122
  }
149
123
  }
150
124
  else if (targetFieldPath) {
@@ -157,35 +131,22 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
157
131
  const ext = xp?.ext;
158
132
  // Schema references follow the dialog's root/theme conventions; explicit tool paths do not change.
159
133
  const schemaReferences = Array.isArray(ext?.reference_image)
160
- ? ext.reference_image
161
- .filter((ref) => typeof ref === 'string' && ref.trim())
162
- .map((ref) => {
134
+ ? ext.reference_image.filter(ref => typeof ref === 'string' && ref.trim()).map(ref => {
163
135
  const raw = ref.trim().replace(/\\/g, '/');
164
- const normalized = raw
165
- .replace(/^\.\/+/, '')
166
- .replace(/^\/+/, '')
167
- .replace(/\/{2,}/g, '/');
136
+ const normalized = raw.replace(/^\.\/+/, '').replace(/^\/+/, '').replace(/\/{2,}/g, '/');
168
137
  if (/^\/?(?:assets|src|public)\//.test(raw) || normalized.startsWith('src/'))
169
138
  return normalized;
170
- if (!themeId || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
139
+ if (!themeMatch || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
171
140
  return raw;
172
- return path.posix.join('src/theme', themeId, normalized);
173
- })
174
- : [];
175
- const references = options.referenceImagePaths?.length
176
- ? options.referenceImagePaths
177
- : options.useTargetReference === false
178
- ? []
179
- : schemaReferences.length
180
- ? schemaReferences
181
- : [relativeTarget];
182
- const referenceImagePaths = references.map((ref) => /^https?:\/\//i.test(ref)
183
- ? ref
141
+ return path.posix.join('src/theme', themeMatch[1], normalized);
142
+ }) : [];
143
+ const references = options.referenceImagePaths?.length ? options.referenceImagePaths
144
+ : options.useTargetReference === false ? [] : schemaReferences.length ? schemaReferences : [relativeTarget];
145
+ const referenceImagePaths = references.map(ref => /^https?:\/\//i.test(ref) ? ref
184
146
  : path.relative(realpathSync(root), canonicalProjectPath(root, ref)).replace(/\\/g, '/'));
185
147
  const hasSchemaBackground = typeof ext?.remove_background === 'boolean';
186
148
  if (!hasSchemaBackground && targetHasTransparency === undefined) {
187
- targetHasTransparency =
188
- !!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
149
+ targetHasTransparency = !!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
189
150
  }
190
151
  const removeBackground = resolveImageBackgroundRequirement({
191
152
  prompt: options.prompt ?? '',
@@ -210,16 +171,12 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
210
171
  targetAssetPath: relativeTarget,
211
172
  fieldPath: field?.path,
212
173
  referenceImagePaths,
213
- width: delivery.width,
214
- height: delivery.height,
174
+ width: delivery.width, height: delivery.height,
215
175
  aspectRatio: typeof ext?.ratio === 'string' ? ext.ratio : `${delivery.width}:${delivery.height}`,
216
176
  outputFormat: (removeBackground && delivery.format === 'jpeg' && !delivery.fromSchemaMimeType
217
- ? 'png'
218
- : delivery.format),
177
+ ? 'png' : delivery.format),
219
178
  outputFormatFromSchema: delivery.fromSchemaMimeType,
220
- fitMode: delivery.fitMode,
221
- removeBackground: delivery.removeBackground,
222
- paddingColor: delivery.paddingColor,
179
+ fitMode: delivery.fitMode, removeBackground: delivery.removeBackground, paddingColor: delivery.paddingColor,
223
180
  removeBackgroundFromSchema: hasSchemaBackground,
224
181
  imageModelRef: typeof ext?.imageModelRef === 'string' ? ext.imageModelRef : undefined,
225
182
  };
@@ -231,40 +188,33 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
231
188
  contexts.push(await contextForField(field));
232
189
  }
233
190
  catch (error) {
234
- const selected = field?.path.length === targetFieldPath?.length &&
235
- field?.path.every((key, index) => key === targetFieldPath?.[index]);
191
+ const selected = field?.path.length === targetFieldPath?.length
192
+ && field?.path.every((key, index) => key === targetFieldPath?.[index]);
236
193
  if (!targetFieldPath || selected)
237
194
  throw error;
238
195
  // Invalid sibling requirements still protect the original shared file.
239
196
  unresolvedSharedField = true;
240
197
  }
241
198
  }
242
- const context = targetFieldPath
243
- ? contexts.find((candidate) => candidate.fieldPath?.length === targetFieldPath.length &&
244
- candidate.fieldPath.every((key, index) => key === targetFieldPath[index]))
245
- : contexts[0];
199
+ const context = targetFieldPath ? contexts.find(candidate => candidate.fieldPath?.length === targetFieldPath.length
200
+ && candidate.fieldPath.every((key, index) => key === targetFieldPath[index])) : contexts[0];
246
201
  const deliveryKey = ({ fieldPath: _fieldPath, outputFormatFromSchema: _formatSource, removeBackgroundFromSchema: _backgroundSource, aspectRatio, ...delivery }) => {
247
202
  const ratio = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
248
- return JSON.stringify({
249
- ...delivery,
250
- aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio,
251
- });
203
+ return JSON.stringify({ ...delivery, aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio });
252
204
  };
253
- const conflicting = unresolvedSharedField ||
254
- contexts.some((candidate) => deliveryKey(candidate) !== deliveryKey(context));
205
+ const conflicting = unresolvedSharedField || contexts.some(candidate => deliveryKey(candidate) !== deliveryKey(context));
255
206
  if (conflicting && !targetFieldPath) {
256
- throw new Error(`Multiple image fields use ${relativeTarget} with different references or delivery constraints. Set targetFieldPath (or --target-field-path) to one of: ${fields.map((field) => JSON.stringify(field.path)).join(', ')}.`);
207
+ throw new Error(`Multiple image fields use ${relativeTarget} with different references or delivery constraints. Set targetFieldPath (or --target-field-path) to one of: ${fields.map(field => JSON.stringify(field.path)).join(', ')}.`);
257
208
  }
258
209
  if (targetFieldPath) {
259
210
  // A separate output retains exactly the selected field's requirements.
260
- if (conflicting ||
261
- contexts.some((candidate) => (candidate.outputFormatFromSchema && !context.outputFormatFromSchema) ||
262
- (candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
211
+ if (conflicting || contexts.some(candidate => (candidate.outputFormatFromSchema && !context.outputFormatFromSchema)
212
+ || (candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
263
213
  context.requiresSeparateOutput = true;
264
214
  }
265
215
  else {
266
- context.outputFormatFromSchema = contexts.some((candidate) => candidate.outputFormatFromSchema);
267
- context.removeBackgroundFromSchema = contexts.some((candidate) => candidate.removeBackgroundFromSchema);
216
+ context.outputFormatFromSchema = contexts.some(candidate => candidate.outputFormatFromSchema);
217
+ context.removeBackgroundFromSchema = contexts.some(candidate => candidate.removeBackgroundFromSchema);
268
218
  }
269
219
  assertImageAssetOutputPaths(context, options.outputPaths ?? [], root, options.allowedOutputRoots);
270
220
  if (context.referenceImagePaths.length > 8)
@@ -29,7 +29,7 @@ function holderAlive(holder) {
29
29
  }
30
30
  /** Persist an identity before sending. Store neither credentials nor request/response bodies. */
31
31
  export function beginToolOperation(path, serializedBody, recoverId, options = {}) {
32
- const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
32
+ const directory = options.directory ?? process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
33
33
  if (!isAbsolute(directory))
34
34
  throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
35
35
  mkdirSync(directory, { recursive: true, mode: 0o700 });
@@ -98,8 +98,8 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
98
98
  };
99
99
  }
100
100
  /** Read an existing identity without creating an intent or changing its receipt state. */
101
- export function findToolOperation(path, serializedBody, requestId) {
102
- const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
101
+ export function findToolOperation(path, serializedBody, requestId, directoryOverride) {
102
+ const directory = directoryOverride ?? process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
103
103
  if (!isAbsolute(directory))
104
104
  throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
105
105
  const inputHash = createHash('sha256').update(serializedBody).digest('hex');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.84-beta",
3
+ "version": "0.1.86",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -57,8 +57,8 @@
57
57
  "@gltf-transform/core": "^4.3.0",
58
58
  "@gltf-transform/extensions": "^4.3.0",
59
59
  "@gltf-transform/functions": "^4.3.0",
60
- "@playcraft/build": "0.1.84-beta",
61
- "@playcraft/common": "0.1.84-beta",
60
+ "@playcraft/build": "0.1.86",
61
+ "@playcraft/common": "0.1.86",
62
62
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
63
63
  "chokidar": "^4.0.3",
64
64
  "commander": "^13.1.0",