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