aui-agent-builder 0.4.15 → 0.4.17

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,13 +7,13 @@ import chalk from "chalk";
7
7
  import { getAuthenticatedSession, listAgents as listAgentsSvc, listVersions as listVersionsSvc, switchToAgent, } from "../services/agents.service.js";
8
8
  import { AgentsMenuView, AgentsListView, AgentSwitchView, VersionsListView, AgentDeletePreviewView, AgentDeleteResultView, } from "../ui/views/AgentsView.js";
9
9
  import { Spinner, ErrorDisplay, StatusLine } from "../ui/components/index.js";
10
- import { AUIClient } from "../api-client/index.js";
10
+ import { AUIClient, AUIAPIError } from "../api-client/index.js";
11
11
  import { ApolloClient } from "../api-client/apollo-client.js";
12
12
  import { isJsonMode, outputJson, stderrLog } from "../utils/json-output.js";
13
13
  import { searchableSelect } from "../utils/select-prompt.js";
14
14
  import { getConfig, loadSession, saveSession, loadProjectConfig, findProjectRoot } from "../config/index.js";
15
15
  import { resolveAgentManagementInfo } from "./util/agent-resolve.js";
16
- import { ConfigError } from "../errors/index.js";
16
+ import { CLIError, ConfigError, ValidationError } from "../errors/index.js";
17
17
  import { getTracer, SpanStatusCode, setUserContext } from "../telemetry.js";
18
18
  function renderView(element) {
19
19
  const instance = render(element);
@@ -44,6 +44,147 @@ async function deleteNetworkBestEffort(client, networkId) {
44
44
  return { deleted: false, errorMessage: formatApiErrorBody(err) };
45
45
  }
46
46
  }
47
+ /**
48
+ * A MongoDB ObjectId string — the only shape the provisioning endpoint
49
+ * accepts for `network_category_id` (it 422s on anything else with
50
+ * `network_category_id must match ^[0-9a-f]{24}$`). We use this to decide
51
+ * whether a raw `--category` value can be forwarded as-is when the
52
+ * categories endpoint is unavailable to resolve names/keys.
53
+ */
54
+ const OBJECT_ID_RE = /^[0-9a-f]{24}$/i;
55
+ /**
56
+ * Resolve a `--category` value (id, key, or name) to its category id,
57
+ * defaulting to the "General" category when nothing is passed.
58
+ *
59
+ * This replaces the old per-call resolution blocks that silently swallowed
60
+ * a failed `categories.list()` (CTS-16872): when the lookup failed they
61
+ * kept the raw value (e.g. the literal "General"), which then 422'd at the
62
+ * provisioning endpoint, and the announced "default General" was never
63
+ * actually applied. Instead we now:
64
+ * - resolve id / key / name against the live category list,
65
+ * - forward an already-valid ObjectId unchanged (server decides),
66
+ * - throw a clear, actionable error otherwise (instead of a downstream
67
+ * 422 / "needs a category").
68
+ */
69
+ async function resolveCategoryId(client, raw) {
70
+ let categories;
71
+ try {
72
+ categories = (await client.categories.list()).data ?? [];
73
+ }
74
+ catch (error) {
75
+ // Couldn't reach the categories endpoint. The only safe thing to do is
76
+ // forward a value that's already a valid ObjectId; anything else (a
77
+ // name/key we can't resolve, or the default) must fail loudly.
78
+ if (raw && OBJECT_ID_RE.test(raw))
79
+ return { id: raw, label: raw };
80
+ throw new CLIError(`Could not load categories to resolve --category${raw ? ` "${raw}"` : ""}.`, {
81
+ code: "API_SERVER_ERROR",
82
+ suggestion: "List available categories with `aui agents --categories`, then pass --category <id>. Retry shortly if this is transient.",
83
+ cause: error,
84
+ });
85
+ }
86
+ if (raw) {
87
+ const up = raw.toUpperCase();
88
+ const match = categories.find((c) => c._id === raw ||
89
+ c.key?.toUpperCase() === up ||
90
+ c.name?.toUpperCase() === up);
91
+ if (match)
92
+ return { id: match._id, label: `${match.name} (${match.key})` };
93
+ // Unknown to us but a structurally valid id — let the server decide.
94
+ if (OBJECT_ID_RE.test(raw))
95
+ return { id: raw, label: raw };
96
+ throw new ValidationError(`Category "${raw}" not found.`, {
97
+ suggestion: `List categories with \`aui agents --categories\`.${categories.length
98
+ ? ` Available: ${categories.map((c) => `${c.name} (${c.key})`).slice(0, 10).join(", ")}${categories.length > 10 ? `, … (${categories.length} total)` : ""}`
99
+ : ""}`,
100
+ });
101
+ }
102
+ // No --category passed: apply the real "General" default.
103
+ const general = categories.find((c) => c.name?.toUpperCase() === "GENERAL" || c.key?.toUpperCase() === "GENERAL");
104
+ if (general)
105
+ return { id: general._id, label: `${general.name} (${general.key})` };
106
+ if (categories[0]) {
107
+ return { id: categories[0]._id, label: `${categories[0].name} (${categories[0].key})` };
108
+ }
109
+ throw new CLIError("No categories are available to default to.", {
110
+ code: "API_CLIENT_ERROR",
111
+ suggestion: "Pass an explicit --category <id>. List the ids with `aui agents --categories`.",
112
+ });
113
+ }
114
+ /**
115
+ * Fetch ALL organizations the user can see (following pagination). Returns
116
+ * `[]` on any error — callers treat "couldn't list" the same as "none" and
117
+ * fall back to flags/session. Used by the non-interactive-safe `--create`
118
+ * scope resolver (CTS-16852).
119
+ */
120
+ async function fetchAllOrganizations(client) {
121
+ try {
122
+ const first = await client.organizations.listMy(1, 50);
123
+ const docs = [...first.data.docs];
124
+ const totalPages = first.data.totalPages ?? 1;
125
+ if (totalPages > 1) {
126
+ const rest = await Promise.all(Array.from({ length: totalPages - 1 }, (_, i) => i + 2).map((p) => client.organizations.listMy(p, 50)));
127
+ for (const r of rest)
128
+ docs.push(...r.data.docs);
129
+ }
130
+ return docs.map((o) => ({ id: o._id || o.id || "", name: o.name }));
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ }
136
+ /**
137
+ * Fetch ALL accounts in the current org scope (following pagination).
138
+ * Returns `[]` on any error. See `fetchAllOrganizations`.
139
+ */
140
+ async function fetchAllAccounts(client) {
141
+ try {
142
+ const first = await client.accounts.list(1, 50);
143
+ const docs = [...first.data.docs];
144
+ const totalPages = first.data.totalPages ?? 1;
145
+ if (totalPages > 1) {
146
+ const rest = await Promise.all(Array.from({ length: totalPages - 1 }, (_, i) => i + 2).map((p) => client.accounts.list(p, 50)));
147
+ for (const r of rest)
148
+ docs.push(...r.data.docs);
149
+ }
150
+ return docs.map((a) => ({
151
+ id: a._id || a.id || "",
152
+ name: a.name,
153
+ niceName: a.niceName,
154
+ }));
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ }
160
+ /**
161
+ * Whether an error from the provisioning endpoint means the agent/network
162
+ * name is already taken (HTTP 409, or a backend message to that effect).
163
+ * Used by `--if-not-exists` to import the existing agent instead of failing.
164
+ */
165
+ function isAlreadyExistsError(error) {
166
+ if (error instanceof AUIAPIError && error.status === 409)
167
+ return true;
168
+ const msg = error instanceof Error ? error.message : String(error);
169
+ return /already exists|name already|already in use/i.test(msg);
170
+ }
171
+ /**
172
+ * Find a regular (NETWORK-scoped) agent by exact name in the current
173
+ * account. Used by `--if-not-exists` to recover from a 409 (name reserved)
174
+ * — including the parallel-create orphan case (CTS-16874) where the agent
175
+ * was provisioned server-side but the local import never ran.
176
+ */
177
+ async function findExistingAgentByName(client, name, accountId) {
178
+ try {
179
+ const resp = await client.agentManagement.listAgents(client.getOrganizationId(), 1, 50, { account_id: accountId || undefined, name });
180
+ const wanted = name.trim().toLowerCase();
181
+ return (resp.items.find((a) => (a.name ?? "").trim().toLowerCase() === wanted) ??
182
+ null);
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ }
47
188
  /**
48
189
  * Build a human-readable summary from an API error.
49
190
  *
@@ -140,6 +281,10 @@ async function runAgents(options = {}) {
140
281
  await handleListAgents();
141
282
  return;
142
283
  }
284
+ if (options.categories) {
285
+ await handleListCategories();
286
+ return;
287
+ }
143
288
  if (options.versions !== undefined) {
144
289
  const agentId = typeof options.versions === "string" ? options.versions : undefined;
145
290
  await handleListVersions(agentId);
@@ -221,6 +366,49 @@ async function runAgents(options = {}) {
221
366
  await importFromOtherOrg();
222
367
  }
223
368
  }
369
+ async function handleListCategories() {
370
+ // Network categories are org-scoped; their ids are required by
371
+ // `aui agents --create --category <id>` (the provisioning endpoint only
372
+ // accepts a 24-hex ObjectId). Before this there was no way to discover
373
+ // them from the CLI — users had to GET an existing agent to read its
374
+ // `network_category_id` (CTS-16872).
375
+ const config = getConfig();
376
+ const client = new AUIClient({
377
+ baseUrl: config.apiUrl,
378
+ authToken: config.authToken,
379
+ accountId: config.accountId,
380
+ organizationId: config.organizationId,
381
+ environment: config.environment,
382
+ });
383
+ if (isJsonMode()) {
384
+ stderrLog("Fetching categories...");
385
+ const resp = await client.categories.list();
386
+ outputJson({
387
+ categories: (resp.data ?? []).map((c) => ({
388
+ id: c._id,
389
+ key: c.key,
390
+ name: c.name,
391
+ status: c.status,
392
+ })),
393
+ });
394
+ return;
395
+ }
396
+ const spinnerInstance = render(_jsx(Spinner, { label: "Fetching categories..." }));
397
+ try {
398
+ const resp = await client.categories.list();
399
+ spinnerInstance.unmount();
400
+ const cats = resp.data ?? [];
401
+ if (cats.length === 0) {
402
+ logView(_jsx(StatusLine, { kind: "warning", label: "No categories found for this organization." }));
403
+ return;
404
+ }
405
+ renderView(_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(StatusLine, { kind: "info", label: `Categories (${cats.length}) — pass the id to --category` }), cats.map((c) => (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: c._id }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { children: c.name }), _jsx(Text, { color: "gray", children: c.key ? ` (${c.key})` : "" })] }, c._id)))] }));
406
+ }
407
+ catch (error) {
408
+ spinnerInstance.unmount();
409
+ renderView(_jsx(ErrorDisplay, { error: error, message: "Failed to fetch categories." }));
410
+ }
411
+ }
224
412
  async function handleListAgents() {
225
413
  // Account scope for listing comes from, in priority:
226
414
  // 1. --account-id (explicit; wins anywhere, in or out of a folder)
@@ -987,7 +1175,7 @@ async function runAgents(options = {}) {
987
1175
  logView(_jsx(StatusLine, { kind: "warning", label: `Network shell could not be removed automatically: ${networkResult.errorMessage}. The agent may still appear in some legacy listings.` }));
988
1176
  }
989
1177
  const cleared = clearSessionIfMatchesAgent(networkId, agentManagementId);
990
- renderView(_jsx(AgentDeleteResultView, { scope: "agent", agentName: agentName, cleared: cleared }));
1178
+ renderView(_jsx(AgentDeleteResultView, { scope: "agent", agentName: agentName, cleared: cleared, networkOrphaned: !networkResult.deleted, networkId: networkId }));
991
1179
  }
992
1180
  catch (error) {
993
1181
  deleteSpinner.unmount();
@@ -1235,32 +1423,30 @@ async function runAgents(options = {}) {
1235
1423
  });
1236
1424
  let networkId = opts.networkId;
1237
1425
  let categoryId = opts.category;
1426
+ let categoryLabel;
1238
1427
  let agentName = opts.name;
1239
- // Resolve category early — supports ID, key, or name
1428
+ // Resolve an explicit `--category` early — supports ID, key, or name.
1429
+ // Unlike the old block this NO LONGER silently swallows a failed lookup
1430
+ // (which used to forward the raw "General" and 422 downstream — CTS-16872):
1431
+ // `resolveCategoryId` throws a clear error instead. The default-category
1432
+ // resolution for the no-`--category` case happens later, once the org is
1433
+ // selected (interactive flow), so we only resolve here when one was passed.
1240
1434
  if (categoryId) {
1241
- try {
1242
- const catResp = await client.categories.list();
1243
- const catUpper = categoryId.toUpperCase();
1244
- const match = catResp.data.find((c) => c._id === categoryId ||
1245
- c.key?.toUpperCase() === catUpper ||
1246
- c.name?.toUpperCase() === catUpper);
1247
- if (match) {
1248
- categoryId = match._id;
1249
- }
1250
- else {
1251
- const available = catResp.data
1252
- .map((c) => `${c.name} (${c.key})`)
1253
- .slice(0, 10)
1254
- .join(", ");
1255
- renderView(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StatusLine, { kind: "error", label: `Category "${opts.category}" not found.` }), _jsxs(Text, { color: "gray", children: [" Available: ", available, catResp.data.length > 10 ? `, ... (${catResp.data.length} total)` : ""] }), _jsx(Text, { children: " " })] }));
1256
- return;
1257
- }
1258
- }
1259
- catch {
1260
- // keep raw value — categories endpoint might be unavailable
1261
- }
1435
+ const resolved = await resolveCategoryId(client, categoryId);
1436
+ categoryId = resolved.id;
1437
+ categoryLabel = resolved.label;
1438
+ }
1439
+ // Pin explicit scope from flags up-front so the interactive flow can
1440
+ // skip the matching picker. `--account-id` pins both org+account (it's
1441
+ // account-unique); `--organization-id` pins only the org so a
1442
+ // non-interactive create can skip the org picker without also choosing
1443
+ // an account (CTS-16852).
1444
+ if (opts.organizationId) {
1445
+ client.setScope({ organizationId: opts.organizationId });
1446
+ const currentSession = loadSession();
1447
+ currentSession.organization_id = opts.organizationId;
1448
+ saveSession(currentSession);
1262
1449
  }
1263
- // If --account-id is passed, set scope directly (skip org/account prompts)
1264
1450
  if (opts.accountId) {
1265
1451
  client.setScope({ accountId: opts.accountId });
1266
1452
  const currentSession = loadSession();
@@ -1286,8 +1472,14 @@ async function runAgents(options = {}) {
1286
1472
  }
1287
1473
  // Prompt for name if it wasn't passed on the CLI. Same prompt as
1288
1474
  // the regular interactive flow (kept consistent so users don't
1289
- // have to learn a separate template UX).
1475
+ // have to learn a separate template UX). Fail clearly instead of
1476
+ // hanging when there's no TTY (CTS-16852).
1290
1477
  if (!agentName) {
1478
+ if (!canPrompt()) {
1479
+ throw new ValidationError("A template name is required.", {
1480
+ suggestion: "Pass --name <name> for non-interactive create.",
1481
+ });
1482
+ }
1291
1483
  const { name: inputName } = await inquirer.prompt([
1292
1484
  {
1293
1485
  type: "input",
@@ -1337,14 +1529,14 @@ async function runAgents(options = {}) {
1337
1529
  // Direct single-command: aui agents --create --name X --category Y (new network)
1338
1530
  if (agentName && categoryId && !networkId) {
1339
1531
  // Bundle-mode: hand the whole flow (network → agent → version →
1340
- // publish → activate) to the Apollo provisioning endpoint.
1341
- const created = await createBundledAgentViaApollo({
1532
+ // publish → activate) to the Apollo provisioning endpoint. The helper
1533
+ // exits non-zero on provision failure and supports --if-not-exists.
1534
+ await createRegularAgentAndImport(client, {
1342
1535
  name: agentName,
1343
1536
  networkCategoryId: categoryId,
1344
1537
  organizationId: client.getOrganizationId(),
1345
1538
  accountId: client.getAccountId(),
1346
- });
1347
- await autoImportAndEnter(created, opts.dir, agentName);
1539
+ }, { dir: opts.dir, ifNotExists: opts.ifNotExists });
1348
1540
  return;
1349
1541
  // ─── RECORDS-MODE CREATION RETIRED — moved to bundle-mode creation ──
1350
1542
  // The legacy per-step records path (network create → createAgentOnBackend
@@ -1370,75 +1562,85 @@ async function runAgents(options = {}) {
1370
1562
  // return;
1371
1563
  }
1372
1564
  // ─── Interactive flow with step indicators ───
1373
- logView(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u25C6 " }), _jsx(Text, { bold: true, children: "Create a New Agent" })] }), !categoryId && (_jsxs(_Fragment, { children: [_jsx(Box, { children: _jsx(Text, { color: "gray", children: " Using default category (General). To use a specific one:" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " aui agents --create --category " }), _jsx(Text, { color: "gray", italic: true, children: "<name or key>" })] })] })), categoryId && (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " Category: " }), _jsx(Text, { children: opts.category })] }))] }));
1565
+ logView(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u25C6 " }), _jsx(Text, { bold: true, children: "Create a New Agent" })] }), !categoryId && (_jsxs(_Fragment, { children: [_jsx(Box, { children: _jsx(Text, { color: "gray", children: " No --category passed; will use the default (General) if available." }) }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " Override with aui agents --create --category " }), _jsx(Text, { color: "gray", italic: true, children: "<name or key>" }), _jsx(Text, { color: "gray", children: " (list ids: aui agents --categories)" })] })] })), categoryId && (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " Category: " }), _jsx(Text, { children: categoryLabel ?? opts.category })] }))] }));
1374
1566
  if (!opts.accountId) {
1375
- // Step 1: Select organization
1567
+ // Resolve org + account scope. This used to ALWAYS render a picker when
1568
+ // there was >1 option, which hangs a non-interactive create (no TTY)
1569
+ // — `aui agents --create --name X --dir .` would block on "Select
1570
+ // organization" forever (CTS-16852). Now we only prompt when we can
1571
+ // (`canPrompt`); otherwise we resolve from `--organization-id` / the
1572
+ // session, and fail with a clear, actionable error if it's still
1573
+ // ambiguous rather than hanging.
1574
+ const sessionForScope = loadSession();
1575
+ // Step 1: Organization
1376
1576
  logView(_jsx(Text, { color: "gray", children: " \u250C Step 1 \u2192 Select Organization" }));
1377
- {
1577
+ if (opts.organizationId) {
1578
+ // Already pinned above from the flag — nothing to choose.
1579
+ logView(_jsx(StatusLine, { kind: "success", label: `Organization: ${opts.organizationId}` }));
1580
+ }
1581
+ else {
1378
1582
  const orgSpinner = render(_jsx(Spinner, { label: "Fetching organizations..." }));
1379
- try {
1380
- // Fetch page 1 to learn totalPages, then fetch the rest in parallel
1381
- // so orgs beyond the first 50 are not silently dropped.
1382
- const orgFirst = await client.organizations.listMy(1, 50);
1383
- const orgs = [...orgFirst.data.docs];
1384
- const orgTotalPages = orgFirst.data.totalPages ?? 1;
1385
- if (orgTotalPages > 1) {
1386
- const restPages = Array.from({ length: orgTotalPages - 1 }, (_, i) => i + 2);
1387
- const rest = await Promise.all(restPages.map((p) => client.organizations.listMy(p, 50)));
1388
- for (const r of rest)
1389
- orgs.push(...r.data.docs);
1390
- }
1391
- orgSpinner.unmount();
1392
- if (orgs.length === 1) {
1393
- client.setScope({ organizationId: orgs[0]._id || orgs[0].id });
1394
- logView(_jsx(StatusLine, { kind: "success", label: `Organization: ${orgs[0].name}` }));
1395
- }
1396
- else if (orgs.length > 1) {
1583
+ const orgs = await fetchAllOrganizations(client);
1584
+ orgSpinner.unmount();
1585
+ if (orgs.length === 1) {
1586
+ client.setScope({ organizationId: orgs[0].id });
1587
+ logView(_jsx(StatusLine, { kind: "success", label: `Organization: ${orgs[0].name}` }));
1588
+ }
1589
+ else if (orgs.length > 1) {
1590
+ if (canPrompt()) {
1397
1591
  const chosen = await searchableSelect({
1398
1592
  message: "Select organization:",
1399
- choices: orgs.map((o) => ({ name: o.name, value: o._id || o.id })),
1593
+ choices: orgs.map((o) => ({ name: o.name, value: o.id })),
1400
1594
  });
1401
1595
  client.setScope({ organizationId: chosen });
1402
1596
  }
1597
+ else if (sessionForScope?.organization_id) {
1598
+ client.setScope({ organizationId: sessionForScope.organization_id });
1599
+ logView(_jsx(StatusLine, { kind: "muted", label: `Organization: ${sessionForScope.organization_id} (from session)` }));
1600
+ }
1601
+ else {
1602
+ throw new ValidationError("Multiple organizations available and no TTY to choose one.", {
1603
+ suggestion: "Pass --organization-id <id> (or --account-id <id> to pin both) for non-interactive create. List them with `aui account --list`.",
1604
+ });
1605
+ }
1403
1606
  }
1404
- catch {
1405
- orgSpinner.unmount();
1406
- }
1607
+ // 0 orgs (e.g. listing failed): leave scope as-is; downstream calls
1608
+ // surface the real error.
1407
1609
  }
1408
- // Step 2: Select account
1610
+ // Step 2: Account
1409
1611
  logView(_jsx(Text, { color: "gray", children: " \u251C Step 2 \u2192 Select Account" }));
1410
1612
  {
1411
1613
  const accSpinner = render(_jsx(Spinner, { label: "Fetching accounts..." }));
1412
- try {
1413
- // Fetch page 1 to learn totalPages, then fetch the rest in parallel
1414
- // so accounts beyond the first 50 are not silently dropped.
1415
- const accFirst = await client.accounts.list(1, 50);
1416
- const accounts = [...accFirst.data.docs];
1417
- const accTotalPages = accFirst.data.totalPages ?? 1;
1418
- if (accTotalPages > 1) {
1419
- const restPages = Array.from({ length: accTotalPages - 1 }, (_, i) => i + 2);
1420
- const rest = await Promise.all(restPages.map((p) => client.accounts.list(p, 50)));
1421
- for (const r of rest)
1422
- accounts.push(...r.data.docs);
1423
- }
1424
- accSpinner.unmount();
1425
- if (accounts.length === 1) {
1426
- client.setScope({ accountId: accounts[0]._id || accounts[0].id });
1427
- logView(_jsx(StatusLine, { kind: "success", label: `Account: ${accounts[0].name}` }));
1428
- }
1429
- else if (accounts.length > 1) {
1614
+ const accounts = await fetchAllAccounts(client);
1615
+ accSpinner.unmount();
1616
+ if (accounts.length === 1) {
1617
+ client.setScope({ accountId: accounts[0].id });
1618
+ logView(_jsx(StatusLine, { kind: "success", label: `Account: ${accounts[0].name}` }));
1619
+ }
1620
+ else if (accounts.length > 1) {
1621
+ if (canPrompt()) {
1430
1622
  const chosen = await searchableSelect({
1431
1623
  message: "Select account:",
1432
- choices: accounts.map((a) => ({ name: `${a.name} (${a.niceName})`, value: a._id || a.id })),
1624
+ choices: accounts.map((a) => ({
1625
+ name: a.niceName ? `${a.name} (${a.niceName})` : a.name,
1626
+ value: a.id,
1627
+ })),
1433
1628
  });
1434
1629
  client.setScope({ accountId: chosen });
1435
1630
  }
1631
+ else if (sessionForScope?.account_id) {
1632
+ client.setScope({ accountId: sessionForScope.account_id });
1633
+ logView(_jsx(StatusLine, { kind: "muted", label: `Account: ${sessionForScope.account_id} (from session)` }));
1634
+ }
1635
+ else {
1636
+ throw new ValidationError("Multiple accounts available and no TTY to choose one.", {
1637
+ suggestion: "Pass --account-id <id> for non-interactive create. List them with `aui account --list`.",
1638
+ });
1639
+ }
1436
1640
  }
1437
- catch {
1438
- accSpinner.unmount();
1439
- }
1641
+ // 0 accounts: leave scope as-is.
1440
1642
  }
1441
- // Persist selected org/account to session
1643
+ // Persist resolved org/account to session.
1442
1644
  {
1443
1645
  const currentSession = loadSession();
1444
1646
  currentSession.organization_id = client.getOrganizationId();
@@ -1449,6 +1651,13 @@ async function runAgents(options = {}) {
1449
1651
  // Step 3: Agent name
1450
1652
  logView(_jsx(Text, { color: "gray", children: " \u251C Step 3 \u2192 Name Your Agent" }));
1451
1653
  if (!agentName) {
1654
+ // Don't render a blocking prompt with no TTY — fail clearly instead of
1655
+ // hanging (CTS-16852).
1656
+ if (!canPrompt()) {
1657
+ throw new ValidationError("An agent name is required.", {
1658
+ suggestion: "Pass --name <name> for non-interactive create.",
1659
+ });
1660
+ }
1452
1661
  const { name: inputName } = await inquirer.prompt([
1453
1662
  {
1454
1663
  type: "input",
@@ -1461,35 +1670,26 @@ async function runAgents(options = {}) {
1461
1670
  }
1462
1671
  // Step 4: Creating the agent
1463
1672
  logView(_jsx(Text, { color: "gray", children: " \u2514 Step 4 \u2192 Creating Agent" }));
1464
- // Resolve category: supports ID, key, or name. Defaults to General.
1465
- try {
1466
- const catResp = await client.categories.list();
1467
- if (categoryId) {
1468
- const catUpper = categoryId.toUpperCase();
1469
- const match = catResp.data.find((c) => c._id === categoryId ||
1470
- c.key?.toUpperCase() === catUpper ||
1471
- c.name?.toUpperCase() === catUpper);
1472
- if (match)
1473
- categoryId = match._id;
1474
- }
1475
- else {
1476
- const general = catResp.data.find((c) => c.name?.toUpperCase() === "GENERAL");
1477
- categoryId = general?._id || catResp.data[0]?._id || "";
1478
- }
1479
- }
1480
- catch {
1481
- // proceed with whatever categoryId we have
1673
+ // Resolve category now that the org is selected: supports ID, key, or
1674
+ // name, and applies a REAL "General" default when none was passed. Throws
1675
+ // a clear error (instead of silently sending an empty / raw value that
1676
+ // 422s) when it can't resolve — CTS-16872.
1677
+ {
1678
+ const resolved = await resolveCategoryId(client, categoryId);
1679
+ categoryId = resolved.id;
1680
+ categoryLabel = resolved.label;
1681
+ logView(_jsx(StatusLine, { kind: "muted", label: `Category: ${resolved.label}` }));
1482
1682
  }
1483
1683
  // Bundle-mode: the Apollo provisioning endpoint runs the whole flow
1484
1684
  // (network → agent → version-from-template → publish → activate) in a
1485
- // single call. This is the only creation path now.
1486
- const created = await createBundledAgentViaApollo({
1685
+ // single call. This is the only creation path now. The helper exits
1686
+ // non-zero on provision failure and supports --if-not-exists.
1687
+ await createRegularAgentAndImport(client, {
1487
1688
  name: agentName,
1488
- networkCategoryId: categoryId || "",
1689
+ networkCategoryId: categoryId,
1489
1690
  organizationId: client.getOrganizationId(),
1490
1691
  accountId: client.getAccountId(),
1491
- });
1492
- await autoImportAndEnter(created, opts.dir, agentName);
1692
+ }, { dir: opts.dir, ifNotExists: opts.ifNotExists });
1493
1693
  return;
1494
1694
  // ─── RECORDS-MODE CREATION RETIRED — moved to bundle-mode creation ──
1495
1695
  // The legacy interactive records path (create network → createAgentOnBackend
@@ -1626,10 +1826,63 @@ async function autoImportAndEnter(created, dir, agentName) {
1626
1826
  tag,
1627
1827
  dir,
1628
1828
  agentName,
1829
+ // A failed import after a successful provision is an orphan, not a
1830
+ // best-effort miss — fail the create so scripts don't see success then
1831
+ // crash on the missing dir (CTS-16874).
1832
+ strict: true,
1629
1833
  });
1630
1834
  if (importedDir)
1631
1835
  enterProjectShell(importedDir);
1632
1836
  }
1837
+ /**
1838
+ * Create a regular (runtime) bundle agent and import it, with optional
1839
+ * `--if-not-exists` recovery.
1840
+ *
1841
+ * Shared by the two regular create paths (direct single-command and the
1842
+ * interactive fall-through). On a name conflict (HTTP 409) — including the
1843
+ * parallel-create orphan case where the agent was provisioned but never
1844
+ * imported locally (CTS-16874) — `--if-not-exists` resolves the existing
1845
+ * agent by name and imports it instead of failing. Without the flag the
1846
+ * 409 propagates and the command exits non-zero.
1847
+ */
1848
+ async function createRegularAgentAndImport(client, input, opts) {
1849
+ try {
1850
+ const created = await createBundledAgentViaApollo(input);
1851
+ await autoImportAndEnter(created, opts.dir, input.name);
1852
+ }
1853
+ catch (error) {
1854
+ if (!opts.ifNotExists || !isAlreadyExistsError(error))
1855
+ throw error;
1856
+ const existing = await findExistingAgentByName(client, input.name, input.accountId);
1857
+ if (!existing) {
1858
+ // Name is taken but we can't see the agent (different account/org, or
1859
+ // a half-provisioned orphan the listing doesn't return yet). Surface
1860
+ // the conflict rather than pretend it resolved.
1861
+ throw new CLIError(`Agent "${input.name}" already exists but could not be found to import.`, {
1862
+ code: "API_CLIENT_ERROR",
1863
+ suggestion: "Check the agent's account/org with `aui agents --list --account-id <id>`, then import it with `aui import-agent <agent-id>`.",
1864
+ cause: error,
1865
+ });
1866
+ }
1867
+ logView(_jsx(StatusLine, { kind: "info", label: `Agent "${input.name}" already exists (${existing.id}) — importing the existing agent (--if-not-exists).` }));
1868
+ // Repoint the session at the existing agent, mirroring a fresh create.
1869
+ const session = loadSession();
1870
+ session.agent_management_id = existing.id;
1871
+ const existingNetworkId = existing.scope?.network_id ?? undefined;
1872
+ if (existingNetworkId) {
1873
+ session.network_id = existingNetworkId;
1874
+ session.network_name = input.name;
1875
+ }
1876
+ delete session.version_id;
1877
+ delete session.version_number;
1878
+ delete session.version_revision_number;
1879
+ saveSession(session);
1880
+ await autoImportAndEnter({
1881
+ agentId: existing.id,
1882
+ activeVersionId: existing.active_version_id ?? undefined,
1883
+ }, opts.dir, input.name);
1884
+ }
1885
+ }
1633
1886
  /**
1634
1887
  * Resolve the id of the latest DRAFT version for a freshly provisioned bundle
1635
1888
  * agent. Apollo creates a draft after publishing the first version; this finds
@@ -1667,6 +1920,21 @@ async function resolveLatestDraftId(agentId) {
1667
1920
  * 2. AUI_AUTO_IMPORT=1 / =0 → explicit opt-in / opt-out (automation + tests).
1668
1921
  * 3. otherwise → only when BOTH stdin and stdout are TTYs.
1669
1922
  */
1923
+ /**
1924
+ * Whether the CLI may render a blocking interactive prompt right now.
1925
+ *
1926
+ * Stricter than `isInteractiveSession` (which honors `AUI_AUTO_IMPORT` and
1927
+ * is about the post-create conveniences): a prompt is only safe when BOTH
1928
+ * std streams are real TTYs and we're not in JSON mode. Used to gate the
1929
+ * `--create` org/account pickers so a non-interactive run resolves scope
1930
+ * from flags/session or fails with a clear message instead of hanging on a
1931
+ * picker with no TTY (CTS-16852).
1932
+ */
1933
+ function canPrompt() {
1934
+ if (isJsonMode())
1935
+ return false;
1936
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
1937
+ }
1670
1938
  function isInteractiveSession() {
1671
1939
  if (isJsonMode())
1672
1940
  return false;
@@ -1751,6 +2019,15 @@ async function autoImportLatestDraft(agentId, opts) {
1751
2019
  return resolvedDir;
1752
2020
  }
1753
2021
  catch (error) {
2022
+ if (opts.strict) {
2023
+ // The agent exists server-side but the local project was never
2024
+ // written — surface the orphan + an exact reclaim path, and fail.
2025
+ throw new CLIError(`Agent "${opts.agentName ?? agentId}" was provisioned but the local import failed: ${error instanceof Error ? error.message : String(error)}`, {
2026
+ code: "API_CLIENT_ERROR",
2027
+ suggestion: `The agent exists server-side (name reserved). Import it with \`aui import-agent ${agentId} --dir ${targetDir}\`, or re-run create with \`--if-not-exists\` to import the existing agent.`,
2028
+ cause: error,
2029
+ });
2030
+ }
1754
2031
  logView(_jsxs(Box, { flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsx(StatusLine, { kind: "warning", label: `Auto-import failed: ${error instanceof Error ? error.message : String(error)}` }), _jsx(Text, { color: "gray", children: ` Import it manually with: aui import-agent --dir ${targetDir}` })] }));
1755
2032
  return null;
1756
2033
  }
@@ -1827,8 +2104,11 @@ async function createBundledAgentViaApollo(input) {
1827
2104
  const isTemplate = input.kind === "template";
1828
2105
  const noun = isTemplate ? "template" : "bundle agent";
1829
2106
  if (!input.networkCategoryId) {
1830
- logView(_jsx(StatusLine, { kind: "error", label: `Bundle-mode ${noun} create needs a category pass --category <name-or-key>.` }));
1831
- return null;
2107
+ // Throw (not return null) so the command exits non-zeroa missing
2108
+ // category is a hard failure, never a silent success (CTS-16872).
2109
+ throw new ValidationError(`Bundle-mode ${noun} create needs a category — pass --category <name-or-key>.`, {
2110
+ suggestion: "List available categories with `aui agents --categories`.",
2111
+ });
1832
2112
  }
1833
2113
  const apollo = new ApolloClient({
1834
2114
  authToken: config.authToken,
@@ -1879,14 +2159,26 @@ async function createBundledAgentViaApollo(input) {
1879
2159
  const activeVersionTag = typeof res.active_version_tag === "string" ? res.active_version_tag : undefined;
1880
2160
  const activeVersion = activeVersionTag || activeVersionId || "";
1881
2161
  logView(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StatusLine, { kind: "success", label: `${isTemplate ? "Template" : "Bundle agent"} created: ${res.name || input.name}${agentId ? ` (${agentId})` : ""}` }), isTemplate ? (_jsx(Text, { color: "gray", children: ` Use it as a clone source: aui version create --source template --from-template ${agentId || "<templateId>"}` })) : (_jsx(Text, { color: "gray", children: " Apollo provisioned it end-to-end: network → agent → version (from template) → published → activated." })), activeVersion ? (_jsx(StatusLine, { kind: "muted", label: `Active version: ${activeVersion}` })) : null, !isTemplate && agentId ? (_jsx(StatusLine, { kind: "muted", label: "Session updated to this agent." })) : null] }));
1882
- return agentId
1883
- ? { agentId, activeVersionId, activeVersionTag }
1884
- : null;
2162
+ // A 2xx that somehow carries no agent id is still a failure — the
2163
+ // caller can't import/repoint to a nameless agent. Throw rather than
2164
+ // return null so the command exits non-zero (CTS-16874).
2165
+ if (!agentId) {
2166
+ throw new CLIError(`Provisioning ${noun} "${input.name}" returned no agent id.`, {
2167
+ code: "API_SERVER_ERROR",
2168
+ suggestion: "Re-run with AUI_DEBUG=1 to see the raw response, or inspect it with `aui curl --failed`.",
2169
+ });
2170
+ }
2171
+ return { agentId, activeVersionId, activeVersionTag };
1885
2172
  }
1886
2173
  catch (error) {
1887
2174
  spinner.unmount();
1888
- renderView(_jsx(ErrorDisplay, { error: error, message: `Failed to provision ${noun}: ${error instanceof Error ? error.message : String(error)}` }));
1889
- return null;
2175
+ // Re-throw so the command exits non-zero. Previously this swallowed the
2176
+ // error and returned null, so `aui agents --create` printed "✗ Failed…"
2177
+ // but still exited 0 — scripted fleet creation then treated the failure
2178
+ // as success and crashed on the missing dir / looped re-creating
2179
+ // (CTS-16874). `handleError` (the action wrapper) renders the same rich
2180
+ // error display this used to render inline.
2181
+ throw error;
1890
2182
  }
1891
2183
  }
1892
2184
  export async function createAgentOnBackend(name, networkId, networkCategoryId, modeOpts = {}) {