aui-agent-builder 0.4.16 → 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.
- package/dist/commands/agents.d.ts +26 -1
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +402 -110
- package/dist/commands/agents.js.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/ui/views/AgentsView.d.ts +9 -1
- package/dist/ui/views/AgentsView.d.ts.map +1 -1
- package/dist/ui/views/AgentsView.js +4 -1
- package/dist/ui/views/AgentsView.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/agents.js
CHANGED
|
@@ -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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
//
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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.
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
}
|
|
1607
|
+
// 0 orgs (e.g. listing failed): leave scope as-is; downstream calls
|
|
1608
|
+
// surface the real error.
|
|
1407
1609
|
}
|
|
1408
|
-
// Step 2:
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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) => ({
|
|
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
|
-
|
|
1438
|
-
accSpinner.unmount();
|
|
1439
|
-
}
|
|
1641
|
+
// 0 accounts: leave scope as-is.
|
|
1440
1642
|
}
|
|
1441
|
-
// Persist
|
|
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
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1831
|
-
|
|
2107
|
+
// Throw (not return null) so the command exits non-zero — a 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
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
-
|
|
1889
|
-
|
|
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 = {}) {
|