@enter-pro/enter-cli 0.4.0 → 0.4.2

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,10 +1,16 @@
1
1
  import { Command } from "commander";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { writeFileSync, readFileSync } from "fs";
3
4
  import * as client from "../client.js";
4
- import { print, printMessage, printResult, printTable, pickList, getFormat } from "../output.js";
5
+ import { print, printMessage, printResult, printTable, pick, pickList, getFormat } from "../output.js";
5
6
  import { pollUntil, TimeoutError } from "../poll.js";
6
7
  import { resolveLifecycleStatus } from "../lifecycle.js";
8
+ import { ThreadEvents, isStateEvent } from "../thread-events.js";
9
+ import { safeOutput } from "../safe-output.js";
10
+ import { errorEnvelope } from "../errors.js";
11
+ import { registerThreadTasks } from "./thread-tasks.js";
7
12
  export const threadCmd = new Command("thread").description("Manage project threads and chat");
13
+ registerThreadTasks(threadCmd);
8
14
  // A turn has reached a terminal state when it appears in this set; everything
9
15
  // else (running/pending/queued/agent_start/agent_running/...) means in-flight.
10
16
  const TERMINAL_TURN_STATUSES = new Set(["completed", "cancelled", "error", "failed"]);
@@ -21,22 +27,45 @@ function normalizeAction(raw) {
21
27
  updated_at: String(raw.updated_at ?? raw.UpdatedAt ?? ""),
22
28
  };
23
29
  }
24
- async function loadToolCallArgs(projectId, action, toolName) {
30
+ async function loadToolCallArgs(projectId, action, toolName, context = {}) {
25
31
  const turn = String(action.turn);
26
- const data = await client.get(`/v1/projects/${projectId}/thread/messages`, { start_turn: turn, end_turn: turn });
32
+ const data = await (context.messages ??= client.get(`/v1/projects/${projectId}/thread/messages`, { start_turn: turn, end_turn: turn }, context.signal));
33
+ // Prefer completed arguments, but cards can exist before tool_call_end.
34
+ // Stream deltas may split JSON across tool_call_arguments events.
35
+ const candidates = new Map();
36
+ const names = new Map();
27
37
  for (const e of data.messages ?? []) {
28
- if (e.message_type !== "tool_call_end")
38
+ const type = String(e.message_type ?? "");
39
+ if (!["tool_call_start", "tool_call_arguments", "tool_call_arguments_start", "tool_call_arguments_delta", "tool_call_arguments_end", "tool_call_end"].includes(type))
29
40
  continue;
30
- const detail = e.detail?.tool_call_end;
31
- if (!detail || detail.tool_name !== toolName)
41
+ const detail = e.detail?.[type];
42
+ if (!detail)
32
43
  continue;
33
- // When the action carries a tool_call_id, prefer the matching event so we
34
- // don't pick up a stale invocation from an earlier round in the same turn.
35
- if (action.tool_call_id && detail.tool_call_id !== action.tool_call_id)
44
+ const callId = String(detail.tool_call_id ?? "");
45
+ if (detail.tool_name)
46
+ names.set(callId, String(detail.tool_name));
47
+ if (names.get(callId) !== toolName && !(action.tool_call_id && action.tool_call_id === callId && !detail.tool_name))
36
48
  continue;
37
- const raw = String(detail.full_arguments ?? "");
49
+ if (action.tool_call_id && callId !== action.tool_call_id)
50
+ continue;
51
+ const raw = String(detail.full_arguments ?? detail.accumulated_arguments ?? detail.arguments_delta ?? detail.arguments ?? detail.tool_call_args ?? "");
52
+ if (!raw)
53
+ continue;
54
+ if (detail.full_arguments !== undefined || detail.accumulated_arguments !== undefined)
55
+ candidates.set(callId, raw);
56
+ else if (type === "tool_call_arguments" || type === "tool_call_arguments_delta" || detail.arguments_delta !== undefined)
57
+ candidates.set(callId, (candidates.get(callId) ?? "") + raw);
58
+ else
59
+ candidates.set(callId, raw);
60
+ }
61
+ if (candidates.size > 1 && !action.tool_call_id)
62
+ return { kind: "not_found" };
63
+ for (const raw of candidates.values()) {
38
64
  try {
39
- return { kind: "ok", value: JSON.parse(raw) };
65
+ const parsed = JSON.parse(raw);
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
67
+ return { kind: "parse_error", raw_arguments: "Expected an argument object" };
68
+ return { kind: "ok", value: parsed };
40
69
  }
41
70
  catch {
42
71
  return { kind: "parse_error", raw_arguments: raw };
@@ -51,8 +80,8 @@ const TOOL_HANDLERS = {
51
80
  kind: "questions",
52
81
  approveSuffix: (pid, a) => `--answers '<json>' OR enter-cli thread approve ${pid} ${a.action_id} --skip-answers`,
53
82
  instructions: "Questions are included in this action's `questions` field. Forward each question (with its options and multiSelect) to the user, collect answers, then run approve_command --answers '<json>' (or --skip-answers).",
54
- enrich: async (projectId, action) => {
55
- const args = await loadToolCallArgs(projectId, action, "ask_user_question");
83
+ enrich: async (projectId, action, context) => {
84
+ const args = await loadToolCallArgs(projectId, action, "ask_user_question", context);
56
85
  switch (args.kind) {
57
86
  case "ok":
58
87
  return { questions: args.value.questions ?? [] };
@@ -65,13 +94,24 @@ const TOOL_HANDLERS = {
65
94
  },
66
95
  supabase_add_secret: {
67
96
  kind: "secret",
68
- approveSuffix: () => `--secret-name <NAME> --secret-value <VALUE>`,
69
- instructions: "Ask the user for the secret name and value, then substitute into approve_command.",
97
+ approveSuffix: () => `--secret-name <NAME> --secret-value-stdin`,
98
+ instructions: "Use a secure input surface for the secret name and value; direct CLI callers can use stdin. Never request or echo credentials in ordinary chat.",
99
+ },
100
+ supabase_configure_auth_provider: {
101
+ kind: "auth_provider",
102
+ approveSuffix: () => "",
103
+ instructions: "Configure the requested provider through Enter's secure form, then approve. For direct CLI use, --auth-config-stdin accepts provider configuration JSON through stdin. Never collect credentials in ordinary chat. Approval verifies saved configuration with the backend.",
104
+ enrich: async (projectId, action, context) => ({ provider: await authProviderFor(projectId, action, context) }),
70
105
  },
71
106
  stripe_enable: {
72
107
  kind: "secret",
73
- approveSuffix: () => `--secret-name STRIPE_SECRET_KEY --secret-value <sk_...>`,
74
- instructions: "Ask the user for the secret name and value, then substitute into approve_command.",
108
+ approveSuffix: () => `--secret-value-stdin`,
109
+ instructions: "Use a secure input surface or direct CLI stdin for the Stripe key. Never request credentials in ordinary chat.",
110
+ },
111
+ stripe_update_key_and_migrate: {
112
+ kind: "secret",
113
+ approveSuffix: () => `--secret-value-stdin`,
114
+ instructions: "Product IDs come from the pending tool call. Supply the new Stripe key through a secure input surface or direct CLI stdin, never ordinary chat.",
75
115
  },
76
116
  // confirm_plan_mode: surface the plan text directly on the action so callers
77
117
  // don't have to fetch + parse thread messages themselves.
@@ -79,8 +119,8 @@ const TOOL_HANDLERS = {
79
119
  kind: "none",
80
120
  approveSuffix: () => "",
81
121
  instructions: "Plan is included in this action's `plan` field. Show it to the user, then run approve_command.",
82
- enrich: async (projectId, action) => {
83
- const args = await loadToolCallArgs(projectId, action, "confirm_plan_mode");
122
+ enrich: async (projectId, action, context) => {
123
+ const args = await loadToolCallArgs(projectId, action, "confirm_plan_mode", context);
84
124
  switch (args.kind) {
85
125
  case "ok": {
86
126
  const v = args.value;
@@ -102,8 +142,42 @@ const TOOL_HANDLERS = {
102
142
  const DEFAULT_HANDLER = {
103
143
  kind: "none",
104
144
  approveSuffix: () => "",
105
- instructions: "No input required. Run the approve_command as-is.",
145
+ instructions: "No additional input required. Approve only within the user-authorized scope; input_kind none does not itself grant authorization.",
106
146
  };
147
+ const FEATURE_ENABLE_ROUTES = new Map([
148
+ ["supabase_enable", "entercloud/enable"],
149
+ ["enable_analytics", "analytics/enable"],
150
+ ["i18n_enable", "i18n/enable"],
151
+ ["enable_ai_capability", "ai-capability/connect"],
152
+ ]);
153
+ const AUTH_PROVIDER_FIELDS = {
154
+ google: ["enabled", "client_ids", "client_secret", "skip_nonce_checks"],
155
+ wechat: ["enabled", "client_id", "client_secret"],
156
+ alipay: ["enabled", "app_id", "private_key"],
157
+ feishu: ["enabled", "app_id", "app_secret"],
158
+ };
159
+ async function authProviderFor(projectId, action, context = {}) {
160
+ if (!action.tool_call_id)
161
+ throw new Error("Auth provider action is missing its tool_call_id");
162
+ const args = await loadToolCallArgs(projectId, action, action.tool_name, context);
163
+ const provider = args.kind === "ok" ? args.value?.provider : undefined;
164
+ if (typeof provider !== "string" || !Object.hasOwn(AUTH_PROVIDER_FIELDS, provider)) {
165
+ throw new Error("Unable to resolve the requested auth provider from the matching tool call");
166
+ }
167
+ return provider;
168
+ }
169
+ function parseObjectJSON(value, option) {
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(value);
173
+ }
174
+ catch {
175
+ throw new Error(`${option} must contain a JSON object`);
176
+ }
177
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
178
+ throw new Error(`${option} must contain a JSON object`);
179
+ return parsed;
180
+ }
107
181
  function handlerFor(toolName) {
108
182
  return TOOL_HANDLERS[toolName] ?? DEFAULT_HANDLER;
109
183
  }
@@ -112,21 +186,31 @@ function buildApproveCommand(projectId, action) {
112
186
  const suffix = handlerFor(action.tool_name).approveSuffix(projectId, action);
113
187
  return suffix ? `${base} ${suffix}` : base;
114
188
  }
115
- async function fetchPendingActions(projectId) {
116
- const data = await client.post(`/v1/projects/${projectId}/thread/actions`, {});
189
+ async function fetchPendingActions(projectId, actionIds = [], signal) {
190
+ return (await fetchActions(projectId, actionIds, signal)).filter((a) => a.status === "waiting_response");
191
+ }
192
+ async function fetchActions(projectId, actionIds = [], signal) {
193
+ const body = actionIds.length > 0 ? { actions: actionIds } : {};
194
+ const data = await client.post(`/v1/projects/${projectId}/thread/actions`, body, signal);
117
195
  const resp = data;
118
- const all = (resp.actions || []).map(normalizeAction);
119
- return all.filter((a) => a.status === "waiting_response");
196
+ return (resp.actions || []).map(normalizeAction);
120
197
  }
121
198
  threadCmd
122
199
  .command("chat <project_id>")
123
- .description("Send a chat message to project thread")
200
+ .description("Submit a message immediately, including while a turn is running. Enter may queue a new turn or interject into the active turn; accepted does not mean applied.")
124
201
  .option("-m, --message <text>", "Chat message content")
125
202
  .option("--file <path>", "Read message content from a file (for long or multi-line messages)")
203
+ .option("--stdin", "Read message from stdin, avoiding shell quoting and process arguments")
204
+ .option("--chat-id <id>", "Target a specific chat")
126
205
  .option("--auto-approve", "Pass auto_approve flag to the server")
127
206
  .action(async (id, opts, cmd) => {
128
207
  let content;
129
- if (opts.file) {
208
+ if ([opts.file !== undefined, opts.message !== undefined, Boolean(opts.stdin)].filter(Boolean).length !== 1)
209
+ throw new Error("Provide exactly one of --message, --file or --stdin");
210
+ if (opts.stdin) {
211
+ content = readFileSync(0, "utf-8");
212
+ }
213
+ else if (opts.file) {
130
214
  content = readFileSync(opts.file, "utf-8");
131
215
  }
132
216
  else if (opts.message) {
@@ -136,11 +220,19 @@ threadCmd
136
220
  console.error("Error: either -m <text> or --file <path> is required");
137
221
  process.exit(1);
138
222
  }
223
+ if (!content.trim())
224
+ throw new Error("Message must not be empty");
139
225
  const body = { prompt: content, attachments: [] };
140
226
  if (opts.autoApprove)
141
227
  body.auto_approve = true;
228
+ if (opts.chatId)
229
+ body.chat_id = opts.chatId;
142
230
  const data = await client.post(`/v1/projects/${id}/thread/chat`, body);
143
- print(getFormat(cmd), data);
231
+ print(getFormat(cmd), {
232
+ ...data,
233
+ submission_status: "accepted",
234
+ ...threadInteraction(id, { taskId: String(data.task_id ?? ""), chatId: opts.chatId }),
235
+ });
144
236
  });
145
237
  threadCmd
146
238
  .command("messages <project_id>")
@@ -150,7 +242,11 @@ threadCmd
150
242
  .option("--turn <n>", "Get messages for a specific turn (shorthand for --start-turn N --end-turn N)")
151
243
  .option("--latest", "Get messages from the most recent turn (most common usage)")
152
244
  .option("--tail <n>", "Get messages from the last N turns")
153
- .option("--follow", "Follow the latest turn in real-time via WebSocket")
245
+ .option("--follow", "Stream NDJSON events over WebSocket; no automatic approval")
246
+ .option("--cursor <id>", "Resume after a previously returned event ID")
247
+ .option("--chat-id <id>", "Scope the stream to a chat")
248
+ .option("--timeout <seconds>", "Bound --follow lifetime", "60")
249
+ .option("--max-events <n>", "Stop --follow after N events")
154
250
  .action(async (id, opts, cmd) => {
155
251
  if (opts.follow) {
156
252
  await followThreadStream(id, opts);
@@ -197,66 +293,45 @@ threadCmd
197
293
  const data = await client.get(`/v1/projects/${id}/thread/messages`, params);
198
294
  print(getFormat(cmd), data);
199
295
  });
200
- async function followThreadStream(projectId, _opts) {
201
- const wsBase = (await import("../config.js")).baseURL()
202
- .replace(/^https?:\/\//, "ws://")
203
- .replace(/^wss?:\/\//, "ws://");
204
- const { getToken } = await import("../auth.js");
205
- const url = `${wsBase}/v1/projects/${projectId}/thread/stream`;
206
- const token = getToken();
207
- // Use native WebSocket available in Node 22+ or ws package fallback
208
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
- let WS;
296
+ async function followThreadStream(projectId, opts) {
297
+ const timeout = positiveNumber(opts.timeout ?? "60", "--timeout") * 1000;
298
+ if (timeout > 2147483647)
299
+ throw new Error("--timeout is too large");
300
+ const turn = opts.turn ? positiveNumber(opts.turn, "--turn", true) : undefined;
301
+ if (opts.cursor && !/^\d+-\d+$/.test(opts.cursor))
302
+ throw new Error("--cursor must be an event ID such as 123-0");
303
+ const maxEvents = opts.maxEvents ? positiveNumber(opts.maxEvents, "--max-events", true) : Infinity;
304
+ const controller = new AbortController();
305
+ const timer = setTimeout(() => controller.abort(), timeout);
306
+ const abort = () => controller.abort();
307
+ process.once("SIGINT", abort);
308
+ process.once("SIGTERM", abort);
309
+ let count = 0;
310
+ let stream;
210
311
  try {
211
- WS = WebSocket;
212
- }
213
- catch {
214
- try {
215
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
216
- // @ts-ignore -- optional peer dependency
217
- const mod = await import("ws");
218
- WS = mod.default;
219
- }
220
- catch {
221
- console.error("Error: WebSocket not available. Node 22+ required or install 'ws' package.");
222
- process.exit(1);
312
+ stream = new ThreadEvents(projectId, {
313
+ signal: controller.signal, turn,
314
+ chatId: opts.chatId, cursor: opts.cursor,
315
+ onMode: mode => console.error(`[events] ${mode}`),
316
+ onEvent: event => {
317
+ process.stdout.write(JSON.stringify(safeOutput(event)) + "\n");
318
+ if (++count >= maxEvents)
319
+ controller.abort();
320
+ },
321
+ });
322
+ while (!controller.signal.aborted) {
323
+ if (stream.failure)
324
+ throw stream.failure;
325
+ await stream.wait(stream.revision, Math.min(timeout, 30000));
223
326
  }
224
327
  }
225
- const wsUrl = token ? `${url}?token=${encodeURIComponent(token)}` : url;
226
- const ws = new WS(wsUrl, {
227
- headers: token ? { Authorization: `Bearer ${token}` } : {},
228
- });
229
- const terminalTypes = new Set([
230
- "turn_end", "turn_cancelled", "turn_error", "build_end",
231
- ]);
232
- let connected = false;
233
- ws.on("open", () => {
234
- connected = true;
235
- console.error("[follow] WebSocket connected, streaming messages...");
236
- });
237
- ws.on("message", (data) => {
238
- try {
239
- const msg = JSON.parse(data.toString());
240
- process.stdout.write(JSON.stringify(msg) + "\n");
241
- if (typeof msg.message_type === "string" && terminalTypes.has(msg.message_type)) {
242
- console.error(`[follow] Turn ended (${msg.message_type}), closing.`);
243
- ws.close();
244
- }
245
- }
246
- catch {
247
- process.stdout.write(data.toString() + "\n");
248
- }
249
- });
250
- ws.on("error", (err) => {
251
- console.error(`[follow] WebSocket error: ${err.message}`);
252
- if (!connected)
253
- process.exit(1);
254
- });
255
- ws.on("close", () => {
256
- console.error("[follow] Connection closed.");
257
- });
258
- // Keep process alive until ws closes
259
- await new Promise((resolve) => ws.on("close", resolve));
328
+ finally {
329
+ stream?.close();
330
+ clearTimeout(timer);
331
+ process.removeListener("SIGINT", abort);
332
+ process.removeListener("SIGTERM", abort);
333
+ console.error(`[events] stopped; events=${count}; cursor=${stream?.cursor ?? "none"}; reason=${stream?.reason ?? "observation_ended"}`);
334
+ }
260
335
  }
261
336
  threadCmd
262
337
  .command("turns <project_id>")
@@ -266,7 +341,7 @@ threadCmd
266
341
  const data = await client.get(`/v1/projects/${id}/thread/turns`);
267
342
  const resp = data;
268
343
  const turns = resp.turns || [];
269
- const items = opts.latest ? turns.slice(-1) : turns;
344
+ const items = opts.latest ? turns.slice(0, 1) : turns;
270
345
  const picked = pickList(items, [
271
346
  "id", "turn", "turn_name", "status", "model", "credits_consumed", "created_at",
272
347
  ]);
@@ -285,96 +360,230 @@ threadCmd
285
360
  ]);
286
361
  printTable(["Turn", "Name", "Status", "Model", "Credits", "Created"], rows);
287
362
  });
288
- threadCmd
289
- .command("wait <project_id>")
290
- .description("Wait until the latest turn reaches a terminal state. Exits with status: completed | blocked | failed.")
291
- .option("--timeout <seconds>", "Timeout in seconds", "300")
292
- .action(async (id, opts, cmd) => {
293
- const timeoutMs = parseInt(opts.timeout, 10) * 1000;
294
- console.error("Waiting for turn to complete...");
295
- let lastTurn = null;
296
- try {
297
- const turns = await pollUntil(async () => {
298
- const data = await client.get(`/v1/projects/${id}/thread/turns`);
299
- const resp = data;
300
- return resp.turns || [];
301
- }, (turns) => {
302
- if (turns.length === 0)
303
- return false;
304
- // turns are returned newest-first.
305
- const latest = turns[0];
306
- return TERMINAL_TURN_STATUSES.has(String(latest.status));
307
- }, {
308
- intervalMs: 2000,
309
- timeoutMs,
310
- onTick: (elapsed) => {
311
- process.stderr.write(`\rWaiting for turn... ${Math.round(elapsed / 1000)}s elapsed`);
312
- },
313
- });
314
- process.stderr.write("\n");
315
- lastTurn = turns[0] ?? null;
363
+ export function threadInteraction(id, { taskId, chatId, turn, requireBuild } = {}) {
364
+ const target = (taskId ? ` --task-id ${taskId}` : turn === undefined ? "" : ` --turn ${turn}`)
365
+ + (chatId ? ` --chat-id ${chatId}` : "") + (requireBuild ? " --require-build" : "");
366
+ return {
367
+ monitoring_required: true,
368
+ wait_command: `enter-cli --output json thread wait ${id}${target}`,
369
+ watch_command: `enter-cli thread watch ${id}${target}`,
370
+ status_command: `enter-cli --output json thread status ${id}${target}`,
371
+ follow_up_command: `enter-cli --output json thread chat ${id}${chatId ? ` --chat-id ${chatId}` : ""} --file <message-file>`,
372
+ instructions: "Accepted or timeout is not completion. Follow this task until blocked, failed or completed. Forward questions unchanged; approve only within existing authorization. Watching never approves or cancels remote work. Hosts own background scheduling.",
373
+ };
374
+ }
375
+ function positiveNumber(value, option, integer = false) {
376
+ const number = Number(value);
377
+ if (!Number.isFinite(number) || number <= 0 || (integer && !Number.isSafeInteger(number))) {
378
+ throw new Error(`${option} must be a positive ${integer ? "integer" : "number"}`);
316
379
  }
317
- catch (err) {
318
- if (err instanceof TimeoutError) {
319
- console.error(`\nTimed out after ${opts.timeout}s waiting for the turn to terminate`);
320
- process.exit(1);
321
- }
322
- throw err;
380
+ return number;
381
+ }
382
+ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
383
+ const data = await client.get(`/v1/projects/${id}/thread/turns`, chatId ? { chat_id: chatId } : undefined, signal);
384
+ const turns = data.turns ?? [];
385
+ const selected = taskId ? turns.find(t => t.id === taskId || t.task_id === taskId)
386
+ : turnNumber === undefined ? turns[0] : turns.find(t => Number(t.turn) === turnNumber);
387
+ if (!selected && taskId) {
388
+ const queue = await client.get(`/v1/projects/${id}/thread/tasks`, { simple: "true", ...(chatId ? { chat_id: chatId } : {}) }, signal);
389
+ return { status: queue.task_ids?.includes(taskId) ? "queued" : "unknown", project_id: id, turn: null,
390
+ reason: queue.task_ids?.includes(taskId) ? "task_queued" : "task_not_observed",
391
+ ...(!queue.task_ids?.includes(taskId) ? {
392
+ correlation: "unavailable",
393
+ project_activity: turns[0] ? pick(turns[0], ["id", "turn", "status", "chat_id", "commit_id"]) : null,
394
+ project_status_command: `enter-cli --output json thread status ${id}${chatId ? ` --chat-id ${chatId}` : ""}`,
395
+ message: "This ID may be an interjection rather than a queued task. Project activity is separate and does not prove this submission was applied. Inspect thread messages and the resulting change; do not resubmit blindly.",
396
+ } : {}) };
323
397
  }
324
- const turnStatus = String(lastTurn?.status ?? "");
325
- if (turnStatus === "cancelled" || turnStatus === "error") {
326
- print(getFormat(cmd), {
327
- status: "failed",
328
- reason: `turn_${turnStatus}`,
329
- project_id: id,
330
- turn: lastTurn,
331
- });
332
- process.exit(1);
398
+ if (!selected) {
399
+ return { status: turnNumber === undefined ? "idle" : "unknown", reason: turnNumber === undefined ? "no_turns" : "turn_not_found", project_id: id, turn: null };
400
+ }
401
+ const base = { project_id: id, turn: selected };
402
+ const status = String(selected.status ?? "");
403
+ if (["cancelled", "error", "failed"].includes(status)) {
404
+ return { ...base, status: "failed", reason: `turn_${status}` };
333
405
  }
334
- // Turn is completed but a "completed" turn can still be blocked
335
- // by a pending tool action. Check actions exactly once now.
336
- const pending = await fetchPendingActions(id);
337
- if (pending.length > 0) {
338
- const enriched = await Promise.all(pending.map(async (a) => {
406
+ const pending = (await fetchPendingActions(id, [], signal)).filter(a => String(a.turn) === String(selected.turn));
407
+ if (pending.length) {
408
+ const context = { signal };
409
+ const actions = await Promise.all(pending.map(async (a) => {
339
410
  const handler = handlerFor(a.tool_name);
340
- const base = {
341
- action_id: a.action_id,
342
- tool_name: a.tool_name,
343
- turn: a.turn,
344
- input_kind: handler.kind,
345
- approve_command: buildApproveCommand(id, a),
346
- instructions: handler.instructions,
411
+ const result = {
412
+ action_id: a.action_id, tool_name: a.tool_name, turn: a.turn,
413
+ input_kind: handler.kind, approve_command: buildApproveCommand(id, a), instructions: handler.instructions,
347
414
  };
348
- if (!handler.enrich)
349
- return base;
350
- try {
351
- const extra = await handler.enrich(id, a);
352
- return { ...base, ...extra };
353
- }
354
- catch (err) {
355
- return { ...base, enrich_error: err.message };
415
+ if (handler.enrich) {
416
+ try {
417
+ Object.assign(result, await handler.enrich(id, a, context));
418
+ }
419
+ catch (error) {
420
+ if (signal.aborted)
421
+ throw error;
422
+ result.enrich_error = error.message;
423
+ }
356
424
  }
425
+ return result;
357
426
  }));
358
- print(getFormat(cmd), {
359
- status: "blocked",
360
- reason: "pending_actions",
361
- project_id: id,
362
- actions: enriched,
363
- });
364
- return;
427
+ return { ...base, status: "blocked", reason: "pending_actions", actions };
365
428
  }
366
- // Turn done, no gate. Return current project snapshot. Note: a completed
367
- // turn may not produce a commit (e.g. config-only changes), so we do not
368
- // wait for build_status.commit_id here. Callers that need a committed
369
- // build (e.g. `proj publish`) check that themselves.
370
- const detail = await client.get(`/v1/projects/${id}/detail`);
429
+ if (status !== "completed")
430
+ return { ...base, status: status ? "running" : "unknown" };
431
+ const detail = await client.get(`/v1/projects/${id}/detail`, undefined, signal);
371
432
  const project = (detail.project ?? detail);
372
- print(getFormat(cmd), {
373
- status: "completed",
374
- project_id: id,
375
- project: { ...project, lifecycle_status: resolveLifecycleStatus(project) },
433
+ // This is turn completion. Project build state remains separate and may refer
434
+ // to an earlier build; callers must not interpret it as this turn's build ID.
435
+ return { ...base, status: "completed", project: { ...project, lifecycle_status: resolveLifecycleStatus(project) } };
436
+ }
437
+ function projectSnapshot(snapshot, compact = false) {
438
+ const result = { ...snapshot };
439
+ if (compact && snapshot.turn)
440
+ result.turn = pick(snapshot.turn, ["id", "turn", "status", "turn_name", "created_at", "updated_at", "commit_id", "chat_id"]);
441
+ if (snapshot.project) {
442
+ const p = snapshot.project;
443
+ if (compact)
444
+ result.project = pick(p, ["project_id", "name", "status", "lifecycle_status", "commit", "commit_turn", "preview_url", "publish_url", "build_status"]);
445
+ const build = p.build_status;
446
+ result.build_matches_turn = Boolean(snapshot.turn?.commit_id && build?.commit_id === snapshot.turn.commit_id);
447
+ const supabase = p.supabase;
448
+ result.integrations = { cloud: supabase?.status ?? "unknown", ai: p.ai_connection_state ?? (p.ai_capability_enabled === true ? "enabled" : "unknown") };
449
+ }
450
+ return result;
451
+ }
452
+ // Owns observation and lifetime only; callers choose JSON or NDJSON rendering.
453
+ async function observeThread(id, opts, wait, onSnapshot) {
454
+ let target = opts.turn === undefined ? undefined : positiveNumber(opts.turn, "--turn", true);
455
+ if (opts.taskId && target !== undefined)
456
+ throw new Error("Use only one of --task-id and --turn");
457
+ if (opts.transport && !["auto", "poll"].includes(opts.transport))
458
+ throw new Error("--transport must be auto or poll");
459
+ if (opts.cursor && !/^\d+-\d+$/.test(opts.cursor))
460
+ throw new Error("--cursor must be an event ID such as 123-0");
461
+ const timeoutMs = positiveNumber(opts.timeout, "--timeout") * 1000;
462
+ if (timeoutMs > 2147483647)
463
+ throw new Error("--timeout is too large");
464
+ const controller = new AbortController();
465
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
466
+ const interrupt = () => controller.abort("SIGINT");
467
+ const terminate = () => controller.abort("SIGTERM");
468
+ process.once("SIGINT", interrupt);
469
+ process.once("SIGTERM", terminate);
470
+ let last = { status: "unknown", project_id: id, turn: null };
471
+ let querying = false;
472
+ let stream;
473
+ let reconcileUntil = 0;
474
+ const emit = (extra = {}, final = false) => onSnapshot({
475
+ ...last,
476
+ ...(["idle", "running", "queued", "pending", "unknown"].includes(last.status)
477
+ ? threadInteraction(id, { taskId: opts.taskId, chatId: opts.chatId, turn: target, requireBuild: opts.requireBuild }) : {}),
478
+ ...(opts.taskId ? { task_id: opts.taskId } : {}),
479
+ ...(stream ? { transport: stream.mode, transport_reason: stream.reason, cursor: stream.cursor } : {}), ...extra,
480
+ }, final);
481
+ const interruption = () => {
482
+ const reason = controller.signal.reason;
483
+ if (reason !== "SIGINT" && reason !== "SIGTERM")
484
+ return undefined;
485
+ emit({ interrupted: true, signal: reason }, true);
486
+ return reason === "SIGINT" ? 130 : 143;
487
+ };
488
+ try {
489
+ while (!controller.signal.aborted) {
490
+ if (stream?.failure)
491
+ throw stream.failure;
492
+ const revision = stream?.revision ?? 0;
493
+ querying = true;
494
+ last = await readThreadSnapshot(id, target, controller.signal, opts.taskId, opts.chatId);
495
+ if (opts.requireBuild && last.status === "completed") {
496
+ const build = last.project?.build_status;
497
+ const matches = Boolean(last.turn?.commit_id && build?.commit_id === last.turn.commit_id);
498
+ if (!matches || typeof build?.success !== "boolean")
499
+ last = { ...last, status: "running", reason: "awaiting_matching_build" };
500
+ else if (build?.success === false)
501
+ last = { ...last, status: "failed", reason: "build_failed" };
502
+ }
503
+ querying = false;
504
+ if (target === undefined && last.turn && !opts.taskId)
505
+ target = positiveNumber(String(last.turn.turn), "server turn", true);
506
+ // A newly accepted task can be temporarily absent from both reads. Keep
507
+ // bounded observation alive without claiming it is pending or failed.
508
+ const unobservedTask = last.status === "unknown" && last.reason === "task_not_observed";
509
+ if (!wait || (!unobservedTask && !["running", "idle", "queued", "pending"].includes(last.status))) {
510
+ emit({}, true);
511
+ return last.status === "failed" || last.status === "unknown" ? 1 : 0;
512
+ }
513
+ if (!stream && opts.transport !== "poll") {
514
+ stream = new ThreadEvents(id, {
515
+ signal: controller.signal, turn: target ?? (last.turn ? Number(last.turn.turn) : undefined),
516
+ chatId: opts.chatId ?? last.turn?.chat_id, cursor: opts.cursor, stateOnly: true,
517
+ onEvent: event => { if (isStateEvent(event))
518
+ reconcileUntil = Date.now() + 1500; },
519
+ });
520
+ }
521
+ emit();
522
+ if (stream)
523
+ await stream.wait(revision, Date.now() < reconcileUntil ? 250 : stream.mode === "websocket" ? 30000 : 2000);
524
+ else
525
+ await delay(2000, undefined, { signal: controller.signal });
526
+ }
527
+ const interrupted = interruption();
528
+ if (interrupted !== undefined)
529
+ return interrupted;
530
+ emit({ wait_timed_out: true, query_timed_out: false }, true);
531
+ return 2;
532
+ }
533
+ catch (error) {
534
+ const interrupted = interruption();
535
+ if (interrupted !== undefined)
536
+ return interrupted;
537
+ if (!controller.signal.aborted) {
538
+ emit({ ...errorEnvelope(error), last_observed: true }, true);
539
+ return 1;
540
+ }
541
+ emit({ wait_timed_out: wait, query_timed_out: querying, ...(querying ? { error: { code: "QUERY_TIMEOUT", retryable: true, outcome_unknown: false } } : {}) }, true);
542
+ return wait && !querying ? 2 : 1;
543
+ }
544
+ finally {
545
+ stream?.close();
546
+ clearTimeout(timer);
547
+ process.removeListener("SIGINT", interrupt);
548
+ process.removeListener("SIGTERM", terminate);
549
+ }
550
+ }
551
+ export async function reportThread(id, opts, cmd, wait, watch = false) {
552
+ if (opts.full && opts.compact)
553
+ throw new Error("Use only one of --full and --compact");
554
+ let signature = "";
555
+ process.exitCode = await observeThread(id, opts, wait, (snapshot, final) => {
556
+ const data = projectSnapshot(snapshot, !opts.full && (opts.compact || watch));
557
+ if (!watch) {
558
+ if (final)
559
+ print(getFormat(cmd), safeOutput(data));
560
+ return;
561
+ }
562
+ // Cursor-only changes should not flood agents with identical snapshots.
563
+ const next = JSON.stringify({ ...data, cursor: undefined });
564
+ if (next !== signature || final) {
565
+ process.stdout.write(JSON.stringify(safeOutput({ type: final ? "result" : "snapshot", ...data })) + "\n");
566
+ signature = next;
567
+ }
376
568
  });
377
- });
569
+ }
570
+ for (const [name, timeout, description] of [
571
+ ["status", "30", "Read one task snapshot; never wait for completion"],
572
+ ["wait", "10", "Wait for an action or terminal state using events with polling fallback"],
573
+ ["watch", "60", "Stream meaningful state changes as NDJSON; exit on pending actions or terminal state"],
574
+ ]) {
575
+ threadCmd.command(`${name} <project_id>`).description(description)
576
+ .option("--turn <n>", "Select a fixed turn number")
577
+ .option("--task-id <id>", "Follow exactly the task returned by chat/approve, including queue time")
578
+ .option("--chat-id <id>", "Scope turn lookup and events to a chat")
579
+ .option("--cursor <id>", "Resume stream after an event ID")
580
+ .option("--transport <mode>", "auto: WebSocket with fallback; poll: HTTP only", "auto")
581
+ .option("--require-build", "Require a matching successful build before reporting completion")
582
+ .option("--compact", "Return only monitoring fields (default for watch)")
583
+ .option("--full", "Include full sanitized metadata (default for status/wait)")
584
+ .option("--timeout <seconds>", "Bound the entire call without cancelling Enter", timeout)
585
+ .action(async (id, opts, cmd) => reportThread(id, opts, cmd, name !== "status", name === "watch"));
586
+ }
378
587
  threadCmd
379
588
  .command("diff <project_id> <turn_number>")
380
589
  .description("Get diff for a turn")
@@ -424,12 +633,25 @@ threadCmd
424
633
  .description("Restore thread to a specific turn (1-based turn number from `thread turns`)")
425
634
  .requiredOption("--turn <n>", "1-based turn number to restore to (NOT a turn UUID)")
426
635
  .action(async (id, opts, cmd) => {
427
- const turn = parseInt(opts.turn, 10);
428
- if (!Number.isFinite(turn) || turn < 1) {
429
- throw new Error("--turn must be a positive integer (1-based turn number)");
636
+ const turn = positiveNumber(opts.turn, "--turn", true);
637
+ try {
638
+ const data = await client.post(`/v1/projects/${id}/thread/restore`, { turn });
639
+ print(getFormat(cmd), data);
640
+ }
641
+ catch (error) {
642
+ print(getFormat(cmd), {
643
+ ...errorEnvelope(error),
644
+ project_id: id,
645
+ requested_turn: turn,
646
+ restored: "unknown",
647
+ verification_commands: [
648
+ `enter-cli --output json thread tasks ${id}`,
649
+ `enter-cli --output json thread status ${id}`,
650
+ `enter-cli --output json project get ${id}`,
651
+ ],
652
+ });
653
+ process.exitCode = 1;
430
654
  }
431
- const data = await client.post(`/v1/projects/${id}/thread/restore`, { turn: turn });
432
- print(getFormat(cmd), data);
433
655
  });
434
656
  threadCmd
435
657
  .command("actions <project_id>")
@@ -465,23 +687,27 @@ threadCmd
465
687
  ]);
466
688
  printTable(["Action ID", "Tool", "Status", "Turn", "Created"], rows);
467
689
  });
468
- async function resolveActionId(projectId, explicit) {
469
- if (explicit)
470
- return { kind: "ok", action_id: explicit };
471
- const pending = await fetchPendingActions(projectId);
690
+ async function resolveAction(projectId, explicit) {
691
+ const pending = await fetchPendingActions(projectId, explicit ? [explicit] : []);
692
+ if (explicit) {
693
+ const action = pending.find((candidate) => candidate.action_id === explicit);
694
+ if (!action)
695
+ throw new Error(`Pending action not found: ${explicit}`);
696
+ return { kind: "ok", action };
697
+ }
472
698
  if (pending.length === 0)
473
699
  return { kind: "none", pending };
474
700
  if (pending.length > 1)
475
701
  return { kind: "ambiguous", pending };
476
- return { kind: "ok", action_id: pending[0].action_id };
702
+ return { kind: "ok", action: pending[0] };
477
703
  }
478
704
  // Emit a structured no-op result + return null (caller should not call the API),
479
- // or return the resolved action_id when ok. Exits non-zero on ambiguity since
705
+ // or return the resolved action when ok. Exits non-zero on ambiguity since
480
706
  // it's a real error that scripts need to notice.
481
707
  function handleResolution(projectId, verb, result, format) {
482
708
  switch (result.kind) {
483
709
  case "ok":
484
- return result.action_id;
710
+ return result.action;
485
711
  case "none": {
486
712
  const past = verb === "approve" ? "approved" : "rejected";
487
713
  printResult(format, { [past]: false, reason: "no_pending_actions", project_id: projectId }, `Nothing to ${verb} (no pending actions on this project's thread).`);
@@ -517,60 +743,189 @@ threadCmd
517
743
  .description([
518
744
  "Approve a pending tool action. action_id is optional when exactly one pending action exists.",
519
745
  "Tool-specific options:",
520
- " supabase_add_secret : --secret-name <name> --secret-value <value>",
521
- " stripe_enable : --secret-name STRIPE_SECRET_KEY --secret-value sk_...",
746
+ " supabase_add_secret : --secret-name <name> --secret-value-stdin",
747
+ " stripe_enable : --secret-value-stdin",
748
+ " stripe_update_key_and_migrate: --secret-value-stdin",
522
749
  " ask_user_question : --answers '<json>' or --skip-answers",
523
- " others (supabase_enable, stripe_create_products_and_prices,",
524
- " enable_ai_capability, confirm_skill): no extra flags needed",
750
+ " supabase_configure_auth_provider: verify saved configuration, or save provider JSON with --auth-config-stdin first",
751
+ " feature enable cards: run their dedicated backend flow before resolving the action",
752
+ " other actions : no extra flags unless thread wait says otherwise",
525
753
  ].join("\n"))
526
- .option("--secret-name <name>", "Secret variable name (supabase_add_secret / stripe_enable)")
527
- .option("--secret-value <value>", "Secret variable value (supabase_add_secret / stripe_enable)")
754
+ .option("--secret-name <name>", "Secret variable name (supabase_add_secret)")
755
+ .option("--secret-value <value>", "Legacy secret argument; prefer --secret-value-stdin")
756
+ .option("--secret-value-stdin", "Read the secret value from stdin instead of process arguments")
757
+ .option("--auth-config-stdin", "Read configuration JSON for the action's auth provider from stdin; credentials never go into the approval response")
528
758
  .option("--tool-result <result>", "Custom tool result string")
529
759
  .option("--answers <json>", 'Answers for ask_user_question, JSON: \'{"Q text": {"selected_options": ["A"], "other_text": ""}}\'')
530
760
  .option("--skip-answers", "Skip all questions for ask_user_question (sets skipped: true)")
531
761
  .action(async (projectId, actionIdArg, opts, cmd) => {
532
762
  const format = getFormat(cmd);
533
- const resolved = await resolveActionId(projectId, actionIdArg);
534
- const actionId = handleResolution(projectId, "approve", resolved, format);
535
- if (!actionId)
763
+ const resolved = await resolveAction(projectId, actionIdArg);
764
+ const action = handleResolution(projectId, "approve", resolved, format);
765
+ if (!action)
536
766
  return;
767
+ if (opts.secretValue !== undefined && opts.secretValueStdin) {
768
+ throw new Error("Use only one of --secret-value or --secret-value-stdin");
769
+ }
770
+ if (opts.answers !== undefined && opts.skipAnswers)
771
+ throw new Error("Use only one of --answers or --skip-answers");
772
+ if (opts.authConfigStdin && action.tool_name !== "supabase_configure_auth_provider")
773
+ throw new Error("--auth-config-stdin is only supported for auth provider actions");
774
+ if (action.tool_name === "supabase_configure_auth_provider") {
775
+ if (opts.secretName !== undefined || opts.secretValue !== undefined || opts.secretValueStdin || opts.toolResult !== undefined || opts.answers !== undefined || opts.skipAnswers) {
776
+ throw new Error("Auth provider actions do not accept secret, tool-result or question-answer flags; use --auth-config-stdin or Enter's secure form");
777
+ }
778
+ const provider = await authProviderFor(projectId, action);
779
+ const configPath = `/v1/projects/${projectId}/entercloud/auth/config`;
780
+ if (opts.authConfigStdin) {
781
+ const config = parseObjectJSON(readFileSync(0, "utf8"), "--auth-config-stdin");
782
+ for (const key of Object.keys(config)) {
783
+ if (!AUTH_PROVIDER_FIELDS[provider].includes(key))
784
+ throw new Error("Unsupported field in auth provider configuration");
785
+ if (["enabled", "skip_nonce_checks"].includes(key) ? typeof config[key] !== "boolean" : typeof config[key] !== "string") {
786
+ throw new Error("Invalid value type in auth provider configuration");
787
+ }
788
+ }
789
+ if (config.enabled === false)
790
+ throw new Error("Cannot approve a disabled auth provider");
791
+ await client.patch(configPath, { providers: { [provider]: { ...config, enabled: true } } });
792
+ }
793
+ const data = await client.get(configPath);
794
+ if (data.config?.providers?.[provider]?.enabled !== true) {
795
+ throw new Error(`Auth provider ${provider} is not enabled. Configure it through Enter's secure form or --auth-config-stdin before approval.`);
796
+ }
797
+ // The backend additionally verifies stored credentials and the card's
798
+ // ExpectedProvider before claiming the action. Never send credentials here.
799
+ const result = await client.post(`/v1/projects/${projectId}/thread/chat`, {
800
+ action_response: { action_id: action.action_id, response: "approved", auth_provider_result: { provider } },
801
+ });
802
+ print(format, safeOutput({
803
+ ...result, approved: true,
804
+ action_id: action.action_id, tool_name: action.tool_name,
805
+ ...threadInteraction(projectId, { taskId: String(result.task_id ?? "") }),
806
+ }));
807
+ return;
808
+ }
809
+ const secretValue = opts.secretValueStdin
810
+ ? readFileSync(0, "utf8").replace(/\r?\n$/, "")
811
+ : opts.secretValue;
812
+ if (action.tool_name === "supabase_add_secret" && (!opts.secretName?.trim() || !secretValue)) {
813
+ throw new Error("--secret-name and a non-empty --secret-value-stdin (or --secret-value) are required for supabase_add_secret");
814
+ }
815
+ if (action.tool_name === "ask_user_question" && opts.answers === undefined && !opts.skipAnswers) {
816
+ throw new Error("--answers or --skip-answers is required for ask_user_question");
817
+ }
818
+ const answers = opts.answers !== undefined ? parseObjectJSON(opts.answers, "--answers") : undefined;
819
+ if (answers) {
820
+ for (const answer of Object.values(answers)) {
821
+ if (!answer || typeof answer !== "object" || Array.isArray(answer))
822
+ throw new Error("Each answer must be an object");
823
+ const value = answer;
824
+ if (!Array.isArray(value.selected_options) || !value.selected_options.every(item => typeof item === "string") || (value.other_text !== undefined && typeof value.other_text !== "string")) {
825
+ throw new Error("Each answer requires selected_options as a string array and optional other_text as a string");
826
+ }
827
+ }
828
+ }
829
+ let featureResult;
830
+ let handledFeature = false;
831
+ const featureRoute = FEATURE_ENABLE_ROUTES.get(action.tool_name);
832
+ if (featureRoute) {
833
+ handledFeature = true;
834
+ // AI All's legacy default flow requires an absent body. An empty object
835
+ // is parsed as an explicit request with no mode and rejected by the API.
836
+ featureResult = await client.post(`/v1/projects/${projectId}/${featureRoute}`, action.tool_name === "enable_ai_capability" ? undefined : {});
837
+ }
838
+ else if (action.tool_name === "stripe_enable") {
839
+ if (!secretValue)
840
+ throw new Error("--secret-value or --secret-value-stdin is required for stripe_enable");
841
+ handledFeature = true;
842
+ featureResult = await client.post(`/v1/projects/${projectId}/connect-stripe`, {
843
+ stripe_secret_key: secretValue,
844
+ });
845
+ }
846
+ else if (action.tool_name === "stripe_update_key_and_migrate") {
847
+ if (!secretValue)
848
+ throw new Error("--secret-value or --secret-value-stdin is required for stripe_update_key_and_migrate");
849
+ if (!action.tool_call_id)
850
+ throw new Error("tool_call_id is required for stripe_update_key_and_migrate");
851
+ const args = await loadToolCallArgs(projectId, action, "stripe_update_key_and_migrate");
852
+ const productIDs = args.kind === "ok" && args.value !== null && !Array.isArray(args.value)
853
+ ? args.value.product_ids
854
+ : undefined;
855
+ if (!Array.isArray(productIDs) || !productIDs.every((id) => typeof id === "string" && id.trim() !== "")) {
856
+ throw new Error("Unable to read product_ids from the stripe_update_key_and_migrate action");
857
+ }
858
+ const normalizedProductIDs = productIDs.map((id) => id.trim());
859
+ handledFeature = true;
860
+ featureResult = await client.post(`/v1/projects/${projectId}/stripe/update-and-migrate`, {
861
+ new_secret_key: secretValue,
862
+ product_ids: normalizedProductIDs,
863
+ });
864
+ }
865
+ if (handledFeature) {
866
+ const confirmation = AbortSignal.timeout(10000);
867
+ let actions;
868
+ try {
869
+ actions = await pollUntil(() => fetchActions(projectId, [action.action_id], confirmation), (items) => {
870
+ const candidate = items.find((item) => item.action_id === action.action_id);
871
+ return candidate !== undefined && candidate.status !== "waiting_response";
872
+ }, { intervalMs: 250, timeoutMs: 10000 });
873
+ }
874
+ catch (error) {
875
+ if (error instanceof TimeoutError || confirmation.aborted) {
876
+ throw new Error(`Feature flow completed but action status is still pending: ${action.action_id}. Do not retry automatically.`);
877
+ }
878
+ throw error;
879
+ }
880
+ const resolvedAction = actions.find((candidate) => candidate.action_id === action.action_id);
881
+ if (resolvedAction?.status !== "approved") {
882
+ throw new Error(`Dedicated feature flow did not approve action: ${action.action_id} (${resolvedAction?.status ?? "missing"})`);
883
+ }
884
+ print(format, safeOutput({
885
+ ...(featureResult && typeof featureResult === "object" ? featureResult : { result: featureResult }),
886
+ approved: true, action_id: action.action_id, tool_name: action.tool_name,
887
+ ...threadInteraction(projectId),
888
+ }));
889
+ return;
890
+ }
537
891
  const actionResponse = {
538
- action_id: actionId,
892
+ action_id: action.action_id,
539
893
  response: "approved",
540
894
  };
541
895
  if (opts.secretName)
542
896
  actionResponse.secret_name = opts.secretName;
543
- if (opts.secretValue)
544
- actionResponse.secret_value = opts.secretValue;
897
+ if (secretValue)
898
+ actionResponse.secret_value = secretValue;
545
899
  if (opts.toolResult)
546
900
  actionResponse.tool_result = opts.toolResult;
547
901
  if (opts.skipAnswers) {
548
902
  actionResponse.question_answers = { answers: {}, skipped: true };
549
903
  }
550
- else if (opts.answers) {
551
- try {
552
- const parsed = JSON.parse(opts.answers);
553
- actionResponse.question_answers = { answers: parsed, skipped: false };
554
- }
555
- catch {
556
- console.error("Error: --answers must be valid JSON");
557
- process.exit(1);
558
- }
904
+ else if (answers) {
905
+ actionResponse.question_answers = { answers, skipped: false };
559
906
  }
560
907
  const data = await client.post(`/v1/projects/${projectId}/thread/chat`, {
561
908
  action_response: actionResponse,
562
909
  });
563
- print(format, data);
910
+ print(format, safeOutput({
911
+ ...data, approved: true,
912
+ action_id: action.action_id, tool_name: action.tool_name,
913
+ ...threadInteraction(projectId, { taskId: String(data.task_id ?? "") }),
914
+ }));
564
915
  });
565
916
  threadCmd
566
917
  .command("reject <project_id> [action_id]")
567
918
  .description("Reject a pending tool action. action_id is optional when exactly one pending action exists.")
568
919
  .action(async (projectId, actionIdArg, _opts, cmd) => {
569
920
  const format = getFormat(cmd);
570
- const resolved = await resolveActionId(projectId, actionIdArg);
571
- const actionId = handleResolution(projectId, "reject", resolved, format);
572
- if (!actionId)
573
- return;
921
+ let actionId = actionIdArg;
922
+ if (!actionId) {
923
+ const resolved = await resolveAction(projectId, undefined);
924
+ const action = handleResolution(projectId, "reject", resolved, format);
925
+ if (!action)
926
+ return;
927
+ actionId = action.action_id;
928
+ }
574
929
  const data = await client.post(`/v1/projects/${projectId}/thread/chat`, {
575
930
  action_response: { action_id: actionId, response: "rejected" },
576
931
  });