@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.
- package/README.md +252 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +20 -2
- package/dist/client.d.ts +3 -2
- package/dist/client.js +85 -31
- package/dist/commands/login.js +5 -3
- package/dist/commands/project.js +11 -29
- package/dist/commands/thread-tasks.d.ts +2 -0
- package/dist/commands/thread-tasks.js +23 -0
- package/dist/commands/thread.d.ts +27 -0
- package/dist/commands/thread.js +457 -187
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +29 -0
- package/dist/index.js +9 -2
- package/dist/safe-output.d.ts +6 -0
- package/dist/safe-output.js +29 -0
- package/dist/thread-events.d.ts +36 -0
- package/dist/thread-events.js +186 -0
- package/package.json +17 -9
package/dist/commands/thread.js
CHANGED
|
@@ -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
|
-
|
|
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?.
|
|
31
|
-
if (!detail
|
|
41
|
+
const detail = e.detail?.[type];
|
|
42
|
+
if (!detail)
|
|
32
43
|
continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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.
|
|
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.
|
|
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("
|
|
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),
|
|
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", "
|
|
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,
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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(
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
355
|
-
action_id: a.action_id,
|
|
356
|
-
|
|
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 (
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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 =
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
632
|
-
|
|
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,
|
|
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]")
|