@enter-pro/enter-cli 0.4.1 → 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 ?? [] };
@@ -66,17 +95,23 @@ const TOOL_HANDLERS = {
66
95
  supabase_add_secret: {
67
96
  kind: "secret",
68
97
  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.",
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
108
  approveSuffix: () => `--secret-value-stdin`,
74
- instructions: "Ask the user for their Stripe secret key, then run approve_command and provide the key through stdin.",
109
+ instructions: "Use a secure input surface or direct CLI stdin for the Stripe key. Never request credentials in ordinary chat.",
75
110
  },
76
111
  stripe_update_key_and_migrate: {
77
112
  kind: "secret",
78
113
  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.",
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.",
80
115
  },
81
116
  // confirm_plan_mode: surface the plan text directly on the action so callers
82
117
  // don't have to fetch + parse thread messages themselves.
@@ -84,8 +119,8 @@ const TOOL_HANDLERS = {
84
119
  kind: "none",
85
120
  approveSuffix: () => "",
86
121
  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");
122
+ enrich: async (projectId, action, context) => {
123
+ const args = await loadToolCallArgs(projectId, action, "confirm_plan_mode", context);
89
124
  switch (args.kind) {
90
125
  case "ok": {
91
126
  const v = args.value;
@@ -107,7 +142,7 @@ const TOOL_HANDLERS = {
107
142
  const DEFAULT_HANDLER = {
108
143
  kind: "none",
109
144
  approveSuffix: () => "",
110
- 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.",
111
146
  };
112
147
  const FEATURE_ENABLE_ROUTES = new Map([
113
148
  ["supabase_enable", "entercloud/enable"],
@@ -115,6 +150,34 @@ const FEATURE_ENABLE_ROUTES = new Map([
115
150
  ["i18n_enable", "i18n/enable"],
116
151
  ["enable_ai_capability", "ai-capability/connect"],
117
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
+ }
118
181
  function handlerFor(toolName) {
119
182
  return TOOL_HANDLERS[toolName] ?? DEFAULT_HANDLER;
120
183
  }
@@ -123,24 +186,31 @@ function buildApproveCommand(projectId, action) {
123
186
  const suffix = handlerFor(action.tool_name).approveSuffix(projectId, action);
124
187
  return suffix ? `${base} ${suffix}` : base;
125
188
  }
126
- async function fetchPendingActions(projectId, actionIds = []) {
127
- return (await fetchActions(projectId, actionIds)).filter((a) => a.status === "waiting_response");
189
+ async function fetchPendingActions(projectId, actionIds = [], signal) {
190
+ return (await fetchActions(projectId, actionIds, signal)).filter((a) => a.status === "waiting_response");
128
191
  }
129
- async function fetchActions(projectId, actionIds = []) {
192
+ async function fetchActions(projectId, actionIds = [], signal) {
130
193
  const body = actionIds.length > 0 ? { actions: actionIds } : {};
131
- const data = await client.post(`/v1/projects/${projectId}/thread/actions`, body);
194
+ const data = await client.post(`/v1/projects/${projectId}/thread/actions`, body, signal);
132
195
  const resp = data;
133
196
  return (resp.actions || []).map(normalizeAction);
134
197
  }
135
198
  threadCmd
136
199
  .command("chat <project_id>")
137
- .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.")
138
201
  .option("-m, --message <text>", "Chat message content")
139
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")
140
205
  .option("--auto-approve", "Pass auto_approve flag to the server")
141
206
  .action(async (id, opts, cmd) => {
142
207
  let content;
143
- 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) {
144
214
  content = readFileSync(opts.file, "utf-8");
145
215
  }
146
216
  else if (opts.message) {
@@ -150,11 +220,19 @@ threadCmd
150
220
  console.error("Error: either -m <text> or --file <path> is required");
151
221
  process.exit(1);
152
222
  }
223
+ if (!content.trim())
224
+ throw new Error("Message must not be empty");
153
225
  const body = { prompt: content, attachments: [] };
154
226
  if (opts.autoApprove)
155
227
  body.auto_approve = true;
228
+ if (opts.chatId)
229
+ body.chat_id = opts.chatId;
156
230
  const data = await client.post(`/v1/projects/${id}/thread/chat`, body);
157
- 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
+ });
158
236
  });
159
237
  threadCmd
160
238
  .command("messages <project_id>")
@@ -164,7 +242,11 @@ threadCmd
164
242
  .option("--turn <n>", "Get messages for a specific turn (shorthand for --start-turn N --end-turn N)")
165
243
  .option("--latest", "Get messages from the most recent turn (most common usage)")
166
244
  .option("--tail <n>", "Get messages from the last N turns")
167
- .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")
168
250
  .action(async (id, opts, cmd) => {
169
251
  if (opts.follow) {
170
252
  await followThreadStream(id, opts);
@@ -211,66 +293,45 @@ threadCmd
211
293
  const data = await client.get(`/v1/projects/${id}/thread/messages`, params);
212
294
  print(getFormat(cmd), data);
213
295
  });
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;
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;
224
311
  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);
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));
237
326
  }
238
327
  }
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));
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
+ }
274
335
  }
275
336
  threadCmd
276
337
  .command("turns <project_id>")
@@ -280,7 +341,7 @@ threadCmd
280
341
  const data = await client.get(`/v1/projects/${id}/thread/turns`);
281
342
  const resp = data;
282
343
  const turns = resp.turns || [];
283
- const items = opts.latest ? turns.slice(-1) : turns;
344
+ const items = opts.latest ? turns.slice(0, 1) : turns;
284
345
  const picked = pickList(items, [
285
346
  "id", "turn", "turn_name", "status", "model", "credits_consumed", "created_at",
286
347
  ]);
@@ -299,96 +360,230 @@ threadCmd
299
360
  ]);
300
361
  printTable(["Turn", "Name", "Status", "Model", "Credits", "Created"], rows);
301
362
  });
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);
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"}`);
347
379
  }
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) => {
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
+ } : {}) };
397
+ }
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}` };
405
+ }
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) => {
353
410
  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,
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,
361
414
  };
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 };
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
+ }
370
424
  }
425
+ return result;
371
426
  }));
372
- print(getFormat(cmd), {
373
- status: "blocked",
374
- reason: "pending_actions",
375
- project_id: id,
376
- actions: enriched,
377
- });
378
- return;
427
+ return { ...base, status: "blocked", reason: "pending_actions", actions };
379
428
  }
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`);
429
+ if (status !== "completed")
430
+ return { ...base, status: status ? "running" : "unknown" };
431
+ const detail = await client.get(`/v1/projects/${id}/detail`, undefined, signal);
385
432
  const project = (detail.project ?? detail);
386
- print(getFormat(cmd), {
387
- status: "completed",
388
- project_id: id,
389
- 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
+ }
390
568
  });
391
- });
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
+ }
392
587
  threadCmd
393
588
  .command("diff <project_id> <turn_number>")
394
589
  .description("Get diff for a turn")
@@ -438,12 +633,25 @@ threadCmd
438
633
  .description("Restore thread to a specific turn (1-based turn number from `thread turns`)")
439
634
  .requiredOption("--turn <n>", "1-based turn number to restore to (NOT a turn UUID)")
440
635
  .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)");
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;
444
654
  }
445
- const data = await client.post(`/v1/projects/${id}/thread/restore`, { turn: turn });
446
- print(getFormat(cmd), data);
447
655
  });
448
656
  threadCmd
449
657
  .command("actions <project_id>")
@@ -539,12 +747,14 @@ threadCmd
539
747
  " stripe_enable : --secret-value-stdin",
540
748
  " stripe_update_key_and_migrate: --secret-value-stdin",
541
749
  " ask_user_question : --answers '<json>' or --skip-answers",
750
+ " supabase_configure_auth_provider: verify saved configuration, or save provider JSON with --auth-config-stdin first",
542
751
  " feature enable cards: run their dedicated backend flow before resolving the action",
543
752
  " other actions : no extra flags unless thread wait says otherwise",
544
753
  ].join("\n"))
545
754
  .option("--secret-name <name>", "Secret variable name (supabase_add_secret)")
546
755
  .option("--secret-value <value>", "Legacy secret argument; prefer --secret-value-stdin")
547
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")
548
758
  .option("--tool-result <result>", "Custom tool result string")
549
759
  .option("--answers <json>", 'Answers for ask_user_question, JSON: \'{"Q text": {"selected_options": ["A"], "other_text": ""}}\'')
550
760
  .option("--skip-answers", "Skip all questions for ask_user_question (sets skipped: true)")
@@ -554,18 +764,76 @@ threadCmd
554
764
  const action = handleResolution(projectId, "approve", resolved, format);
555
765
  if (!action)
556
766
  return;
557
- if (opts.secretValue && opts.secretValueStdin) {
767
+ if (opts.secretValue !== undefined && opts.secretValueStdin) {
558
768
  throw new Error("Use only one of --secret-value or --secret-value-stdin");
559
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
+ }
560
809
  const secretValue = opts.secretValueStdin
561
810
  ? readFileSync(0, "utf8").replace(/\r?\n$/, "")
562
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
+ }
563
829
  let featureResult;
564
830
  let handledFeature = false;
565
831
  const featureRoute = FEATURE_ENABLE_ROUTES.get(action.tool_name);
566
832
  if (featureRoute) {
567
833
  handledFeature = true;
568
- featureResult = await client.post(`/v1/projects/${projectId}/${featureRoute}`, {});
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 : {});
569
837
  }
570
838
  else if (action.tool_name === "stripe_enable") {
571
839
  if (!secretValue)
@@ -595,15 +863,16 @@ threadCmd
595
863
  });
596
864
  }
597
865
  if (handledFeature) {
866
+ const confirmation = AbortSignal.timeout(10000);
598
867
  let actions;
599
868
  try {
600
- actions = await pollUntil(() => fetchActions(projectId, [action.action_id]), (items) => {
869
+ actions = await pollUntil(() => fetchActions(projectId, [action.action_id], confirmation), (items) => {
601
870
  const candidate = items.find((item) => item.action_id === action.action_id);
602
871
  return candidate !== undefined && candidate.status !== "waiting_response";
603
872
  }, { intervalMs: 250, timeoutMs: 10000 });
604
873
  }
605
874
  catch (error) {
606
- if (error instanceof TimeoutError) {
875
+ if (error instanceof TimeoutError || confirmation.aborted) {
607
876
  throw new Error(`Feature flow completed but action status is still pending: ${action.action_id}. Do not retry automatically.`);
608
877
  }
609
878
  throw error;
@@ -612,7 +881,11 @@ threadCmd
612
881
  if (resolvedAction?.status !== "approved") {
613
882
  throw new Error(`Dedicated feature flow did not approve action: ${action.action_id} (${resolvedAction?.status ?? "missing"})`);
614
883
  }
615
- print(format, featureResult);
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
+ }));
616
889
  return;
617
890
  }
618
891
  const actionResponse = {
@@ -628,20 +901,17 @@ threadCmd
628
901
  if (opts.skipAnswers) {
629
902
  actionResponse.question_answers = { answers: {}, skipped: true };
630
903
  }
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
- }
904
+ else if (answers) {
905
+ actionResponse.question_answers = { answers, skipped: false };
640
906
  }
641
907
  const data = await client.post(`/v1/projects/${projectId}/thread/chat`, {
642
908
  action_response: actionResponse,
643
909
  });
644
- 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
+ }));
645
915
  });
646
916
  threadCmd
647
917
  .command("reject <project_id> [action_id]")