@playcraft/cli 0.1.85-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.
@@ -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
+ }
@@ -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 {
@@ -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');