@f5xc-salesdemos/xcsh 18.28.1 → 18.30.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.28.1",
4
+ "version": "18.30.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",
@@ -31,28 +31,29 @@
31
31
  "xcsh": "src/cli.ts"
32
32
  },
33
33
  "scripts": {
34
- "build": "bun run generate-build-info && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset",
34
+ "build": "bun run generate-build-info && bun run generate-api-spec-index && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset",
35
35
  "check": "biome check . && bun run check:types",
36
- "check:types": "bun run generate-build-info && tsgo -p tsconfig.json --noEmit",
36
+ "check:types": "bun run generate-build-info && bun run generate-api-spec-index && tsgo -p tsconfig.json --noEmit",
37
37
  "lint": "biome lint .",
38
- "test": "bun run generate-build-info && bun test --max-concurrency 4",
39
- "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-build-info",
38
+ "test": "bun run generate-build-info && bun run generate-api-spec-index && bun test --max-concurrency 4",
39
+ "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
40
40
  "fmt": "biome format --write . && bun run format-prompts",
41
41
  "format-prompts": "bun scripts/format-prompts.ts",
42
42
  "generate-docs-index": "bun scripts/generate-docs-index.ts",
43
+ "generate-api-spec-index": "bun scripts/generate-api-spec-index.ts",
43
44
  "generate-build-info": "bun scripts/generate-build-info.ts",
44
- "prepack": "bun scripts/generate-docs-index.ts && bun scripts/generate-build-info.ts",
45
+ "prepack": "bun scripts/generate-docs-index.ts && bun scripts/generate-api-spec-index.ts && bun scripts/generate-build-info.ts",
45
46
  "generate-template": "bun scripts/generate-template.ts"
46
47
  },
47
48
  "dependencies": {
48
49
  "@agentclientprotocol/sdk": "0.16.1",
49
50
  "@mozilla/readability": "^0.6",
50
- "@f5xc-salesdemos/xcsh-stats": "18.28.1",
51
- "@f5xc-salesdemos/pi-agent-core": "18.28.1",
52
- "@f5xc-salesdemos/pi-ai": "18.28.1",
53
- "@f5xc-salesdemos/pi-natives": "18.28.1",
54
- "@f5xc-salesdemos/pi-tui": "18.28.1",
55
- "@f5xc-salesdemos/pi-utils": "18.28.1",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.30.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.30.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.30.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.30.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.30.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.30.0",
56
57
  "@sinclair/typebox": "^0.34",
57
58
  "@xterm/headless": "^6.0",
58
59
  "ajv": "^8.18",
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { gzipSync } from "node:zlib";
8
+
9
+ interface IndexEntry {
10
+ domain: string;
11
+ title: string;
12
+ description: string;
13
+ "x-f5xc-description-short": string;
14
+ file: string;
15
+ path_count: number;
16
+ schema_count: number;
17
+ "x-f5xc-complexity": string;
18
+ "x-f5xc-category": string;
19
+ "x-f5xc-use-cases"?: string[];
20
+ "x-f5xc-related-domains"?: string[];
21
+ "x-f5xc-primary-resources"?: Array<{ name: string; description: string }>;
22
+ }
23
+
24
+ interface RawIndex {
25
+ version: string;
26
+ timestamp: string;
27
+ specifications: IndexEntry[];
28
+ }
29
+
30
+ const REPO = "f5xc-salesdemos/api-specs-enriched";
31
+ const PINNED_TAG = "v2.1.62";
32
+ const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/api-spec-index.generated.ts");
33
+
34
+ async function downloadFromRelease(): Promise<string> {
35
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "api-specs-"));
36
+ const tag = process.env.API_SPECS_TAG ?? PINNED_TAG;
37
+ const zipName = `f5xc-api-specs-${tag}.zip`;
38
+ const downloadUrl = `https://github.com/${REPO}/releases/download/${tag}/${zipName}`;
39
+
40
+ console.log(`Downloading API specs from ${downloadUrl}...`);
41
+ const response = await fetch(downloadUrl, { redirect: "follow" });
42
+ if (!response.ok) {
43
+ throw new Error(`Failed to download release: ${response.status} ${response.statusText}`);
44
+ }
45
+
46
+ const zipPath = path.join(tmpDir, zipName);
47
+ const buffer = Buffer.from(await response.arrayBuffer());
48
+ fs.writeFileSync(zipPath, buffer);
49
+
50
+ const extractDir = path.join(tmpDir, "extracted");
51
+ fs.mkdirSync(extractDir, { recursive: true });
52
+ execFileSync("unzip", ["-q", zipPath, "-d", extractDir], { stdio: "inherit" });
53
+
54
+ const domainsDir = path.join(extractDir, "domains");
55
+ if (fs.existsSync(domainsDir) && fs.existsSync(path.join(extractDir, "index.json"))) {
56
+ for (const file of fs.readdirSync(domainsDir)) {
57
+ fs.copyFileSync(path.join(domainsDir, file), path.join(extractDir, file));
58
+ }
59
+ }
60
+
61
+ return extractDir;
62
+ }
63
+
64
+ async function findSpecsDir(): Promise<string> {
65
+ const envDir = process.env.API_SPECS_DIR;
66
+ if (envDir && fs.existsSync(envDir)) {
67
+ return envDir;
68
+ }
69
+
70
+ const localCheckout = path.resolve(import.meta.dir, "../../../../api-specs-enriched/docs/specifications/api");
71
+ if (fs.existsSync(localCheckout)) {
72
+ return localCheckout;
73
+ }
74
+
75
+ return downloadFromRelease();
76
+ }
77
+
78
+ const specsDir = await findSpecsDir();
79
+ console.log(`Reading specs from: ${specsDir}`);
80
+
81
+ const indexPath = path.join(specsDir, "index.json");
82
+ if (!fs.existsSync(indexPath)) {
83
+ console.error(`index.json not found at: ${indexPath}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ const rawIndex: RawIndex = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
88
+
89
+ const domainEntries: string[] = [];
90
+ const blobEntries: string[] = [];
91
+ let processedCount = 0;
92
+ let skippedCount = 0;
93
+
94
+ for (const entry of rawIndex.specifications) {
95
+ const specFile = path.join(specsDir, entry.file);
96
+ if (!fs.existsSync(specFile)) {
97
+ console.warn(` Skipping ${entry.domain}: spec file not found at ${specFile}`);
98
+ skippedCount++;
99
+ continue;
100
+ }
101
+
102
+ const specContent = fs.readFileSync(specFile, "utf-8");
103
+ const compressed = gzipSync(Buffer.from(specContent));
104
+ const b64 = compressed.toString("base64");
105
+
106
+ const resources = (entry["x-f5xc-primary-resources"] ?? []).map(
107
+ r => `\t\t\t{ name: ${JSON.stringify(r.name)}, description: ${JSON.stringify(r.description)} },`,
108
+ );
109
+
110
+ const useCases = entry["x-f5xc-use-cases"];
111
+ const relatedDomains = entry["x-f5xc-related-domains"];
112
+
113
+ domainEntries.push(
114
+ [
115
+ "\t\t{",
116
+ `\t\t\tdomain: ${JSON.stringify(entry.domain)},`,
117
+ `\t\t\ttitle: ${JSON.stringify(entry.title)},`,
118
+ `\t\t\tdescription: ${JSON.stringify(entry.description)},`,
119
+ `\t\t\tdescriptionShort: ${JSON.stringify(entry["x-f5xc-description-short"])},`,
120
+ `\t\t\tcategory: ${JSON.stringify(entry["x-f5xc-category"])},`,
121
+ `\t\t\tpathCount: ${entry.path_count},`,
122
+ `\t\t\tschemaCount: ${entry.schema_count},`,
123
+ `\t\t\tcomplexity: ${JSON.stringify(entry["x-f5xc-complexity"])},`,
124
+ `\t\t\tresources: [`,
125
+ ...resources,
126
+ `\t\t\t],`,
127
+ useCases ? `\t\t\tuseCases: ${JSON.stringify(useCases)},` : undefined,
128
+ relatedDomains?.length ? `\t\t\trelatedDomains: ${JSON.stringify(relatedDomains)},` : undefined,
129
+ "\t\t},",
130
+ ]
131
+ .filter(Boolean)
132
+ .join("\n"),
133
+ );
134
+
135
+ blobEntries.push(`\t${JSON.stringify(entry.domain)}: ${JSON.stringify(b64)},`);
136
+ processedCount++;
137
+ }
138
+
139
+ const output = [
140
+ "// Auto-generated by scripts/generate-api-spec-index.ts - DO NOT EDIT",
141
+ "",
142
+ `import type { ApiSpecIndex } from "./api-spec-types";`,
143
+ "",
144
+ `export const API_SPEC_VERSION = ${JSON.stringify(rawIndex.version)};`,
145
+ "",
146
+ `export const API_SPEC_INDEX: ApiSpecIndex = {`,
147
+ `\tversion: ${JSON.stringify(rawIndex.version)},`,
148
+ `\ttimestamp: ${JSON.stringify(rawIndex.timestamp)},`,
149
+ `\tdomains: [`,
150
+ ...domainEntries,
151
+ `\t],`,
152
+ `};`,
153
+ "",
154
+ `export const API_SPEC_BLOBS: Readonly<Record<string, string>> = {`,
155
+ ...blobEntries,
156
+ `};`,
157
+ "",
158
+ ].join("\n");
159
+
160
+ await Bun.write(outputPath, output);
161
+
162
+ const outputSize = (Buffer.byteLength(output) / 1024 / 1024).toFixed(1);
163
+ console.log(
164
+ `Generated ${path.relative(process.cwd(), outputPath)} (${processedCount} domains, ${skippedCount} skipped, ${outputSize} MB)`,
165
+ );
@@ -0,0 +1,373 @@
1
+ import { gunzipSync } from "node:zlib";
2
+ import { LRUCache } from "lru-cache";
3
+ import type { ApiSpecDomainEntry, ApiSpecIndex, OpenAPISpec } from "./api-spec-types";
4
+ import type { InternalResource, InternalUrl } from "./types";
5
+
6
+ const LRU_CAPACITY = 5;
7
+ const SCHEMA_RENDER_MAX_DEPTH = 3;
8
+
9
+ export interface ApiSpecResolver {
10
+ resolve(url: InternalUrl): Promise<InternalResource>;
11
+ }
12
+
13
+ export function createApiSpecResolver(index: ApiSpecIndex, blobs: Record<string, string>): ApiSpecResolver {
14
+ const cache = new LRUCache<string, OpenAPISpec>({ max: LRU_CAPACITY });
15
+
16
+ function decompress(domain: string): OpenAPISpec {
17
+ const cached = cache.get(domain);
18
+ if (cached) return cached;
19
+
20
+ const blob = blobs[domain];
21
+ if (!blob) {
22
+ throw new Error(`No spec blob for domain: ${domain}`);
23
+ }
24
+
25
+ const buffer = Buffer.from(blob, "base64");
26
+ const decompressed = gunzipSync(buffer);
27
+ const spec = JSON.parse(decompressed.toString("utf-8")) as OpenAPISpec;
28
+ cache.set(domain, spec);
29
+ return spec;
30
+ }
31
+
32
+ return {
33
+ async resolve(url: InternalUrl): Promise<InternalResource> {
34
+ const pathname = url.rawPathname ?? url.pathname;
35
+ const domain = pathname.replace(/^\//, "").replace(/\/$/, "");
36
+
37
+ if (!domain) {
38
+ return makeResource(url, renderDomainIndex(index));
39
+ }
40
+
41
+ const entry = index.domains.find(d => d.domain === domain);
42
+ if (!entry) {
43
+ return makeResource(url, renderUnknownDomain(domain, index));
44
+ }
45
+
46
+ const resource = url.searchParams.get("resource");
47
+ const pathFilter = url.searchParams.get("path");
48
+
49
+ if (resource) {
50
+ const spec = decompress(domain);
51
+ const matchingPaths = filterPathsByResource(spec, resource);
52
+ if (Object.keys(matchingPaths).length === 0) {
53
+ return makeResource(url, renderUnknownResource(resource, entry, spec));
54
+ }
55
+ return makeResource(url, renderResourceSpec(domain, resource, spec));
56
+ }
57
+
58
+ if (pathFilter) {
59
+ const spec = decompress(domain);
60
+ return makeResource(url, renderPathSpec(domain, pathFilter, spec));
61
+ }
62
+
63
+ const spec = decompress(domain);
64
+ return makeResource(url, renderDomainDetail(domain, entry, spec));
65
+ },
66
+ };
67
+ }
68
+
69
+ function makeResource(url: InternalUrl, content: string): InternalResource {
70
+ return {
71
+ url: url.href,
72
+ content,
73
+ contentType: "text/markdown",
74
+ size: Buffer.byteLength(content, "utf-8"),
75
+ sourcePath: `xcsh://${url.rawHost}${url.rawPathname ?? "/"}`,
76
+ };
77
+ }
78
+
79
+ function renderDomainIndex(index: ApiSpecIndex): string {
80
+ const rows = index.domains.map(
81
+ d => `| ${d.domain} | ${d.category} | ${d.resources.length} | ${d.pathCount} | ${d.descriptionShort} |`,
82
+ );
83
+
84
+ return [
85
+ `# F5 XC API Specifications (v${index.version})`,
86
+ "",
87
+ `${index.domains.length} domains. Read \`xcsh://api-spec/{domain}\` for resource details.`,
88
+ "",
89
+ "| Domain | Category | Resources | Paths | Description |",
90
+ "|--------|----------|-----------|-------|-------------|",
91
+ ...rows,
92
+ "",
93
+ ].join("\n");
94
+ }
95
+
96
+ function renderDomainDetail(domain: string, entry: ApiSpecDomainEntry, spec: OpenAPISpec): string {
97
+ const groups = groupPathsBySchema(spec);
98
+ const schemaNames = [...groups.keys()].sort();
99
+
100
+ const resourceRows = schemaNames.map(s => {
101
+ const paths = groups.get(s)!;
102
+ const opCount = Object.values(paths).reduce((n, m) => n + Object.keys(m).length, 0);
103
+ return `| ${s} | ${opCount} operations |`;
104
+ });
105
+
106
+ const operationRows: string[] = [];
107
+ for (const [pathKey, methods] of Object.entries(spec.paths)) {
108
+ for (const [method, op] of Object.entries(methods)) {
109
+ if (typeof op !== "object" || !op) continue;
110
+ const summary = (op as Record<string, unknown>).summary ?? "";
111
+ operationRows.push(`| ${method.toUpperCase()} | ${pathKey} | ${summary} |`);
112
+ }
113
+ }
114
+
115
+ const sections = [
116
+ `# ${entry.title} — F5 XC API`,
117
+ "",
118
+ `Category: ${entry.category} | Paths: ${entry.pathCount} | Complexity: ${entry.complexity}`,
119
+ "",
120
+ "## Resources",
121
+ "",
122
+ "| Resource | Info |",
123
+ "|----------|------|",
124
+ ...resourceRows,
125
+ "",
126
+ "## Operations",
127
+ "",
128
+ "| Method | Path | Summary |",
129
+ "|--------|------|---------|",
130
+ ...operationRows,
131
+ ];
132
+
133
+ if (entry.useCases?.length) {
134
+ sections.push("", "## Use Cases", ...entry.useCases.map(u => `- ${u}`));
135
+ }
136
+
137
+ if (entry.relatedDomains?.length) {
138
+ sections.push("", "## Related Domains", `- ${entry.relatedDomains.join(", ")}`);
139
+ }
140
+
141
+ sections.push("", `Read \`xcsh://api-spec/${domain}?resource={name}\` for full endpoint specification.`, "");
142
+
143
+ return sections.join("\n");
144
+ }
145
+
146
+ function extractSchemaComponent(operationId: string): string | null {
147
+ const match = operationId.match(/^ves\.io\.schema\.(.+?)\.(?:API|CustomAPI)\./);
148
+ return match ? match[1] : null;
149
+ }
150
+
151
+ function groupPathsBySchema(spec: OpenAPISpec): Map<string, Record<string, Record<string, unknown>>> {
152
+ const groups = new Map<string, Record<string, Record<string, unknown>>>();
153
+
154
+ for (const [pathKey, methods] of Object.entries(spec.paths)) {
155
+ for (const [method, op] of Object.entries(methods)) {
156
+ if (typeof op !== "object" || !op) continue;
157
+ const opId = (op as Record<string, unknown>).operationId as string | undefined;
158
+ if (!opId) continue;
159
+ const schema = extractSchemaComponent(opId);
160
+ if (!schema) continue;
161
+ if (!groups.has(schema)) {
162
+ groups.set(schema, {});
163
+ }
164
+ const group = groups.get(schema)!;
165
+ if (!group[pathKey]) group[pathKey] = {};
166
+ group[pathKey][method] = op as Record<string, unknown>;
167
+ }
168
+ }
169
+
170
+ return groups;
171
+ }
172
+
173
+ function filterPathsByResource(spec: OpenAPISpec, resource: string): Record<string, Record<string, unknown>> {
174
+ const groups = groupPathsBySchema(spec);
175
+
176
+ const exactKey = resource.replace(/-/g, "_");
177
+ if (groups.has(exactKey)) {
178
+ return groups.get(exactKey)!;
179
+ }
180
+
181
+ const partial = new Map<string, Record<string, Record<string, unknown>>>();
182
+ for (const [schema, paths] of groups) {
183
+ const schemaEnd = schema.split(".").at(-1) ?? schema;
184
+ if (
185
+ schemaEnd === exactKey ||
186
+ schemaEnd.includes(exactKey) ||
187
+ exactKey.includes(schemaEnd) ||
188
+ schema === exactKey ||
189
+ schema.endsWith(`.${exactKey}`)
190
+ ) {
191
+ for (const [path, methods] of Object.entries(paths)) {
192
+ if (!partial.has("merged")) partial.set("merged", {});
193
+ const merged = partial.get("merged")!;
194
+ if (!merged[path]) merged[path] = {};
195
+ Object.assign(merged[path], methods);
196
+ }
197
+ }
198
+ }
199
+ if (partial.size > 0) {
200
+ return partial.get("merged") ?? {};
201
+ }
202
+
203
+ const pluralized = exactKey.endsWith("s") ? exactKey : `${exactKey}s`;
204
+ const result: Record<string, Record<string, unknown>> = {};
205
+ for (const [pathKey, methods] of Object.entries(spec.paths)) {
206
+ const segments = pathKey.split("/");
207
+ if (segments.some(s => s === pluralized || s === exactKey)) {
208
+ result[pathKey] = methods;
209
+ }
210
+ }
211
+ return result;
212
+ }
213
+
214
+ function renderResourceSpec(_domain: string, resource: string, spec: OpenAPISpec): string {
215
+ const matchingPaths = filterPathsByResource(spec, resource);
216
+ const sections = [`# ${resource} — Full API Specification`, ""];
217
+
218
+ for (const [pathKey, methods] of Object.entries(matchingPaths)) {
219
+ for (const [method, op] of Object.entries(methods)) {
220
+ if (typeof op !== "object" || !op) continue;
221
+ const operation = op as Record<string, unknown>;
222
+ sections.push(`## ${method.toUpperCase()} ${pathKey}`, "");
223
+ if (operation.summary) sections.push(String(operation.summary), "");
224
+
225
+ const params = operation.parameters as Array<Record<string, unknown>> | undefined;
226
+ if (params?.length) {
227
+ sections.push("### Parameters", "");
228
+ sections.push("| Name | In | Required | Type | Description |");
229
+ sections.push("|------|-----|----------|------|-------------|");
230
+ for (const p of params) {
231
+ const schema = (p.schema as Record<string, unknown>) ?? {};
232
+ sections.push(
233
+ `| ${p.name} | ${p.in} | ${p.required ? "yes" : "no"} | ${schema.type ?? "unknown"} | ${p.description ?? ""} |`,
234
+ );
235
+ }
236
+ sections.push("");
237
+ }
238
+
239
+ const reqBody = operation.requestBody as Record<string, unknown> | undefined;
240
+ if (reqBody) {
241
+ sections.push("### Request Body", "");
242
+ const content = reqBody.content as Record<string, Record<string, unknown>> | undefined;
243
+ const jsonContent = content?.["application/json"];
244
+ if (jsonContent?.schema) {
245
+ const schema = resolveSchemaRef(jsonContent.schema as Record<string, unknown>, spec);
246
+ sections.push(renderSchemaAsTable(schema, spec));
247
+ }
248
+ }
249
+
250
+ const responses = operation.responses as Record<string, Record<string, unknown>> | undefined;
251
+ if (responses) {
252
+ for (const [status, resp] of Object.entries(responses)) {
253
+ if (typeof resp !== "object" || !resp) continue;
254
+ const respContent = (resp as Record<string, unknown>).content as
255
+ | Record<string, Record<string, unknown>>
256
+ | undefined;
257
+ const jsonResp = respContent?.["application/json"];
258
+ if (jsonResp?.schema) {
259
+ sections.push(`### Response ${status}`, "");
260
+ const schema = resolveSchemaRef(jsonResp.schema as Record<string, unknown>, spec);
261
+ sections.push(renderSchemaAsTable(schema, spec));
262
+ }
263
+ }
264
+ }
265
+
266
+ sections.push("---", "");
267
+ }
268
+ }
269
+
270
+ return sections.join("\n");
271
+ }
272
+
273
+ function renderPathSpec(_domain: string, pathKey: string, spec: OpenAPISpec): string {
274
+ const methods = spec.paths[pathKey];
275
+ if (!methods) {
276
+ const available = Object.keys(spec.paths).slice(0, 10);
277
+ return [`# Path not found: ${pathKey}`, "", "Available paths:", ...available.map(p => `- \`${p}\``), ""].join(
278
+ "\n",
279
+ );
280
+ }
281
+
282
+ const sections = [`# ${pathKey}`, ""];
283
+
284
+ for (const [method, op] of Object.entries(methods)) {
285
+ if (typeof op !== "object" || !op) continue;
286
+ const operation = op as Record<string, unknown>;
287
+ sections.push(`## ${method.toUpperCase()}`, "");
288
+ if (operation.summary) sections.push(String(operation.summary), "");
289
+ }
290
+
291
+ return sections.join("\n");
292
+ }
293
+
294
+ function resolveSchemaRef(schema: Record<string, unknown>, spec: OpenAPISpec): Record<string, unknown> {
295
+ const ref = schema.$ref as string | undefined;
296
+ if (!ref) return schema;
297
+
298
+ const match = ref.match(/^#\/components\/schemas\/(.+)$/);
299
+ if (!match) return schema;
300
+
301
+ const schemaName = match[1];
302
+ const resolved = spec.components?.schemas?.[schemaName];
303
+ return (resolved as Record<string, unknown>) ?? schema;
304
+ }
305
+
306
+ function renderSchemaAsTable(schema: Record<string, unknown>, spec: OpenAPISpec, depth = 0, prefix = ""): string {
307
+ if (depth > SCHEMA_RENDER_MAX_DEPTH) return "";
308
+
309
+ const resolved = resolveSchemaRef(schema, spec);
310
+ const properties = resolved.properties as Record<string, Record<string, unknown>> | undefined;
311
+ if (!properties) {
312
+ const type = (resolved.type as string) ?? "object";
313
+ return `Type: ${type}\n`;
314
+ }
315
+
316
+ const required = (resolved.required as string[]) ?? [];
317
+ const rows: string[] = [];
318
+
319
+ if (depth === 0) {
320
+ rows.push("| Field | Type | Required | Description |");
321
+ rows.push("|-------|------|----------|-------------|");
322
+ }
323
+
324
+ for (const [name, prop] of Object.entries(properties)) {
325
+ const fieldProp = resolveSchemaRef(prop, spec);
326
+ const fieldName = prefix ? `${prefix}.${name}` : name;
327
+ const type = (fieldProp.type as string) ?? "object";
328
+ const desc = (fieldProp.description as string) ?? "";
329
+ const isRequired = required.includes(name) ? "yes" : "no";
330
+
331
+ rows.push(`| ${fieldName} | ${type} | ${isRequired} | ${desc} |`);
332
+
333
+ if (type === "object" && fieldProp.properties && depth < SCHEMA_RENDER_MAX_DEPTH) {
334
+ const nested = renderSchemaAsTable(fieldProp, spec, depth + 1, fieldName);
335
+ const nestedLines = nested.split("\n").filter(l => l.startsWith("|") && !l.startsWith("| Field"));
336
+ rows.push(...nestedLines);
337
+ }
338
+ }
339
+
340
+ rows.push("");
341
+ return rows.join("\n");
342
+ }
343
+
344
+ function renderUnknownDomain(requested: string, index: ApiSpecIndex): string {
345
+ const suggestions = index.domains
346
+ .filter(d => d.domain.includes(requested) || requested.includes(d.domain.slice(0, 3)))
347
+ .slice(0, 5);
348
+
349
+ const sections = [`# Domain not found: ${requested}`, ""];
350
+
351
+ if (suggestions.length > 0) {
352
+ sections.push("Did you mean:", ...suggestions.map(d => `- \`${d.domain}\` — ${d.descriptionShort}`), "");
353
+ }
354
+
355
+ sections.push("Available domains:", ...index.domains.map(d => `- \`${d.domain}\` — ${d.descriptionShort}`), "");
356
+
357
+ return sections.join("\n");
358
+ }
359
+
360
+ function renderUnknownResource(requested: string, entry: ApiSpecDomainEntry, spec: OpenAPISpec): string {
361
+ const groups = groupPathsBySchema(spec);
362
+ const schemaNames = [...groups.keys()].sort();
363
+
364
+ return [
365
+ `# Resource not found: ${requested}`,
366
+ "",
367
+ `Available resources in ${entry.domain} (from API operations):`,
368
+ ...schemaNames.map(s => `- \`${s}\``),
369
+ "",
370
+ `Use \`xcsh://api-spec/${entry.domain}?resource={name}\` with one of the above.`,
371
+ "",
372
+ ].join("\n");
373
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Types for the embedded API specification index and domain specs.
3
+ *
4
+ * These types define the shape of data generated at build time by
5
+ * scripts/generate-api-spec-index.ts from the api-specs-enriched repository.
6
+ */
7
+
8
+ export interface ApiSpecDomainResource {
9
+ readonly name: string;
10
+ readonly description: string;
11
+ }
12
+
13
+ export interface ApiSpecDomainEntry {
14
+ readonly domain: string;
15
+ readonly title: string;
16
+ readonly description: string;
17
+ readonly descriptionShort: string;
18
+ readonly category: string;
19
+ readonly pathCount: number;
20
+ readonly schemaCount: number;
21
+ readonly complexity: string;
22
+ readonly resources: readonly ApiSpecDomainResource[];
23
+ readonly useCases?: readonly string[];
24
+ readonly relatedDomains?: readonly string[];
25
+ }
26
+
27
+ export interface ApiSpecIndex {
28
+ readonly version: string;
29
+ readonly timestamp: string;
30
+ readonly domains: readonly ApiSpecDomainEntry[];
31
+ }
32
+
33
+ export interface OpenAPIPathOperation {
34
+ readonly summary?: string;
35
+ readonly description?: string;
36
+ readonly operationId?: string;
37
+ readonly parameters?: readonly Record<string, unknown>[];
38
+ readonly requestBody?: Record<string, unknown>;
39
+ readonly responses?: Record<string, unknown>;
40
+ readonly [key: string]: unknown;
41
+ }
42
+
43
+ export interface OpenAPISpec {
44
+ readonly info: {
45
+ readonly title: string;
46
+ readonly version: string;
47
+ readonly [key: string]: unknown;
48
+ };
49
+ readonly paths: Record<string, Record<string, OpenAPIPathOperation>>;
50
+ readonly components?: {
51
+ readonly schemas?: Record<string, Record<string, unknown>>;
52
+ readonly [key: string]: unknown;
53
+ };
54
+ readonly [key: string]: unknown;
55
+ }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.28.1",
21
- "commit": "42862f837ad12f42e37bc29e47ab6f58be779de0",
22
- "shortCommit": "42862f8",
20
+ "version": "18.30.0",
21
+ "commit": "72667c39746cfce92ec26d5d81f2a99617e1d4fe",
22
+ "shortCommit": "72667c3",
23
23
  "branch": "main",
24
- "tag": "v18.28.1",
25
- "commitDate": "2026-04-30T11:56:41Z",
26
- "buildDate": "2026-04-30T12:21:15.369Z",
24
+ "tag": "v18.30.0",
25
+ "commitDate": "2026-05-01T03:26:38Z",
26
+ "buildDate": "2026-05-01T03:52:47.886Z",
27
27
  "dirty": false,
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/42862f837ad12f42e37bc29e47ab6f58be779de0",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.28.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/72667c39746cfce92ec26d5d81f2a99617e1d4fe",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.30.0"
33
33
  };
@@ -21,6 +21,8 @@
21
21
  */
22
22
 
23
23
  export * from "./agent-protocol";
24
+ export * from "./api-spec-resolve";
25
+ export * from "./api-spec-types";
24
26
  export * from "./artifact-protocol";
25
27
  export * from "./jobs-protocol";
26
28
  export * from "./json-query";
@@ -8,21 +8,49 @@
8
8
  * - xcsh:// - Lists all available documentation files
9
9
  * - xcsh://<file>.md - Reads a specific documentation file
10
10
  * - xcsh://about - Identity fingerprint (version, commit, branch, repo)
11
+ * - xcsh://api-spec/ - API specification index
12
+ * - xcsh://api-spec/{domain} - Domain detail
13
+ * - xcsh://api-spec/{domain}?resource={name} - Resource spec
11
14
  */
12
15
  import * as path from "node:path";
13
16
  import type { ContextStatus } from "../services/f5xc-context";
17
+ import { type ApiSpecResolver, createApiSpecResolver } from "./api-spec-resolve";
18
+ import type { ApiSpecIndex } from "./api-spec-types";
14
19
  import { getRuntimeBuildInfo, type RuntimeBuildInfo, renderAboutDoc } from "./build-info-runtime";
15
20
  import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
16
21
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
17
22
 
18
23
  const SCHEME_PREFIX = "xcsh://";
19
24
  const ABOUT_ROUTE = "about";
25
+ const API_SPEC_HOST = "api-spec";
26
+
27
+ const EMPTY_INDEX: ApiSpecIndex = { version: "unknown", timestamp: "", domains: [] };
28
+
29
+ let _apiSpecCache: { index: ApiSpecIndex; blobs: Record<string, string>; version: string } | null = null;
30
+
31
+ function loadApiSpecs(): { index: ApiSpecIndex; blobs: Record<string, string>; version: string } {
32
+ if (_apiSpecCache) return _apiSpecCache;
33
+ try {
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const mod = require("./api-spec-index.generated");
36
+ _apiSpecCache = {
37
+ index: mod.API_SPEC_INDEX ?? EMPTY_INDEX,
38
+ blobs: mod.API_SPEC_BLOBS ?? {},
39
+ version: mod.API_SPEC_VERSION ?? "unknown",
40
+ };
41
+ } catch {
42
+ _apiSpecCache = { index: EMPTY_INDEX, blobs: {}, version: "unknown" };
43
+ }
44
+ return _apiSpecCache;
45
+ }
20
46
 
21
47
  export interface InternalDocsProtocolOptions {
22
48
  /** Override runtime build-info resolution. Primarily for tests. */
23
49
  readonly resolveBuildInfo?: () => Promise<RuntimeBuildInfo>;
24
50
  /** Sync getter returning the current context status (or null if unconfigured / unavailable). */
25
51
  readonly getContextStatus?: () => ContextStatus | null;
52
+ /** Override the API spec resolver. Primarily for tests. */
53
+ readonly apiSpecResolver?: ApiSpecResolver;
26
54
  }
27
55
 
28
56
  /**
@@ -35,14 +63,29 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
35
63
  readonly scheme = "xcsh";
36
64
  readonly #resolveBuildInfo: () => Promise<RuntimeBuildInfo>;
37
65
  readonly #getContextStatus: (() => ContextStatus | null) | undefined;
66
+ #apiSpecResolver: ApiSpecResolver | null;
38
67
 
39
68
  constructor(options: InternalDocsProtocolOptions = {}) {
40
69
  this.#resolveBuildInfo = options.resolveBuildInfo ?? getRuntimeBuildInfo;
41
70
  this.#getContextStatus = options.getContextStatus;
71
+ this.#apiSpecResolver = options.apiSpecResolver ?? null;
72
+ }
73
+
74
+ #getApiSpecResolver(): ApiSpecResolver {
75
+ if (!this.#apiSpecResolver) {
76
+ const specs = loadApiSpecs();
77
+ this.#apiSpecResolver = createApiSpecResolver(specs.index, specs.blobs);
78
+ }
79
+ return this.#apiSpecResolver;
42
80
  }
43
81
 
44
82
  async resolve(url: InternalUrl): Promise<InternalResource> {
45
83
  const host = url.rawHost || url.hostname;
84
+
85
+ if (host === API_SPEC_HOST) {
86
+ return this.#getApiSpecResolver().resolve(url);
87
+ }
88
+
46
89
  const pathname = url.rawPathname ?? url.pathname;
47
90
  const filename = host ? (pathname && pathname !== "/" ? host + pathname : host) : "";
48
91
 
@@ -58,9 +101,15 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
58
101
  throw new Error("No documentation files found");
59
102
  }
60
103
 
104
+ const specs = loadApiSpecs();
61
105
  const syntheticEntry = `- [${ABOUT_ROUTE}](${SCHEME_PREFIX}${ABOUT_ROUTE}) — identity and build fingerprint`;
62
- const listing = [syntheticEntry, ...EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](${SCHEME_PREFIX}${f})`)].join("\n");
63
- const totalCount = EMBEDDED_DOC_FILENAMES.length + 1;
106
+ const apiSpecEntry = `- [${API_SPEC_HOST}/](${SCHEME_PREFIX}${API_SPEC_HOST}/) — F5 XC API specifications (${specs.index.domains.length} domains, v${specs.version})`;
107
+ const listing = [
108
+ syntheticEntry,
109
+ apiSpecEntry,
110
+ ...EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](${SCHEME_PREFIX}${f})`),
111
+ ].join("\n");
112
+ const totalCount = EMBEDDED_DOC_FILENAMES.length + 2;
64
113
  const content = `# Documentation\n\n${totalCount} files available:\n\n${listing}\n`;
65
114
 
66
115
  return {
@@ -189,6 +189,12 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
189
189
  - `mcp://<resource-uri>` — MCP resource from a connected server; matched against exact resource URIs first, then RFC 6570 URI templates advertised by connected servers
190
190
  - `xcsh://..` — Internal xcsh documentation. **MUST NOT** read unless the user asks about xcsh itself.
191
191
  - `xcsh://about` — Identity, version, build fingerprint, architecture, self-improvement. **MUST** read for any question about xcsh before exploring `~/.xcsh/`.
192
+ - `xcsh://api-spec/` — F5 Distributed Cloud API specifications ({{apiSpecDomainCount}} domains, v{{apiSpecVersion}}).
193
+ **MUST NOT** read proactively. When the user needs to interact with the F5 XC API:
194
+ 1. Read `xcsh://api-spec/` to identify the correct domain
195
+ 2. Read `xcsh://api-spec/{domain}` to find the resource and operations
196
+ 3. Read `xcsh://api-spec/{domain}?resource={name}` for full endpoint specification
197
+ Never guess API paths or request schemas — always consult the spec first.
192
198
 
193
199
  In `bash`, URIs auto-resolve to filesystem paths (e.g., `python skill://my-skill/scripts/init.py`).
194
200
 
@@ -16,6 +16,31 @@ import { isApplicableToContext, loadSkills, type Skill } from "./extensibility/s
16
16
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
17
17
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
18
18
 
19
+ let _apiSpecMeta: { domainCount: number; version: string } | null = null;
20
+
21
+ function getApiSpecMeta(): { domainCount: number; version: string } {
22
+ if (_apiSpecMeta) return _apiSpecMeta;
23
+ try {
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const mod = require("./internal-urls/api-spec-index.generated");
26
+ _apiSpecMeta = {
27
+ domainCount: mod.API_SPEC_INDEX?.domains?.length ?? 0,
28
+ version: mod.API_SPEC_VERSION ?? "unknown",
29
+ };
30
+ } catch {
31
+ _apiSpecMeta = { domainCount: 0, version: "unknown" };
32
+ }
33
+ return _apiSpecMeta;
34
+ }
35
+
36
+ function apiSpecDomainCount(): number {
37
+ return getApiSpecMeta().domainCount;
38
+ }
39
+
40
+ function apiSpecVersion(): string {
41
+ return getApiSpecMeta().version;
42
+ }
43
+
19
44
  interface AlwaysApplyRule {
20
45
  name: string;
21
46
  content: string;
@@ -633,6 +658,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
633
658
  secretsEnabled,
634
659
  context,
635
660
  knowledgeTopics: options.knowledgeTopics,
661
+ apiSpecDomainCount: apiSpecDomainCount(),
662
+ apiSpecVersion: apiSpecVersion(),
636
663
  };
637
664
  let rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
638
665