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