@enter-pro/enter-cli 0.4.2 → 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.
@@ -7,7 +7,8 @@ import { pollUntil, TimeoutError } from "../poll.js";
7
7
  import { resolveLifecycleStatus } from "../lifecycle.js";
8
8
  import { ThreadEvents, isStateEvent } from "../thread-events.js";
9
9
  import { safeOutput } from "../safe-output.js";
10
- import { errorEnvelope } from "../errors.js";
10
+ import { RequestError, errorEnvelope } from "../errors.js";
11
+ import { workflowResult } from "../workflow.js";
11
12
  import { registerThreadTasks } from "./thread-tasks.js";
12
13
  export const threadCmd = new Command("thread").description("Manage project threads and chat");
13
14
  registerThreadTasks(threadCmd);
@@ -27,6 +28,8 @@ function normalizeAction(raw) {
27
28
  updated_at: String(raw.updated_at ?? raw.UpdatedAt ?? ""),
28
29
  };
29
30
  }
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.";
30
33
  async function loadToolCallArgs(projectId, action, toolName, context = {}) {
31
34
  const turn = String(action.turn);
32
35
  const data = await (context.messages ??= client.get(`/v1/projects/${projectId}/thread/messages`, { start_turn: turn, end_turn: turn }, context.signal));
@@ -73,18 +76,22 @@ async function loadToolCallArgs(projectId, action, toolName, context = {}) {
73
76
  }
74
77
  return { kind: "not_found" };
75
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.';
76
80
  const TOOL_HANDLERS = {
77
81
  // ask_user_question: surface the questions array directly so callers don't
78
82
  // have to fetch + parse thread messages to know what to ask the user.
79
83
  ask_user_question: {
80
84
  kind: "questions",
81
- approveSuffix: (pid, a) => `--answers '<json>' OR enter-cli thread approve ${pid} ${a.action_id} --skip-answers`,
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).",
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.`,
83
87
  enrich: async (projectId, action, context) => {
84
88
  const args = await loadToolCallArgs(projectId, action, "ask_user_question", context);
85
89
  switch (args.kind) {
86
90
  case "ok":
87
- return { questions: args.value.questions ?? [] };
91
+ return {
92
+ questions: args.value.questions ?? [],
93
+ skip_command: `enter-cli thread approve ${projectId} ${action.action_id} --skip-answers`,
94
+ };
88
95
  case "parse_error":
89
96
  return { questions: { raw_arguments: args.raw_arguments, parse_error: true } };
90
97
  case "not_found":
@@ -94,31 +101,31 @@ const TOOL_HANDLERS = {
94
101
  },
95
102
  supabase_add_secret: {
96
103
  kind: "secret",
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.",
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.",
99
106
  },
100
107
  supabase_configure_auth_provider: {
101
108
  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) }),
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)),
105
112
  },
106
113
  stripe_enable: {
107
114
  kind: "secret",
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.",
115
+ approveSuffix: `--secret-value-stdin`,
116
+ instructions: STRIPE_INPUT_INSTRUCTIONS,
110
117
  },
111
118
  stripe_update_key_and_migrate: {
112
119
  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.",
120
+ approveSuffix: `--secret-value-stdin`,
121
+ instructions: `${STRIPE_INPUT_INSTRUCTIONS} Product IDs come from the pending tool call.`,
115
122
  },
116
123
  // confirm_plan_mode: surface the plan text directly on the action so callers
117
124
  // don't have to fetch + parse thread messages themselves.
118
125
  confirm_plan_mode: {
119
126
  kind: "none",
120
- approveSuffix: () => "",
121
- instructions: "Plan is included in this action's `plan` field. Show it to the user, then run approve_command.",
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.",
122
129
  enrich: async (projectId, action, context) => {
123
130
  const args = await loadToolCallArgs(projectId, action, "confirm_plan_mode", context);
124
131
  switch (args.kind) {
@@ -141,7 +148,7 @@ const TOOL_HANDLERS = {
141
148
  };
142
149
  const DEFAULT_HANDLER = {
143
150
  kind: "none",
144
- approveSuffix: () => "",
151
+ approveSuffix: "",
145
152
  instructions: "No additional input required. Approve only within the user-authorized scope; input_kind none does not itself grant authorization.",
146
153
  };
147
154
  const FEATURE_ENABLE_ROUTES = new Map([
@@ -156,6 +163,25 @@ const AUTH_PROVIDER_FIELDS = {
156
163
  alipay: ["enabled", "app_id", "private_key"],
157
164
  feishu: ["enabled", "app_id", "app_secret"],
158
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
+ }
159
185
  async function authProviderFor(projectId, action, context = {}) {
160
186
  if (!action.tool_call_id)
161
187
  throw new Error("Auth provider action is missing its tool_call_id");
@@ -183,7 +209,7 @@ function handlerFor(toolName) {
183
209
  }
184
210
  function buildApproveCommand(projectId, action) {
185
211
  const base = `enter-cli thread approve ${projectId} ${action.action_id}`;
186
- const suffix = handlerFor(action.tool_name).approveSuffix(projectId, action);
212
+ const suffix = handlerFor(action.tool_name).approveSuffix;
187
213
  return suffix ? `${base} ${suffix}` : base;
188
214
  }
189
215
  async function fetchPendingActions(projectId, actionIds = [], signal) {
@@ -217,8 +243,7 @@ threadCmd
217
243
  content = opts.message;
218
244
  }
219
245
  else {
220
- console.error("Error: either -m <text> or --file <path> is required");
221
- process.exit(1);
246
+ throw new Error("Provide non-empty input using --message, --file or --stdin");
222
247
  }
223
248
  if (!content.trim())
224
249
  throw new Error("Message must not be empty");
@@ -241,6 +266,7 @@ threadCmd
241
266
  .option("--end-turn <n>", "End turn number")
242
267
  .option("--turn <n>", "Get messages for a specific turn (shorthand for --start-turn N --end-turn N)")
243
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")
244
270
  .option("--tail <n>", "Get messages from the last N turns")
245
271
  .option("--follow", "Stream NDJSON events over WebSocket; no automatic approval")
246
272
  .option("--cursor <id>", "Resume after a previously returned event ID")
@@ -249,6 +275,8 @@ threadCmd
249
275
  .option("--max-events <n>", "Stop --follow after N events")
250
276
  .action(async (id, opts, cmd) => {
251
277
  if (opts.follow) {
278
+ if (opts.text)
279
+ throw new Error("--text is not supported with --follow");
252
280
  await followThreadStream(id, opts);
253
281
  return;
254
282
  }
@@ -264,7 +292,7 @@ threadCmd
264
292
  const resp = turnsData;
265
293
  const turns = resp.turns || [];
266
294
  if (turns.length === 0) {
267
- printMessage("No turns found.");
295
+ print(getFormat(cmd), { messages: [] });
268
296
  return;
269
297
  }
270
298
  if (opts.latest) {
@@ -283,15 +311,24 @@ threadCmd
283
311
  }
284
312
  }
285
313
  if (!startTurn) {
286
- console.error("Error: specify --start-turn, --turn, --latest, --tail, or --follow");
287
- process.exit(1);
314
+ throw new Error("Specify --start-turn, --turn, --latest, --tail, or --follow");
288
315
  }
289
316
  const params = {};
290
317
  params.start_turn = startTurn;
291
318
  if (endTurn)
292
319
  params.end_turn = endTurn;
293
320
  const data = await client.get(`/v1/projects/${id}/thread/messages`, params);
294
- print(getFormat(cmd), data);
321
+ if (opts.text) {
322
+ const rows = Array.isArray(data) ? data : data.messages ?? [];
323
+ const messages = rows.flatMap((row) => {
324
+ const content = row?.detail?.assistant_content?.content;
325
+ return row?.message_type === "assistant_content" && typeof content === "string"
326
+ ? [{ turn: row.turn, text: content }] : [];
327
+ });
328
+ print(getFormat(cmd), safeOutput({ messages }));
329
+ }
330
+ else
331
+ print(getFormat(cmd), data);
295
332
  });
296
333
  async function followThreadStream(projectId, opts) {
297
334
  const timeout = positiveNumber(opts.timeout ?? "60", "--timeout") * 1000;
@@ -364,12 +401,13 @@ export function threadInteraction(id, { taskId, chatId, turn, requireBuild } = {
364
401
  const target = (taskId ? ` --task-id ${taskId}` : turn === undefined ? "" : ` --turn ${turn}`)
365
402
  + (chatId ? ` --chat-id ${chatId}` : "") + (requireBuild ? " --require-build" : "");
366
403
  return {
404
+ workflow: workflowResult({ status: "running", task_id: taskId, turn: { turn, chat_id: chatId } }),
367
405
  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}`,
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`,
371
409
  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.",
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.",
373
411
  };
374
412
  }
375
413
  function positiveNumber(value, option, integer = false) {
@@ -386,9 +424,10 @@ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
386
424
  : turnNumber === undefined ? turns[0] : turns.find(t => Number(t.turn) === turnNumber);
387
425
  if (!selected && taskId) {
388
426
  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) ? {
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 ? {
392
431
  correlation: "unavailable",
393
432
  project_activity: turns[0] ? pick(turns[0], ["id", "turn", "status", "chat_id", "commit_id"]) : null,
394
433
  project_status_command: `enter-cli --output json thread status ${id}${chatId ? ` --chat-id ${chatId}` : ""}`,
@@ -435,18 +474,22 @@ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
435
474
  return { ...base, status: "completed", project: { ...project, lifecycle_status: resolveLifecycleStatus(project) } };
436
475
  }
437
476
  function projectSnapshot(snapshot, compact = false) {
477
+ const workflow = workflowResult(snapshot);
438
478
  const result = { ...snapshot };
439
479
  if (compact && snapshot.turn)
440
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
+ }
441
484
  if (snapshot.project) {
442
485
  const p = snapshot.project;
443
486
  if (compact)
444
487
  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);
488
+ result.build_matches_turn = workflow.build?.matches_task ?? false;
447
489
  const supabase = p.supabase;
448
490
  result.integrations = { cloud: supabase?.status ?? "unknown", ai: p.ai_connection_state ?? (p.ai_capability_enabled === true ? "enabled" : "unknown") };
449
491
  }
492
+ result.workflow = workflow;
450
493
  return result;
451
494
  }
452
495
  // Owns observation and lifetime only; callers choose JSON or NDJSON rendering.
@@ -491,7 +534,19 @@ async function observeThread(id, opts, wait, onSnapshot) {
491
534
  throw stream.failure;
492
535
  const revision = stream?.revision ?? 0;
493
536
  querying = true;
494
- last = await readThreadSnapshot(id, target, controller.signal, opts.taskId, opts.chatId);
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
+ }
495
550
  if (opts.requireBuild && last.status === "completed") {
496
551
  const build = last.project?.build_status;
497
552
  const matches = Boolean(last.turn?.commit_id && build?.commit_id === last.turn.commit_id);
@@ -600,7 +655,7 @@ threadCmd
600
655
  else {
601
656
  writeFileSync(path, JSON.stringify(data, null, 2));
602
657
  }
603
- printMessage(`Diff written to ${path}`);
658
+ printResult(getFormat(cmd), { path }, `Diff written to ${path}`);
604
659
  return;
605
660
  }
606
661
  print(getFormat(cmd), data);
@@ -608,10 +663,11 @@ threadCmd
608
663
  threadCmd
609
664
  .command("cancel <project_id>")
610
665
  .description("Cancel the currently running turn. No-op if nothing is running.")
611
- .action(async (id, _opts, cmd) => {
666
+ .option("--chat-id <id>", "Scope cancellation to this chat")
667
+ .action(async (id, opts, cmd) => {
612
668
  // Pre-check the latest turn — the server returns code 1000 even when
613
669
  // nothing is running, which is misleading. We want a clean idempotent no-op.
614
- 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);
615
671
  const turns = turnsResp.turns ?? [];
616
672
  const latest = turns[0];
617
673
  const isRunning = latest && !TERMINAL_TURN_STATUSES.has(String(latest.status ?? ""));
@@ -622,11 +678,13 @@ threadCmd
622
678
  reason: "no_running_turn",
623
679
  latest_turn: latest?.turn ?? null,
624
680
  latest_status: latest?.status ?? null,
681
+ tasks_command: `enter-cli --output json thread tasks ${id}${opts.chatId ? ` --chat-id ${opts.chatId}` : ""}`,
625
682
  }, `Nothing to cancel (latest turn ${latest?.turn ?? "?"} status: ${latest?.status ?? "unknown"}).`);
626
683
  return;
627
684
  }
628
- await client.post(`/v1/projects/${id}/thread/cancel`);
629
- printResult(format, { cancelled: true, turn: latest.turn }, `Cancelled turn ${latest.turn}.`);
685
+ const chatId = opts.chatId ?? latest.chat_id;
686
+ await client.post(`/v1/projects/${id}/thread/cancel`, chatId ? { chat_id: chatId } : undefined);
687
+ printResult(format, { cancelled: true, turn: latest.turn, ...(chatId ? { chat_id: chatId } : {}) }, `Cancelled turn ${latest.turn}.`);
630
688
  });
631
689
  threadCmd
632
690
  .command("restore <project_id>")
@@ -756,7 +814,7 @@ threadCmd
756
814
  .option("--secret-value-stdin", "Read the secret value from stdin instead of process arguments")
757
815
  .option("--auth-config-stdin", "Read configuration JSON for the action's auth provider from stdin; credentials never go into the approval response")
758
816
  .option("--tool-result <result>", "Custom tool result string")
759
- .option("--answers <json>", 'Answers for ask_user_question, JSON: \'{"Q text": {"selected_options": ["A"], "other_text": ""}}\'')
817
+ .option("--answers <json>", `Answers for ask_user_question. ${ANSWERS_FORMAT}`)
760
818
  .option("--skip-answers", "Skip all questions for ask_user_question (sets skipped: true)")
761
819
  .action(async (projectId, actionIdArg, opts, cmd) => {
762
820
  const format = getFormat(cmd);
@@ -792,7 +850,17 @@ threadCmd
792
850
  }
793
851
  const data = await client.get(configPath);
794
852
  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.`);
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;
796
864
  }
797
865
  // The backend additionally verifies stored credentials and the card's
798
866
  // ExpectedProvider before claiming the action. Never send credentials here.
@@ -819,10 +887,10 @@ threadCmd
819
887
  if (answers) {
820
888
  for (const answer of Object.values(answers)) {
821
889
  if (!answer || typeof answer !== "object" || Array.isArray(answer))
822
- throw new Error("Each answer must be an object");
890
+ throw new Error(`Invalid --answers: each answer must be an object. ${ANSWERS_FORMAT}`);
823
891
  const value = answer;
824
892
  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");
893
+ throw new Error(`Invalid --answers: selected_options must be a string array and other_text an optional string. ${ANSWERS_FORMAT}`);
826
894
  }
827
895
  }
828
896
  }
@@ -6,7 +6,7 @@ export const whoamiCmd = new Command("whoami")
6
6
  .description("Show current user info")
7
7
  .action(async (_opts, cmd) => {
8
8
  if (!isAuthenticated()) {
9
- throw new Error("Not authenticated. Run `enter login` or set ENTER_API_KEY environment variable.");
9
+ throw new Error("Not authenticated. Run `enter-cli login` or set ENTER_API_KEY environment variable.");
10
10
  }
11
11
  const data = await client.get("/v1/users/info");
12
12
  const format = cmd.optsWithGlobals().output || "json";
@@ -1,12 +1,9 @@
1
1
  import { Command } from "commander";
2
2
  import * as client from "../client.js";
3
- import { print, printMessage, printTable, pickList } from "../output.js";
3
+ import { print, printResult, printTable, pickList, getFormat } from "../output.js";
4
4
  export const workspaceCmd = new Command("workspace")
5
5
  .alias("ws")
6
6
  .description("Manage workspaces");
7
- function getFormat(cmd) {
8
- return cmd.optsWithGlobals().output || "json";
9
- }
10
7
  workspaceCmd
11
8
  .command("list")
12
9
  .description("List workspaces")
@@ -68,9 +65,9 @@ workspaceCmd
68
65
  workspaceCmd
69
66
  .command("delete <workspace_id>")
70
67
  .description("Delete a workspace")
71
- .action(async (id) => {
68
+ .action(async (id, _opts, cmd) => {
72
69
  await client.del(`/v1/workspaces/${id}`);
73
- printMessage("Workspace deleted successfully.");
70
+ printResult(getFormat(cmd), { deleted: true, workspace_id: id }, "Workspace deleted successfully.");
74
71
  });
75
72
  // Members subcommand group
76
73
  const membersCmd = new Command("members").description("Manage workspace members");
@@ -118,7 +115,7 @@ membersCmd
118
115
  .description("Remove a member from workspace")
119
116
  .option("--email <email>", "Member email")
120
117
  .option("--user-id <id>", "Member user ID")
121
- .action(async (id, opts) => {
118
+ .action(async (id, opts, cmd) => {
122
119
  if (!opts.email && !opts.userId) {
123
120
  throw new Error("--email or --user-id is required");
124
121
  }
@@ -128,7 +125,7 @@ membersCmd
128
125
  if (opts.userId)
129
126
  body.user_id = Number(opts.userId);
130
127
  await client.post(`/v1/workspaces/${id}/members/remove`, body);
131
- printMessage("Member removed successfully.");
128
+ printResult(getFormat(cmd), { removed: true, workspace_id: id }, "Member removed successfully.");
132
129
  });
133
130
  membersCmd
134
131
  .command("update-role <workspace_id>")
@@ -155,9 +152,9 @@ membersCmd
155
152
  membersCmd
156
153
  .command("leave <workspace_id>")
157
154
  .description("Leave a workspace")
158
- .action(async (id) => {
155
+ .action(async (id, _opts, cmd) => {
159
156
  await client.post(`/v1/workspaces/${id}/leave`);
160
- printMessage("Left workspace successfully.");
157
+ printResult(getFormat(cmd), { left: true, workspace_id: id }, "Left workspace successfully.");
161
158
  });
162
159
  workspaceCmd.addCommand(membersCmd);
163
160
  // Credits subcommand group
package/dist/config.d.ts CHANGED
@@ -2,7 +2,6 @@ export interface Config {
2
2
  api_url: string;
3
3
  base_path: string;
4
4
  output: string;
5
- default_workspace: string;
6
5
  }
7
6
  export declare function configDir(): string;
8
7
  export declare function loadConfig(): Config;
package/dist/config.js CHANGED
@@ -8,7 +8,6 @@ const defaults = {
8
8
  api_url: "https://api.enter.pro",
9
9
  base_path: "/code/api",
10
10
  output: "json",
11
- default_workspace: "",
12
11
  };
13
12
  function ensureConfigDir() {
14
13
  if (!existsSync(CONFIG_DIR)) {
@@ -32,8 +31,6 @@ function getEnvOverrides() {
32
31
  overrides.base_path = process.env.ENTER_BASE_PATH;
33
32
  if (process.env.ENTER_OUTPUT)
34
33
  overrides.output = process.env.ENTER_OUTPUT;
35
- if (process.env.ENTER_DEFAULT_WORKSPACE)
36
- overrides.default_workspace = process.env.ENTER_DEFAULT_WORKSPACE;
37
34
  return overrides;
38
35
  }
39
36
  export function configDir() {
@@ -42,15 +39,24 @@ export function configDir() {
42
39
  export function loadConfig() {
43
40
  const fileConfig = loadFromFile();
44
41
  const envOverrides = getEnvOverrides();
45
- return { ...defaults, ...fileConfig, ...envOverrides };
42
+ const merged = { ...defaults, ...fileConfig, ...envOverrides };
43
+ return Object.fromEntries(Object.keys(defaults).map(key => [key, merged[key]]));
46
44
  }
47
45
  export function setConfig(key, value) {
46
+ validateKey(key);
47
+ if (key === "output" && !["json", "yaml", "table"].includes(value))
48
+ throw new Error("output must be json, yaml or table");
48
49
  ensureConfigDir();
49
50
  const current = loadFromFile();
50
51
  current[key] = value;
51
52
  writeFileSync(CONFIG_FILE, yaml.dump(current), "utf-8");
52
53
  }
54
+ function validateKey(key) {
55
+ if (!Object.hasOwn(defaults, key))
56
+ throw new Error(`Unsupported setting "${key}". Supported settings: ${Object.keys(defaults).join(", ")}. Pass workspace IDs explicitly to project commands.`);
57
+ }
53
58
  export function getConfig(key) {
59
+ validateKey(key);
54
60
  const cfg = loadConfig();
55
61
  return cfg[key] || "";
56
62
  }
package/dist/output.d.ts CHANGED
@@ -6,21 +6,5 @@ export declare function printMessage(msg: string): void;
6
6
  export declare function printResult(format: string, structured: unknown, message: string): void;
7
7
  import type { Command } from "commander";
8
8
  export declare function getFormat(cmd: Command): string;
9
- export declare function printError(err: Error | string): void;
10
9
  export declare function pick<T extends Record<string, unknown>>(obj: T, keys: string[]): Record<string, unknown>;
11
10
  export declare function pickList(items: Record<string, unknown>[], keys: string[]): Record<string, unknown>[];
12
- export interface ListEnvelope {
13
- items: unknown[];
14
- total?: number;
15
- page?: number;
16
- page_size?: number;
17
- }
18
- export interface TableConfig {
19
- headers: string[];
20
- rowMapper: (item: Record<string, unknown>) => string[];
21
- }
22
- export declare function printList(format: string, data: ListEnvelope, tableConfig?: TableConfig): void;
23
- export declare function printSingle(format: string, data: unknown, tableConfig?: {
24
- headers: string[];
25
- rowMapper: (item: Record<string, unknown>) => string[];
26
- }): void;
package/dist/output.js CHANGED
@@ -59,9 +59,6 @@ export function printResult(format, structured, message) {
59
59
  export function getFormat(cmd) {
60
60
  return cmd.optsWithGlobals().output || "json";
61
61
  }
62
- export function printError(err) {
63
- console.error(`Error: ${typeof err === "string" ? err : err.message}`);
64
- }
65
62
  export function pick(obj, keys) {
66
63
  const result = {};
67
64
  for (const key of keys) {
@@ -72,18 +69,3 @@ export function pick(obj, keys) {
72
69
  export function pickList(items, keys) {
73
70
  return items.map((item) => pick(item, keys));
74
71
  }
75
- export function printList(format, data, tableConfig) {
76
- if (format === "table" && tableConfig) {
77
- const rows = data.items.map(tableConfig.rowMapper);
78
- printTable(tableConfig.headers, rows);
79
- return;
80
- }
81
- print(format, data);
82
- }
83
- export function printSingle(format, data, tableConfig) {
84
- if (format === "table" && tableConfig) {
85
- printTable(tableConfig.headers, [tableConfig.rowMapper(data)]);
86
- return;
87
- }
88
- print(format, data);
89
- }
package/dist/poll.d.ts CHANGED
@@ -2,6 +2,7 @@ export declare class TimeoutError extends Error {
2
2
  constructor(elapsedMs: number);
3
3
  }
4
4
  export interface PollOptions {
5
+ signal?: AbortSignal;
5
6
  intervalMs?: number;
6
7
  timeoutMs?: number;
7
8
  onTick?: (elapsed: number, data: unknown) => void;
package/dist/poll.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
1
2
  export class TimeoutError extends Error {
2
3
  constructor(elapsedMs) {
3
4
  super(`Timed out after ${Math.round(elapsedMs / 1000)}s`);
@@ -10,6 +11,7 @@ export async function pollUntil(fetcher, predicate, options) {
10
11
  const onTick = options?.onTick;
11
12
  const start = Date.now();
12
13
  while (true) {
14
+ options?.signal?.throwIfAborted();
13
15
  const data = await fetcher();
14
16
  const elapsed = Date.now() - start;
15
17
  if (predicate(data)) {
@@ -19,6 +21,6 @@ export async function pollUntil(fetcher, predicate, options) {
19
21
  throw new TimeoutError(elapsed);
20
22
  }
21
23
  onTick?.(elapsed, data);
22
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
24
+ await delay(Math.min(intervalMs, Math.max(0, timeoutMs - elapsed)), undefined, { signal: options?.signal });
23
25
  }
24
26
  }
@@ -30,6 +30,7 @@ export declare class ThreadEvents {
30
30
  private wake;
31
31
  private setMode;
32
32
  private connect;
33
+ private connectAuthenticated;
33
34
  wait(afterRevision: number, timeoutMs: number): Promise<void>;
34
35
  close(): void;
35
36
  }
@@ -2,7 +2,7 @@ import WebSocket from "ws";
2
2
  import { HttpsProxyAgent } from "https-proxy-agent";
3
3
  import { getProxyForUrl } from "proxy-from-env";
4
4
  import { baseURL } from "./config.js";
5
- import { getToken } from "./auth.js";
5
+ import { getValidToken } from "./auth.js";
6
6
  import { RequestError } from "./errors.js";
7
7
  export function streamURL(base, projectId, options) {
8
8
  const url = new URL(`${base.replace(/\/$/, "")}/v1/projects/${encodeURIComponent(projectId)}/thread/stream`);
@@ -77,10 +77,20 @@ export class ThreadEvents {
77
77
  this.wake();
78
78
  }
79
79
  connect() {
80
+ void this.connectAuthenticated().catch(error => {
81
+ if (this.stopped)
82
+ return;
83
+ this.failure = new RequestError("STREAM_SETUP_ERROR", error instanceof Error ? error.message : "Could not initialize Enter event stream.");
84
+ this.setMode("polling", "stream_setup_failed");
85
+ });
86
+ }
87
+ async connectAuthenticated() {
80
88
  if (this.stopped)
81
89
  return;
82
90
  const url = streamURL(baseURL(), this.projectId, { ...this.options, cursor: this.cursor });
83
- const token = getToken();
91
+ const token = await getValidToken(this.options.signal);
92
+ if (this.stopped)
93
+ return;
84
94
  // proxy-from-env honors NO_PROXY, including loopback fixture servers.
85
95
  const proxy = process.env.NODE_USE_ENV_PROXY === "0" ? "" : getProxyForUrl(url.toString().replace(/^ws/, "http"));
86
96
  const socket = new WebSocket(url, {
@@ -0,0 +1,44 @@
1
+ export type ThreadStatus = 'idle' | 'running' | 'queued' | 'pending' | 'blocked' | 'completed' | 'failed' | 'unknown';
2
+ export type InputKind = 'none' | 'secret' | 'questions' | 'auth_provider';
3
+ export interface WorkflowSnapshot {
4
+ status: ThreadStatus;
5
+ task_id?: unknown;
6
+ turn?: Record<string, unknown> | null;
7
+ actions?: Record<string, unknown>[];
8
+ project?: Record<string, unknown>;
9
+ reason?: string;
10
+ interrupted?: boolean;
11
+ wait_timed_out?: boolean;
12
+ observation_retrying?: boolean;
13
+ error?: {
14
+ retryable?: boolean;
15
+ };
16
+ }
17
+ /** Host-facing workflow decisions, independent of transport and backend phases. */
18
+ export declare function workflowResult(snapshot: WorkflowSnapshot): {
19
+ actions?: {
20
+ id: unknown;
21
+ kind: unknown;
22
+ decision: string;
23
+ required_fields: {};
24
+ submit_command: unknown;
25
+ }[] | undefined;
26
+ build?: {
27
+ commit: {} | null;
28
+ matches_task: boolean;
29
+ success: {} | null;
30
+ } | undefined;
31
+ observation: {
32
+ timed_out: boolean;
33
+ retrying: boolean;
34
+ interrupted: boolean;
35
+ };
36
+ reason?: string | undefined;
37
+ state: string;
38
+ task: {
39
+ id: {} | null;
40
+ turn: {} | null;
41
+ chat_id: {} | null;
42
+ };
43
+ next_action: string;
44
+ };