@f5xc-salesdemos/xcsh 18.58.1 → 18.59.1
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.59.1",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
50
50
|
"@mozilla/readability": "^0.6",
|
|
51
|
-
"@f5xc-salesdemos/xcsh-stats": "18.
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
51
|
+
"@f5xc-salesdemos/xcsh-stats": "18.59.1",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "18.59.1",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "18.59.1",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "18.59.1",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "18.59.1",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "18.59.1",
|
|
57
57
|
"@sinclair/typebox": "^0.34",
|
|
58
58
|
"@xterm/headless": "^6.0",
|
|
59
59
|
"ajv": "^8.18",
|
|
@@ -372,7 +372,7 @@ export function createLogExperimentTool(
|
|
|
372
372
|
},
|
|
373
373
|
renderResult(result, _options, theme): Component {
|
|
374
374
|
const details = result.details;
|
|
375
|
-
if (!details) {
|
|
375
|
+
if (!isLogDetails(details)) {
|
|
376
376
|
return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0);
|
|
377
377
|
}
|
|
378
378
|
return {
|
|
@@ -770,8 +770,16 @@ function truncateAsiValue(value: ASIData[string]): string {
|
|
|
770
770
|
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
771
771
|
}
|
|
772
772
|
|
|
773
|
+
function isLogDetails(value: unknown): value is LogDetails {
|
|
774
|
+
if (typeof value !== "object" || value === null) return false;
|
|
775
|
+
return "experiment" in value && "state" in value;
|
|
776
|
+
}
|
|
777
|
+
|
|
773
778
|
function renderSummary(details: LogDetails, theme: Theme, width?: number): string {
|
|
774
779
|
const { experiment, state } = details;
|
|
780
|
+
if (!experiment || !state) {
|
|
781
|
+
return theme.fg("dim", "(no experiment data)");
|
|
782
|
+
}
|
|
775
783
|
const color = experiment.status === "keep" ? "success" : experiment.status === "discard" ? "warning" : "error";
|
|
776
784
|
let summary = `${theme.fg(color, experiment.status.toUpperCase())} ${theme.fg("muted", truncateToWidth(replaceTabs(experiment.description ?? ""), Math.max(20, (width ?? 100) - 30)))}`;
|
|
777
785
|
summary += ` ${theme.fg("contentAccent", `${state.metricName}=${formatNum(experiment.metric, state.metricUnit)}`)}`;
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.59.1",
|
|
21
|
+
"commit": "fa007898365b1a63e38748e06d2c86c13779ff53",
|
|
22
|
+
"shortCommit": "fa00789",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.59.1",
|
|
25
|
+
"commitDate": "2026-05-10T17:48:40Z",
|
|
26
|
+
"buildDate": "2026-05-10T18:15:56.886Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/fa007898365b1a63e38748e06d2c86c13779ff53",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.59.1"
|
|
33
33
|
};
|
|
@@ -14,4 +14,12 @@ Payload values like `$F5XC_NAMESPACE` are auto-expanded from the active context.
|
|
|
14
14
|
|
|
15
15
|
Use this tool after reading the API catalog to get the endpoint path and payload structure.
|
|
16
16
|
|
|
17
|
+
Response format:
|
|
18
|
+
- **List**: `{"items": […], "errors": []}` — each item has `name`, `namespace`, `uid`.
|
|
19
|
+
- **Single resource**: `{"metadata": {"name", "namespace"}, "system_metadata": {"uid", "creation_timestamp"}, "spec": {…}}` — noise-reduced in TUI (nulls/empties stripped).
|
|
20
|
+
- **Create/Update**: Returns the full resource object. TUI shows a Created/Updated summary with name, uid, timestamp.
|
|
21
|
+
- **Delete**: Returns `{}`. TUI shows contextual confirmation.
|
|
22
|
+
- **Error**: `{"code": <int>, "message": "…"}` — codes: 3=INVALID_ARGUMENT, 5=NOT_FOUND, 6=ALREADY_EXISTS, 7=PERMISSION_DENIED, 13=INTERNAL.
|
|
23
|
+
|
|
24
|
+
GET requests auto-retry once on transient errors (429/503) after 1s backoff. POST/PUT/DELETE are never retried.
|
|
17
25
|
API calls to the same F5 XC tenant reuse a single TLS connection — sequential calls are faster than parallel calls. Do not issue multiple xcsh_api calls in the same turn; issue them one at a time.
|
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
* TUI renderer for the xcsh_api tool.
|
|
3
3
|
*
|
|
4
4
|
* Provides rich, context-aware visualization for F5 XC API calls:
|
|
5
|
-
* - renderCall: method badge + path while request is pending
|
|
6
|
-
* - renderResult: bordered output
|
|
7
|
-
*
|
|
5
|
+
* - renderCall: method badge + compact path while request is pending
|
|
6
|
+
* - renderResult: bordered output with intelligent response rendering:
|
|
7
|
+
* - List responses: compact resource name summary (capped at 20 items)
|
|
8
|
+
* - Single resources: Summary section (name, uid, created, status) + noise-reduced JSON
|
|
9
|
+
* - Create/Update: Created/Updated confirmation with key identity fields
|
|
10
|
+
* - Delete: contextual success message with resource name
|
|
11
|
+
* - Errors: API error message promoted to Guidance, JSON body suppressed
|
|
12
|
+
* - Request payload section for mutating methods (POST/PUT/PATCH)
|
|
8
13
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Always renders full output — no collapsed mode. This is the primary tool
|
|
14
|
-
* for F5 XC platform operations and benefits from full visibility.
|
|
14
|
+
* Header meta: context name, colored duration, item count, body size, error code label.
|
|
15
|
+
* Borders: success=dim, error=red, pending=accent.
|
|
16
|
+
* JSON noise reduction via stripEmpty (nulls, empty strings, empty arrays stripped;
|
|
17
|
+
* empty objects preserved for F5 XC protobuf oneof markers).
|
|
15
18
|
*/
|
|
16
19
|
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
17
20
|
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
@@ -24,6 +27,12 @@ import type { XcshApiToolDetails } from "./xcsh-api";
|
|
|
24
27
|
|
|
25
28
|
const TOOL_TITLE = "XC-API";
|
|
26
29
|
|
|
30
|
+
/** Maximum response body lines before truncation. */
|
|
31
|
+
const MAX_RESPONSE_LINES = 80;
|
|
32
|
+
|
|
33
|
+
/** Maximum request payload lines before truncation. */
|
|
34
|
+
const MAX_PAYLOAD_LINES = 30;
|
|
35
|
+
|
|
27
36
|
interface XcshApiRenderArgs {
|
|
28
37
|
method?: string;
|
|
29
38
|
path?: string;
|
|
@@ -50,6 +59,108 @@ function statusColor(status: number): ThemeColor {
|
|
|
50
59
|
return "error";
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
/** Format byte size to human-readable string (e.g. "1.2 KB", "3.4 MB"). */
|
|
63
|
+
function formatBytes(bytes: number): string {
|
|
64
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
65
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
66
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Strip null, empty string, and empty array fields recursively.
|
|
71
|
+
* Reduces JSON noise from F5 XC API responses which contain many null/empty defaults.
|
|
72
|
+
* Preserves empty objects `{}` — these are F5 XC protobuf oneof presence markers
|
|
73
|
+
* (e.g. `use_origin_server_name: {}` means that option is selected).
|
|
74
|
+
*/
|
|
75
|
+
function stripEmpty(obj: unknown): unknown {
|
|
76
|
+
if (Array.isArray(obj)) return obj.map(stripEmpty).filter(v => v != null);
|
|
77
|
+
if (obj && typeof obj === "object") {
|
|
78
|
+
const entries = Object.entries(obj as Record<string, unknown>);
|
|
79
|
+
// Preserve source-empty objects (F5 XC oneof presence markers)
|
|
80
|
+
if (entries.length === 0) return obj;
|
|
81
|
+
const out: Record<string, unknown> = {};
|
|
82
|
+
for (const [k, v] of entries) {
|
|
83
|
+
if (v == null || v === "") continue;
|
|
84
|
+
if (Array.isArray(v) && v.length === 0) continue;
|
|
85
|
+
const cleaned = stripEmpty(v);
|
|
86
|
+
if (cleaned != null) out[k] = cleaned;
|
|
87
|
+
}
|
|
88
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
89
|
+
}
|
|
90
|
+
return obj;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Format ISO timestamp to human-readable: `2026-05-10T00:02:42.577Z` → `2026-05-10 00:02 UTC`. */
|
|
94
|
+
function formatTimestamp(iso: string): string {
|
|
95
|
+
return iso
|
|
96
|
+
.replace("T", " ")
|
|
97
|
+
.replace(/:\d{2}\.\d+Z$/, " UTC")
|
|
98
|
+
.replace(/:\d{2}Z$/, " UTC");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Push a labeled section with optional line truncation. */
|
|
102
|
+
function pushSection(
|
|
103
|
+
sections: Array<{ label?: string; lines: string[] }>,
|
|
104
|
+
label: string,
|
|
105
|
+
lines: string[],
|
|
106
|
+
maxLines: number,
|
|
107
|
+
uiTheme: Theme,
|
|
108
|
+
): void {
|
|
109
|
+
if (lines.length > maxLines) {
|
|
110
|
+
const truncated = lines.slice(0, maxLines);
|
|
111
|
+
truncated.push(uiTheme.fg("dim", `… ${lines.length - maxLines} more lines`));
|
|
112
|
+
sections.push({ label, lines: truncated });
|
|
113
|
+
} else {
|
|
114
|
+
sections.push({ label, lines });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Build a compact summary section for a single F5 XC resource (identity + spec). */
|
|
119
|
+
function buildResourceSummary(
|
|
120
|
+
parsed: Record<string, unknown>,
|
|
121
|
+
pathParts: string[],
|
|
122
|
+
method: string,
|
|
123
|
+
uiTheme: Theme,
|
|
124
|
+
): { label: string; lines: string[] } | null {
|
|
125
|
+
const metadata = parsed.metadata as Record<string, unknown> | undefined;
|
|
126
|
+
const sysMeta = parsed.system_metadata as Record<string, unknown> | undefined;
|
|
127
|
+
if (!metadata || typeof metadata.name !== "string") return null;
|
|
128
|
+
|
|
129
|
+
const lines: string[] = [];
|
|
130
|
+
lines.push(uiTheme.fg("toolOutput", ` name: ${metadata.name}`));
|
|
131
|
+
if (typeof metadata.namespace === "string") lines.push(uiTheme.fg("dim", ` namespace: ${metadata.namespace}`));
|
|
132
|
+
if (typeof sysMeta?.uid === "string") lines.push(uiTheme.fg("dim", ` uid: ${sysMeta.uid}`));
|
|
133
|
+
const createdAt = sysMeta?.creation_timestamp;
|
|
134
|
+
if (typeof createdAt === "string") lines.push(uiTheme.fg("dim", ` created: ${formatTimestamp(createdAt)}`));
|
|
135
|
+
if (metadata.disable === true) lines.push(uiTheme.fg("warning", ` status: DISABLED`));
|
|
136
|
+
|
|
137
|
+
// Compact spec line: resource type from path + key config values
|
|
138
|
+
const spec = parsed.spec;
|
|
139
|
+
if (spec && typeof spec === "object") {
|
|
140
|
+
const specEntries = Object.entries(spec as Record<string, unknown>);
|
|
141
|
+
// Only show spec line when there are actual entries (skip empty spec: {})
|
|
142
|
+
if (specEntries.length > 0) {
|
|
143
|
+
const specScalars = specEntries
|
|
144
|
+
.filter(
|
|
145
|
+
([, v]) =>
|
|
146
|
+
typeof v === "number" ||
|
|
147
|
+
typeof v === "boolean" ||
|
|
148
|
+
(typeof v === "string" && v.length > 0 && v.length <= 30),
|
|
149
|
+
)
|
|
150
|
+
.slice(0, 4)
|
|
151
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
152
|
+
.join(", ");
|
|
153
|
+
const resourceType = (pathParts.at(-2) ?? "config").replace(/_/g, " ").replace(/s$/, "");
|
|
154
|
+
const specLine = specScalars ? `${resourceType} (${specScalars})` : resourceType;
|
|
155
|
+
lines.push(uiTheme.fg("dim", ` spec: ${specLine}`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const isMutating = method === "POST" || method === "PUT" || method === "PATCH";
|
|
160
|
+
const label = isMutating ? (method === "POST" ? "Created" : "Updated") : "Summary";
|
|
161
|
+
return { label: uiTheme.fg("toolTitle", label), lines };
|
|
162
|
+
}
|
|
163
|
+
|
|
53
164
|
/**
|
|
54
165
|
* Split the text content from the tool result into its constituent parts.
|
|
55
166
|
*
|
|
@@ -103,11 +214,14 @@ export const xcshApiToolRenderer = {
|
|
|
103
214
|
renderCall(args: XcshApiRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
104
215
|
const method = args.method ?? "???";
|
|
105
216
|
const apiPath = args.path ?? "…";
|
|
217
|
+
// Compact long paths for pending state consistency with result header
|
|
218
|
+
const parts = apiPath.split("/").filter(Boolean);
|
|
219
|
+
const pendingPath = parts.length > 3 ? `…/${parts.slice(-3).join("/")}` : apiPath;
|
|
106
220
|
const text = renderStatusLine(
|
|
107
221
|
{
|
|
108
222
|
icon: "pending",
|
|
109
223
|
title: TOOL_TITLE,
|
|
110
|
-
description:
|
|
224
|
+
description: pendingPath,
|
|
111
225
|
badge: { label: method, color: methodColor(method) },
|
|
112
226
|
},
|
|
113
227
|
uiTheme,
|
|
@@ -135,6 +249,9 @@ export const xcshApiToolRenderer = {
|
|
|
135
249
|
// Malformed URL — fall through to args.path
|
|
136
250
|
}
|
|
137
251
|
}
|
|
252
|
+
// Path intelligence: extract resource name and compact display path
|
|
253
|
+
const pathParts = displayPath.split("/").filter(Boolean);
|
|
254
|
+
const resourceName = pathParts.at(-1) ?? "";
|
|
138
255
|
|
|
139
256
|
const status = details?.status ?? 0;
|
|
140
257
|
const statusText = status > 0 ? `${status}` : "failed";
|
|
@@ -145,16 +262,36 @@ export const xcshApiToolRenderer = {
|
|
|
145
262
|
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
146
263
|
}
|
|
147
264
|
|
|
148
|
-
// --- Header ---
|
|
265
|
+
// --- Header with separate method and status badges ---
|
|
266
|
+
const methodBadge = { label: method, color: methodColor(method) };
|
|
267
|
+
const errorLabel = details?.errorCodeLabel;
|
|
268
|
+
const statusDisplay = errorLabel ? `${statusText} ${errorLabel}` : statusText;
|
|
269
|
+
const statusBadge = uiTheme.fg(status > 0 ? statusColor(status) : "error", `[${statusDisplay}]`);
|
|
270
|
+
|
|
149
271
|
const meta: string[] = [];
|
|
272
|
+
meta.push(statusBadge);
|
|
150
273
|
if (details?.contextName) meta.push(uiTheme.fg("statusLineContextF5xcFg", details.contextName));
|
|
151
|
-
if (details?.durationMs !== undefined)
|
|
274
|
+
if (details?.durationMs !== undefined) {
|
|
275
|
+
const durationColor: ThemeColor =
|
|
276
|
+
details.durationMs < 200 ? "success" : details.durationMs > 1000 ? "warning" : "dim";
|
|
277
|
+
meta.push(uiTheme.fg(durationColor, `${details.durationMs}ms`));
|
|
278
|
+
}
|
|
279
|
+
if (details?.itemCount !== undefined) meta.push(uiTheme.fg("dim", `${details.itemCount} items`));
|
|
280
|
+
if (details?.bodySize !== undefined) meta.push(uiTheme.fg("dim", formatBytes(details.bodySize)));
|
|
281
|
+
if (details?.contentType && !details.contentType.includes("json"))
|
|
282
|
+
meta.push(uiTheme.fg("dim", details.contentType));
|
|
283
|
+
// Show requestId for errors (useful for support/debugging)
|
|
284
|
+
if (isError && details?.requestId) meta.push(uiTheme.fg("dim", `req:${details.requestId.slice(0, 8)}`));
|
|
285
|
+
if (details?.retried) meta.push(uiTheme.fg("warning", "retried"));
|
|
286
|
+
|
|
287
|
+
const compactPath = pathParts.length > 3 ? `…/${pathParts.slice(-3).join("/")}` : displayPath;
|
|
288
|
+
|
|
152
289
|
const header = renderStatusLine(
|
|
153
290
|
{
|
|
154
291
|
title: TOOL_TITLE,
|
|
155
292
|
titleColor: "contentAccent",
|
|
156
|
-
description:
|
|
157
|
-
badge:
|
|
293
|
+
description: compactPath,
|
|
294
|
+
badge: methodBadge,
|
|
158
295
|
meta: meta.length > 0 ? meta : undefined,
|
|
159
296
|
},
|
|
160
297
|
uiTheme,
|
|
@@ -165,17 +302,119 @@ export const xcshApiToolRenderer = {
|
|
|
165
302
|
const { json, guidance, raw } = splitResultContent(textContent, isError);
|
|
166
303
|
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
167
304
|
|
|
168
|
-
// Section
|
|
169
|
-
|
|
170
|
-
|
|
305
|
+
// Section 2: Request payload (for mutating methods with a body)
|
|
306
|
+
if (args?.payload && method !== "GET") {
|
|
307
|
+
try {
|
|
308
|
+
const prettyPayload = JSON.stringify(args.payload, null, 2);
|
|
309
|
+
const payloadLines = highlightCode(prettyPayload, "json");
|
|
310
|
+
const sanitized = payloadLines.map(line => replaceTabs(line));
|
|
311
|
+
// Show expanded variable substitutions
|
|
312
|
+
if (details?.expandedVars && details.expandedVars.length > 0) {
|
|
313
|
+
const varLines = details.expandedVars.map(({ variable, value }) =>
|
|
314
|
+
uiTheme.fg("dim", ` ${variable} → ${value}`),
|
|
315
|
+
);
|
|
316
|
+
sanitized.push(...varLines);
|
|
317
|
+
}
|
|
318
|
+
pushSection(sections, uiTheme.fg("toolTitle", "Request"), sanitized, MAX_PAYLOAD_LINES, uiTheme);
|
|
319
|
+
} catch {
|
|
320
|
+
// Payload not serializable — skip section
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Section: Response body — syntax-highlighted JSON or plain text
|
|
325
|
+
// Parse JSON once for all intelligence branches
|
|
326
|
+
const emptyBody = json === "{}" || (!json && (!raw.trim() || raw.trim() === "{}"));
|
|
327
|
+
let parsed: Record<string, unknown> | null = null;
|
|
328
|
+
if (json && !emptyBody) {
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(json) as Record<string, unknown>;
|
|
331
|
+
} catch {
|
|
332
|
+
// Not parseable — render raw
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const emptyList = Array.isArray(parsed?.items) && (parsed!.items as unknown[]).length === 0;
|
|
171
336
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
337
|
+
if ((emptyBody || emptyList) && !guidance) {
|
|
338
|
+
// Contextual success message based on HTTP method and response shape
|
|
339
|
+
let successMessage = emptyList ? "No items found." : "Empty response";
|
|
340
|
+
const rn = resourceName ? ` \u2018${resourceName}\u2019` : "";
|
|
341
|
+
if (!emptyList && status >= 200 && status < 300) {
|
|
342
|
+
if (method === "DELETE") successMessage = `Resource${rn} deleted successfully.`;
|
|
343
|
+
else if (method === "POST") successMessage = `Resource${rn} created successfully.`;
|
|
344
|
+
else if (method === "PUT" || method === "PATCH") successMessage = `Resource${rn} updated successfully.`;
|
|
345
|
+
}
|
|
175
346
|
sections.push({
|
|
176
347
|
label: uiTheme.fg("toolTitle", "Response"),
|
|
177
|
-
lines:
|
|
348
|
+
lines: [uiTheme.fg("dim", successMessage)],
|
|
178
349
|
});
|
|
350
|
+
} else if (json && parsed) {
|
|
351
|
+
// Branch 1: List response with named items — compact summary
|
|
352
|
+
const items = parsed.items;
|
|
353
|
+
if (Array.isArray(items) && items.length > 0) {
|
|
354
|
+
const itemEntries = (items as Array<Record<string, unknown>>)
|
|
355
|
+
.map(item => {
|
|
356
|
+
const name = typeof item.name === "string" ? item.name : null;
|
|
357
|
+
return name ? { name, disabled: item.disabled === true } : null;
|
|
358
|
+
})
|
|
359
|
+
.filter(Boolean) as Array<{ name: string; disabled: boolean }>;
|
|
360
|
+
if (itemEntries.length > 0) {
|
|
361
|
+
const maxListItems = 20;
|
|
362
|
+
const displayed = itemEntries.slice(0, maxListItems);
|
|
363
|
+
const summaryLines = displayed.map(({ name, disabled }) =>
|
|
364
|
+
disabled
|
|
365
|
+
? ` ${uiTheme.fg("dim", name)} ${uiTheme.fg("warning", "DISABLED")}`
|
|
366
|
+
: uiTheme.fg("toolOutput", ` ${name}`),
|
|
367
|
+
);
|
|
368
|
+
if (itemEntries.length > maxListItems)
|
|
369
|
+
summaryLines.push(uiTheme.fg("dim", ` … and ${itemEntries.length - maxListItems} more`));
|
|
370
|
+
pushSection(
|
|
371
|
+
sections,
|
|
372
|
+
uiTheme.fg("toolTitle", `Response (${itemEntries.length} items)`),
|
|
373
|
+
summaryLines,
|
|
374
|
+
maxListItems + 1,
|
|
375
|
+
uiTheme,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
// Branch 2: Single resource with metadata — summary + noise-reduced JSON
|
|
380
|
+
const summary = !isError ? buildResourceSummary(parsed, pathParts, method, uiTheme) : null;
|
|
381
|
+
if (summary) sections.push(summary);
|
|
382
|
+
const isMutating = method === "POST" || method === "PUT" || method === "PATCH";
|
|
383
|
+
|
|
384
|
+
// Determine if JSON body should be suppressed
|
|
385
|
+
let apiErrorMessage: string | undefined;
|
|
386
|
+
if (isError && typeof parsed.message === "string" && parsed.message) apiErrorMessage = parsed.message;
|
|
387
|
+
const skipJsonBody = (summary && isMutating) || (isError && (guidance || apiErrorMessage));
|
|
388
|
+
|
|
389
|
+
// Show extracted API error for errors without statusGuidance (400, 422, etc.)
|
|
390
|
+
if (apiErrorMessage && !guidance) {
|
|
391
|
+
sections.push({
|
|
392
|
+
label: uiTheme.fg("toolTitle", "Error"),
|
|
393
|
+
lines: [uiTheme.fg("error", apiErrorMessage)],
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!skipJsonBody) {
|
|
398
|
+
const displayJson = JSON.stringify(stripEmpty(parsed), null, 2) ?? json;
|
|
399
|
+
const jsonLines = displayJson.split("\n");
|
|
400
|
+
const highlighted = isError
|
|
401
|
+
? jsonLines.map(line => uiTheme.fg("dim", replaceTabs(line)))
|
|
402
|
+
: highlightCode(displayJson, "json").map(line => replaceTabs(line));
|
|
403
|
+
|
|
404
|
+
let keyCount: number | undefined;
|
|
405
|
+
if (typeof parsed === "object" && !Array.isArray(parsed)) keyCount = Object.keys(parsed).length;
|
|
406
|
+
const responseLabel =
|
|
407
|
+
keyCount !== undefined && keyCount > 0 ? `Response (${keyCount} keys)` : "Response";
|
|
408
|
+
|
|
409
|
+
pushSection(sections, uiTheme.fg("toolTitle", responseLabel), highlighted, MAX_RESPONSE_LINES, uiTheme);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} else if (json) {
|
|
413
|
+
// Non-parseable JSON — render raw
|
|
414
|
+
const highlighted = isError
|
|
415
|
+
? json.split("\n").map(line => uiTheme.fg("dim", replaceTabs(line)))
|
|
416
|
+
: highlightCode(json, "json").map(line => replaceTabs(line));
|
|
417
|
+
sections.push({ label: uiTheme.fg("toolTitle", "Response"), lines: highlighted });
|
|
179
418
|
} else if (raw.trim() && !guidance) {
|
|
180
419
|
// Non-JSON, non-guidance body
|
|
181
420
|
sections.push({
|
|
@@ -187,12 +426,14 @@ export const xcshApiToolRenderer = {
|
|
|
187
426
|
});
|
|
188
427
|
}
|
|
189
428
|
|
|
190
|
-
// Section
|
|
429
|
+
// Section 4: Error guidance (for HTTP error responses)
|
|
191
430
|
if (guidance) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
431
|
+
// Extract the API's specific error message from JSON body for prominent display
|
|
432
|
+
const guidanceLines: string[] = [];
|
|
433
|
+
const apiMessage = parsed && typeof parsed.message === "string" ? parsed.message : undefined;
|
|
434
|
+
if (apiMessage) guidanceLines.push(uiTheme.fg("error", apiMessage));
|
|
435
|
+
guidanceLines.push(uiTheme.fg("warning", guidance));
|
|
436
|
+
sections.push({ label: uiTheme.fg("toolTitle", "Guidance"), lines: guidanceLines });
|
|
196
437
|
}
|
|
197
438
|
|
|
198
439
|
// --- Render with CachedOutputBlock ---
|
package/src/tools/xcsh-api.ts
CHANGED
|
@@ -32,10 +32,35 @@ export interface XcshApiToolDetails {
|
|
|
32
32
|
durationMs?: number;
|
|
33
33
|
/** Active context profile name, if available. */
|
|
34
34
|
contextName?: string;
|
|
35
|
+
/** Response body size in bytes. */
|
|
36
|
+
bodySize?: number;
|
|
37
|
+
/** Number of items in the response `items` array (list operations). */
|
|
38
|
+
itemCount?: number;
|
|
39
|
+
/** Response content-type header. */
|
|
40
|
+
contentType?: string;
|
|
41
|
+
/** F5 XC gRPC error code label (e.g. NOT_FOUND, ALREADY_EXISTS) when present in response body. */
|
|
42
|
+
errorCodeLabel?: string;
|
|
43
|
+
/** Whether the request was automatically retried after a transient error (429/503). */
|
|
44
|
+
retried?: boolean;
|
|
45
|
+
/** Payload variables that were expanded (e.g. $F5XC_NAMESPACE → r-mordasiewicz). */
|
|
46
|
+
expandedVars?: Array<{ variable: string; value: string }>;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
|
|
38
50
|
|
|
51
|
+
/** F5 XC gRPC error code labels for human-readable error display. */
|
|
52
|
+
const F5XC_ERROR_CODES: Record<number, string> = {
|
|
53
|
+
3: "INVALID_ARGUMENT",
|
|
54
|
+
5: "NOT_FOUND",
|
|
55
|
+
6: "ALREADY_EXISTS",
|
|
56
|
+
7: "PERMISSION_DENIED",
|
|
57
|
+
8: "RESOURCE_EXHAUSTED",
|
|
58
|
+
9: "FAILED_PRECONDITION",
|
|
59
|
+
13: "INTERNAL",
|
|
60
|
+
14: "UNAVAILABLE",
|
|
61
|
+
16: "UNAUTHENTICATED",
|
|
62
|
+
};
|
|
63
|
+
|
|
39
64
|
export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolDetails> {
|
|
40
65
|
readonly name = "xcsh_api";
|
|
41
66
|
readonly label = "API";
|
|
@@ -93,13 +118,14 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
93
118
|
return `Access denied${ctxHint}. The API token may lack the required role or permission for this operation. Check the token's role assignments in the F5 XC console.`;
|
|
94
119
|
case 404: {
|
|
95
120
|
const ns = process.env.F5XC_NAMESPACE ?? this.#contextEnv.get("F5XC_NAMESPACE") ?? "default";
|
|
96
|
-
return `Resource not found
|
|
121
|
+
return `Resource not found in namespace \`${ns}\`${ctxHint}. Use a GET list operation to verify existing resources.`;
|
|
97
122
|
}
|
|
98
123
|
case 409:
|
|
99
124
|
return `Resource already exists${ctxHint}. Use PUT to replace the existing resource, or DELETE it first before creating a new one.`;
|
|
100
125
|
case 429:
|
|
101
126
|
return `API rate limit exceeded${ctxHint}. Wait briefly and retry the request.`;
|
|
102
127
|
default:
|
|
128
|
+
if (status >= 500) return `Server error (${status})${ctxHint}. This may be transient — retry the request.`;
|
|
103
129
|
return null;
|
|
104
130
|
}
|
|
105
131
|
}
|
|
@@ -153,15 +179,45 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
153
179
|
signal: fetchSignal,
|
|
154
180
|
};
|
|
155
181
|
|
|
182
|
+
let expandedVars: Array<{ variable: string; value: string }> | undefined;
|
|
156
183
|
if (params.payload && params.method !== "GET") {
|
|
157
184
|
headers["Content-Type"] = "application/json";
|
|
158
185
|
const payloadJson = JSON.stringify(params.payload);
|
|
159
|
-
|
|
186
|
+
const resolved = this.#contextEnv.resolvePayloadVars(payloadJson);
|
|
187
|
+
init.body = resolved;
|
|
188
|
+
// Track which $F5XC_* variables were expanded
|
|
189
|
+
if (resolved !== payloadJson) {
|
|
190
|
+
expandedVars = [];
|
|
191
|
+
const env = this.#contextEnv;
|
|
192
|
+
for (const match of payloadJson.matchAll(/\$F5XC_([A-Z0-9_]+)/g)) {
|
|
193
|
+
const key = `F5XC_${match[1]}`;
|
|
194
|
+
const value = env.get(key) ?? process.env[key];
|
|
195
|
+
if (value) expandedVars.push({ variable: `$${key}`, value });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
const startMs = performance.now();
|
|
163
201
|
try {
|
|
164
|
-
|
|
202
|
+
let response = await fetch(url, init);
|
|
203
|
+
|
|
204
|
+
// Auto-retry idempotent GET requests on transient errors (429/503)
|
|
205
|
+
let retried = false;
|
|
206
|
+
if (params.method === "GET" && (response.status === 429 || response.status === 503) && !fetchSignal.aborted) {
|
|
207
|
+
// Parse Retry-After header: seconds (integer) or HTTP-date
|
|
208
|
+
const retryAfter = response.headers.get("retry-after");
|
|
209
|
+
let delayMs = 1000;
|
|
210
|
+
if (retryAfter) {
|
|
211
|
+
const seconds = Number.parseInt(retryAfter, 10);
|
|
212
|
+
if (Number.isFinite(seconds) && seconds > 0) delayMs = Math.min(seconds * 1000, 10_000);
|
|
213
|
+
}
|
|
214
|
+
await Bun.sleep(delayMs);
|
|
215
|
+
if (!fetchSignal.aborted) {
|
|
216
|
+
response = await fetch(url, init);
|
|
217
|
+
retried = true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
165
221
|
const raw = await response.text();
|
|
166
222
|
const durationMs = Math.round(performance.now() - startMs);
|
|
167
223
|
const contentType = response.headers.get("content-type") ?? "";
|
|
@@ -176,12 +232,41 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
176
232
|
const statusLine = `${response.status} ${response.statusText}`;
|
|
177
233
|
|
|
178
234
|
const contextName = this.#contextEnv.getContextName();
|
|
179
|
-
const
|
|
235
|
+
const bodySize = raw.length;
|
|
236
|
+
// Parse response JSON once for item count, error code label, etc.
|
|
237
|
+
let parsedBody: Record<string, unknown> | null = null;
|
|
238
|
+
let itemCount: number | undefined;
|
|
239
|
+
try {
|
|
240
|
+
parsedBody = JSON.parse(raw) as Record<string, unknown>;
|
|
241
|
+
if (Array.isArray(parsedBody?.items)) itemCount = (parsedBody.items as unknown[]).length;
|
|
242
|
+
} catch {
|
|
243
|
+
// Not JSON — skip structured extraction
|
|
244
|
+
}
|
|
245
|
+
const detail: XcshApiToolDetails = {
|
|
246
|
+
status: response.status,
|
|
247
|
+
url,
|
|
248
|
+
method: params.method,
|
|
249
|
+
requestId,
|
|
250
|
+
durationMs,
|
|
251
|
+
contextName,
|
|
252
|
+
bodySize,
|
|
253
|
+
itemCount,
|
|
254
|
+
contentType: contentType || undefined,
|
|
255
|
+
retried: retried || undefined,
|
|
256
|
+
expandedVars,
|
|
257
|
+
};
|
|
180
258
|
|
|
181
259
|
// Context-aware CRUD error guidance for common HTTP status codes
|
|
182
260
|
const guidance = this.#statusGuidance(response.status);
|
|
183
261
|
if (guidance) {
|
|
184
|
-
|
|
262
|
+
// Enrich with F5 XC error code label when present in response body
|
|
263
|
+
let errorCodePrefix = "";
|
|
264
|
+
const codeLabel = parsedBody ? F5XC_ERROR_CODES[parsedBody.code as number] : undefined;
|
|
265
|
+
if (codeLabel) {
|
|
266
|
+
errorCodePrefix = `[${codeLabel}] `;
|
|
267
|
+
detail.errorCodeLabel = codeLabel;
|
|
268
|
+
}
|
|
269
|
+
return this.#errorResult(`${statusLine}\n\n${bodyText}\n\n${errorCodePrefix}${guidance}`, detail);
|
|
185
270
|
}
|
|
186
271
|
|
|
187
272
|
return {
|