@enter-pro/enter-cli 0.4.1 → 0.4.3

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,17 @@
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 { RequestError, errorEnvelope } from "../errors.js";
11
+ import { workflowResult } from "../workflow.js";
12
+ import { registerThreadTasks } from "./thread-tasks.js";
7
13
  export const threadCmd = new Command("thread").description("Manage project threads and chat");
14
+ registerThreadTasks(threadCmd);
8
15
  // A turn has reached a terminal state when it appears in this set; everything
9
16
  // else (running/pending/queued/agent_start/agent_running/...) means in-flight.
10
17
  const TERMINAL_TURN_STATUSES = new Set(["completed", "cancelled", "error", "failed"]);
@@ -21,22 +28,47 @@ function normalizeAction(raw) {
21
28
  updated_at: String(raw.updated_at ?? raw.UpdatedAt ?? ""),
22
29
  };
23
30
  }
24
- async function loadToolCallArgs(projectId, action, toolName) {
31
+ // Single registry: adding a new tool only requires editing one row.
32
+ const STRIPE_INPUT_INSTRUCTIONS = "Submit the Stripe key through --secret-value-stdin from an authorized input source. Reuse input already supplied for this task; otherwise offer an available secure input or local-file path. Do not echo the key or embed it in shell text or build prompts. Report connection errors without assuming their cause.";
33
+ async function loadToolCallArgs(projectId, action, toolName, context = {}) {
25
34
  const turn = String(action.turn);
26
- const data = await client.get(`/v1/projects/${projectId}/thread/messages`, { start_turn: turn, end_turn: turn });
35
+ const data = await (context.messages ??= client.get(`/v1/projects/${projectId}/thread/messages`, { start_turn: turn, end_turn: turn }, context.signal));
36
+ // Prefer completed arguments, but cards can exist before tool_call_end.
37
+ // Stream deltas may split JSON across tool_call_arguments events.
38
+ const candidates = new Map();
39
+ const names = new Map();
27
40
  for (const e of data.messages ?? []) {
28
- if (e.message_type !== "tool_call_end")
41
+ const type = String(e.message_type ?? "");
42
+ 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
43
  continue;
30
- const detail = e.detail?.tool_call_end;
31
- if (!detail || detail.tool_name !== toolName)
44
+ const detail = e.detail?.[type];
45
+ if (!detail)
32
46
  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)
47
+ const callId = String(detail.tool_call_id ?? "");
48
+ if (detail.tool_name)
49
+ names.set(callId, String(detail.tool_name));
50
+ if (names.get(callId) !== toolName && !(action.tool_call_id && action.tool_call_id === callId && !detail.tool_name))
36
51
  continue;
37
- const raw = String(detail.full_arguments ?? "");
52
+ if (action.tool_call_id && callId !== action.tool_call_id)
53
+ continue;
54
+ const raw = String(detail.full_arguments ?? detail.accumulated_arguments ?? detail.arguments_delta ?? detail.arguments ?? detail.tool_call_args ?? "");
55
+ if (!raw)
56
+ continue;
57
+ if (detail.full_arguments !== undefined || detail.accumulated_arguments !== undefined)
58
+ candidates.set(callId, raw);
59
+ else if (type === "tool_call_arguments" || type === "tool_call_arguments_delta" || detail.arguments_delta !== undefined)
60
+ candidates.set(callId, (candidates.get(callId) ?? "") + raw);
61
+ else
62
+ candidates.set(callId, raw);
63
+ }
64
+ if (candidates.size > 1 && !action.tool_call_id)
65
+ return { kind: "not_found" };
66
+ for (const raw of candidates.values()) {
38
67
  try {
39
- return { kind: "ok", value: JSON.parse(raw) };
68
+ const parsed = JSON.parse(raw);
69
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
70
+ return { kind: "parse_error", raw_arguments: "Expected an argument object" };
71
+ return { kind: "ok", value: parsed };
40
72
  }
41
73
  catch {
42
74
  return { kind: "parse_error", raw_arguments: raw };
@@ -44,18 +76,22 @@ async function loadToolCallArgs(projectId, action, toolName) {
44
76
  }
45
77
  return { kind: "not_found" };
46
78
  }
79
+ const ANSWERS_FORMAT = 'Use the exact original question text as each key, not a host question ID. Each value must be {"selected_options":["exact option label"],"other_text":"optional free text"}. Use an empty selected_options array for free-text-only answers. See thread approve --help.';
47
80
  const TOOL_HANDLERS = {
48
81
  // ask_user_question: surface the questions array directly so callers don't
49
82
  // have to fetch + parse thread messages to know what to ask the user.
50
83
  ask_user_question: {
51
84
  kind: "questions",
52
- approveSuffix: (pid, a) => `--answers '<json>' OR enter-cli thread approve ${pid} ${a.action_id} --skip-answers`,
53
- 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");
85
+ approveSuffix: "--answers '<json>'",
86
+ instructions: `Forward questions, options and multiSelect to the user unchanged. Map their response back to the original question text. ${ANSWERS_FORMAT} After receiving the user response, run approve_command with that JSON. Use skip_command only if the user asks to skip.`,
87
+ enrich: async (projectId, action, context) => {
88
+ const args = await loadToolCallArgs(projectId, action, "ask_user_question", context);
56
89
  switch (args.kind) {
57
90
  case "ok":
58
- return { questions: args.value.questions ?? [] };
91
+ return {
92
+ questions: args.value.questions ?? [],
93
+ skip_command: `enter-cli thread approve ${projectId} ${action.action_id} --skip-answers`,
94
+ };
59
95
  case "parse_error":
60
96
  return { questions: { raw_arguments: args.raw_arguments, parse_error: true } };
61
97
  case "not_found":
@@ -65,27 +101,33 @@ const TOOL_HANDLERS = {
65
101
  },
66
102
  supabase_add_secret: {
67
103
  kind: "secret",
68
- approveSuffix: () => `--secret-name <NAME> --secret-value-stdin`,
69
- instructions: "Ask the user for the secret name and value, then run approve_command and provide the value through stdin.",
104
+ approveSuffix: `--secret-name <NAME> --secret-value-stdin`,
105
+ instructions: "Submit the secret name and value through --secret-value-stdin from an authorized input source. Reuse input already supplied for this task; otherwise ask for the missing value through an available secure input or local-file path. Do not echo the value or embed it in shell text or build prompts.",
106
+ },
107
+ supabase_configure_auth_provider: {
108
+ kind: "auth_provider",
109
+ approveSuffix: "",
110
+ instructions: "Configure this provider with --auth-config-stdin using an authorized input source, or use Enter's provider form. Reuse configuration already supplied for this task; ask only for missing fields. After form submission query status before approving again. Missing input is not build failure.",
111
+ enrich: async (projectId, action, context) => authProviderInput(await authProviderFor(projectId, action, context)),
70
112
  },
71
113
  stripe_enable: {
72
114
  kind: "secret",
73
- approveSuffix: () => `--secret-value-stdin`,
74
- instructions: "Ask the user for their Stripe secret key, then run approve_command and provide the key through stdin.",
115
+ approveSuffix: `--secret-value-stdin`,
116
+ instructions: STRIPE_INPUT_INSTRUCTIONS,
75
117
  },
76
118
  stripe_update_key_and_migrate: {
77
119
  kind: "secret",
78
- approveSuffix: () => `--secret-value-stdin`,
79
- instructions: "Product IDs come from the pending tool call. Ask the user for their new Stripe secret key, then run approve_command and provide the key through stdin.",
120
+ approveSuffix: `--secret-value-stdin`,
121
+ instructions: `${STRIPE_INPUT_INSTRUCTIONS} Product IDs come from the pending tool call.`,
80
122
  },
81
123
  // confirm_plan_mode: surface the plan text directly on the action so callers
82
124
  // don't have to fetch + parse thread messages themselves.
83
125
  confirm_plan_mode: {
84
126
  kind: "none",
85
- approveSuffix: () => "",
86
- instructions: "Plan is included in this action's `plan` field. Show it to the user, then run approve_command.",
87
- enrich: async (projectId, action) => {
88
- const args = await loadToolCallArgs(projectId, action, "confirm_plan_mode");
127
+ approveSuffix: "",
128
+ instructions: "Plan is included in this action's `plan` field. Present it for the user's approval and wait for their decision before running approve_command. Displaying the plan is not approval.",
129
+ enrich: async (projectId, action, context) => {
130
+ const args = await loadToolCallArgs(projectId, action, "confirm_plan_mode", context);
89
131
  switch (args.kind) {
90
132
  case "ok": {
91
133
  const v = args.value;
@@ -106,8 +148,8 @@ const TOOL_HANDLERS = {
106
148
  };
107
149
  const DEFAULT_HANDLER = {
108
150
  kind: "none",
109
- approveSuffix: () => "",
110
- instructions: "No input required. Run the approve_command as-is.",
151
+ approveSuffix: "",
152
+ instructions: "No additional input required. Approve only within the user-authorized scope; input_kind none does not itself grant authorization.",
111
153
  };
112
154
  const FEATURE_ENABLE_ROUTES = new Map([
113
155
  ["supabase_enable", "entercloud/enable"],
@@ -115,46 +157,107 @@ const FEATURE_ENABLE_ROUTES = new Map([
115
157
  ["i18n_enable", "i18n/enable"],
116
158
  ["enable_ai_capability", "ai-capability/connect"],
117
159
  ]);
160
+ const AUTH_PROVIDER_FIELDS = {
161
+ google: ["enabled", "client_ids", "client_secret", "skip_nonce_checks"],
162
+ wechat: ["enabled", "client_id", "client_secret"],
163
+ alipay: ["enabled", "app_id", "private_key"],
164
+ feishu: ["enabled", "app_id", "app_secret"],
165
+ };
166
+ // Input requirements are separate from optional provider settings. Never put
167
+ // stored configuration values (including masked secrets) in action metadata.
168
+ const AUTH_PROVIDER_INPUTS = {
169
+ google: ["client_ids", "client_secret"],
170
+ wechat: ["client_id", "client_secret"],
171
+ alipay: ["app_id", "private_key"],
172
+ feishu: ["app_id", "app_secret"],
173
+ };
174
+ function authProviderInput(provider) {
175
+ return {
176
+ provider,
177
+ configuration: {
178
+ input_surface: "enter_project_auth_form",
179
+ input_methods: ["auth_config_stdin", "enter_project_auth_form"],
180
+ fields: AUTH_PROVIDER_INPUTS[provider],
181
+ instructions: "Supply the listed fields as JSON through --auth-config-stdin from an authorized input source; Enter's provider form is an alternative, not a required detour. If input is missing, offer an available secure input or local-file path. Do not echo credentials or embed them in shell text or build prompts. Use the actual OAuth callback URL when configuring the provider. After saving through the form query status, since it may already have approved the action.",
182
+ },
183
+ };
184
+ }
185
+ async function authProviderFor(projectId, action, context = {}) {
186
+ if (!action.tool_call_id)
187
+ throw new Error("Auth provider action is missing its tool_call_id");
188
+ const args = await loadToolCallArgs(projectId, action, action.tool_name, context);
189
+ const provider = args.kind === "ok" ? args.value?.provider : undefined;
190
+ if (typeof provider !== "string" || !Object.hasOwn(AUTH_PROVIDER_FIELDS, provider)) {
191
+ throw new Error("Unable to resolve the requested auth provider from the matching tool call");
192
+ }
193
+ return provider;
194
+ }
195
+ function parseObjectJSON(value, option) {
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(value);
199
+ }
200
+ catch {
201
+ throw new Error(`${option} must contain a JSON object`);
202
+ }
203
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
204
+ throw new Error(`${option} must contain a JSON object`);
205
+ return parsed;
206
+ }
118
207
  function handlerFor(toolName) {
119
208
  return TOOL_HANDLERS[toolName] ?? DEFAULT_HANDLER;
120
209
  }
121
210
  function buildApproveCommand(projectId, action) {
122
211
  const base = `enter-cli thread approve ${projectId} ${action.action_id}`;
123
- const suffix = handlerFor(action.tool_name).approveSuffix(projectId, action);
212
+ const suffix = handlerFor(action.tool_name).approveSuffix;
124
213
  return suffix ? `${base} ${suffix}` : base;
125
214
  }
126
- async function fetchPendingActions(projectId, actionIds = []) {
127
- return (await fetchActions(projectId, actionIds)).filter((a) => a.status === "waiting_response");
215
+ async function fetchPendingActions(projectId, actionIds = [], signal) {
216
+ return (await fetchActions(projectId, actionIds, signal)).filter((a) => a.status === "waiting_response");
128
217
  }
129
- async function fetchActions(projectId, actionIds = []) {
218
+ async function fetchActions(projectId, actionIds = [], signal) {
130
219
  const body = actionIds.length > 0 ? { actions: actionIds } : {};
131
- const data = await client.post(`/v1/projects/${projectId}/thread/actions`, body);
220
+ const data = await client.post(`/v1/projects/${projectId}/thread/actions`, body, signal);
132
221
  const resp = data;
133
222
  return (resp.actions || []).map(normalizeAction);
134
223
  }
135
224
  threadCmd
136
225
  .command("chat <project_id>")
137
- .description("Send a chat message to project thread")
226
+ .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.")
138
227
  .option("-m, --message <text>", "Chat message content")
139
228
  .option("--file <path>", "Read message content from a file (for long or multi-line messages)")
229
+ .option("--stdin", "Read message from stdin, avoiding shell quoting and process arguments")
230
+ .option("--chat-id <id>", "Target a specific chat")
140
231
  .option("--auto-approve", "Pass auto_approve flag to the server")
141
232
  .action(async (id, opts, cmd) => {
142
233
  let content;
143
- if (opts.file) {
234
+ if ([opts.file !== undefined, opts.message !== undefined, Boolean(opts.stdin)].filter(Boolean).length !== 1)
235
+ throw new Error("Provide exactly one of --message, --file or --stdin");
236
+ if (opts.stdin) {
237
+ content = readFileSync(0, "utf-8");
238
+ }
239
+ else if (opts.file) {
144
240
  content = readFileSync(opts.file, "utf-8");
145
241
  }
146
242
  else if (opts.message) {
147
243
  content = opts.message;
148
244
  }
149
245
  else {
150
- console.error("Error: either -m <text> or --file <path> is required");
151
- process.exit(1);
246
+ throw new Error("Provide non-empty input using --message, --file or --stdin");
152
247
  }
248
+ if (!content.trim())
249
+ throw new Error("Message must not be empty");
153
250
  const body = { prompt: content, attachments: [] };
154
251
  if (opts.autoApprove)
155
252
  body.auto_approve = true;
253
+ if (opts.chatId)
254
+ body.chat_id = opts.chatId;
156
255
  const data = await client.post(`/v1/projects/${id}/thread/chat`, body);
157
- print(getFormat(cmd), data);
256
+ print(getFormat(cmd), {
257
+ ...data,
258
+ submission_status: "accepted",
259
+ ...threadInteraction(id, { taskId: String(data.task_id ?? ""), chatId: opts.chatId }),
260
+ });
158
261
  });
159
262
  threadCmd
160
263
  .command("messages <project_id>")
@@ -163,10 +266,17 @@ threadCmd
163
266
  .option("--end-turn <n>", "End turn number")
164
267
  .option("--turn <n>", "Get messages for a specific turn (shorthand for --start-turn N --end-turn N)")
165
268
  .option("--latest", "Get messages from the most recent turn (most common usage)")
269
+ .option("--text", "Return assistant text with turn numbers, without raw event envelopes")
166
270
  .option("--tail <n>", "Get messages from the last N turns")
167
- .option("--follow", "Follow the latest turn in real-time via WebSocket")
271
+ .option("--follow", "Stream NDJSON events over WebSocket; no automatic approval")
272
+ .option("--cursor <id>", "Resume after a previously returned event ID")
273
+ .option("--chat-id <id>", "Scope the stream to a chat")
274
+ .option("--timeout <seconds>", "Bound --follow lifetime", "60")
275
+ .option("--max-events <n>", "Stop --follow after N events")
168
276
  .action(async (id, opts, cmd) => {
169
277
  if (opts.follow) {
278
+ if (opts.text)
279
+ throw new Error("--text is not supported with --follow");
170
280
  await followThreadStream(id, opts);
171
281
  return;
172
282
  }
@@ -182,7 +292,7 @@ threadCmd
182
292
  const resp = turnsData;
183
293
  const turns = resp.turns || [];
184
294
  if (turns.length === 0) {
185
- printMessage("No turns found.");
295
+ print(getFormat(cmd), { messages: [] });
186
296
  return;
187
297
  }
188
298
  if (opts.latest) {
@@ -201,76 +311,64 @@ threadCmd
201
311
  }
202
312
  }
203
313
  if (!startTurn) {
204
- console.error("Error: specify --start-turn, --turn, --latest, --tail, or --follow");
205
- process.exit(1);
314
+ throw new Error("Specify --start-turn, --turn, --latest, --tail, or --follow");
206
315
  }
207
316
  const params = {};
208
317
  params.start_turn = startTurn;
209
318
  if (endTurn)
210
319
  params.end_turn = endTurn;
211
320
  const data = await client.get(`/v1/projects/${id}/thread/messages`, params);
212
- print(getFormat(cmd), data);
321
+ if (opts.text) {
322
+ const rows = Array.isArray(data) ? data : data.messages ?? [];
323
+ const messages = rows.flatMap((row) => {
324
+ const content = row?.detail?.assistant_content?.content;
325
+ return row?.message_type === "assistant_content" && typeof content === "string"
326
+ ? [{ turn: row.turn, text: content }] : [];
327
+ });
328
+ print(getFormat(cmd), safeOutput({ messages }));
329
+ }
330
+ else
331
+ print(getFormat(cmd), data);
213
332
  });
214
- async function followThreadStream(projectId, _opts) {
215
- const wsBase = (await import("../config.js")).baseURL()
216
- .replace(/^https?:\/\//, "ws://")
217
- .replace(/^wss?:\/\//, "ws://");
218
- const { getToken } = await import("../auth.js");
219
- const url = `${wsBase}/v1/projects/${projectId}/thread/stream`;
220
- const token = getToken();
221
- // Use native WebSocket available in Node 22+ or ws package fallback
222
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
223
- let WS;
333
+ async function followThreadStream(projectId, opts) {
334
+ const timeout = positiveNumber(opts.timeout ?? "60", "--timeout") * 1000;
335
+ if (timeout > 2147483647)
336
+ throw new Error("--timeout is too large");
337
+ const turn = opts.turn ? positiveNumber(opts.turn, "--turn", true) : undefined;
338
+ if (opts.cursor && !/^\d+-\d+$/.test(opts.cursor))
339
+ throw new Error("--cursor must be an event ID such as 123-0");
340
+ const maxEvents = opts.maxEvents ? positiveNumber(opts.maxEvents, "--max-events", true) : Infinity;
341
+ const controller = new AbortController();
342
+ const timer = setTimeout(() => controller.abort(), timeout);
343
+ const abort = () => controller.abort();
344
+ process.once("SIGINT", abort);
345
+ process.once("SIGTERM", abort);
346
+ let count = 0;
347
+ let stream;
224
348
  try {
225
- WS = WebSocket;
226
- }
227
- catch {
228
- try {
229
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
230
- // @ts-ignore -- optional peer dependency
231
- const mod = await import("ws");
232
- WS = mod.default;
233
- }
234
- catch {
235
- console.error("Error: WebSocket not available. Node 22+ required or install 'ws' package.");
236
- process.exit(1);
349
+ stream = new ThreadEvents(projectId, {
350
+ signal: controller.signal, turn,
351
+ chatId: opts.chatId, cursor: opts.cursor,
352
+ onMode: mode => console.error(`[events] ${mode}`),
353
+ onEvent: event => {
354
+ process.stdout.write(JSON.stringify(safeOutput(event)) + "\n");
355
+ if (++count >= maxEvents)
356
+ controller.abort();
357
+ },
358
+ });
359
+ while (!controller.signal.aborted) {
360
+ if (stream.failure)
361
+ throw stream.failure;
362
+ await stream.wait(stream.revision, Math.min(timeout, 30000));
237
363
  }
238
364
  }
239
- const wsUrl = token ? `${url}?token=${encodeURIComponent(token)}` : url;
240
- const ws = new WS(wsUrl, {
241
- headers: token ? { Authorization: `Bearer ${token}` } : {},
242
- });
243
- const terminalTypes = new Set([
244
- "turn_end", "turn_cancelled", "turn_error", "build_end",
245
- ]);
246
- let connected = false;
247
- ws.on("open", () => {
248
- connected = true;
249
- console.error("[follow] WebSocket connected, streaming messages...");
250
- });
251
- ws.on("message", (data) => {
252
- try {
253
- const msg = JSON.parse(data.toString());
254
- process.stdout.write(JSON.stringify(msg) + "\n");
255
- if (typeof msg.message_type === "string" && terminalTypes.has(msg.message_type)) {
256
- console.error(`[follow] Turn ended (${msg.message_type}), closing.`);
257
- ws.close();
258
- }
259
- }
260
- catch {
261
- process.stdout.write(data.toString() + "\n");
262
- }
263
- });
264
- ws.on("error", (err) => {
265
- console.error(`[follow] WebSocket error: ${err.message}`);
266
- if (!connected)
267
- process.exit(1);
268
- });
269
- ws.on("close", () => {
270
- console.error("[follow] Connection closed.");
271
- });
272
- // Keep process alive until ws closes
273
- await new Promise((resolve) => ws.on("close", resolve));
365
+ finally {
366
+ stream?.close();
367
+ clearTimeout(timer);
368
+ process.removeListener("SIGINT", abort);
369
+ process.removeListener("SIGTERM", abort);
370
+ console.error(`[events] stopped; events=${count}; cursor=${stream?.cursor ?? "none"}; reason=${stream?.reason ?? "observation_ended"}`);
371
+ }
274
372
  }
275
373
  threadCmd
276
374
  .command("turns <project_id>")
@@ -280,7 +378,7 @@ threadCmd
280
378
  const data = await client.get(`/v1/projects/${id}/thread/turns`);
281
379
  const resp = data;
282
380
  const turns = resp.turns || [];
283
- const items = opts.latest ? turns.slice(-1) : turns;
381
+ const items = opts.latest ? turns.slice(0, 1) : turns;
284
382
  const picked = pickList(items, [
285
383
  "id", "turn", "turn_name", "status", "model", "credits_consumed", "created_at",
286
384
  ]);
@@ -299,96 +397,248 @@ threadCmd
299
397
  ]);
300
398
  printTable(["Turn", "Name", "Status", "Model", "Credits", "Created"], rows);
301
399
  });
302
- threadCmd
303
- .command("wait <project_id>")
304
- .description("Wait until the latest turn reaches a terminal state. Exits with status: completed | blocked | failed.")
305
- .option("--timeout <seconds>", "Timeout in seconds", "300")
306
- .action(async (id, opts, cmd) => {
307
- const timeoutMs = parseInt(opts.timeout, 10) * 1000;
308
- console.error("Waiting for turn to complete...");
309
- let lastTurn = null;
310
- try {
311
- const turns = await pollUntil(async () => {
312
- const data = await client.get(`/v1/projects/${id}/thread/turns`);
313
- const resp = data;
314
- return resp.turns || [];
315
- }, (turns) => {
316
- if (turns.length === 0)
317
- return false;
318
- // turns are returned newest-first.
319
- const latest = turns[0];
320
- return TERMINAL_TURN_STATUSES.has(String(latest.status));
321
- }, {
322
- intervalMs: 2000,
323
- timeoutMs,
324
- onTick: (elapsed) => {
325
- process.stderr.write(`\rWaiting for turn... ${Math.round(elapsed / 1000)}s elapsed`);
326
- },
327
- });
328
- process.stderr.write("\n");
329
- lastTurn = turns[0] ?? null;
330
- }
331
- catch (err) {
332
- if (err instanceof TimeoutError) {
333
- console.error(`\nTimed out after ${opts.timeout}s waiting for the turn to terminate`);
334
- process.exit(1);
335
- }
336
- throw err;
337
- }
338
- const turnStatus = String(lastTurn?.status ?? "");
339
- if (turnStatus === "cancelled" || turnStatus === "error") {
340
- print(getFormat(cmd), {
341
- status: "failed",
342
- reason: `turn_${turnStatus}`,
343
- project_id: id,
344
- turn: lastTurn,
345
- });
346
- process.exit(1);
400
+ export function threadInteraction(id, { taskId, chatId, turn, requireBuild } = {}) {
401
+ const target = (taskId ? ` --task-id ${taskId}` : turn === undefined ? "" : ` --turn ${turn}`)
402
+ + (chatId ? ` --chat-id ${chatId}` : "") + (requireBuild ? " --require-build" : "");
403
+ return {
404
+ workflow: workflowResult({ status: "running", task_id: taskId, turn: { turn, chat_id: chatId } }),
405
+ monitoring_required: true,
406
+ wait_command: `enter-cli --output json thread wait ${id}${target} --timeout 10`,
407
+ watch_command: `enter-cli thread watch ${id}${target} --timeout 60`,
408
+ status_command: `enter-cli --output json thread status ${id}${target} --timeout 10`,
409
+ follow_up_command: `enter-cli --output json thread chat ${id}${chatId ? ` --chat-id ${chatId}` : ""} --file <message-file>`,
410
+ instructions: "Accepted or timeout is not completion. Follow this task until blocked, failed or completed. Forward questions unchanged; approve only within existing authorization. Use short foreground waits and handle new user messages before continuing observation. Use background watch only with non-blocking output collection and completion notifications; do not follow it with a long blocking job wait. Observation timeout is normal: keep the same task and bounded wait, rather than increasing timeouts. CLI observation timeout and host job-wait timeout are separate limits, not elapsed time. Watching never approves or cancels remote work.",
411
+ };
412
+ }
413
+ function positiveNumber(value, option, integer = false) {
414
+ const number = Number(value);
415
+ if (!Number.isFinite(number) || number <= 0 || (integer && !Number.isSafeInteger(number))) {
416
+ throw new Error(`${option} must be a positive ${integer ? "integer" : "number"}`);
347
417
  }
348
- // Turn is completed — but a "completed" turn can still be blocked
349
- // by a pending tool action. Check actions exactly once now.
350
- const pending = await fetchPendingActions(id);
351
- if (pending.length > 0) {
352
- const enriched = await Promise.all(pending.map(async (a) => {
418
+ return number;
419
+ }
420
+ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
421
+ const data = await client.get(`/v1/projects/${id}/thread/turns`, chatId ? { chat_id: chatId } : undefined, signal);
422
+ const turns = data.turns ?? [];
423
+ const selected = taskId ? turns.find(t => t.id === taskId || t.task_id === taskId)
424
+ : turnNumber === undefined ? turns[0] : turns.find(t => Number(t.turn) === turnNumber);
425
+ if (!selected && taskId) {
426
+ const queue = await client.get(`/v1/projects/${id}/thread/tasks`, { simple: "true", ...(chatId ? { chat_id: chatId } : {}) }, signal);
427
+ const queued = queue.task_ids?.includes(taskId);
428
+ return { status: queued ? "queued" : "unknown", project_id: id, turn: null,
429
+ reason: queued ? "task_queued" : "task_not_observed",
430
+ ...(!queued ? {
431
+ correlation: "unavailable",
432
+ project_activity: turns[0] ? pick(turns[0], ["id", "turn", "status", "chat_id", "commit_id"]) : null,
433
+ project_status_command: `enter-cli --output json thread status ${id}${chatId ? ` --chat-id ${chatId}` : ""}`,
434
+ 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.",
435
+ } : {}) };
436
+ }
437
+ if (!selected) {
438
+ return { status: turnNumber === undefined ? "idle" : "unknown", reason: turnNumber === undefined ? "no_turns" : "turn_not_found", project_id: id, turn: null };
439
+ }
440
+ const base = { project_id: id, turn: selected };
441
+ const status = String(selected.status ?? "");
442
+ if (["cancelled", "error", "failed"].includes(status)) {
443
+ return { ...base, status: "failed", reason: `turn_${status}` };
444
+ }
445
+ const pending = (await fetchPendingActions(id, [], signal)).filter(a => String(a.turn) === String(selected.turn));
446
+ if (pending.length) {
447
+ const context = { signal };
448
+ const actions = await Promise.all(pending.map(async (a) => {
353
449
  const handler = handlerFor(a.tool_name);
354
- const base = {
355
- action_id: a.action_id,
356
- tool_name: a.tool_name,
357
- turn: a.turn,
358
- input_kind: handler.kind,
359
- approve_command: buildApproveCommand(id, a),
360
- instructions: handler.instructions,
450
+ const result = {
451
+ action_id: a.action_id, tool_name: a.tool_name, turn: a.turn,
452
+ input_kind: handler.kind, approve_command: buildApproveCommand(id, a), instructions: handler.instructions,
361
453
  };
362
- if (!handler.enrich)
363
- return base;
364
- try {
365
- const extra = await handler.enrich(id, a);
366
- return { ...base, ...extra };
367
- }
368
- catch (err) {
369
- return { ...base, enrich_error: err.message };
454
+ if (handler.enrich) {
455
+ try {
456
+ Object.assign(result, await handler.enrich(id, a, context));
457
+ }
458
+ catch (error) {
459
+ if (signal.aborted)
460
+ throw error;
461
+ result.enrich_error = error.message;
462
+ }
370
463
  }
464
+ return result;
371
465
  }));
372
- print(getFormat(cmd), {
373
- status: "blocked",
374
- reason: "pending_actions",
375
- project_id: id,
376
- actions: enriched,
377
- });
378
- return;
466
+ return { ...base, status: "blocked", reason: "pending_actions", actions };
379
467
  }
380
- // Turn done, no gate. Return current project snapshot. Note: a completed
381
- // turn may not produce a commit (e.g. config-only changes), so we do not
382
- // wait for build_status.commit_id here. Callers that need a committed
383
- // build (e.g. `proj publish`) check that themselves.
384
- const detail = await client.get(`/v1/projects/${id}/detail`);
468
+ if (status !== "completed")
469
+ return { ...base, status: status ? "running" : "unknown" };
470
+ const detail = await client.get(`/v1/projects/${id}/detail`, undefined, signal);
385
471
  const project = (detail.project ?? detail);
386
- print(getFormat(cmd), {
387
- status: "completed",
388
- project_id: id,
389
- project: { ...project, lifecycle_status: resolveLifecycleStatus(project) },
472
+ // This is turn completion. Project build state remains separate and may refer
473
+ // to an earlier build; callers must not interpret it as this turn's build ID.
474
+ return { ...base, status: "completed", project: { ...project, lifecycle_status: resolveLifecycleStatus(project) } };
475
+ }
476
+ function projectSnapshot(snapshot, compact = false) {
477
+ const workflow = workflowResult(snapshot);
478
+ const result = { ...snapshot };
479
+ if (compact && snapshot.turn)
480
+ result.turn = pick(snapshot.turn, ["id", "turn", "status", "turn_name", "created_at", "updated_at", "commit_id", "chat_id"]);
481
+ if (snapshot.status === "completed" && snapshot.turn) {
482
+ result.messages_command = `enter-cli --output json thread messages ${snapshot.project_id} --turn ${snapshot.turn.turn} --text`;
483
+ }
484
+ if (snapshot.project) {
485
+ const p = snapshot.project;
486
+ if (compact)
487
+ result.project = pick(p, ["project_id", "name", "status", "lifecycle_status", "commit", "commit_turn", "preview_url", "publish_url", "build_status"]);
488
+ result.build_matches_turn = workflow.build?.matches_task ?? false;
489
+ const supabase = p.supabase;
490
+ result.integrations = { cloud: supabase?.status ?? "unknown", ai: p.ai_connection_state ?? (p.ai_capability_enabled === true ? "enabled" : "unknown") };
491
+ }
492
+ result.workflow = workflow;
493
+ return result;
494
+ }
495
+ // Owns observation and lifetime only; callers choose JSON or NDJSON rendering.
496
+ async function observeThread(id, opts, wait, onSnapshot) {
497
+ let target = opts.turn === undefined ? undefined : positiveNumber(opts.turn, "--turn", true);
498
+ if (opts.taskId && target !== undefined)
499
+ throw new Error("Use only one of --task-id and --turn");
500
+ if (opts.transport && !["auto", "poll"].includes(opts.transport))
501
+ throw new Error("--transport must be auto or poll");
502
+ if (opts.cursor && !/^\d+-\d+$/.test(opts.cursor))
503
+ throw new Error("--cursor must be an event ID such as 123-0");
504
+ const timeoutMs = positiveNumber(opts.timeout, "--timeout") * 1000;
505
+ if (timeoutMs > 2147483647)
506
+ throw new Error("--timeout is too large");
507
+ const controller = new AbortController();
508
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
509
+ const interrupt = () => controller.abort("SIGINT");
510
+ const terminate = () => controller.abort("SIGTERM");
511
+ process.once("SIGINT", interrupt);
512
+ process.once("SIGTERM", terminate);
513
+ let last = { status: "unknown", project_id: id, turn: null };
514
+ let querying = false;
515
+ let stream;
516
+ let reconcileUntil = 0;
517
+ const emit = (extra = {}, final = false) => onSnapshot({
518
+ ...last,
519
+ ...(["idle", "running", "queued", "pending", "unknown"].includes(last.status)
520
+ ? threadInteraction(id, { taskId: opts.taskId, chatId: opts.chatId, turn: target, requireBuild: opts.requireBuild }) : {}),
521
+ ...(opts.taskId ? { task_id: opts.taskId } : {}),
522
+ ...(stream ? { transport: stream.mode, transport_reason: stream.reason, cursor: stream.cursor } : {}), ...extra,
523
+ }, final);
524
+ const interruption = () => {
525
+ const reason = controller.signal.reason;
526
+ if (reason !== "SIGINT" && reason !== "SIGTERM")
527
+ return undefined;
528
+ emit({ interrupted: true, signal: reason }, true);
529
+ return reason === "SIGINT" ? 130 : 143;
530
+ };
531
+ try {
532
+ while (!controller.signal.aborted) {
533
+ if (stream?.failure)
534
+ throw stream.failure;
535
+ const revision = stream?.revision ?? 0;
536
+ querying = true;
537
+ try {
538
+ last = await readThreadSnapshot(id, target, controller.signal, opts.taskId, opts.chatId);
539
+ }
540
+ catch (error) {
541
+ // A read outage does not end remote work. Keep bounded observation alive;
542
+ // never retry writes or authentication/permission failures here.
543
+ if (!wait || controller.signal.aborted || !(error instanceof RequestError) || !error.retryable)
544
+ throw error;
545
+ querying = false;
546
+ emit({ ...errorEnvelope(error), observation_retrying: true, last_observed: true });
547
+ await delay(1000, undefined, { signal: controller.signal });
548
+ continue;
549
+ }
550
+ if (opts.requireBuild && last.status === "completed") {
551
+ const build = last.project?.build_status;
552
+ const matches = Boolean(last.turn?.commit_id && build?.commit_id === last.turn.commit_id);
553
+ if (!matches || typeof build?.success !== "boolean")
554
+ last = { ...last, status: "running", reason: "awaiting_matching_build" };
555
+ else if (build?.success === false)
556
+ last = { ...last, status: "failed", reason: "build_failed" };
557
+ }
558
+ querying = false;
559
+ if (target === undefined && last.turn && !opts.taskId)
560
+ target = positiveNumber(String(last.turn.turn), "server turn", true);
561
+ // A newly accepted task can be temporarily absent from both reads. Keep
562
+ // bounded observation alive without claiming it is pending or failed.
563
+ const unobservedTask = last.status === "unknown" && last.reason === "task_not_observed";
564
+ if (!wait || (!unobservedTask && !["running", "idle", "queued", "pending"].includes(last.status))) {
565
+ emit({}, true);
566
+ return last.status === "failed" || last.status === "unknown" ? 1 : 0;
567
+ }
568
+ if (!stream && opts.transport !== "poll") {
569
+ stream = new ThreadEvents(id, {
570
+ signal: controller.signal, turn: target ?? (last.turn ? Number(last.turn.turn) : undefined),
571
+ chatId: opts.chatId ?? last.turn?.chat_id, cursor: opts.cursor, stateOnly: true,
572
+ onEvent: event => { if (isStateEvent(event))
573
+ reconcileUntil = Date.now() + 1500; },
574
+ });
575
+ }
576
+ emit();
577
+ if (stream)
578
+ await stream.wait(revision, Date.now() < reconcileUntil ? 250 : stream.mode === "websocket" ? 30000 : 2000);
579
+ else
580
+ await delay(2000, undefined, { signal: controller.signal });
581
+ }
582
+ const interrupted = interruption();
583
+ if (interrupted !== undefined)
584
+ return interrupted;
585
+ emit({ wait_timed_out: true, query_timed_out: false }, true);
586
+ return 2;
587
+ }
588
+ catch (error) {
589
+ const interrupted = interruption();
590
+ if (interrupted !== undefined)
591
+ return interrupted;
592
+ if (!controller.signal.aborted) {
593
+ emit({ ...errorEnvelope(error), last_observed: true }, true);
594
+ return 1;
595
+ }
596
+ emit({ wait_timed_out: wait, query_timed_out: querying, ...(querying ? { error: { code: "QUERY_TIMEOUT", retryable: true, outcome_unknown: false } } : {}) }, true);
597
+ return wait && !querying ? 2 : 1;
598
+ }
599
+ finally {
600
+ stream?.close();
601
+ clearTimeout(timer);
602
+ process.removeListener("SIGINT", interrupt);
603
+ process.removeListener("SIGTERM", terminate);
604
+ }
605
+ }
606
+ export async function reportThread(id, opts, cmd, wait, watch = false) {
607
+ if (opts.full && opts.compact)
608
+ throw new Error("Use only one of --full and --compact");
609
+ let signature = "";
610
+ process.exitCode = await observeThread(id, opts, wait, (snapshot, final) => {
611
+ const data = projectSnapshot(snapshot, !opts.full && (opts.compact || watch));
612
+ if (!watch) {
613
+ if (final)
614
+ print(getFormat(cmd), safeOutput(data));
615
+ return;
616
+ }
617
+ // Cursor-only changes should not flood agents with identical snapshots.
618
+ const next = JSON.stringify({ ...data, cursor: undefined });
619
+ if (next !== signature || final) {
620
+ process.stdout.write(JSON.stringify(safeOutput({ type: final ? "result" : "snapshot", ...data })) + "\n");
621
+ signature = next;
622
+ }
390
623
  });
391
- });
624
+ }
625
+ for (const [name, timeout, description] of [
626
+ ["status", "30", "Read one task snapshot; never wait for completion"],
627
+ ["wait", "10", "Wait for an action or terminal state using events with polling fallback"],
628
+ ["watch", "60", "Stream meaningful state changes as NDJSON; exit on pending actions or terminal state"],
629
+ ]) {
630
+ threadCmd.command(`${name} <project_id>`).description(description)
631
+ .option("--turn <n>", "Select a fixed turn number")
632
+ .option("--task-id <id>", "Follow exactly the task returned by chat/approve, including queue time")
633
+ .option("--chat-id <id>", "Scope turn lookup and events to a chat")
634
+ .option("--cursor <id>", "Resume stream after an event ID")
635
+ .option("--transport <mode>", "auto: WebSocket with fallback; poll: HTTP only", "auto")
636
+ .option("--require-build", "Require a matching successful build before reporting completion")
637
+ .option("--compact", "Return only monitoring fields (default for watch)")
638
+ .option("--full", "Include full sanitized metadata (default for status/wait)")
639
+ .option("--timeout <seconds>", "Bound the entire call without cancelling Enter", timeout)
640
+ .action(async (id, opts, cmd) => reportThread(id, opts, cmd, name !== "status", name === "watch"));
641
+ }
392
642
  threadCmd
393
643
  .command("diff <project_id> <turn_number>")
394
644
  .description("Get diff for a turn")
@@ -405,7 +655,7 @@ threadCmd
405
655
  else {
406
656
  writeFileSync(path, JSON.stringify(data, null, 2));
407
657
  }
408
- printMessage(`Diff written to ${path}`);
658
+ printResult(getFormat(cmd), { path }, `Diff written to ${path}`);
409
659
  return;
410
660
  }
411
661
  print(getFormat(cmd), data);
@@ -413,10 +663,11 @@ threadCmd
413
663
  threadCmd
414
664
  .command("cancel <project_id>")
415
665
  .description("Cancel the currently running turn. No-op if nothing is running.")
416
- .action(async (id, _opts, cmd) => {
666
+ .option("--chat-id <id>", "Scope cancellation to this chat")
667
+ .action(async (id, opts, cmd) => {
417
668
  // Pre-check the latest turn — the server returns code 1000 even when
418
669
  // nothing is running, which is misleading. We want a clean idempotent no-op.
419
- const turnsResp = await client.get(`/v1/projects/${id}/thread/turns`);
670
+ const turnsResp = await client.get(`/v1/projects/${id}/thread/turns`, opts.chatId ? { chat_id: opts.chatId } : undefined);
420
671
  const turns = turnsResp.turns ?? [];
421
672
  const latest = turns[0];
422
673
  const isRunning = latest && !TERMINAL_TURN_STATUSES.has(String(latest.status ?? ""));
@@ -427,23 +678,38 @@ threadCmd
427
678
  reason: "no_running_turn",
428
679
  latest_turn: latest?.turn ?? null,
429
680
  latest_status: latest?.status ?? null,
681
+ tasks_command: `enter-cli --output json thread tasks ${id}${opts.chatId ? ` --chat-id ${opts.chatId}` : ""}`,
430
682
  }, `Nothing to cancel (latest turn ${latest?.turn ?? "?"} status: ${latest?.status ?? "unknown"}).`);
431
683
  return;
432
684
  }
433
- await client.post(`/v1/projects/${id}/thread/cancel`);
434
- printResult(format, { cancelled: true, turn: latest.turn }, `Cancelled turn ${latest.turn}.`);
685
+ const chatId = opts.chatId ?? latest.chat_id;
686
+ await client.post(`/v1/projects/${id}/thread/cancel`, chatId ? { chat_id: chatId } : undefined);
687
+ printResult(format, { cancelled: true, turn: latest.turn, ...(chatId ? { chat_id: chatId } : {}) }, `Cancelled turn ${latest.turn}.`);
435
688
  });
436
689
  threadCmd
437
690
  .command("restore <project_id>")
438
691
  .description("Restore thread to a specific turn (1-based turn number from `thread turns`)")
439
692
  .requiredOption("--turn <n>", "1-based turn number to restore to (NOT a turn UUID)")
440
693
  .action(async (id, opts, cmd) => {
441
- const turn = parseInt(opts.turn, 10);
442
- if (!Number.isFinite(turn) || turn < 1) {
443
- throw new Error("--turn must be a positive integer (1-based turn number)");
694
+ const turn = positiveNumber(opts.turn, "--turn", true);
695
+ try {
696
+ const data = await client.post(`/v1/projects/${id}/thread/restore`, { turn });
697
+ print(getFormat(cmd), data);
698
+ }
699
+ catch (error) {
700
+ print(getFormat(cmd), {
701
+ ...errorEnvelope(error),
702
+ project_id: id,
703
+ requested_turn: turn,
704
+ restored: "unknown",
705
+ verification_commands: [
706
+ `enter-cli --output json thread tasks ${id}`,
707
+ `enter-cli --output json thread status ${id}`,
708
+ `enter-cli --output json project get ${id}`,
709
+ ],
710
+ });
711
+ process.exitCode = 1;
444
712
  }
445
- const data = await client.post(`/v1/projects/${id}/thread/restore`, { turn: turn });
446
- print(getFormat(cmd), data);
447
713
  });
448
714
  threadCmd
449
715
  .command("actions <project_id>")
@@ -539,14 +805,16 @@ threadCmd
539
805
  " stripe_enable : --secret-value-stdin",
540
806
  " stripe_update_key_and_migrate: --secret-value-stdin",
541
807
  " ask_user_question : --answers '<json>' or --skip-answers",
808
+ " supabase_configure_auth_provider: verify saved configuration, or save provider JSON with --auth-config-stdin first",
542
809
  " feature enable cards: run their dedicated backend flow before resolving the action",
543
810
  " other actions : no extra flags unless thread wait says otherwise",
544
811
  ].join("\n"))
545
812
  .option("--secret-name <name>", "Secret variable name (supabase_add_secret)")
546
813
  .option("--secret-value <value>", "Legacy secret argument; prefer --secret-value-stdin")
547
814
  .option("--secret-value-stdin", "Read the secret value from stdin instead of process arguments")
815
+ .option("--auth-config-stdin", "Read configuration JSON for the action's auth provider from stdin; credentials never go into the approval response")
548
816
  .option("--tool-result <result>", "Custom tool result string")
549
- .option("--answers <json>", 'Answers for ask_user_question, JSON: \'{"Q text": {"selected_options": ["A"], "other_text": ""}}\'')
817
+ .option("--answers <json>", `Answers for ask_user_question. ${ANSWERS_FORMAT}`)
550
818
  .option("--skip-answers", "Skip all questions for ask_user_question (sets skipped: true)")
551
819
  .action(async (projectId, actionIdArg, opts, cmd) => {
552
820
  const format = getFormat(cmd);
@@ -554,18 +822,86 @@ threadCmd
554
822
  const action = handleResolution(projectId, "approve", resolved, format);
555
823
  if (!action)
556
824
  return;
557
- if (opts.secretValue && opts.secretValueStdin) {
825
+ if (opts.secretValue !== undefined && opts.secretValueStdin) {
558
826
  throw new Error("Use only one of --secret-value or --secret-value-stdin");
559
827
  }
828
+ if (opts.answers !== undefined && opts.skipAnswers)
829
+ throw new Error("Use only one of --answers or --skip-answers");
830
+ if (opts.authConfigStdin && action.tool_name !== "supabase_configure_auth_provider")
831
+ throw new Error("--auth-config-stdin is only supported for auth provider actions");
832
+ if (action.tool_name === "supabase_configure_auth_provider") {
833
+ if (opts.secretName !== undefined || opts.secretValue !== undefined || opts.secretValueStdin || opts.toolResult !== undefined || opts.answers !== undefined || opts.skipAnswers) {
834
+ 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");
835
+ }
836
+ const provider = await authProviderFor(projectId, action);
837
+ const configPath = `/v1/projects/${projectId}/entercloud/auth/config`;
838
+ if (opts.authConfigStdin) {
839
+ const config = parseObjectJSON(readFileSync(0, "utf8"), "--auth-config-stdin");
840
+ for (const key of Object.keys(config)) {
841
+ if (!AUTH_PROVIDER_FIELDS[provider].includes(key))
842
+ throw new Error("Unsupported field in auth provider configuration");
843
+ if (["enabled", "skip_nonce_checks"].includes(key) ? typeof config[key] !== "boolean" : typeof config[key] !== "string") {
844
+ throw new Error("Invalid value type in auth provider configuration");
845
+ }
846
+ }
847
+ if (config.enabled === false)
848
+ throw new Error("Cannot approve a disabled auth provider");
849
+ await client.patch(configPath, { providers: { [provider]: { ...config, enabled: true } } });
850
+ }
851
+ const data = await client.get(configPath);
852
+ if (data.config?.providers?.[provider]?.enabled !== true) {
853
+ print(format, {
854
+ status: "blocked", reason: "auth_provider_configuration_required",
855
+ project_id: projectId, approved: false,
856
+ actions: [{
857
+ action_id: action.action_id, tool_name: action.tool_name, turn: action.turn,
858
+ input_kind: "auth_provider", ...authProviderInput(provider),
859
+ approve_command: buildApproveCommand(projectId, action),
860
+ }],
861
+ next_step: "Provide the missing fields via --auth-config-stdin, or save them in Enter's provider form and query thread status. Continue with existing authorized input without asking the user to enter it again.",
862
+ });
863
+ return;
864
+ }
865
+ // The backend additionally verifies stored credentials and the card's
866
+ // ExpectedProvider before claiming the action. Never send credentials here.
867
+ const result = await client.post(`/v1/projects/${projectId}/thread/chat`, {
868
+ action_response: { action_id: action.action_id, response: "approved", auth_provider_result: { provider } },
869
+ });
870
+ print(format, safeOutput({
871
+ ...result, approved: true,
872
+ action_id: action.action_id, tool_name: action.tool_name,
873
+ ...threadInteraction(projectId, { taskId: String(result.task_id ?? "") }),
874
+ }));
875
+ return;
876
+ }
560
877
  const secretValue = opts.secretValueStdin
561
878
  ? readFileSync(0, "utf8").replace(/\r?\n$/, "")
562
879
  : opts.secretValue;
880
+ if (action.tool_name === "supabase_add_secret" && (!opts.secretName?.trim() || !secretValue)) {
881
+ throw new Error("--secret-name and a non-empty --secret-value-stdin (or --secret-value) are required for supabase_add_secret");
882
+ }
883
+ if (action.tool_name === "ask_user_question" && opts.answers === undefined && !opts.skipAnswers) {
884
+ throw new Error("--answers or --skip-answers is required for ask_user_question");
885
+ }
886
+ const answers = opts.answers !== undefined ? parseObjectJSON(opts.answers, "--answers") : undefined;
887
+ if (answers) {
888
+ for (const answer of Object.values(answers)) {
889
+ if (!answer || typeof answer !== "object" || Array.isArray(answer))
890
+ throw new Error(`Invalid --answers: each answer must be an object. ${ANSWERS_FORMAT}`);
891
+ const value = answer;
892
+ if (!Array.isArray(value.selected_options) || !value.selected_options.every(item => typeof item === "string") || (value.other_text !== undefined && typeof value.other_text !== "string")) {
893
+ throw new Error(`Invalid --answers: selected_options must be a string array and other_text an optional string. ${ANSWERS_FORMAT}`);
894
+ }
895
+ }
896
+ }
563
897
  let featureResult;
564
898
  let handledFeature = false;
565
899
  const featureRoute = FEATURE_ENABLE_ROUTES.get(action.tool_name);
566
900
  if (featureRoute) {
567
901
  handledFeature = true;
568
- featureResult = await client.post(`/v1/projects/${projectId}/${featureRoute}`, {});
902
+ // AI All's legacy default flow requires an absent body. An empty object
903
+ // is parsed as an explicit request with no mode and rejected by the API.
904
+ featureResult = await client.post(`/v1/projects/${projectId}/${featureRoute}`, action.tool_name === "enable_ai_capability" ? undefined : {});
569
905
  }
570
906
  else if (action.tool_name === "stripe_enable") {
571
907
  if (!secretValue)
@@ -595,15 +931,16 @@ threadCmd
595
931
  });
596
932
  }
597
933
  if (handledFeature) {
934
+ const confirmation = AbortSignal.timeout(10000);
598
935
  let actions;
599
936
  try {
600
- actions = await pollUntil(() => fetchActions(projectId, [action.action_id]), (items) => {
937
+ actions = await pollUntil(() => fetchActions(projectId, [action.action_id], confirmation), (items) => {
601
938
  const candidate = items.find((item) => item.action_id === action.action_id);
602
939
  return candidate !== undefined && candidate.status !== "waiting_response";
603
940
  }, { intervalMs: 250, timeoutMs: 10000 });
604
941
  }
605
942
  catch (error) {
606
- if (error instanceof TimeoutError) {
943
+ if (error instanceof TimeoutError || confirmation.aborted) {
607
944
  throw new Error(`Feature flow completed but action status is still pending: ${action.action_id}. Do not retry automatically.`);
608
945
  }
609
946
  throw error;
@@ -612,7 +949,11 @@ threadCmd
612
949
  if (resolvedAction?.status !== "approved") {
613
950
  throw new Error(`Dedicated feature flow did not approve action: ${action.action_id} (${resolvedAction?.status ?? "missing"})`);
614
951
  }
615
- print(format, featureResult);
952
+ print(format, safeOutput({
953
+ ...(featureResult && typeof featureResult === "object" ? featureResult : { result: featureResult }),
954
+ approved: true, action_id: action.action_id, tool_name: action.tool_name,
955
+ ...threadInteraction(projectId),
956
+ }));
616
957
  return;
617
958
  }
618
959
  const actionResponse = {
@@ -628,20 +969,17 @@ threadCmd
628
969
  if (opts.skipAnswers) {
629
970
  actionResponse.question_answers = { answers: {}, skipped: true };
630
971
  }
631
- else if (opts.answers) {
632
- try {
633
- const parsed = JSON.parse(opts.answers);
634
- actionResponse.question_answers = { answers: parsed, skipped: false };
635
- }
636
- catch {
637
- console.error("Error: --answers must be valid JSON");
638
- process.exit(1);
639
- }
972
+ else if (answers) {
973
+ actionResponse.question_answers = { answers, skipped: false };
640
974
  }
641
975
  const data = await client.post(`/v1/projects/${projectId}/thread/chat`, {
642
976
  action_response: actionResponse,
643
977
  });
644
- print(format, data);
978
+ print(format, safeOutput({
979
+ ...data, approved: true,
980
+ action_id: action.action_id, tool_name: action.tool_name,
981
+ ...threadInteraction(projectId, { taskId: String(data.task_id ?? "") }),
982
+ }));
645
983
  });
646
984
  threadCmd
647
985
  .command("reject <project_id> [action_id]")