@f5xc-salesdemos/xcsh 18.59.1 → 18.61.0

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.59.1",
4
+ "version": "18.61.0",
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.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",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.61.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.61.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.61.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.61.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.61.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.61.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.59.1",
21
- "commit": "fa007898365b1a63e38748e06d2c86c13779ff53",
22
- "shortCommit": "fa00789",
20
+ "version": "18.61.0",
21
+ "commit": "3fb67bac59bac8144b1a3cda6a4db0567e20beb8",
22
+ "shortCommit": "3fb67ba",
23
23
  "branch": "main",
24
- "tag": "v18.59.1",
25
- "commitDate": "2026-05-10T17:48:40Z",
26
- "buildDate": "2026-05-10T18:15:56.886Z",
24
+ "tag": "v18.61.0",
25
+ "commitDate": "2026-05-10T20:38:37Z",
26
+ "buildDate": "2026-05-10T20:59:01.622Z",
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/fa007898365b1a63e38748e06d2c86c13779ff53",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.59.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/3fb67bac59bac8144b1a3cda6a4db0567e20beb8",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.61.0"
33
33
  };
@@ -3,23 +3,18 @@ Execute an F5 Distributed Cloud API call directly.
3
3
  Handles authentication, URL construction, and HTTP execution.
4
4
  Credentials are resolved from the active context profile (`/context`). Environment variables
5
5
  `F5XC_API_URL` and `F5XC_API_TOKEN` override context values when set.
6
-
7
6
  Path parameters like `{namespace}` are auto-resolved from the active context when not
8
7
  explicitly provided in `params`. For example, `{namespace}` resolves to the context's
9
8
  default namespace (`F5XC_NAMESPACE`).
10
-
11
9
  Pass all path `{placeholder}` values via `params`, e.g. `{ namespace: "default", name: "example-lb", vh_name: "example-vh" }`.
12
10
  Body is sent for all methods except GET when `payload` is provided — including DELETE operations that require a body.
13
11
  Payload values like `$F5XC_NAMESPACE` are auto-expanded from the active context.
14
-
15
12
  Use this tool after reading the API catalog to get the endpoint path and payload structure.
16
-
17
13
  Response format:
18
14
  - **List**: `{"items": […], "errors": []}` — each item has `name`, `namespace`, `uid`.
19
15
  - **Single resource**: `{"metadata": {"name", "namespace"}, "system_metadata": {"uid", "creation_timestamp"}, "spec": {…}}` — noise-reduced in TUI (nulls/empties stripped).
20
16
  - **Create/Update**: Returns the full resource object. TUI shows a Created/Updated summary with name, uid, timestamp.
21
17
  - **Delete**: Returns `{}`. TUI shows contextual confirmation.
22
18
  - **Error**: `{"code": <int>, "message": "…"}` — codes: 3=INVALID_ARGUMENT, 5=NOT_FOUND, 6=ALREADY_EXISTS, 7=PERMISSION_DENIED, 13=INTERNAL.
23
-
24
19
  GET requests auto-retry once on transient errors (429/503) after 1s backoff. POST/PUT/DELETE are never retried.
25
20
  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.
@@ -9,88 +9,45 @@ const PROMPT_HIDDEN: ReadonlySet<string> = new Set([
9
9
  F5XC_NAMESPACE,
10
10
  F5XC_CONTEXT_NAME,
11
11
  ]);
12
-
13
12
  /** Keys never expanded in payloads — credentials that must not leak into request bodies. */
14
13
  const PAYLOAD_HIDDEN: ReadonlySet<string> = new Set([F5XC_API_TOKEN, F5XC_API_URL]);
15
14
 
16
- /**
17
- * Bridge between F5 XC context profiles and the xcsh_api tool.
18
- *
19
- * Reads from `Settings.bash.environment` (populated by ContextService on profile
20
- * activation) and provides credential resolution, path parameter auto-filling,
21
- * and payload variable expansion. Consumed by:
22
- * - `XcshApiTool` for credential and namespace resolution during API calls
23
- * - `sdk.ts` for surfacing non-sensitive context vars in the system prompt
24
- */
25
15
  export interface ContextEnv {
26
- /** Get a single env var value from bash.environment, or undefined. */
27
16
  get(key: string): string | undefined;
28
-
29
- /**
30
- * Resolve `{placeholder}` values in a URL path.
31
- * Explicit params are applied first. Remaining `{key}` placeholders are
32
- * resolved from bash.environment: `{namespace}` → F5XC_NAMESPACE,
33
- * `{key}` → F5XC_{KEY.toUpperCase()}.
34
- * Unresolvable placeholders are left intact.
35
- */
17
+ /** Resolve {placeholder} values in a URL path. Explicit params first, then auto-resolve from bash.environment. */
36
18
  resolvePath(path: string, explicitParams?: Record<string, string>): string;
37
-
38
- /**
39
- * Expand `$F5XC_*` variable references in a serialized JSON payload string.
40
- * Unresolvable references are left intact.
41
- */
19
+ /** Expand $F5XC_* variable references in a serialized JSON payload string. */
42
20
  resolvePayloadVars(payloadJson: string): string;
43
-
44
- /**
45
- * Return non-sensitive F5XC_* env vars from bash.environment, suitable for
46
- * display in the LLM system prompt. Excludes ALWAYS_HIDDEN keys, keys
47
- * matching SECRET_ENV_PATTERNS, and explicitly provided sensitiveKeys.
48
- */
21
+ /** Return non-sensitive F5XC_* env vars from bash.environment for system prompt display. */
49
22
  getNonSensitiveVars(): Record<string, string>;
50
-
51
- /** Return the active context profile name, or undefined if not set. */
52
23
  getContextName(): string | undefined;
53
24
  }
54
25
 
55
- export interface ContextEnvOptions {
56
- /** Additional keys to treat as sensitive (e.g. from context.sensitiveKeys). */
57
- sensitiveKeys?: ReadonlySet<string>;
58
- }
26
+ export type ContextEnvOptions = { sensitiveKeys?: ReadonlySet<string> };
59
27
 
60
- /**
61
- * Create a ContextEnv instance bound to the current bash.environment settings.
62
- *
63
- * @param settings - Any object with a `get(key)` method returning the value of
64
- * "bash.environment" as `Record<string, string>`. Pass `Settings.instance` or
65
- * `session.settings` in production; pass a stub in tests.
66
- * @param options - Optional configuration (sensitiveKeys to exclude).
67
- */
28
+ function isSensitiveKey(key: string, hidden: ReadonlySet<string>, sensitive: ReadonlySet<string>): boolean {
29
+ return hidden.has(key) || SECRET_ENV_PATTERNS.test(key) || sensitive.has(key);
30
+ }
68
31
  export function createContextEnv(settings: { get(key: string): unknown }, options?: ContextEnvOptions): ContextEnv {
69
32
  function bashEnv(): Record<string, string> {
70
33
  return (settings.get("bash.environment") ?? {}) as Record<string, string>;
71
34
  }
72
-
73
35
  function allSensitiveKeys(): ReadonlySet<string> {
74
36
  if (options?.sensitiveKeys) return options.sensitiveKeys;
75
37
  const fromSettings = settings.get("f5xc.sensitiveKeys");
76
38
  return new Set(Array.isArray(fromSettings) ? (fromSettings as string[]) : []);
77
39
  }
78
-
79
40
  return {
80
41
  get(key: string): string | undefined {
81
42
  return bashEnv()[key];
82
43
  },
83
-
84
44
  getContextName(): string | undefined {
85
45
  return bashEnv()[F5XC_CONTEXT_NAME] || undefined;
86
46
  },
87
47
 
88
48
  resolvePath(path: string, explicitParams?: Record<string, string>): string {
89
49
  let resolved = path;
90
-
91
- // Apply explicit params first — collect substituted ranges to prevent
92
- // double-substitution (values containing {placeholder} syntax must not
93
- // be re-resolved by the auto-resolve pass below).
50
+ // Apply explicit params first — collect substituted ranges to prevent double-substitution
94
51
  const substituted = new Set<number>();
95
52
  if (explicitParams) {
96
53
  for (const [key, value] of Object.entries(explicitParams)) {
@@ -98,13 +55,11 @@ export function createContextEnv(settings: { get(key: string): unknown }, option
98
55
  let idx = resolved.indexOf(placeholder);
99
56
  while (idx !== -1) {
100
57
  resolved = resolved.slice(0, idx) + value + resolved.slice(idx + placeholder.length);
101
- // Mark all character positions within the substituted value
102
58
  for (let i = idx; i < idx + value.length; i++) substituted.add(i);
103
59
  idx = resolved.indexOf(placeholder, idx + value.length);
104
60
  }
105
61
  }
106
62
  }
107
-
108
63
  // Auto-resolve remaining {placeholder} values from bash.environment
109
64
  const env = bashEnv();
110
65
  const sensitive = allSensitiveKeys();
@@ -114,27 +69,20 @@ export function createContextEnv(settings: { get(key: string): unknown }, option
114
69
  // {namespace} maps directly to F5XC_NAMESPACE
115
70
  const envKey = key === "namespace" ? F5XC_NAMESPACE : `F5XC_${key.toUpperCase()}`;
116
71
  // Never auto-inject credential or sensitive values into URL paths
117
- if (PAYLOAD_HIDDEN.has(envKey)) return match;
118
- if (SECRET_ENV_PATTERNS.test(envKey)) return match;
119
- if (sensitive.has(envKey)) return match;
72
+ if (isSensitiveKey(envKey, PAYLOAD_HIDDEN, sensitive)) return match;
120
73
  return env[envKey] ?? process.env[envKey] ?? match;
121
74
  });
122
-
123
75
  return resolved;
124
76
  },
125
77
 
126
78
  resolvePayloadVars(payloadJson: string): string {
127
79
  const env = bashEnv();
128
80
  const sensitive = allSensitiveKeys();
129
- // Pattern matches $F5XC_* anywhere in the string (no word boundary).
130
- // This is intentional: payload values like "$F5XC_NAMESPACE" are the
131
- // primary use case and don't appear with preceding word chars in practice.
81
+ // $F5XC_* matches without word boundary intentional for payload values
132
82
  return payloadJson.replace(/\$F5XC_([A-Z0-9_]+)/g, (match, suffix) => {
133
83
  const key = `F5XC_${suffix}`;
134
84
  // Never expand credential keys into payloads
135
- if (PAYLOAD_HIDDEN.has(key)) return match;
136
- if (SECRET_ENV_PATTERNS.test(key)) return match;
137
- if (sensitive.has(key)) return match;
85
+ if (isSensitiveKey(key, PAYLOAD_HIDDEN, sensitive)) return match;
138
86
  const value = env[key] ?? process.env[key];
139
87
  if (value === undefined) return match;
140
88
  // JSON-escape the substituted value to prevent injection
@@ -143,17 +91,12 @@ export function createContextEnv(settings: { get(key: string): unknown }, option
143
91
  },
144
92
 
145
93
  getNonSensitiveVars(): Record<string, string> {
146
- const env = bashEnv();
147
94
  const sensitive = allSensitiveKeys();
148
- const result: Record<string, string> = {};
149
- for (const [key, value] of Object.entries(env)) {
150
- if (!key.startsWith("F5XC_")) continue;
151
- if (PROMPT_HIDDEN.has(key)) continue;
152
- if (SECRET_ENV_PATTERNS.test(key)) continue;
153
- if (sensitive.has(key)) continue;
154
- result[key] = value;
155
- }
156
- return result;
95
+ return Object.fromEntries(
96
+ Object.entries(bashEnv()).filter(
97
+ ([key]) => key.startsWith("F5XC_") && !isSensitiveKey(key, PROMPT_HIDDEN, sensitive),
98
+ ),
99
+ );
157
100
  },
158
101
  };
159
102
  }
@@ -1,21 +1,4 @@
1
- /**
2
- * TUI renderer for the xcsh_api tool.
3
- *
4
- * Provides rich, context-aware visualization for F5 XC API calls:
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)
13
- *
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).
18
- */
1
+ /** TUI renderer for the xcsh_api tool — rich, context-aware visualization for F5 XC API calls. */
19
2
  import type { Component } from "@f5xc-salesdemos/pi-tui";
20
3
  import { Text } from "@f5xc-salesdemos/pi-tui";
21
4
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -26,49 +9,19 @@ import { formatErrorMessage, replaceTabs } from "./render-utils";
26
9
  import type { XcshApiToolDetails } from "./xcsh-api";
27
10
 
28
11
  const TOOL_TITLE = "XC-API";
29
-
30
- /** Maximum response body lines before truncation. */
31
12
  const MAX_RESPONSE_LINES = 80;
32
-
33
- /** Maximum request payload lines before truncation. */
34
13
  const MAX_PAYLOAD_LINES = 30;
35
14
 
36
- interface XcshApiRenderArgs {
37
- method?: string;
38
- path?: string;
39
- params?: Record<string, string>;
40
- payload?: unknown;
41
- }
15
+ type XcshApiRenderArgs = { method?: string; path?: string; params?: Record<string, string>; payload?: unknown };
42
16
 
43
- /** Map HTTP method to a theme color for the badge. */
44
- function methodColor(method: string): ThemeColor {
45
- switch (method) {
46
- case "GET":
47
- return "accent";
48
- case "DELETE":
49
- return "error";
50
- default:
51
- return "warning";
52
- }
53
- }
17
+ const METHOD_COLORS: Record<string, ThemeColor> = { GET: "accent", DELETE: "error" };
54
18
 
55
- /** Map HTTP status code to a theme color. */
56
19
  function statusColor(status: number): ThemeColor {
57
- if (status < 300) return "success";
58
- if (status < 400) return "warning";
59
- return "error";
60
- }
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`;
20
+ return status < 300 ? "success" : status < 400 ? "warning" : "error";
67
21
  }
68
22
 
69
23
  /**
70
24
  * 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
25
  * Preserves empty objects `{}` — these are F5 XC protobuf oneof presence markers
73
26
  * (e.g. `use_origin_server_name: {}` means that option is selected).
74
27
  */
@@ -80,8 +33,7 @@ function stripEmpty(obj: unknown): unknown {
80
33
  if (entries.length === 0) return obj;
81
34
  const out: Record<string, unknown> = {};
82
35
  for (const [k, v] of entries) {
83
- if (v == null || v === "") continue;
84
- if (Array.isArray(v) && v.length === 0) continue;
36
+ if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) continue;
85
37
  const cleaned = stripEmpty(v);
86
38
  if (cleaned != null) out[k] = cleaned;
87
39
  }
@@ -89,36 +41,22 @@ function stripEmpty(obj: unknown): unknown {
89
41
  }
90
42
  return obj;
91
43
  }
92
-
93
- /** Format ISO timestamp to human-readable: `2026-05-10T00:02:42.577Z` → `2026-05-10 00:02 UTC`. */
94
44
  function formatTimestamp(iso: string): string {
95
- return iso
96
- .replace("T", " ")
97
- .replace(/:\d{2}\.\d+Z$/, " UTC")
98
- .replace(/:\d{2}Z$/, " UTC");
45
+ return iso.replace("T", " ").replace(/:\d{2}(\.\d+)?Z$/, " UTC");
99
46
  }
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 });
47
+ function stripProtobufPrefix(message: string): string {
48
+ return message.replace(/^ves\.io\.schema\.\S+:\s*/i, "");
49
+ }
50
+ function tryPrettyJson(text: string): string | null {
51
+ try {
52
+ return JSON.stringify(JSON.parse(text.trim()), null, 2);
53
+ } catch {
54
+ return null;
115
55
  }
116
56
  }
117
-
118
- /** Build a compact summary section for a single F5 XC resource (identity + spec). */
119
57
  function buildResourceSummary(
120
58
  parsed: Record<string, unknown>,
121
- pathParts: string[],
59
+ _pathParts: string[],
122
60
  method: string,
123
61
  uiTheme: Theme,
124
62
  ): { label: string; lines: string[] } | null {
@@ -132,30 +70,10 @@ function buildResourceSummary(
132
70
  if (typeof sysMeta?.uid === "string") lines.push(uiTheme.fg("dim", ` uid: ${sysMeta.uid}`));
133
71
  const createdAt = sysMeta?.creation_timestamp;
134
72
  if (typeof createdAt === "string") lines.push(uiTheme.fg("dim", ` created: ${formatTimestamp(createdAt)}`));
73
+ const creatorId = sysMeta?.creator_id;
74
+ if (typeof creatorId === "string") lines.push(uiTheme.fg("dim", ` creator: ${creatorId}`));
135
75
  if (metadata.disable === true) lines.push(uiTheme.fg("warning", ` status: DISABLED`));
136
76
 
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
77
  const isMutating = method === "POST" || method === "PUT" || method === "PATCH";
160
78
  const label = isMutating ? (method === "POST" ? "Created" : "Updated") : "Summary";
161
79
  return { label: uiTheme.fg("toolTitle", label), lines };
@@ -177,12 +95,8 @@ function splitResultContent(textContent: string, isError: boolean): { json?: str
177
95
  const body = bodyStart >= 0 ? textContent.slice(bodyStart + 2) : textContent;
178
96
 
179
97
  if (!isError) {
180
- // Success: entire body is JSON
181
- try {
182
- return { json: JSON.stringify(JSON.parse(body.trim()), null, 2), raw: body };
183
- } catch {
184
- return { raw: body };
185
- }
98
+ const pretty = tryPrettyJson(body);
99
+ return pretty ? { json: pretty, raw: body } : { raw: body };
186
100
  }
187
101
 
188
102
  // Error: body is "compactJSON\n\nguidanceText"
@@ -190,39 +104,30 @@ function splitResultContent(textContent: string, isError: boolean): { json?: str
190
104
  if (guidanceSplit >= 0) {
191
105
  const jsonPart = body.slice(0, guidanceSplit);
192
106
  const guidancePart = body.slice(guidanceSplit + 2);
193
- try {
194
- return {
195
- json: JSON.stringify(JSON.parse(jsonPart.trim()), null, 2),
196
- guidance: guidancePart.trim(),
197
- raw: body,
198
- };
199
- } catch {
200
- // First part isn't JSON — treat whole body as guidance
201
- return { guidance: body.trim(), raw: body };
202
- }
107
+ const pretty = tryPrettyJson(jsonPart);
108
+ if (pretty) return { json: pretty, guidance: guidancePart.trim(), raw: body };
109
+ // First part isn't JSON treat whole body as guidance
110
+ return { guidance: body.trim(), raw: body };
203
111
  }
204
112
 
205
113
  // No double newline — might be just JSON or just text
206
- try {
207
- return { json: JSON.stringify(JSON.parse(body.trim()), null, 2), raw: body };
208
- } catch {
209
- return { guidance: body.trim(), raw: body };
210
- }
114
+ const pretty = tryPrettyJson(body);
115
+ return pretty ? { json: pretty, raw: body } : { guidance: body.trim(), raw: body };
211
116
  }
212
117
 
213
118
  export const xcshApiToolRenderer = {
214
119
  renderCall(args: XcshApiRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
215
120
  const method = args.method ?? "???";
216
121
  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;
122
+ const methodBadge = uiTheme.fg(
123
+ METHOD_COLORS[method] ?? "warning",
124
+ `${uiTheme.format.bracketLeft}${method}${uiTheme.format.bracketRight}`,
125
+ );
220
126
  const text = renderStatusLine(
221
127
  {
222
128
  icon: "pending",
223
129
  title: TOOL_TITLE,
224
- description: pendingPath,
225
- badge: { label: method, color: methodColor(method) },
130
+ description: `${methodBadge} ${uiTheme.fg("muted", apiPath)}`,
226
131
  },
227
132
  uiTheme,
228
133
  );
@@ -262,60 +167,54 @@ export const xcshApiToolRenderer = {
262
167
  return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
263
168
  }
264
169
 
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;
170
+ // --- Header: METHOD [STATUS] full-path ---
171
+ const methodBadge = uiTheme.fg(
172
+ METHOD_COLORS[method] ?? "warning",
173
+ `${uiTheme.format.bracketLeft}${method}${uiTheme.format.bracketRight}`,
174
+ );
175
+ const statusDisplay = details?.errorCodeLabel ? `${statusText} ${details.errorCodeLabel}` : statusText;
269
176
  const statusBadge = uiTheme.fg(status > 0 ? statusColor(status) : "error", `[${statusDisplay}]`);
270
177
 
271
178
  const meta: string[] = [];
272
- meta.push(statusBadge);
273
- if (details?.contextName) meta.push(uiTheme.fg("statusLineContextF5xcFg", details.contextName));
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
179
  if (isError && details?.requestId) meta.push(uiTheme.fg("dim", `req:${details.requestId.slice(0, 8)}`));
285
180
  if (details?.retried) meta.push(uiTheme.fg("warning", "retried"));
286
181
 
287
- const compactPath = pathParts.length > 3 ? `…/${pathParts.slice(-3).join("/")}` : displayPath;
288
-
289
182
  const header = renderStatusLine(
290
183
  {
291
184
  title: TOOL_TITLE,
292
185
  titleColor: "contentAccent",
293
- description: compactPath,
294
- badge: methodBadge,
186
+ description: `${methodBadge} ${statusBadge} ${uiTheme.fg("muted", displayPath)}`,
295
187
  meta: meta.length > 0 ? meta : undefined,
296
188
  },
297
189
  uiTheme,
298
190
  );
299
191
 
300
192
  // --- Body sections ---
301
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
302
- const { json, guidance, raw } = splitResultContent(textContent, isError);
193
+ const { json, guidance, raw } = splitResultContent(
194
+ result.content?.find(c => c.type === "text")?.text ?? "",
195
+ isError,
196
+ );
303
197
  const sections: Array<{ label?: string; lines: string[] }> = [];
304
198
 
305
- // Section 2: Request payload (for mutating methods with a body)
306
- if (args?.payload && method !== "GET") {
199
+ const addSection = (label: string, lines: string[], maxLines?: number): void => {
200
+ const titled = uiTheme.fg("toolTitle", label);
201
+ if (maxLines && lines.length > maxLines) {
202
+ const truncated = lines.slice(0, maxLines);
203
+ truncated.push(uiTheme.fg("dim", `… ${lines.length - maxLines} more lines`));
204
+ sections.push({ label: titled, lines: truncated });
205
+ } else {
206
+ sections.push({ label: titled, lines });
207
+ }
208
+ };
209
+
210
+ // Section: Request payload — show resolved body (actual JSON sent to API)
211
+ if (method !== "GET" && (details?.resolvedPayload || args?.payload)) {
307
212
  try {
308
- const prettyPayload = JSON.stringify(args.payload, null, 2);
213
+ const payloadSource = details?.resolvedPayload ? JSON.parse(details.resolvedPayload) : args?.payload;
214
+ const prettyPayload = JSON.stringify(payloadSource, null, 2);
309
215
  const payloadLines = highlightCode(prettyPayload, "json");
310
216
  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);
217
+ addSection("Request", sanitized, MAX_PAYLOAD_LINES);
319
218
  } catch {
320
219
  // Payload not serializable — skip section
321
220
  }
@@ -324,14 +223,16 @@ export const xcshApiToolRenderer = {
324
223
  // Section: Response body — syntax-highlighted JSON or plain text
325
224
  // Parse JSON once for all intelligence branches
326
225
  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
- }
226
+ const parsed =
227
+ json && !emptyBody
228
+ ? (() => {
229
+ try {
230
+ return JSON.parse(json) as Record<string, unknown>;
231
+ } catch {
232
+ return null;
233
+ }
234
+ })()
235
+ : null;
335
236
  const emptyList = Array.isArray(parsed?.items) && (parsed!.items as unknown[]).length === 0;
336
237
 
337
238
  if ((emptyBody || emptyList) && !guidance) {
@@ -343,10 +244,7 @@ export const xcshApiToolRenderer = {
343
244
  else if (method === "POST") successMessage = `Resource${rn} created successfully.`;
344
245
  else if (method === "PUT" || method === "PATCH") successMessage = `Resource${rn} updated successfully.`;
345
246
  }
346
- sections.push({
347
- label: uiTheme.fg("toolTitle", "Response"),
348
- lines: [uiTheme.fg("dim", successMessage)],
349
- });
247
+ addSection("Response", [uiTheme.fg("dim", successMessage)]);
350
248
  } else if (json && parsed) {
351
249
  // Branch 1: List response with named items — compact summary
352
250
  const items = parsed.items;
@@ -367,13 +265,7 @@ export const xcshApiToolRenderer = {
367
265
  );
368
266
  if (itemEntries.length > maxListItems)
369
267
  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
- );
268
+ addSection(`Response (${itemEntries.length} items)`, summaryLines, maxListItems + 1);
377
269
  }
378
270
  } else {
379
271
  // Branch 2: Single resource with metadata — summary + noise-reduced JSON
@@ -383,15 +275,14 @@ export const xcshApiToolRenderer = {
383
275
 
384
276
  // Determine if JSON body should be suppressed
385
277
  let apiErrorMessage: string | undefined;
386
- if (isError && typeof parsed.message === "string" && parsed.message) apiErrorMessage = parsed.message;
387
- const skipJsonBody = (summary && isMutating) || (isError && (guidance || apiErrorMessage));
278
+ if (isError && typeof parsed.message === "string" && parsed.message) {
279
+ apiErrorMessage = stripProtobufPrefix(parsed.message);
280
+ }
281
+ const skipJsonBody = (summary && isMutating) || (isError && (apiErrorMessage || guidance));
388
282
 
389
283
  // Show extracted API error for errors without statusGuidance (400, 422, etc.)
390
284
  if (apiErrorMessage && !guidance) {
391
- sections.push({
392
- label: uiTheme.fg("toolTitle", "Error"),
393
- lines: [uiTheme.fg("error", apiErrorMessage)],
394
- });
285
+ addSection("Error", [uiTheme.fg("error", apiErrorMessage)]);
395
286
  }
396
287
 
397
288
  if (!skipJsonBody) {
@@ -405,8 +296,7 @@ export const xcshApiToolRenderer = {
405
296
  if (typeof parsed === "object" && !Array.isArray(parsed)) keyCount = Object.keys(parsed).length;
406
297
  const responseLabel =
407
298
  keyCount !== undefined && keyCount > 0 ? `Response (${keyCount} keys)` : "Response";
408
-
409
- pushSection(sections, uiTheme.fg("toolTitle", responseLabel), highlighted, MAX_RESPONSE_LINES, uiTheme);
299
+ addSection(responseLabel, highlighted, MAX_RESPONSE_LINES);
410
300
  }
411
301
  }
412
302
  } else if (json) {
@@ -414,26 +304,27 @@ export const xcshApiToolRenderer = {
414
304
  const highlighted = isError
415
305
  ? json.split("\n").map(line => uiTheme.fg("dim", replaceTabs(line)))
416
306
  : highlightCode(json, "json").map(line => replaceTabs(line));
417
- sections.push({ label: uiTheme.fg("toolTitle", "Response"), lines: highlighted });
307
+ addSection("Response", highlighted);
418
308
  } else if (raw.trim() && !guidance) {
419
309
  // Non-JSON, non-guidance body
420
- sections.push({
421
- label: uiTheme.fg("toolTitle", "Response"),
422
- lines: raw
310
+ addSection(
311
+ "Response",
312
+ raw
423
313
  .trim()
424
314
  .split("\n")
425
315
  .map(line => uiTheme.fg("toolOutput", replaceTabs(line))),
426
- });
316
+ );
427
317
  }
428
318
 
429
319
  // Section 4: Error guidance (for HTTP error responses)
430
320
  if (guidance) {
431
321
  // Extract the API's specific error message from JSON body for prominent display
432
322
  const guidanceLines: string[] = [];
433
- const apiMessage = parsed && typeof parsed.message === "string" ? parsed.message : undefined;
323
+ const apiMessage =
324
+ parsed && typeof parsed.message === "string" ? stripProtobufPrefix(parsed.message) : undefined;
434
325
  if (apiMessage) guidanceLines.push(uiTheme.fg("error", apiMessage));
435
326
  guidanceLines.push(uiTheme.fg("warning", guidance));
436
- sections.push({ label: uiTheme.fg("toolTitle", "Guidance"), lines: guidanceLines });
327
+ addSection("Guidance", guidanceLines);
437
328
  }
438
329
 
439
330
  // --- Render with CachedOutputBlock ---
@@ -42,8 +42,8 @@ export interface XcshApiToolDetails {
42
42
  errorCodeLabel?: string;
43
43
  /** Whether the request was automatically retried after a transient error (429/503). */
44
44
  retried?: boolean;
45
- /** Payload variables that were expanded (e.g. $F5XC_NAMESPACE → r-mordasiewicz). */
46
- expandedVars?: Array<{ variable: string; value: string }>;
45
+ /** The resolved JSON body string sent to the API (after $F5XC_* expansion). */
46
+ resolvedPayload?: string;
47
47
  }
48
48
 
49
49
  type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
@@ -66,22 +66,24 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
66
66
  readonly label = "API";
67
67
  readonly description: string;
68
68
  readonly parameters = xcshApiSchema;
69
-
70
69
  #contextEnv: ContextEnv;
71
- /** Tracks the last API base for context-switch detection and TLS re-warm. */
72
70
  #lastApiBase = "";
73
71
 
74
72
  constructor(session: ToolSession) {
75
73
  this.description = prompt.render(xcshApiDescription);
76
74
  this.#contextEnv = createContextEnv(session.settings);
77
-
78
75
  this.#warmTls();
79
76
  }
80
77
 
81
- /** Pre-warm TLS connection to the current context's API endpoint. */
78
+ #resolveCredentials(): [string, string] {
79
+ return [
80
+ (process.env.F5XC_API_URL ?? this.#contextEnv.get("F5XC_API_URL") ?? "").replace(/\/+$/, ""),
81
+ process.env.F5XC_API_TOKEN ?? this.#contextEnv.get("F5XC_API_TOKEN") ?? "",
82
+ ];
83
+ }
84
+
82
85
  #warmTls(): void {
83
- const apiBase = this.#resolveApiBase();
84
- const apiToken = this.#resolveApiToken();
86
+ const [apiBase, apiToken] = this.#resolveCredentials();
85
87
  if (apiBase && apiToken) {
86
88
  this.#lastApiBase = apiBase;
87
89
  fetch(`${apiBase}/api/web/namespaces`, {
@@ -91,23 +93,10 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
91
93
  }
92
94
  }
93
95
 
94
- #resolveApiBase(): string {
95
- return (process.env.F5XC_API_URL ?? this.#contextEnv.get("F5XC_API_URL") ?? "").replace(/\/+$/, "");
96
- }
97
-
98
- #resolveApiToken(): string {
99
- return process.env.F5XC_API_TOKEN ?? this.#contextEnv.get("F5XC_API_TOKEN") ?? "";
100
- }
101
-
102
96
  #errorResult(text: string, details?: XcshApiToolDetails): XcshApiResult {
103
- return {
104
- content: [{ type: "text", text }],
105
- ...(details ? { details } : {}),
106
- isError: true,
107
- };
97
+ return { content: [{ type: "text", text }], details, isError: true };
108
98
  }
109
99
 
110
- /** Context-aware guidance appended to HTTP error responses for common CRUD failures. */
111
100
  #statusGuidance(status: number): string | null {
112
101
  const ctx = this.#contextEnv.getContextName();
113
102
  const ctxHint = ctx ? ` (context: \`${ctx}\`)` : "";
@@ -131,11 +120,8 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
131
120
  }
132
121
 
133
122
  async execute(_toolCallId: string, params: XcshApiParams, signal?: AbortSignal): Promise<XcshApiResult> {
134
- const apiBase = this.#resolveApiBase();
135
- // Detect context switch: API base changed since last call → re-warm TLS
136
- if (apiBase && apiBase !== this.#lastApiBase) {
137
- this.#warmTls();
138
- }
123
+ const [apiBase, apiToken] = this.#resolveCredentials();
124
+ if (apiBase && apiBase !== this.#lastApiBase) this.#warmTls();
139
125
  if (!apiBase) {
140
126
  const ctx = this.#contextEnv.getContextName();
141
127
  const ctxNote = ctx ? ` Active context \`${ctx}\` has no API URL.` : "";
@@ -143,7 +129,6 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
143
129
  `Error: No API URL configured.${ctxNote} Activate a context with \`/context activate <name>\` or \`/context create\`, or set the F5XC_API_URL environment variable.`,
144
130
  );
145
131
  }
146
- const apiToken = this.#resolveApiToken();
147
132
  if (!apiToken) {
148
133
  const ctx = this.#contextEnv.getContextName();
149
134
  const ctxNote = ctx ? ` Active context \`${ctx}\` has no API token.` : "";
@@ -152,9 +137,6 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
152
137
  );
153
138
  }
154
139
  const resolvedPath = this.#contextEnv.resolvePath(params.path, params.params);
155
-
156
- // Guard: detect unresolved {placeholder} params still remaining in the path.
157
- // Regex matches \w+ (same as ContextEnv.resolvePath) to avoid misaligned detection.
158
140
  const unresolvedPlaceholders = resolvedPath.match(/\{\w+\}/g);
159
141
  if (unresolvedPlaceholders) {
160
142
  return this.#errorResult(
@@ -168,49 +150,29 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
168
150
  Accept: "application/json",
169
151
  "X-Request-ID": requestId,
170
152
  };
171
-
172
- // Combine user abort signal with 30s timeout. User Ctrl+C is respected.
173
153
  const timeoutSignal = AbortSignal.timeout(30_000);
174
154
  const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
155
+ const init: RequestInit = { method: params.method, headers, signal: fetchSignal };
175
156
 
176
- const init: RequestInit = {
177
- method: params.method,
178
- headers,
179
- signal: fetchSignal,
180
- };
181
-
182
- let expandedVars: Array<{ variable: string; value: string }> | undefined;
157
+ let resolvedPayload: string | undefined;
183
158
  if (params.payload && params.method !== "GET") {
184
159
  headers["Content-Type"] = "application/json";
185
160
  const payloadJson = JSON.stringify(params.payload);
186
161
  const resolved = this.#contextEnv.resolvePayloadVars(payloadJson);
187
162
  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
- }
163
+ resolvedPayload = resolved;
198
164
  }
199
165
 
200
166
  const startMs = performance.now();
201
167
  try {
202
168
  let response = await fetch(url, init);
203
169
 
204
- // Auto-retry idempotent GET requests on transient errors (429/503)
170
+ // Auto-retry idempotent GET on transient 429/503
205
171
  let retried = false;
206
172
  if (params.method === "GET" && (response.status === 429 || response.status === 503) && !fetchSignal.aborted) {
207
- // Parse Retry-After header: seconds (integer) or HTTP-date
208
173
  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
- }
174
+ const seconds = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
175
+ const delayMs = Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1000, 10_000) : 1000;
214
176
  await Bun.sleep(delayMs);
215
177
  if (!fetchSignal.aborted) {
216
178
  response = await fetch(url, init);
@@ -226,21 +188,19 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
226
188
  try {
227
189
  bodyText = JSON.stringify(JSON.parse(raw));
228
190
  } catch {
229
- // Server declared JSON but returned unparseable body — fall back to raw text
191
+ // Unparseable JSON body — fall back to raw text
230
192
  }
231
193
  }
232
194
  const statusLine = `${response.status} ${response.statusText}`;
233
-
234
195
  const contextName = this.#contextEnv.getContextName();
235
196
  const bodySize = raw.length;
236
- // Parse response JSON once for item count, error code label, etc.
237
197
  let parsedBody: Record<string, unknown> | null = null;
238
198
  let itemCount: number | undefined;
239
199
  try {
240
200
  parsedBody = JSON.parse(raw) as Record<string, unknown>;
241
201
  if (Array.isArray(parsedBody?.items)) itemCount = (parsedBody.items as unknown[]).length;
242
202
  } catch {
243
- // Not JSON — skip structured extraction
203
+ // Not JSON
244
204
  }
245
205
  const detail: XcshApiToolDetails = {
246
206
  status: response.status,
@@ -253,13 +213,11 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
253
213
  itemCount,
254
214
  contentType: contentType || undefined,
255
215
  retried: retried || undefined,
256
- expandedVars,
216
+ resolvedPayload,
257
217
  };
258
218
 
259
- // Context-aware CRUD error guidance for common HTTP status codes
260
219
  const guidance = this.#statusGuidance(response.status);
261
220
  if (guidance) {
262
- // Enrich with F5 XC error code label when present in response body
263
221
  let errorCodePrefix = "";
264
222
  const codeLabel = parsedBody ? F5XC_ERROR_CODES[parsedBody.code as number] : undefined;
265
223
  if (codeLabel) {
@@ -272,13 +230,12 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
272
230
  return {
273
231
  content: [{ type: "text", text: `${statusLine}\n\n${bodyText}` }],
274
232
  details: detail,
275
- ...(response.status >= 400 ? { isError: true } : {}),
233
+ isError: response.status >= 400 || undefined,
276
234
  };
277
235
  } catch (err) {
278
236
  const durationMs = Math.round(performance.now() - startMs);
279
237
  const contextName = this.#contextEnv.getContextName();
280
238
  const detail = { status: 0, url, method: params.method, requestId, durationMs, contextName };
281
- // Classify error: timeout vs DNS/network vs generic
282
239
  const ctxLabel = contextName ? ` (context: \`${contextName}\`)` : "";
283
240
  if (err instanceof Error && err.name === "AbortError") {
284
241
  // User abort vs 30s timeout produce different AbortErrors
@@ -288,12 +245,11 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
288
245
  return this.#errorResult(message, detail);
289
246
  }
290
247
  const message = err instanceof Error ? err.message : String(err);
291
- if (/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|dns/i.test(message)) {
248
+ if (/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|dns/i.test(message))
292
249
  return this.#errorResult(
293
250
  `Network error${ctxLabel}: ${message}. The API URL may be incorrect. Check with \`/context show\`.`,
294
251
  detail,
295
252
  );
296
- }
297
253
  return this.#errorResult(`Request failed${ctxLabel}: ${message}`, detail);
298
254
  }
299
255
  }