@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.
|
|
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.
|
|
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.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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.61.0",
|
|
21
|
+
"commit": "3fb67bac59bac8144b1a3cda6a4db0567e20beb8",
|
|
22
|
+
"shortCommit": "3fb67ba",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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:
|
|
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
|
|
266
|
-
const methodBadge =
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
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
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
+
addSection("Response", highlighted);
|
|
418
308
|
} else if (raw.trim() && !guidance) {
|
|
419
309
|
// Non-JSON, non-guidance body
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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 =
|
|
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
|
-
|
|
327
|
+
addSection("Guidance", guidanceLines);
|
|
437
328
|
}
|
|
438
329
|
|
|
439
330
|
// --- Render with CachedOutputBlock ---
|
package/src/tools/xcsh-api.ts
CHANGED
|
@@ -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
|
-
/**
|
|
46
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|