@playcraft/cli 0.0.48 → 0.0.51

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.
@@ -1,97 +1,414 @@
1
1
  import inquirer from 'inquirer';
2
- import { exec } from 'child_process';
3
- import { loadGlobalConfig } from '../config.js';
4
- import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from './login.js';
2
+ import { exec } from 'node:child_process';
3
+ import { readFileSync } from 'node:fs';
4
+ import { PlayCraftApiError, PlayCraftHttpClient, isPlayCraftApiError } from '../utils/playcraft-http-client.js';
5
+ import { IMAGE_OPTION_HELP, buildReferenceImagesPrompt, readImageAttachments, uploadImageAttachments, } from '../utils/remix-image-attachments.js';
5
6
  import { initTemplateCommand } from '../remix/init-template.js';
6
- function resolveBackendBaseUrl(options) {
7
- const globalConfig = loadGlobalConfig();
8
- const raw = options.url ||
9
- process.env.PLAYCRAFT_URL ||
10
- globalConfig.url ||
11
- globalConfig.backendUrl ||
12
- DEFAULT_PLAYCRAFT_CLOUD_BASE;
13
- return raw.replace(/\/+$/, '');
14
- }
15
- function resolveJwtToken(options) {
16
- const globalConfig = loadGlobalConfig();
17
- const token = options.token || process.env.PLAYCRAFT_TOKEN || globalConfig.token;
18
- if (!token) {
19
- throw new Error('缺少 JWT token。请先执行 `playcraft login`,或传入 --token / PLAYCRAFT_TOKEN。');
20
- }
21
- return token;
22
- }
23
- function openBrowser(url) {
24
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
25
- exec(`${cmd} "${url}"`, () => undefined);
7
+ const JSON_OUTPUT_HELP = '输出一条 JSON (JavaScript Object Notation, JavaScript 对象表示法) 结果';
8
+ const SERVICE_ADDRESS_HELP = 'PlayCraft 服务地址';
9
+ const PAT_HELP = 'PAT (Personal Access Token, 个人访问令牌)';
10
+ function emitProgress(options, message) {
11
+ (options.json ? process.stderr : process.stdout).write(`${message}\n`);
26
12
  }
27
13
  function formatIdleSec(idleSec) {
28
14
  if (!idleSec || idleSec <= 0)
29
15
  return '0s';
30
16
  if (idleSec < 60)
31
17
  return `${idleSec}s`;
32
- const m = Math.floor(idleSec / 60);
33
- const s = idleSec % 60;
34
- return `${m}m${s}s`;
18
+ return `${Math.floor(idleSec / 60)}m${idleSec % 60}s`;
35
19
  }
36
- function getTtydUrl(agentBaseUrl) {
37
- if (!agentBaseUrl || !agentBaseUrl.includes('/agent/'))
38
- return undefined;
39
- return agentBaseUrl.replace('/agent/', '/ttyd/');
20
+ function parseAgentQuestions(input) {
21
+ let raw = input;
22
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
23
+ raw = raw.questions ?? [raw];
24
+ }
25
+ if (typeof raw === 'string') {
26
+ try {
27
+ raw = JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ if (!Array.isArray(raw))
34
+ return [];
35
+ return raw.flatMap((value) => {
36
+ if (!value || typeof value !== 'object')
37
+ return [];
38
+ const question = value;
39
+ const text = [question.question, question.prompt, question.text]
40
+ .find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
41
+ if (!text)
42
+ return [];
43
+ const options = (Array.isArray(question.options) ? question.options : []).flatMap((option) => {
44
+ if (typeof option === 'string' && option.trim())
45
+ return [{ label: option.trim() }];
46
+ if (!option || typeof option !== 'object')
47
+ return [];
48
+ const item = option;
49
+ const label = [item.label, item.text, item.value, item.description]
50
+ .find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
51
+ if (!label)
52
+ return [];
53
+ return [{
54
+ label: label.trim(),
55
+ ...(typeof item.description === 'string' && item.description.trim()
56
+ ? { description: item.description.trim() }
57
+ : {}),
58
+ }];
59
+ });
60
+ const custom = question.custom !== false;
61
+ if (!options.length && !custom)
62
+ return [];
63
+ return [{
64
+ question: text.trim(),
65
+ ...(typeof question.header === 'string' && question.header.trim()
66
+ ? { header: question.header.trim() }
67
+ : {}),
68
+ options,
69
+ multiple: question.multiple === true,
70
+ custom,
71
+ }];
72
+ });
40
73
  }
41
- async function remixCreate(options) {
42
- const baseUrl = resolveBackendBaseUrl(options);
43
- const token = resolveJwtToken(options);
44
- const endpoint = `${baseUrl}/api/projects/${encodeURIComponent(options.projectId)}/branches`;
45
- const res = await fetch(endpoint, {
46
- method: 'POST',
47
- headers: {
48
- Authorization: `Bearer ${token}`,
49
- 'Content-Type': 'application/json',
50
- },
51
- body: JSON.stringify({
52
- name: options.name,
53
- ref: options.sourceBranch,
54
- }),
74
+ async function promptForAgentAnswers(questions) {
75
+ const answers = [];
76
+ for (let index = 0; index < questions.length; index += 1) {
77
+ const question = questions[index];
78
+ const message = `${question.header ? `${question.header}:` : ''}${question.question}`;
79
+ const choices = question.options.map((option, optionIndex) => ({
80
+ name: option.description && option.description !== option.label
81
+ ? `${option.label} — ${option.description}`
82
+ : option.label,
83
+ value: optionIndex,
84
+ }));
85
+ const customValue = -1;
86
+ if (question.custom)
87
+ choices.push({ name: '其他(手动输入)', value: customValue });
88
+ let selectedIndexes;
89
+ if (!question.options.length && question.custom) {
90
+ selectedIndexes = [customValue];
91
+ }
92
+ else if (question.multiple) {
93
+ const selected = await inquirer.prompt([{
94
+ type: 'checkbox',
95
+ name: 'values',
96
+ message: `${message}(可多选)`,
97
+ choices,
98
+ validate: (values) => values.length > 0 || '请至少选择一项。',
99
+ }]);
100
+ selectedIndexes = selected.values;
101
+ }
102
+ else {
103
+ const selected = await inquirer.prompt([{
104
+ type: 'list',
105
+ name: 'value',
106
+ message,
107
+ choices,
108
+ }]);
109
+ selectedIndexes = [selected.value];
110
+ }
111
+ const answer = selectedIndexes
112
+ .filter((selected) => selected !== customValue)
113
+ .map((selected) => question.options[selected]?.label)
114
+ .filter((label) => Boolean(label));
115
+ if (selectedIndexes.includes(customValue)) {
116
+ const custom = await inquirer.prompt([{
117
+ type: 'input',
118
+ name: 'text',
119
+ message: `${question.header || `问题 ${index + 1}`},请输入自定义答案:`,
120
+ validate: (value) => Boolean(value.trim()) || '答案不能为空。',
121
+ }]);
122
+ answer.push(custom.text.trim());
123
+ }
124
+ answers.push(answer);
125
+ }
126
+ return answers;
127
+ }
128
+ function parseAgentQuestionRequests(input) {
129
+ const values = Array.isArray(input) ? input : input && typeof input === 'object' ? [input] : [];
130
+ return values.flatMap((value) => {
131
+ if (!value || typeof value !== 'object')
132
+ return [];
133
+ const payload = value;
134
+ const id = [payload.id, payload.requestID, payload.requestId]
135
+ .find((candidate) => typeof candidate === 'string' && candidate.length > 0);
136
+ if (!id)
137
+ return [];
138
+ const sessionId = [payload.sessionID, payload.sessionId, payload.session_id]
139
+ .find((candidate) => typeof candidate === 'string' && candidate.length > 0);
140
+ return [{
141
+ id,
142
+ ...(sessionId ? { sessionId } : {}),
143
+ questions: parseAgentQuestions(payload.questions ?? payload),
144
+ raw: value,
145
+ }];
55
146
  });
56
- if (!res.ok) {
57
- const text = await res.text();
58
- throw new Error(`创建 remix 失败: ${res.status} ${text}`);
147
+ }
148
+ async function replyToAgentQuestion(client, ready, options, request, handledQuestionIds) {
149
+ if (request.sessionId && request.sessionId !== ready.agentSessionId)
150
+ return;
151
+ if (handledQuestionIds.has(request.id))
152
+ return;
153
+ handledQuestionIds.add(request.id);
154
+ if (!request.questions.length) {
155
+ throw new PlayCraftApiError({
156
+ code: 'AGENT_REQUEST_FAILED',
157
+ message: 'Agent 请求用户选择,但返回的问题格式无法识别。请打开 Remix 编辑器回答。',
158
+ details: request.raw,
159
+ });
160
+ }
161
+ if (!options.interactive) {
162
+ throw new PlayCraftApiError({
163
+ code: 'AGENT_REQUEST_FAILED',
164
+ message: 'Agent 正在等待用户选择。请在交互式终端运行命令,或打开 Remix 编辑器回答。',
165
+ details: { requestId: request.id, questions: request.questions },
166
+ });
167
+ }
168
+ emitProgress(options, `[等待选择] Agent 提出了 ${request.questions.length} 个问题:`);
169
+ const answers = await promptForAgentAnswers(request.questions);
170
+ await client.post(`${ready.agentEndpoint}/question/${encodeURIComponent(request.id)}/reply`, { answers });
171
+ emitProgress(options, '[已回复] 选择已发送,Agent 继续执行...');
172
+ }
173
+ function openBrowser(url) {
174
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
175
+ exec(`${command} "${url}"`, () => undefined);
176
+ }
177
+ function localServiceUrl(baseUrl, fromPort, toPort) {
178
+ const url = new URL(baseUrl);
179
+ const isLocal = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
180
+ if (isLocal && url.port === fromPort)
181
+ url.port = toPort;
182
+ return url.toString().replace(/\/+$/, '');
183
+ }
184
+ function createRemixClient(options) {
185
+ const configured = new PlayCraftHttpClient(options);
186
+ const apiBaseUrl = localServiceUrl(configured.baseUrl, '3000', '3001');
187
+ if (apiBaseUrl === configured.baseUrl)
188
+ return configured;
189
+ return new PlayCraftHttpClient({
190
+ url: apiBaseUrl,
191
+ token: configured.token,
192
+ requestId: configured.requestId,
193
+ });
194
+ }
195
+ function editorUrl(client, projectId, branch) {
196
+ const configured = process.env.PLAYCRAFT_FRONTEND_URL || process.env.FRONTEND_URL;
197
+ const base = configured || localServiceUrl(client.baseUrl, '3001', '3000');
198
+ return `${base.replace(/\/+$/, '')}/editor/remix/${encodeURIComponent(projectId)}?branch=${encodeURIComponent(branch)}`;
199
+ }
200
+ function remixDetailUrl(client, projectId, branch) {
201
+ const configured = process.env.PLAYCRAFT_FRONTEND_URL || process.env.FRONTEND_URL;
202
+ const base = configured || localServiceUrl(client.baseUrl, '3001', '3000');
203
+ return `${base.replace(/\/+$/, '')}/projects/${encodeURIComponent(projectId)}/remixes/${encodeURIComponent(branch)}`;
204
+ }
205
+ function isAgentPromptExit(value) {
206
+ return ['', 'q', 'quit', 'exit', '退出'].includes(value.trim().toLowerCase());
207
+ }
208
+ async function resolveEditorProjectId(client, projectId) {
209
+ const project = await client.get(`/projects/${encodeURIComponent(projectId)}`);
210
+ if (project?.projectType === 'external' && typeof project.uuid === 'string' && project.uuid) {
211
+ return project.uuid;
59
212
  }
60
- const data = (await res.json());
61
- console.log(`Remix 创建成功: ${data.displayName || data.name}`);
62
- console.log(`分支: ${data.name}`);
213
+ if (project?.id !== undefined && project?.id !== null)
214
+ return String(project.id);
215
+ return projectId;
216
+ }
217
+ async function resolveEditorProjectIdBestEffort(client, projectId, options, fallbackProjectId = projectId) {
218
+ try {
219
+ return await resolveEditorProjectId(client, projectId);
220
+ }
221
+ catch {
222
+ emitProgress(options, `[提示] 项目操作已成功,但暂时无法解析编辑器项目标识,链接临时使用项目 ID:${fallbackProjectId}`);
223
+ return fallbackProjectId;
224
+ }
225
+ }
226
+ function parseList(payload) {
227
+ if (Array.isArray(payload))
228
+ return payload;
229
+ if (payload && typeof payload === 'object' && Array.isArray(payload.result)) {
230
+ return payload.result;
231
+ }
232
+ return [];
233
+ }
234
+ function parseProjectTemplates(payload) {
235
+ return parseList(payload).map((project) => ({
236
+ id: String(project.id),
237
+ uuid: project.uuid,
238
+ name: project.name,
239
+ projectType: project.projectType,
240
+ description: project.description,
241
+ tags: project.tags,
242
+ thumbnailUrl: project.thumbnailUrl,
243
+ sourceBranch: project.settings?.git?.branch || 'main',
244
+ }));
245
+ }
246
+ function readPrompt(options) {
247
+ if (options.prompt && options.promptFile) {
248
+ throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '--prompt 和 --prompt-file 只能使用一个。' });
249
+ }
250
+ const prompt = options.promptFile ? readFileSync(options.promptFile, 'utf8') : options.prompt || '';
251
+ const trimmed = prompt.trim();
252
+ if (!trimmed) {
253
+ throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '需求描述不能为空。' });
254
+ }
255
+ if (Buffer.byteLength(trimmed, 'utf8') > 256 * 1024) {
256
+ throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '需求描述超过 256 KiB (Kibibyte, 千二进制字节) 限制。' });
257
+ }
258
+ return trimmed;
259
+ }
260
+ function resultEnvelope(command, client, data) {
261
+ return { schemaVersion: '1.0', ok: true, command, requestId: client.requestId, data };
262
+ }
263
+ function errorEnvelope(command, client, error) {
264
+ const apiError = isPlayCraftApiError(error)
265
+ ? error
266
+ : new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: error instanceof Error ? error.message : String(error) });
267
+ return {
268
+ schemaVersion: '1.0',
269
+ ok: false,
270
+ command,
271
+ ...(client ? { requestId: client.requestId } : {}),
272
+ error: {
273
+ code: apiError.code,
274
+ message: apiError.message,
275
+ retryable: apiError.retryable,
276
+ ...(apiError.details === undefined ? {} : { details: apiError.details }),
277
+ },
278
+ };
279
+ }
280
+ function exitCodeForError(error) {
281
+ const code = isPlayCraftApiError(error) ? error.code : '';
282
+ if (code === 'AUTH_REQUIRED' || code === 'AUTH_EXPIRED')
283
+ return 3;
284
+ if (code === 'FORBIDDEN' || code === 'TEAM_FORBIDDEN' || code === 'TEAM_NOT_FOUND' || code === 'TEMPLATE_NOT_FOUND')
285
+ return 4;
286
+ if (code === 'AGENT_TIMEOUT' || code === 'AGENT_REQUEST_FAILED' || code === 'AGENT_UNAVAILABLE')
287
+ return 5;
288
+ return 2;
289
+ }
290
+ function handleCommandError(command, options, client, error) {
291
+ if (options.json) {
292
+ process.stdout.write(`${JSON.stringify(errorEnvelope(command, client, error))}\n`);
293
+ }
294
+ else {
295
+ const code = isPlayCraftApiError(error) ? error.code : undefined;
296
+ const message = code === 'AUTH_REQUIRED'
297
+ ? '需要登录。请先运行 `playcraft login`,或提供 PLAYCRAFT_TOKEN。'
298
+ : code === 'AUTH_EXPIRED'
299
+ ? '登录已过期,请重新运行 `playcraft login`。'
300
+ : error instanceof Error ? error.message : String(error);
301
+ console.error(`错误:${message}`);
302
+ }
303
+ process.exitCode = exitCodeForError(error);
304
+ }
305
+ async function remixTeams(options) {
306
+ let client;
307
+ try {
308
+ client = createRemixClient(options);
309
+ const teams = parseList(await client.get('/teams'));
310
+ const data = { items: teams, page: 1, limit: teams.length, total: teams.length, hasMore: false };
311
+ if (options.json) {
312
+ console.log(JSON.stringify(resultEnvelope('remix.teams', client, data)));
313
+ }
314
+ else {
315
+ if (!teams.length)
316
+ console.log('当前账号没有可用团队。');
317
+ else
318
+ teams.forEach((team) => console.log(`${team.id}\t${team.name}`));
319
+ console.log('\n下一步:');
320
+ console.log(' playcraft remix templates --team-id <团队ID>');
321
+ console.log('\n或直接运行完整交互流程:');
322
+ console.log(' playcraft remix run');
323
+ }
324
+ }
325
+ catch (error) {
326
+ handleCommandError('remix.teams', options, client, error);
327
+ }
328
+ }
329
+ async function remixTemplates(options) {
330
+ let client;
331
+ try {
332
+ if (!options.teamId)
333
+ throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '必须提供 --team-id。' });
334
+ client = createRemixClient(options);
335
+ const templates = parseProjectTemplates(await client.get('/projects', {
336
+ teamId: options.teamId,
337
+ limit: '100',
338
+ skip: '0',
339
+ }));
340
+ const filtered = options.tag
341
+ ? templates.filter((template) => Object.values(template.tags || {})
342
+ .flatMap((tags) => Array.isArray(tags) ? tags : [])
343
+ .some((tag) => typeof tag === 'string' && tag.toLowerCase().includes(options.tag.toLowerCase())))
344
+ : templates;
345
+ const data = { items: filtered, page: 1, limit: filtered.length, total: filtered.length, hasMore: false };
346
+ if (options.json) {
347
+ console.log(JSON.stringify(resultEnvelope('remix.templates', client, data)));
348
+ }
349
+ else {
350
+ if (!filtered.length)
351
+ console.log('所选团队当前没有可见项目。');
352
+ else
353
+ filtered.forEach((template) => console.log(`${template.id}\t${template.name}`));
354
+ console.log('\n下一步:');
355
+ console.log(` playcraft remix run --team-id ${options.teamId} --template-id <模板ID>`);
356
+ }
357
+ }
358
+ catch (error) {
359
+ handleCommandError('remix.templates', options, client, error);
360
+ }
361
+ }
362
+ async function remixCreate(options) {
363
+ const client = createRemixClient(options);
364
+ const branch = await client.post(`/projects/${encodeURIComponent(options.projectId)}/branches`, {
365
+ name: options.name,
366
+ ref: options.sourceBranch,
367
+ ...(options.teamId ? { teamId: options.teamId } : {}),
368
+ });
369
+ const branchName = branch.name;
370
+ const editorProjectId = options.editorProjectId || await resolveEditorProjectIdBestEffort(client, options.projectId, options);
371
+ return {
372
+ projectId: options.projectId,
373
+ branch: branchName,
374
+ displayName: branch.displayName,
375
+ editorUrl: editorUrl(client, editorProjectId, branchName),
376
+ };
377
+ }
378
+ function resolveEnsureStreamUrl(client, projectId) {
379
+ const endpoint = new URL(client.url(`/projects/${encodeURIComponent(projectId)}/sandbox/ensure-stream`));
380
+ const isLocalFrontend = ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)
381
+ && endpoint.port === '3000';
382
+ if (isLocalFrontend)
383
+ endpoint.port = '3001';
384
+ return endpoint;
63
385
  }
64
386
  async function remixEnsureStream(options) {
65
- const baseUrl = resolveBackendBaseUrl(options);
66
- const token = resolveJwtToken(options);
67
- const endpoint = new URL(`${baseUrl}/api/projects/${encodeURIComponent(options.projectId)}/sandbox/ensure-stream`);
387
+ const client = createRemixClient(options);
388
+ const endpoint = resolveEnsureStreamUrl(client, options.projectId);
68
389
  endpoint.searchParams.set('branch', options.branch);
390
+ if (options.sourceBranch)
391
+ endpoint.searchParams.set('sourceBranch', options.sourceBranch);
69
392
  if (options.evictConfirmed)
70
393
  endpoint.searchParams.set('evictConfirmed', 'true');
71
- const res = await fetch(endpoint.toString(), {
72
- method: 'GET',
73
- headers: {
74
- Authorization: `Bearer ${token}`,
75
- Accept: 'text/event-stream',
76
- },
394
+ if (options.evictSlotId)
395
+ endpoint.searchParams.set('evictSlotId', options.evictSlotId);
396
+ emitProgress(options, '[连接中] 正在连接沙箱服务...');
397
+ const response = await fetch(endpoint, {
398
+ headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' },
77
399
  });
78
- if (!res.ok || !res.body) {
79
- const text = await res.text();
80
- throw new Error(`ensure-stream 失败: ${res.status} ${text}`);
400
+ if (!response.ok || !response.body) {
401
+ throw new PlayCraftApiError({ code: response.status === 401 ? 'AUTH_EXPIRED' : 'AGENT_REQUEST_FAILED', message: `启动沙箱失败,状态码:${response.status}`, status: response.status, retryable: response.status >= 500 });
81
402
  }
82
- const reader = res.body.getReader();
403
+ const reader = response.body.getReader();
83
404
  const decoder = new TextDecoder();
84
405
  let buffer = '';
85
- const parseBlock = (block) => {
86
- const payload = block
87
- .split('\n')
88
- .filter((line) => line.startsWith('data:'))
89
- .map((line) => line.slice(5).trim())
90
- .join('\n');
91
- if (!payload)
406
+ const parseEvent = (block) => {
407
+ const data = block.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('\n');
408
+ if (!data)
92
409
  return null;
93
410
  try {
94
- return JSON.parse(payload);
411
+ return JSON.parse(data);
95
412
  }
96
413
  catch {
97
414
  return null;
@@ -102,134 +419,524 @@ async function remixEnsureStream(options) {
102
419
  if (done)
103
420
  break;
104
421
  buffer += decoder.decode(value, { stream: true });
105
- let sepIdx = buffer.indexOf('\n\n');
106
- while (sepIdx >= 0) {
107
- const block = buffer.slice(0, sepIdx);
108
- buffer = buffer.slice(sepIdx + 2);
109
- const event = parseBlock(block);
110
- if (!event) {
111
- sepIdx = buffer.indexOf('\n\n');
422
+ const frames = buffer.split(/\r?\n\r?\n/);
423
+ buffer = frames.pop() || '';
424
+ for (const frame of frames) {
425
+ const event = parseEvent(frame);
426
+ if (!event)
112
427
  continue;
113
- }
114
- if (event.type === 'phase') {
115
- console.log(`[${event.phase}]${event.detail ? ` ${event.detail}` : ''}`);
116
- }
117
- else if (event.type === 'log') {
118
- console.log(event.message);
119
- }
120
- else if (event.type === 'error') {
121
- throw new Error(event.message || 'ensure-stream 返回 error 事件');
122
- }
428
+ if (event.type === 'phase')
429
+ emitProgress(options, `[${event.phase}]${event.detail ? ` ${event.detail}` : ''}`);
430
+ else if (event.type === 'log')
431
+ emitProgress(options, event.message);
432
+ else if (event.type === 'error')
433
+ throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: event.message });
123
434
  else if (event.type === 'capacity_exceeded') {
124
- const selectableCandidates = event.candidates.filter((c) => c.evictable !== false);
125
- if (!selectableCandidates.length) {
126
- throw new Error(`容量已满 (${event.current}/${event.max}),但服务端未返回可驱逐候选`);
435
+ if (!options.interactive) {
436
+ throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: `沙箱容量已满(${event.current}/${event.max}),请传入 --evict-slot-id 和 --evict-confirmed。`, details: { candidates: event.candidates } });
127
437
  }
128
- const selected = await inquirer.prompt([
129
- {
130
- type: 'list',
131
- name: 'slotId',
132
- message: `沙箱容量已满 (${event.current}/${event.max}),请选择一个候选项继续驱逐:`,
133
- choices: event.candidates.map((c) => ({
134
- name: `${c.slotId} (${c.projectId}/${c.branchName}) status=${c.agentStatus ?? 'unknown'} idle=${formatIdleSec(c.idleSec)}${c.hasUnpushedChanges ? ' [unpushed]' : ''}`,
135
- value: c.slotId,
136
- disabled: c.evictable === false ? (c.disabledReason ?? 'not evictable') : false,
137
- })),
138
- },
139
- ]);
140
- const confirmed = await inquirer.prompt([
141
- {
142
- type: 'confirm',
143
- name: 'ok',
144
- message: `确认继续驱逐并重试? selected=${selected.slotId}`,
145
- default: false,
146
- },
147
- ]);
148
- if (!confirmed.ok) {
149
- throw new Error('用户取消操作');
150
- }
151
- return remixEnsureStream({ ...options, evictConfirmed: true });
438
+ const candidates = event.candidates.filter((candidate) => candidate.evictable !== false);
439
+ if (!candidates.length)
440
+ throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: `沙箱容量已满(${event.current}/${event.max}),没有可释放的槽位。` });
441
+ const selected = await inquirer.prompt([{ type: 'list', name: 'slotId', message: `沙箱容量已满(${event.current}/${event.max}),请选择要释放的槽位:`, choices: event.candidates.map((candidate) => ({ name: `${candidate.slotId}(${candidate.projectId}/${candidate.branchName})状态=${candidate.agentStatus ?? '未知'} 空闲=${formatIdleSec(candidate.idleSec)}`, value: candidate.slotId, disabled: candidate.evictable === false ? candidate.disabledReason || '不可释放' : false })) }]);
442
+ const confirmed = await inquirer.prompt([{ type: 'confirm', name: 'ok', message: `确定释放槽位 ${selected.slotId} 并重试吗?`, default: false }]);
443
+ if (!confirmed.ok)
444
+ throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: '操作已取消。' });
445
+ return remixEnsureStream({ ...options, evictConfirmed: true, evictSlotId: selected.slotId });
152
446
  }
153
447
  else if (event.type === 'done') {
154
448
  return {
155
449
  sandboxId: event.sandboxId,
156
450
  previewUrl: event.previewUrl,
157
451
  agentBaseUrl: event.agentBaseUrl,
452
+ agentEndpoint: event.agentEndpoint,
453
+ agentSessionId: event.agentSessionId,
454
+ codeAgentSlug: event.codeAgentSlug,
455
+ slotId: event.slotId,
456
+ status: event.status,
457
+ };
458
+ }
459
+ }
460
+ }
461
+ throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱启动连接已结束,但沙箱尚未就绪。' });
462
+ }
463
+ function sessionPath(ready) {
464
+ if (!ready.agentEndpoint || !ready.agentSessionId) {
465
+ throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱未返回 Agent 服务地址或当前会话。' });
466
+ }
467
+ return `${ready.agentEndpoint}/session/${encodeURIComponent(ready.agentSessionId)}`;
468
+ }
469
+ async function ensureAgentSession(client, ready, options) {
470
+ if (!ready.agentEndpoint) {
471
+ throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱未返回 Agent 服务地址。' });
472
+ }
473
+ if (ready.agentSessionId)
474
+ return ready;
475
+ const findLatestSession = async () => {
476
+ const sessions = await client.get(`${ready.agentEndpoint}/session`);
477
+ return [...(Array.isArray(sessions) ? sessions : [])]
478
+ .filter((session) => typeof session.id === 'string' && session.id.length > 0)
479
+ .sort((a, b) => (b.time?.created ?? 0) - (a.time?.created ?? 0))[0];
480
+ };
481
+ const latest = await findLatestSession();
482
+ if (latest?.id)
483
+ return { ...ready, agentSessionId: latest.id };
484
+ emitProgress(options, '[会话中] 正在创建 Agent 会话...');
485
+ let created;
486
+ try {
487
+ created = await client.post(`${ready.agentEndpoint}/session`, {}, {
488
+ signal: AbortSignal.timeout(30_000),
489
+ });
490
+ }
491
+ catch (error) {
492
+ emitProgress(options, '[会话中] 创建响应未返回,正在查询已创建的会话...');
493
+ const recovered = await findLatestSession();
494
+ if (recovered?.id)
495
+ return { ...ready, agentSessionId: recovered.id };
496
+ throw error;
497
+ }
498
+ if (!created?.id) {
499
+ throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: 'Agent 会话创建成功,但未返回会话 ID。' });
500
+ }
501
+ return { ...ready, agentSessionId: created.id };
502
+ }
503
+ async function listPendingAgentQuestions(client, ready) {
504
+ if (!ready.agentEndpoint)
505
+ return [];
506
+ try {
507
+ const payload = await client.get(`${ready.agentEndpoint}/question`);
508
+ return parseAgentQuestionRequests(payload)
509
+ .filter((request) => !request.sessionId || request.sessionId === ready.agentSessionId);
510
+ }
511
+ catch (error) {
512
+ if (isPlayCraftApiError(error) && error.status === 404)
513
+ return [];
514
+ throw error;
515
+ }
516
+ }
517
+ async function waitForAgentIdle(client, ready, timeoutMs, options, handledQuestionIds, baselineAssistantState) {
518
+ const path = `${sessionPath(ready).replace(/\/session\/[^/]+$/, '')}/event`;
519
+ const controller = new AbortController();
520
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
521
+ try {
522
+ const response = await fetch(client.url(path), { headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' }, signal: controller.signal });
523
+ if (!response.ok || !response.body)
524
+ throw new Error(`Agent 事件流连接失败,状态码:${response.status}`);
525
+ const reader = response.body.getReader();
526
+ const decoder = new TextDecoder();
527
+ let buffer = '';
528
+ const pendingQuestions = await listPendingAgentQuestions(client, ready);
529
+ for (const request of pendingQuestions) {
530
+ await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
531
+ }
532
+ if (await hasAgentTurnCompleted(client, ready, baselineAssistantState, true))
533
+ return;
534
+ while (true) {
535
+ const { done, value } = await reader.read();
536
+ if (done)
537
+ break;
538
+ buffer += decoder.decode(value, { stream: true });
539
+ const frames = buffer.split(/\r?\n\r?\n/);
540
+ buffer = frames.pop() || '';
541
+ for (const frame of frames) {
542
+ const data = frame.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('\n');
543
+ if (!data)
544
+ continue;
545
+ let event;
546
+ try {
547
+ event = JSON.parse(data);
548
+ }
549
+ catch {
550
+ continue;
551
+ }
552
+ const properties = event?.properties || {};
553
+ const eventSessionId = properties.sessionID || properties.sessionId || properties.session_id;
554
+ if (event?.type === 'question.asked') {
555
+ const questionPayload = Object.keys(properties).length > 0 ? properties : event;
556
+ const [request] = parseAgentQuestionRequests(questionPayload);
557
+ if (request) {
558
+ await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
559
+ }
560
+ continue;
561
+ }
562
+ const idle = event?.type === 'session.idle' || (event?.type === 'session.status' && properties.status?.type === 'idle');
563
+ if (idle
564
+ && (!eventSessionId || eventSessionId === ready.agentSessionId)
565
+ && await hasAgentTurnCompleted(client, ready, baselineAssistantState, false))
566
+ return;
567
+ if (event?.type === 'session.error' && (!eventSessionId || eventSessionId === ready.agentSessionId)) {
568
+ throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: properties.message || 'Agent 会话执行失败。' });
569
+ }
570
+ }
571
+ }
572
+ throw new Error('Agent 事件流已结束,但会话尚未进入空闲状态。');
573
+ }
574
+ catch (error) {
575
+ if (controller.signal.aborted) {
576
+ throw new PlayCraftApiError({ code: 'AGENT_TIMEOUT', message: `Agent 在 ${Math.ceil(timeoutMs / 1000)} 秒内未完成任务。`, retryable: true });
577
+ }
578
+ throw error;
579
+ }
580
+ finally {
581
+ clearTimeout(timer);
582
+ if (!controller.signal.aborted)
583
+ controller.abort();
584
+ }
585
+ }
586
+ async function listAgentMessages(client, ready) {
587
+ const messages = await client.get(`${sessionPath(ready)}/message`);
588
+ return Array.isArray(messages) ? messages : [];
589
+ }
590
+ function assistantState(messages) {
591
+ return JSON.stringify(messages.filter((message) => (message.info?.role || message.role) === 'assistant'));
592
+ }
593
+ async function currentAgentSessionStatus(client, ready) {
594
+ if (!ready.agentEndpoint || !ready.agentSessionId)
595
+ return undefined;
596
+ try {
597
+ const payload = await client.get(`${ready.agentEndpoint}/session/status`);
598
+ const record = payload && typeof payload === 'object' && !Array.isArray(payload)
599
+ ? payload
600
+ : undefined;
601
+ const direct = record?.[ready.agentSessionId];
602
+ if (record && direct === undefined)
603
+ return 'idle';
604
+ const value = direct?.status ?? direct;
605
+ return typeof value?.type === 'string' ? value.type : undefined;
606
+ }
607
+ catch {
608
+ return undefined;
609
+ }
610
+ }
611
+ async function hasAgentTurnCompleted(client, ready, baselineAssistantState, requireKnownIdle) {
612
+ const messages = await listAgentMessages(client, ready);
613
+ if (assistantState(messages) === baselineAssistantState)
614
+ return false;
615
+ const status = await currentAgentSessionStatus(client, ready);
616
+ return status === 'idle' || (!requireKnownIdle && status === undefined);
617
+ }
618
+ async function lastAssistantText(client, ready) {
619
+ const messages = await listAgentMessages(client, ready);
620
+ const assistants = messages.filter((message) => (message.info?.role || message.role) === 'assistant');
621
+ const last = assistants[assistants.length - 1];
622
+ const text = (last?.parts || []).filter((part) => part.type === 'text' && part.text).map((part) => part.text).join('').trim();
623
+ return text || undefined;
624
+ }
625
+ async function remixPrompt(options) {
626
+ const commandName = options.commandName || 'remix.prompt';
627
+ let client;
628
+ try {
629
+ const prompt = readPrompt(options);
630
+ const imageAttachments = readImageAttachments(options.images);
631
+ client = createRemixClient(options);
632
+ const sandbox = await remixEnsureStream({ ...options, interactive: options.interactive ?? false });
633
+ const ready = await ensureAgentSession(client, sandbox, options);
634
+ const session = sessionPath(ready);
635
+ const editorProjectId = await resolveEditorProjectIdBestEffort(client, options.projectId, options);
636
+ const urls = {
637
+ editor: editorUrl(client, editorProjectId, options.branch),
638
+ ...(ready.previewUrl ? { preview: ready.previewUrl } : {}),
639
+ };
640
+ emitProgress(options, `[已就绪] Remix 编辑器地址:${urls.editor}`);
641
+ const pendingQuestions = await listPendingAgentQuestions(client, ready);
642
+ const handledQuestionIds = new Set();
643
+ if (pendingQuestions.length) {
644
+ emitProgress(options, `[恢复中] 检测到 ${pendingQuestions.length} 个待回答问题,不会重复发送原需求。`);
645
+ }
646
+ const wait = options.wait !== false;
647
+ const baselineAssistantState = wait
648
+ ? assistantState(await listAgentMessages(client, ready))
649
+ : undefined;
650
+ let uploadedImageAttachments = [];
651
+ if (pendingQuestions.length) {
652
+ if (imageAttachments.length > 0) {
653
+ emitProgress(options, `[提示] 当前会话有待回答问题,本次 ${imageAttachments.length} 张图片未上传,也不会重复发送原需求。`);
654
+ }
655
+ for (const request of pendingQuestions) {
656
+ await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
657
+ }
658
+ }
659
+ else {
660
+ if (imageAttachments.length > 0) {
661
+ uploadedImageAttachments = await uploadImageAttachments(client, options.projectId, options.branch, imageAttachments);
662
+ }
663
+ const promptToSend = buildReferenceImagesPrompt(prompt, uploadedImageAttachments);
664
+ await client.post(`${session}/prompt_async`, { parts: [{ type: 'text', text: promptToSend }] });
665
+ }
666
+ if (baselineAssistantState !== undefined) {
667
+ await waitForAgentIdle(client, ready, options.timeout * 1000, options, handledQuestionIds, baselineAssistantState);
668
+ }
669
+ const assistantText = wait ? await lastAssistantText(client, ready) : undefined;
670
+ const data = {
671
+ projectId: options.projectId,
672
+ branch: options.branch,
673
+ created: options.created ?? false,
674
+ ...(options.teamId ? { teamId: options.teamId } : {}),
675
+ ...(options.templateId ? { templateId: options.templateId } : {}),
676
+ urls,
677
+ ...(uploadedImageAttachments.length > 0
678
+ ? {
679
+ attachments: {
680
+ images: uploadedImageAttachments.map(({ fileName, sandboxPath }) => ({ fileName, sandboxPath })),
681
+ },
682
+ }
683
+ : imageAttachments.length > 0 && pendingQuestions.length > 0
684
+ ? { attachments: { requestedImages: imageAttachments.length, sentImages: 0, status: 'not_sent_pending_question' } }
685
+ : {}),
686
+ sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' },
687
+ agent: { slug: ready.codeAgentSlug, sessionId: ready.agentSessionId, status: wait ? 'completed' : 'running', ...(assistantText ? { lastAssistantText: assistantText } : {}) },
688
+ };
689
+ if (options.open)
690
+ openBrowser(data.urls.editor);
691
+ if (options.json)
692
+ console.log(JSON.stringify(resultEnvelope(commandName, client, data)));
693
+ else
694
+ console.log(`需求已${wait ? '完成' : '接收'}:${data.urls.editor}`);
695
+ }
696
+ catch (error) {
697
+ handleCommandError(commandName, options, client, error);
698
+ }
699
+ }
700
+ async function remixRun(options) {
701
+ let client;
702
+ try {
703
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !options.json);
704
+ const requestedImageCount = options.images?.length ?? 0;
705
+ if (requestedImageCount > 0 && !options.prompt && !options.promptFile && !interactive) {
706
+ throw new PlayCraftApiError({
707
+ code: 'ATTACHMENT_PROMPT_REQUIRED',
708
+ message: '非交互模式使用 --image 时必须同时提供 --prompt 或 --prompt-file。',
709
+ });
710
+ }
711
+ client = createRemixClient(options);
712
+ let teamId = options.teamId;
713
+ let templateId = options.templateId;
714
+ let selectedTemplate;
715
+ let created = false;
716
+ if (options.projectId && (options.templateId || options.name || options.teamId)) {
717
+ throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '已有 Remix 模式的 --project-id/--branch 不能与 --team-id、--template-id 或 --name 同时使用。' });
718
+ }
719
+ if (!options.projectId) {
720
+ if (!teamId) {
721
+ if (!interactive)
722
+ throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '非交互模式必须提供 --team-id。' });
723
+ const teams = parseList(await client.get('/teams'));
724
+ if (!teams.length)
725
+ throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '当前登录账号没有可用团队。' });
726
+ teamId = (await inquirer.prompt([{ type: 'list', name: 'teamId', message: '请选择团队:', choices: teams.map((team) => ({ name: team.name, value: team.id })) }])).teamId;
727
+ }
728
+ const templates = parseProjectTemplates(await client.get('/projects', {
729
+ teamId,
730
+ limit: '100',
731
+ skip: '0',
732
+ }));
733
+ if (!templates.length)
734
+ throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '所选团队当前没有可见项目。' });
735
+ if (!templateId) {
736
+ if (!interactive)
737
+ throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '非交互模式必须提供 --template-id。' });
738
+ templateId = (await inquirer.prompt([{ type: 'list', name: 'templateId', message: '请选择模板:', choices: templates.map((template) => ({ name: template.name, value: template.id })) }])).templateId;
739
+ }
740
+ selectedTemplate = templates.find((template) => template.id === templateId);
741
+ if (!selectedTemplate)
742
+ throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: `模板 ${templateId} 不存在或对所选团队不可见。` });
743
+ if (!options.name && !interactive)
744
+ throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '非交互模式必须提供 --name。' });
745
+ const name = (options.name || (await inquirer.prompt([{ type: 'input', name: 'name', message: '请输入 Remix 名称:', validate: (value) => Boolean(value.trim()) || 'Remix 名称不能为空。' }])).name).trim();
746
+ const createdRemix = await remixCreate({
747
+ ...options,
748
+ projectId: selectedTemplate.id,
749
+ name,
750
+ sourceBranch: selectedTemplate.sourceBranch || options.sourceBranch,
751
+ teamId,
752
+ editorProjectId: selectedTemplate.projectType === 'external' && selectedTemplate.uuid
753
+ ? selectedTemplate.uuid
754
+ : selectedTemplate.id,
755
+ });
756
+ options.projectId = createdRemix.projectId;
757
+ options.branch = createdRemix.branch;
758
+ created = true;
759
+ const createdRemixDetailUrl = remixDetailUrl(client, createdRemix.projectId, createdRemix.branch);
760
+ if (!options.json) {
761
+ emitProgress(options, `✓ Remix 创建完成:${createdRemix.displayName || name}`);
762
+ emitProgress(options, `Remix 详情地址:${createdRemixDetailUrl}`);
763
+ }
764
+ let prompt;
765
+ if (options.prompt || options.promptFile) {
766
+ prompt = readPrompt(options);
767
+ }
768
+ else if (interactive) {
769
+ const answer = await inquirer.prompt([{
770
+ type: 'input',
771
+ name: 'prompt',
772
+ message: '请输入需求描述(如不需要使用 Agent 生成,请直接回车或输入 exit 退出):',
773
+ }]);
774
+ if (!isAgentPromptExit(answer.prompt))
775
+ prompt = answer.prompt.trim();
776
+ }
777
+ if (!prompt) {
778
+ const data = {
779
+ projectId: createdRemix.projectId,
780
+ branch: createdRemix.branch,
781
+ created: true,
782
+ teamId,
783
+ templateId,
784
+ urls: {
785
+ remix: createdRemixDetailUrl,
786
+ editor: createdRemix.editorUrl,
787
+ },
788
+ ...(requestedImageCount > 0
789
+ ? { attachments: { requestedImages: requestedImageCount, sentImages: 0, status: 'not_sent' } }
790
+ : {}),
791
+ agent: { started: false },
158
792
  };
793
+ if (options.json)
794
+ console.log(JSON.stringify(resultEnvelope('remix.run', client, data)));
795
+ else
796
+ emitProgress(options, requestedImageCount > 0
797
+ ? '已结束:Remix 已创建,未启动 Agent;所选图片未上传。'
798
+ : '已结束:Remix 已创建,未启动 Agent。');
799
+ return;
159
800
  }
160
- sepIdx = buffer.indexOf('\n\n');
801
+ await remixPrompt({ ...options, promptFile: undefined, projectId: options.projectId, branch: options.branch, prompt, wait: options.wait, timeout: options.timeout, open: options.open, commandName: 'remix.run', created, teamId, templateId, interactive });
802
+ return;
161
803
  }
804
+ if (!options.branch)
805
+ throw new PlayCraftApiError({ code: 'REMIX_NOT_FOUND', message: '已有 Remix 模式必须同时提供 --project-id 和 --branch。' });
806
+ const prompt = options.prompt || options.promptFile
807
+ ? readPrompt(options)
808
+ : interactive
809
+ ? (await inquirer.prompt([{ type: 'input', name: 'prompt', message: '请输入需求描述:', validate: (value) => Boolean(value.trim()) || '需求描述不能为空。' }])).prompt.trim()
810
+ : readPrompt(options);
811
+ await remixPrompt({ ...options, promptFile: undefined, projectId: options.projectId, branch: options.branch, prompt, wait: options.wait, timeout: options.timeout, open: options.open, commandName: 'remix.run', created, teamId, templateId, interactive });
812
+ }
813
+ catch (error) {
814
+ handleCommandError('remix.run', options, client, error);
162
815
  }
163
- throw new Error('ensure-stream 结束但未收到 done 事件');
164
816
  }
165
817
  export function registerRemixCommands(program) {
818
+ const helpTitles = {
819
+ 'Usage:': '用法:',
820
+ 'Arguments:': '参数:',
821
+ 'Options:': '选项:',
822
+ 'Global Options:': '全局选项:',
823
+ 'Commands:': '命令:',
824
+ };
166
825
  const remix = program
167
826
  .command('remix')
168
- .description('Remix 工作流命令(JWT 鉴权,调用用户态 API)');
169
- remix
170
- .command('init-template')
171
- .description('Apply engine scaffold (phaser|threejs) to project or bake full templates (maintainer)')
172
- .option('--engine <name>', 'Engine adapter: phaser | threejs')
173
- .option('--from-atom-tree', 'Read adapter from docs/atom-tree.json meta.engineDecision')
174
- .option('--project-dir <path>', 'Project root', process.cwd())
175
- .option('--force', 'Replace game/ even when adapter mismatch')
176
- .option('--bake', 'Maintainer: write packages/cli/project-templates/{engine}/')
177
- .option('--all-engines', 'With --bake: bake phaser and threejs')
178
- .action(async (opts) => {
179
- await initTemplateCommand({
180
- engine: opts.engine,
181
- fromAtomTree: Boolean(opts.fromAtomTree),
182
- projectDir: String(opts.projectDir || process.cwd()),
183
- force: Boolean(opts.force),
184
- bake: Boolean(opts.bake),
185
- allEngines: Boolean(opts.allEngines),
186
- });
187
- });
188
- remix
189
- .command('create')
190
- .description('创建 remix 分支(POST /api/projects/:id/branches)')
827
+ .description('Remix 工作流命令')
828
+ .helpOption('-h, --help', '显示帮助信息')
829
+ .helpCommand('help [command]', '显示指定命令的帮助信息')
830
+ .configureHelp({ styleTitle: (title) => helpTitles[title] || title });
831
+ remix.command('init-template')
832
+ .description('应用引擎脚手架或烘焙模板')
833
+ .option('--engine <name>', '引擎适配器:phaser | threejs')
834
+ .option('--from-atom-tree', '从 docs/atom-tree.json 读取引擎配置')
835
+ .option('--project-dir <path>', '项目根目录', process.cwd())
836
+ .option('--force', '引擎适配器不一致时仍替换 game/ 目录')
837
+ .option('--bake', '烘焙维护中的项目模板')
838
+ .option('--all-engines', '烘焙所有引擎模板')
839
+ .action(async (opts) => initTemplateCommand({ engine: opts.engine, fromAtomTree: Boolean(opts.fromAtomTree), projectDir: String(opts.projectDir || process.cwd()), force: Boolean(opts.force), bake: Boolean(opts.bake), allEngines: Boolean(opts.allEngines) }));
840
+ remix.command('teams')
841
+ .description('列出当前登录账号可用的团队')
842
+ .option('--url <url>', SERVICE_ADDRESS_HELP)
843
+ .option('--token <token>', PAT_HELP)
844
+ .option('--json', JSON_OUTPUT_HELP)
845
+ .action((opts) => remixTeams(opts));
846
+ remix.command('templates')
847
+ .description('列出项目页中指定团队可见的项目模板')
848
+ .requiredOption('--team-id <id>', '团队 ID (Identifier, 标识符)')
849
+ .option('--tag <filter>', '按标签筛选')
850
+ .option('--url <url>', SERVICE_ADDRESS_HELP)
851
+ .option('--token <token>', PAT_HELP)
852
+ .option('--json', JSON_OUTPUT_HELP)
853
+ .action((opts) => remixTemplates(opts));
854
+ remix.command('create')
855
+ .description('创建真实的 Remix 分支')
191
856
  .requiredOption('--project-id <id>', '项目 ID')
192
- .requiredOption('--name <name>', 'remix 分支名(或展示名)')
857
+ .requiredOption('--name <name>', 'Remix 展示名称')
858
+ .option('--team-id <id>', '所选团队 ID')
193
859
  .option('--source-branch <branch>', '源分支', 'main')
194
- .option('--url <url>', '后端基址(默认取登录配置 / PLAYCRAFT_URL)')
195
- .option('--token <jwt>', 'JWT token(默认取登录配置 / PLAYCRAFT_TOKEN)')
860
+ .option('--url <url>', SERVICE_ADDRESS_HELP)
861
+ .option('--token <token>', PAT_HELP)
862
+ .option('--json', JSON_OUTPUT_HELP)
196
863
  .action(async (opts) => {
197
- await remixCreate({
198
- projectId: String(opts.projectId),
199
- name: String(opts.name),
200
- sourceBranch: String(opts.sourceBranch || 'main'),
201
- url: opts.url,
202
- token: opts.token,
203
- });
864
+ let client;
865
+ try {
866
+ client = createRemixClient(opts);
867
+ const data = await remixCreate({ ...opts, projectId: String(opts.projectId), name: String(opts.name), sourceBranch: String(opts.sourceBranch || 'main') });
868
+ if (opts.json)
869
+ console.log(JSON.stringify(resultEnvelope('remix.create', client, data)));
870
+ else
871
+ console.log(`Remix 创建成功:${data.editorUrl}`);
872
+ }
873
+ catch (error) {
874
+ handleCommandError('remix.create', opts, client, error);
875
+ }
204
876
  });
205
- remix
206
- .command('start')
207
- .description('启动/复用 remix 沙箱(GET ensure-stream SSE)')
877
+ remix.command('start')
878
+ .description('启动或复用 Remix 沙箱')
208
879
  .requiredOption('--project-id <id>', '项目 ID')
209
- .requiredOption('--branch <name>', '分支名(如 remix/my-branch)')
210
- .option('--evict-confirmed', '携带 evictConfirmed=true 重试')
211
- .option('--open', 'ready 后自动打开预览地址')
212
- .option('--url <url>', '后端基址(默认取登录配置 / PLAYCRAFT_URL)')
213
- .option('--token <jwt>', 'JWT token(默认取登录配置 / PLAYCRAFT_TOKEN)')
880
+ .requiredOption('--branch <name>', 'Remix 分支')
881
+ .option('--source-branch <branch>', '分支不存在时使用的源分支')
882
+ .option('--evict-slot-id <id>', '容量已满时要释放的槽位 ID')
883
+ .option('--evict-confirmed', '确认释放指定槽位')
884
+ .option('--open', '沙箱就绪后打开编辑器地址')
885
+ .option('--url <url>', SERVICE_ADDRESS_HELP)
886
+ .option('--token <token>', PAT_HELP)
887
+ .option('--json', JSON_OUTPUT_HELP)
214
888
  .action(async (opts) => {
215
- const ready = await remixEnsureStream({
216
- projectId: String(opts.projectId),
217
- branch: String(opts.branch),
218
- evictConfirmed: Boolean(opts.evictConfirmed),
219
- url: opts.url,
220
- token: opts.token,
221
- });
222
- const ttydUrl = getTtydUrl(ready.agentBaseUrl);
223
- console.log('Sandbox ready:');
224
- console.log(`- sandboxId: ${ready.sandboxId}`);
225
- if (ready.previewUrl)
226
- console.log(`- previewUrl: ${ready.previewUrl}`);
227
- if (ready.agentBaseUrl)
228
- console.log(`- agentBaseUrl: ${ready.agentBaseUrl}`);
229
- if (ttydUrl)
230
- console.log(`- ttydUrl: ${ttydUrl}`);
231
- if (opts.open && ready.previewUrl) {
232
- openBrowser(ready.previewUrl);
889
+ let client;
890
+ try {
891
+ client = createRemixClient(opts);
892
+ const ready = await remixEnsureStream({ ...opts, projectId: String(opts.projectId), branch: String(opts.branch), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json) });
893
+ const editorProjectId = await resolveEditorProjectIdBestEffort(client, String(opts.projectId), opts);
894
+ const data = { projectId: String(opts.projectId), branch: String(opts.branch), urls: { editor: editorUrl(client, editorProjectId, String(opts.branch)), ...(ready.previewUrl ? { preview: ready.previewUrl } : {}) }, sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' }, agent: { slug: ready.codeAgentSlug, sessionId: ready.agentSessionId } };
895
+ if (opts.open)
896
+ openBrowser(data.urls.editor);
897
+ if (opts.json)
898
+ console.log(JSON.stringify(resultEnvelope('remix.start', client, data)));
899
+ else
900
+ console.log(`沙箱已就绪:${data.urls.editor}`);
901
+ }
902
+ catch (error) {
903
+ handleCommandError('remix.start', opts, client, error);
233
904
  }
234
905
  });
906
+ remix.command('prompt')
907
+ .description('向当前 Remix 沙箱会话发送需求和参考图片')
908
+ .requiredOption('--project-id <id>', '项目 ID')
909
+ .requiredOption('--branch <name>', 'Remix 分支')
910
+ .option('--prompt <text>', '直接传入需求描述')
911
+ .option('--prompt-file <path>', '包含需求描述的 UTF-8 (Unicode Transformation Format 8-bit, Unicode 八位转换格式) 文件')
912
+ .option('--image <path...>', IMAGE_OPTION_HELP)
913
+ .option('--wait', '等待会话执行完成(默认)')
914
+ .option('--no-wait', '需求被接收后立即返回')
915
+ .option('--timeout <seconds>', '等待超时秒数', '1800')
916
+ .option('--evict-slot-id <id>', '容量已满时要释放的槽位 ID')
917
+ .option('--evict-confirmed', '确认释放指定槽位')
918
+ .option('--open', '执行完成后打开编辑器地址')
919
+ .option('--url <url>', SERVICE_ADDRESS_HELP)
920
+ .option('--token <token>', PAT_HELP)
921
+ .option('--json', JSON_OUTPUT_HELP)
922
+ .action((opts) => remixPrompt({ ...opts, images: opts.image, projectId: String(opts.projectId), branch: String(opts.branch), timeout: Math.max(1, Number(opts.timeout) || 1800), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json) }));
923
+ remix.command('run')
924
+ .description('选择模板并创建 Remix,可选向 Agent 发送需求和参考图片')
925
+ .option('--team-id <id>', '所选团队 ID')
926
+ .option('--template-id <id>', '项目模板 ID')
927
+ .option('--project-id <id>', '已有 Remix 的项目 ID')
928
+ .option('--branch <name>', '已有 Remix 的分支')
929
+ .option('--name <name>', '新 Remix 的展示名称')
930
+ .option('--source-branch <branch>', '项目未配置默认分支时使用的源分支', 'main')
931
+ .option('--prompt <text>', '直接传入需求描述')
932
+ .option('--prompt-file <path>', '包含需求描述的 UTF-8 (Unicode Transformation Format 8-bit, Unicode 八位转换格式) 文件')
933
+ .option('--image <path...>', IMAGE_OPTION_HELP)
934
+ .option('--wait', '等待会话执行完成(默认)')
935
+ .option('--no-wait', '需求被接收后立即返回')
936
+ .option('--timeout <seconds>', '等待超时秒数', '1800')
937
+ .option('--open', '执行完成后打开编辑器地址')
938
+ .option('--url <url>', SERVICE_ADDRESS_HELP)
939
+ .option('--token <token>', PAT_HELP)
940
+ .option('--json', JSON_OUTPUT_HELP)
941
+ .action((opts) => remixRun({ ...opts, images: opts.image, timeout: Math.max(1, Number(opts.timeout) || 1800) }));
235
942
  }