@f5xc-salesdemos/xcsh 18.30.4 → 18.31.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 +7 -7
- package/scripts/generate-api-spec-index.ts +136 -10
- package/src/internal-urls/api-catalog-resolve.ts +177 -0
- package/src/internal-urls/api-catalog-types.ts +54 -0
- package/src/internal-urls/api-spec-resolve.ts +233 -34
- package/src/internal-urls/api-spec-types.ts +89 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/index.ts +2 -0
- package/src/internal-urls/xcsh-protocol.ts +61 -16
- package/src/prompts/system/system-prompt.md +8 -0
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.31.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.31.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "18.31.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "18.31.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "18.31.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "18.31.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "18.31.0",
|
|
57
57
|
"@sinclair/typebox": "^0.34",
|
|
58
58
|
"@xterm/headless": "^6.0",
|
|
59
59
|
"ajv": "^8.18",
|
|
@@ -11,10 +11,6 @@ interface SpecPathOperation {
|
|
|
11
11
|
[key: string]: unknown;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* Finds the operationId schema components (e.g., 'dns_zone', 'views.forward_proxy_policy')
|
|
16
|
-
* that correspond to a given resource name by matching path segments.
|
|
17
|
-
*/
|
|
18
14
|
function findResourceSchemaComponents(
|
|
19
15
|
resourceName: string,
|
|
20
16
|
paths: Record<string, Record<string, SpecPathOperation>>,
|
|
@@ -38,11 +34,29 @@ function findResourceSchemaComponents(
|
|
|
38
34
|
return [...found];
|
|
39
35
|
}
|
|
40
36
|
|
|
37
|
+
interface IndexEntryResource {
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
description_short?: string;
|
|
41
|
+
tier?: string;
|
|
42
|
+
icon?: string;
|
|
43
|
+
supports_logs?: boolean;
|
|
44
|
+
supports_metrics?: boolean;
|
|
45
|
+
dependencies?: { required: string[]; optional: string[] };
|
|
46
|
+
relationship_hints?: string[];
|
|
47
|
+
schema_components?: string[];
|
|
48
|
+
api_paths?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
interface IndexEntry {
|
|
42
52
|
domain: string;
|
|
43
53
|
title: string;
|
|
44
54
|
description: string;
|
|
45
55
|
"x-f5xc-description-short": string;
|
|
56
|
+
"x-f5xc-description-medium"?: string;
|
|
57
|
+
"x-f5xc-icon"?: string;
|
|
58
|
+
"x-f5xc-is-preview"?: boolean;
|
|
59
|
+
"x-f5xc-requires-tier"?: string;
|
|
46
60
|
file: string;
|
|
47
61
|
path_count: number;
|
|
48
62
|
schema_count: number;
|
|
@@ -50,18 +64,23 @@ interface IndexEntry {
|
|
|
50
64
|
"x-f5xc-category": string;
|
|
51
65
|
"x-f5xc-use-cases"?: string[];
|
|
52
66
|
"x-f5xc-related-domains"?: string[];
|
|
53
|
-
"x-f5xc-primary-resources"?:
|
|
67
|
+
"x-f5xc-primary-resources"?: IndexEntryResource[];
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
interface RawIndex {
|
|
57
71
|
version: string;
|
|
58
72
|
timestamp: string;
|
|
59
73
|
specifications: IndexEntry[];
|
|
74
|
+
"x-f5xc-critical-resources"?: string[];
|
|
75
|
+
"x-f5xc-guided-workflows"?: Record<string, unknown>;
|
|
76
|
+
"x-f5xc-error-resolution"?: Record<string, unknown>;
|
|
77
|
+
"x-f5xc-acronyms"?: Record<string, unknown>;
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
const REPO = "f5xc-salesdemos/api-specs-enriched";
|
|
63
|
-
const PINNED_TAG = "v2.1.
|
|
81
|
+
const PINNED_TAG = "v2.1.63";
|
|
64
82
|
const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/api-spec-index.generated.ts");
|
|
83
|
+
const catalogOutputPath = path.resolve(import.meta.dir, "../src/internal-urls/api-catalog-index.generated.ts");
|
|
65
84
|
|
|
66
85
|
async function downloadFromRelease(): Promise<string> {
|
|
67
86
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "api-specs-"));
|
|
@@ -114,6 +133,34 @@ async function findSpecsDir(): Promise<string> {
|
|
|
114
133
|
return downloadFromRelease();
|
|
115
134
|
}
|
|
116
135
|
|
|
136
|
+
async function downloadCatalog(specsDir: string): Promise<Record<string, unknown> | null> {
|
|
137
|
+
const catalogPath = path.join(specsDir, "api-catalog.json");
|
|
138
|
+
if (fs.existsSync(catalogPath)) {
|
|
139
|
+
return JSON.parse(fs.readFileSync(catalogPath, "utf-8"));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tag = process.env.API_SPECS_TAG ?? PINNED_TAG;
|
|
143
|
+
const catalogUrl = `https://github.com/${REPO}/releases/download/${tag}/api-catalog.json`;
|
|
144
|
+
console.log(`Downloading API catalog from ${catalogUrl}...`);
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetch(catalogUrl, { redirect: "follow" });
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
console.warn(`api-catalog.json not found (${response.status}), skipping catalog generation`);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const text = await response.text();
|
|
152
|
+
return JSON.parse(text);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.warn(`Failed to download api-catalog.json: ${err instanceof Error ? err.message : err}`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function serializeEnrichment(key: string, value: unknown): string | undefined {
|
|
160
|
+
if (!value) return undefined;
|
|
161
|
+
return `\t${key}: ${JSON.stringify(value)},`;
|
|
162
|
+
}
|
|
163
|
+
|
|
117
164
|
let downloadedTmpDir: string | null = null;
|
|
118
165
|
|
|
119
166
|
const specsDir = await findSpecsDir();
|
|
@@ -149,9 +196,22 @@ for (const entry of rawIndex.specifications) {
|
|
|
149
196
|
const b64 = compressed.toString("base64");
|
|
150
197
|
|
|
151
198
|
const resources = (entry["x-f5xc-primary-resources"] ?? []).map(r => {
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
199
|
+
const upstreamSc = r.schema_components ?? [];
|
|
200
|
+
const schemaComponents =
|
|
201
|
+
upstreamSc.length > 0 ? upstreamSc : findResourceSchemaComponents(r.name, specJson.paths ?? {});
|
|
202
|
+
const fields: string[] = [`name: ${JSON.stringify(r.name)}`, `description: ${JSON.stringify(r.description)}`];
|
|
203
|
+
if (schemaComponents.length > 0) fields.push(`schemaComponents: ${JSON.stringify(schemaComponents)}`);
|
|
204
|
+
if (r.api_paths?.length) fields.push(`apiPaths: ${JSON.stringify(r.api_paths)}`);
|
|
205
|
+
if (r.tier) fields.push(`tier: ${JSON.stringify(r.tier)}`);
|
|
206
|
+
if (r.icon) fields.push(`icon: ${JSON.stringify(r.icon)}`);
|
|
207
|
+
if (r.description_short) fields.push(`descriptionShort: ${JSON.stringify(r.description_short)}`);
|
|
208
|
+
if (r.supports_logs != null) fields.push(`supportsLogs: ${r.supports_logs}`);
|
|
209
|
+
if (r.supports_metrics != null) fields.push(`supportsMetrics: ${r.supports_metrics}`);
|
|
210
|
+
if (r.dependencies && (r.dependencies.required.length > 0 || r.dependencies.optional.length > 0)) {
|
|
211
|
+
fields.push(`dependencies: ${JSON.stringify(r.dependencies)}`);
|
|
212
|
+
}
|
|
213
|
+
if (r.relationship_hints?.length) fields.push(`relationshipHints: ${JSON.stringify(r.relationship_hints)}`);
|
|
214
|
+
return `\t\t\t{ ${fields.join(", ")} },`;
|
|
155
215
|
});
|
|
156
216
|
|
|
157
217
|
const useCases = entry["x-f5xc-use-cases"];
|
|
@@ -173,6 +233,14 @@ for (const entry of rawIndex.specifications) {
|
|
|
173
233
|
`\t\t\t],`,
|
|
174
234
|
useCases ? `\t\t\tuseCases: ${JSON.stringify(useCases)},` : undefined,
|
|
175
235
|
relatedDomains?.length ? `\t\t\trelatedDomains: ${JSON.stringify(relatedDomains)},` : undefined,
|
|
236
|
+
entry["x-f5xc-icon"] ? `\t\t\ticon: ${JSON.stringify(entry["x-f5xc-icon"])},` : undefined,
|
|
237
|
+
entry["x-f5xc-description-medium"]
|
|
238
|
+
? `\t\t\tdescriptionMedium: ${JSON.stringify(entry["x-f5xc-description-medium"])},`
|
|
239
|
+
: undefined,
|
|
240
|
+
entry["x-f5xc-is-preview"] ? `\t\t\tisPreview: true,` : undefined,
|
|
241
|
+
entry["x-f5xc-requires-tier"]
|
|
242
|
+
? `\t\t\trequiresTier: ${JSON.stringify(entry["x-f5xc-requires-tier"])},`
|
|
243
|
+
: undefined,
|
|
176
244
|
"\t\t},",
|
|
177
245
|
]
|
|
178
246
|
.filter(Boolean)
|
|
@@ -183,6 +251,11 @@ for (const entry of rawIndex.specifications) {
|
|
|
183
251
|
processedCount++;
|
|
184
252
|
}
|
|
185
253
|
|
|
254
|
+
const criticalResources = rawIndex["x-f5xc-critical-resources"];
|
|
255
|
+
const guidedWorkflows = rawIndex["x-f5xc-guided-workflows"];
|
|
256
|
+
const errorResolution = rawIndex["x-f5xc-error-resolution"];
|
|
257
|
+
const acronyms = rawIndex["x-f5xc-acronyms"];
|
|
258
|
+
|
|
186
259
|
const output = [
|
|
187
260
|
"// Auto-generated by scripts/generate-api-spec-index.ts - DO NOT EDIT",
|
|
188
261
|
"",
|
|
@@ -196,13 +269,19 @@ const output = [
|
|
|
196
269
|
`\tdomains: [`,
|
|
197
270
|
...domainEntries,
|
|
198
271
|
`\t],`,
|
|
272
|
+
serializeEnrichment("criticalResources", criticalResources),
|
|
273
|
+
serializeEnrichment("guidedWorkflows", guidedWorkflows),
|
|
274
|
+
serializeEnrichment("errorResolution", errorResolution),
|
|
275
|
+
serializeEnrichment("acronyms", acronyms),
|
|
199
276
|
`};`,
|
|
200
277
|
"",
|
|
201
278
|
`export const API_SPEC_BLOBS: Readonly<Record<string, string>> = {`,
|
|
202
279
|
...blobEntries,
|
|
203
280
|
`};`,
|
|
204
281
|
"",
|
|
205
|
-
]
|
|
282
|
+
]
|
|
283
|
+
.filter(l => l !== undefined)
|
|
284
|
+
.join("\n");
|
|
206
285
|
|
|
207
286
|
await Bun.write(outputPath, output);
|
|
208
287
|
|
|
@@ -211,6 +290,53 @@ console.log(
|
|
|
211
290
|
`Generated ${path.relative(process.cwd(), outputPath)} (${processedCount} domains, ${skippedCount} skipped, ${outputSize} MB)`,
|
|
212
291
|
);
|
|
213
292
|
|
|
293
|
+
// Generate API catalog index
|
|
294
|
+
const catalog = await downloadCatalog(specsDir);
|
|
295
|
+
if (catalog) {
|
|
296
|
+
const categories = (catalog.categories ?? []) as Array<{ name: string; displayName: string; operations: unknown[] }>;
|
|
297
|
+
const catalogBlobEntries: string[] = [];
|
|
298
|
+
const catalogIndexEntries: string[] = [];
|
|
299
|
+
|
|
300
|
+
for (const cat of categories) {
|
|
301
|
+
const catJson = JSON.stringify(cat);
|
|
302
|
+
const catCompressed = gzipSync(Buffer.from(catJson));
|
|
303
|
+
catalogBlobEntries.push(`\t${JSON.stringify(cat.name)}: ${JSON.stringify(catCompressed.toString("base64"))},`);
|
|
304
|
+
catalogIndexEntries.push(
|
|
305
|
+
`\t\t{ name: ${JSON.stringify(cat.name)}, displayName: ${JSON.stringify(cat.displayName)}, operationCount: ${cat.operations?.length ?? 0} },`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const catalogOutput = [
|
|
310
|
+
"// Auto-generated by scripts/generate-api-spec-index.ts - DO NOT EDIT",
|
|
311
|
+
"",
|
|
312
|
+
`import type { ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";`,
|
|
313
|
+
"",
|
|
314
|
+
`export const API_CATALOG_INDEX: ApiCatalogIndex = {`,
|
|
315
|
+
`\tversion: ${JSON.stringify(catalog.version ?? "unknown")},`,
|
|
316
|
+
`\tdisplayName: ${JSON.stringify(catalog.displayName ?? "F5 Distributed Cloud")},`,
|
|
317
|
+
`\tservice: ${JSON.stringify(catalog.service ?? "f5xc")},`,
|
|
318
|
+
`\tcategoryCount: ${categories.length},`,
|
|
319
|
+
`\tauth: ${JSON.stringify(catalog.auth ?? {})},`,
|
|
320
|
+
`\tdefaults: ${JSON.stringify(catalog.defaults ?? {})},`,
|
|
321
|
+
`};`,
|
|
322
|
+
"",
|
|
323
|
+
`export const API_CATALOG_CATEGORY_SUMMARIES: ReadonlyArray<ApiCatalogCategorySummary> = [`,
|
|
324
|
+
...catalogIndexEntries,
|
|
325
|
+
`];`,
|
|
326
|
+
"",
|
|
327
|
+
`export const API_CATALOG_BLOBS: Readonly<Record<string, string>> = {`,
|
|
328
|
+
...catalogBlobEntries,
|
|
329
|
+
`};`,
|
|
330
|
+
"",
|
|
331
|
+
].join("\n");
|
|
332
|
+
|
|
333
|
+
await Bun.write(catalogOutputPath, catalogOutput);
|
|
334
|
+
const catalogSize = (Buffer.byteLength(catalogOutput) / 1024 / 1024).toFixed(1);
|
|
335
|
+
console.log(
|
|
336
|
+
`Generated ${path.relative(process.cwd(), catalogOutputPath)} (${categories.length} categories, ${catalogSize} MB)`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
214
340
|
if (downloadedTmpDir) {
|
|
215
341
|
fs.rmSync(downloadedTmpDir, { recursive: true, force: true });
|
|
216
342
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import { LRUCache } from "lru-cache";
|
|
3
|
+
import type { ApiCatalogCategory, ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";
|
|
4
|
+
import type { InternalResource, InternalUrl } from "./types";
|
|
5
|
+
|
|
6
|
+
const LRU_CAPACITY = 5;
|
|
7
|
+
|
|
8
|
+
export interface ApiCatalogResolver {
|
|
9
|
+
resolve(url: InternalUrl): Promise<InternalResource>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createApiCatalogResolver(
|
|
13
|
+
index: ApiCatalogIndex,
|
|
14
|
+
categorySummaries: readonly ApiCatalogCategorySummary[],
|
|
15
|
+
blobs: Record<string, string>,
|
|
16
|
+
): ApiCatalogResolver {
|
|
17
|
+
const cache = new LRUCache<string, ApiCatalogCategory>({ max: LRU_CAPACITY });
|
|
18
|
+
|
|
19
|
+
function decompress(category: string): ApiCatalogCategory {
|
|
20
|
+
const cached = cache.get(category);
|
|
21
|
+
if (cached) return cached;
|
|
22
|
+
|
|
23
|
+
const blob = blobs[category];
|
|
24
|
+
if (!blob) throw new Error(`No catalog blob for category: ${category}`);
|
|
25
|
+
|
|
26
|
+
const buffer = Buffer.from(blob, "base64");
|
|
27
|
+
const decompressed = gunzipSync(buffer);
|
|
28
|
+
const cat = JSON.parse(decompressed.toString("utf-8")) as ApiCatalogCategory;
|
|
29
|
+
cache.set(category, cat);
|
|
30
|
+
return cat;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
35
|
+
const pathname = url.rawPathname ?? url.pathname;
|
|
36
|
+
const category = pathname.replace(/^\//, "").replace(/\/$/, "");
|
|
37
|
+
const search = url.searchParams.get("search");
|
|
38
|
+
|
|
39
|
+
if (!category) {
|
|
40
|
+
const content = search
|
|
41
|
+
? renderCatalogSearch(index, categorySummaries, search)
|
|
42
|
+
: renderCatalogIndex(index, categorySummaries);
|
|
43
|
+
return makeResource(url, content);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const summary = categorySummaries.find(c => c.name === category);
|
|
47
|
+
if (!summary) {
|
|
48
|
+
return makeResource(url, renderUnknownCategory(category, categorySummaries));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const cat = decompress(category);
|
|
53
|
+
return makeResource(url, renderCatalogDetail(cat, index));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
56
|
+
return makeResource(url, `# Error loading ${category}\n\n${message}\n`);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeResource(url: InternalUrl, content: string): InternalResource {
|
|
63
|
+
return {
|
|
64
|
+
url: url.href,
|
|
65
|
+
content,
|
|
66
|
+
contentType: "text/markdown",
|
|
67
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
68
|
+
sourcePath: `xcsh://${url.rawHost}${url.rawPathname ?? "/"}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderCatalogIndex(index: ApiCatalogIndex, summaries: readonly ApiCatalogCategorySummary[]): string {
|
|
73
|
+
const rows = summaries.map(c => `| ${c.name} | ${c.displayName} | ${c.operationCount} |`);
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
`# F5 XC API Catalog (v${index.version})`,
|
|
77
|
+
"",
|
|
78
|
+
`${summaries.length} categories. Read \`xcsh://api-catalog/{category}\` for operation details.`,
|
|
79
|
+
"",
|
|
80
|
+
"| Category | Display Name | Operations |",
|
|
81
|
+
"|----------|--------------|------------|",
|
|
82
|
+
...rows,
|
|
83
|
+
"",
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderCatalogSearch(
|
|
88
|
+
_index: ApiCatalogIndex,
|
|
89
|
+
summaries: readonly ApiCatalogCategorySummary[],
|
|
90
|
+
term: string,
|
|
91
|
+
): string {
|
|
92
|
+
const lower = term.toLowerCase();
|
|
93
|
+
const matches = summaries.filter(
|
|
94
|
+
c => c.name.toLowerCase().includes(lower) || c.displayName.toLowerCase().includes(lower),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (matches.length === 0) {
|
|
98
|
+
return [
|
|
99
|
+
`# No categories matching "${term}"`,
|
|
100
|
+
"",
|
|
101
|
+
`Use \`xcsh://api-catalog/\` to see all ${summaries.length} categories.`,
|
|
102
|
+
"",
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rows = matches.map(c => `| ${c.name} | ${c.displayName} | ${c.operationCount} |`);
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
`# API Catalog — search: "${term}"`,
|
|
110
|
+
"",
|
|
111
|
+
`${matches.length} matching categories.`,
|
|
112
|
+
"",
|
|
113
|
+
"| Category | Display Name | Operations |",
|
|
114
|
+
"|----------|--------------|------------|",
|
|
115
|
+
...rows,
|
|
116
|
+
"",
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderCatalogDetail(cat: ApiCatalogCategory, index: ApiCatalogIndex): string {
|
|
121
|
+
const sections: string[] = [`# ${cat.displayName}`, "", `${cat.operations.length} operations.`];
|
|
122
|
+
|
|
123
|
+
for (const op of cat.operations) {
|
|
124
|
+
sections.push("", `## ${op.method.toUpperCase()} ${op.path}`, "");
|
|
125
|
+
sections.push(op.description);
|
|
126
|
+
sections.push(`Danger level: ${op.dangerLevel}`);
|
|
127
|
+
|
|
128
|
+
if (op.parameters.length > 0) {
|
|
129
|
+
sections.push("", "### Parameters", "");
|
|
130
|
+
sections.push("| Name | In | Required | Type | Default |");
|
|
131
|
+
sections.push("|------|-----|----------|------|---------|");
|
|
132
|
+
for (const p of op.parameters) {
|
|
133
|
+
sections.push(`| ${p.name} | ${p.in} | ${p.required ? "yes" : "no"} | ${p.type} | ${p.default ?? ""} |`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (op.bodySchema) {
|
|
138
|
+
const minConfig = (op.bodySchema as Record<string, unknown>)["x-f5xc-minimum-configuration"] as
|
|
139
|
+
| Record<string, unknown>
|
|
140
|
+
| undefined;
|
|
141
|
+
if (minConfig?.required_fields) {
|
|
142
|
+
sections.push("", "### Minimum Configuration");
|
|
143
|
+
sections.push(`Required fields: ${(minConfig.required_fields as string[]).join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
sections.push("", "### Curl Example", "", "```bash");
|
|
148
|
+
const tokenVar = `$${index.auth.tokenSource}`;
|
|
149
|
+
const authHeader = `${index.auth.headerName}: ${index.auth.headerTemplate.replace("$TOKEN", tokenVar).replace("{token}", tokenVar)}`;
|
|
150
|
+
sections.push(`curl -X ${op.method.toUpperCase()} "$${index.auth.baseUrlSource}${op.path}" \\`);
|
|
151
|
+
sections.push(` -H "${authHeader}" \\`);
|
|
152
|
+
if (op.method.toUpperCase() !== "GET" && op.method.toUpperCase() !== "DELETE") {
|
|
153
|
+
sections.push(' -H "Content-Type: application/json" \\');
|
|
154
|
+
sections.push(" -d @payload.json");
|
|
155
|
+
} else {
|
|
156
|
+
const lastLine = sections[sections.length - 1];
|
|
157
|
+
sections[sections.length - 1] = lastLine.replace(/ \\$/, "");
|
|
158
|
+
}
|
|
159
|
+
sections.push("```");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
sections.push("");
|
|
163
|
+
return sections.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderUnknownCategory(requested: string, summaries: readonly ApiCatalogCategorySummary[]): string {
|
|
167
|
+
const suggestions = summaries
|
|
168
|
+
.filter(c => c.name.includes(requested) || requested.includes(c.name.slice(0, 4)))
|
|
169
|
+
.slice(0, 5);
|
|
170
|
+
|
|
171
|
+
const sections = [`# Category not found: ${requested}`, ""];
|
|
172
|
+
if (suggestions.length > 0) {
|
|
173
|
+
sections.push("Did you mean:", ...suggestions.map(c => `- \`${c.name}\` — ${c.displayName}`), "");
|
|
174
|
+
}
|
|
175
|
+
sections.push("Use `xcsh://api-catalog/` to see all categories.", "");
|
|
176
|
+
return sections.join("\n");
|
|
177
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the embedded API catalog index.
|
|
3
|
+
*
|
|
4
|
+
* The catalog provides pre-built curl templates and operation metadata
|
|
5
|
+
* generated at build time from api-catalog.json in the api-specs-enriched repository.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ApiCatalogParameter {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly in: string;
|
|
11
|
+
readonly required: boolean;
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly default?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApiCatalogOperation {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly description: string;
|
|
19
|
+
readonly method: string;
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly dangerLevel: string;
|
|
22
|
+
readonly parameters: readonly ApiCatalogParameter[];
|
|
23
|
+
readonly bodySchema?: Record<string, unknown>;
|
|
24
|
+
readonly responseSchema?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApiCatalogCategory {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly displayName: string;
|
|
30
|
+
readonly operations: readonly ApiCatalogOperation[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ApiCatalogAuth {
|
|
34
|
+
readonly type: string;
|
|
35
|
+
readonly headerName: string;
|
|
36
|
+
readonly headerTemplate: string;
|
|
37
|
+
readonly tokenSource: string;
|
|
38
|
+
readonly baseUrlSource: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ApiCatalogIndex {
|
|
42
|
+
readonly version: string;
|
|
43
|
+
readonly displayName: string;
|
|
44
|
+
readonly service: string;
|
|
45
|
+
readonly categoryCount: number;
|
|
46
|
+
readonly auth: ApiCatalogAuth;
|
|
47
|
+
readonly defaults: Record<string, { readonly source: string }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ApiCatalogCategorySummary {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly displayName: string;
|
|
53
|
+
readonly operationCount: number;
|
|
54
|
+
}
|
|
@@ -6,8 +6,6 @@ import type { InternalResource, InternalUrl } from "./types";
|
|
|
6
6
|
const LRU_CAPACITY = 5;
|
|
7
7
|
const SCHEMA_RENDER_MAX_DEPTH = 3;
|
|
8
8
|
|
|
9
|
-
// Module-level cache for groupPathsBySchema results, keyed by spec identity.
|
|
10
|
-
// Different resolver instances create distinct OpenAPISpec objects so there is no cross-resolver leakage.
|
|
11
9
|
const groupsCache = new WeakMap<OpenAPISpec, Map<string, Record<string, Record<string, OpenAPIPathOperation>>>>();
|
|
12
10
|
|
|
13
11
|
function getCachedGroups(spec: OpenAPISpec): Map<string, Record<string, Record<string, OpenAPIPathOperation>>> {
|
|
@@ -55,6 +53,21 @@ export function createApiSpecResolver(index: ApiSpecIndex, blobs: Record<string,
|
|
|
55
53
|
return makeResource(url, renderDomainIndex(index));
|
|
56
54
|
}
|
|
57
55
|
|
|
56
|
+
// Reserved sub-paths — checked before domain lookup
|
|
57
|
+
if (domain === "workflows" || domain.startsWith("workflows/")) {
|
|
58
|
+
const workflowId = domain.replace(/^workflows\/?/, "");
|
|
59
|
+
return makeResource(url, workflowId ? renderWorkflowDetail(workflowId, index) : renderWorkflowIndex(index));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (domain === "errors" || domain.startsWith("errors/")) {
|
|
63
|
+
const errorKey = domain.replace(/^errors\/?/, "");
|
|
64
|
+
return makeResource(url, errorKey ? renderErrorDetail(errorKey, index) : renderErrorIndex(index));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (domain === "glossary" || domain.startsWith("glossary/")) {
|
|
68
|
+
return makeResource(url, renderGlossary(index));
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
const entry = index.domains.find(d => d.domain === domain);
|
|
59
72
|
if (!entry) {
|
|
60
73
|
return makeResource(url, renderUnknownDomain(domain, index));
|
|
@@ -99,24 +112,67 @@ function makeResource(url: InternalUrl, content: string): InternalResource {
|
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
function renderDomainIndex(index: ApiSpecIndex): string {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
const criticalSet = new Set(index.criticalResources ?? []);
|
|
116
|
+
const rows = index.domains.map(d => {
|
|
117
|
+
const icon = d.icon ?? "";
|
|
118
|
+
const tier = d.requiresTier ?? "";
|
|
119
|
+
const hasCritical = d.resources.some(r => criticalSet.has(r.name));
|
|
120
|
+
const desc = hasCritical ? `${d.descriptionShort} *` : d.descriptionShort;
|
|
121
|
+
return `| ${icon} | ${d.domain} | ${d.category} | ${d.resources.length} | ${d.pathCount} | ${tier} | ${desc} |`;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const lines = [
|
|
107
125
|
`# F5 XC API Specifications (v${index.version})`,
|
|
108
126
|
"",
|
|
109
127
|
`${index.domains.length} domains. Read \`xcsh://api-spec/{domain}\` for resource details.`,
|
|
110
128
|
"",
|
|
111
|
-
"| Domain | Category | Resources | Paths | Description |",
|
|
112
|
-
"
|
|
129
|
+
"| Icon | Domain | Category | Resources | Paths | Tier | Description |",
|
|
130
|
+
"|------|--------|----------|-----------|-------|------|-------------|",
|
|
113
131
|
...rows,
|
|
114
132
|
"",
|
|
115
|
-
]
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
if (criticalSet.size > 0) {
|
|
136
|
+
lines.push("\\* = contains critical resources", "");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return lines.join("\n");
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
function renderDomainDetail(domain: string, entry: ApiSpecDomainEntry, spec: OpenAPISpec): string {
|
|
119
|
-
const
|
|
143
|
+
const sections: string[] = [
|
|
144
|
+
`# ${entry.title} — F5 XC API`,
|
|
145
|
+
"",
|
|
146
|
+
`Category: ${entry.category} | Paths: ${entry.pathCount} | Complexity: ${entry.complexity}`,
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
if (entry.isPreview || entry.requiresTier === "Advanced") {
|
|
150
|
+
const tags: string[] = [];
|
|
151
|
+
if (entry.isPreview) tags.push("Preview API");
|
|
152
|
+
if (entry.requiresTier === "Advanced") tags.push("Requires Advanced tier");
|
|
153
|
+
sections.push("", `> ${tags.join(" | ")}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (entry.descriptionMedium) {
|
|
157
|
+
sections.push("", entry.descriptionMedium);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
sections.push("", "## Resources", "");
|
|
161
|
+
sections.push("| Resource | Description | Tier | Observability | Requires |");
|
|
162
|
+
sections.push("|----------|-------------|------|---------------|----------|");
|
|
163
|
+
for (const r of entry.resources) {
|
|
164
|
+
const tier = r.tier ?? "";
|
|
165
|
+
const obs: string[] = [];
|
|
166
|
+
if (r.supportsLogs) obs.push("logs");
|
|
167
|
+
if (r.supportsMetrics) obs.push("metrics");
|
|
168
|
+
const requires = r.dependencies?.required.join(", ") ?? "";
|
|
169
|
+
sections.push(`| ${r.name} | ${r.description} | ${tier} | ${obs.join(", ")} | ${requires} |`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hints = entry.resources.flatMap(r => r.relationshipHints ?? []);
|
|
173
|
+
if (hints.length > 0) {
|
|
174
|
+
sections.push("", "## Relationships", ...hints.map(h => `- ${h}`));
|
|
175
|
+
}
|
|
120
176
|
|
|
121
177
|
const operationRows: string[] = [];
|
|
122
178
|
for (const [pathKey, methods] of Object.entries(spec.paths)) {
|
|
@@ -127,23 +183,10 @@ function renderDomainDetail(domain: string, entry: ApiSpecDomainEntry, spec: Ope
|
|
|
127
183
|
}
|
|
128
184
|
}
|
|
129
185
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"",
|
|
135
|
-
"## Resources",
|
|
136
|
-
"",
|
|
137
|
-
"| Resource | Description |",
|
|
138
|
-
"|----------|-------------|",
|
|
139
|
-
...resourceRows,
|
|
140
|
-
"",
|
|
141
|
-
"## Operations",
|
|
142
|
-
"",
|
|
143
|
-
"| Method | Path | Summary |",
|
|
144
|
-
"|--------|------|---------|",
|
|
145
|
-
...operationRows,
|
|
146
|
-
];
|
|
186
|
+
sections.push("", "## Operations", "");
|
|
187
|
+
sections.push("| Method | Path | Summary |");
|
|
188
|
+
sections.push("|--------|------|---------|");
|
|
189
|
+
sections.push(...operationRows);
|
|
147
190
|
|
|
148
191
|
if (entry.useCases?.length) {
|
|
149
192
|
sections.push("", "## Use Cases", ...entry.useCases.map(u => `- ${u}`));
|
|
@@ -190,7 +233,20 @@ function filterPathsByResource(
|
|
|
190
233
|
resource: string,
|
|
191
234
|
entry?: ApiSpecDomainEntry,
|
|
192
235
|
): Record<string, Record<string, OpenAPIPathOperation>> {
|
|
193
|
-
//
|
|
236
|
+
// Primary: use pre-computed api_paths from enriched index
|
|
237
|
+
if (entry) {
|
|
238
|
+
const indexedResource = entry.resources.find(r => r.name === resource);
|
|
239
|
+
if (indexedResource?.apiPaths?.length) {
|
|
240
|
+
const result: Record<string, Record<string, OpenAPIPathOperation>> = {};
|
|
241
|
+
for (const ap of indexedResource.apiPaths) {
|
|
242
|
+
const methods = spec.paths[ap];
|
|
243
|
+
if (methods) result[ap] = methods;
|
|
244
|
+
}
|
|
245
|
+
if (Object.keys(result).length > 0) return result;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Fallback 1: index-guided schemaComponents lookup
|
|
194
250
|
if (entry) {
|
|
195
251
|
const indexedResource = entry.resources.find(r => r.name === resource);
|
|
196
252
|
if (indexedResource?.schemaComponents?.length) {
|
|
@@ -199,9 +255,9 @@ function filterPathsByResource(
|
|
|
199
255
|
for (const comp of indexedResource.schemaComponents) {
|
|
200
256
|
const paths = groups.get(comp);
|
|
201
257
|
if (paths) {
|
|
202
|
-
for (const [pathKey,
|
|
258
|
+
for (const [pathKey, pathMethods] of Object.entries(paths)) {
|
|
203
259
|
if (!result[pathKey]) result[pathKey] = {};
|
|
204
|
-
Object.assign(result[pathKey],
|
|
260
|
+
Object.assign(result[pathKey], pathMethods);
|
|
205
261
|
}
|
|
206
262
|
}
|
|
207
263
|
}
|
|
@@ -209,6 +265,7 @@ function filterPathsByResource(
|
|
|
209
265
|
}
|
|
210
266
|
}
|
|
211
267
|
|
|
268
|
+
// Fallback 2: operationId-based heuristic matching
|
|
212
269
|
const groups = getCachedGroups(spec);
|
|
213
270
|
|
|
214
271
|
const exactKey = resource.replace(/-/g, "_");
|
|
@@ -226,11 +283,11 @@ function filterPathsByResource(
|
|
|
226
283
|
schema === exactKey ||
|
|
227
284
|
schema.endsWith(`.${exactKey}`)
|
|
228
285
|
) {
|
|
229
|
-
for (const [
|
|
286
|
+
for (const [p, methods] of Object.entries(paths)) {
|
|
230
287
|
if (!partial.has("merged")) partial.set("merged", {});
|
|
231
288
|
const merged = partial.get("merged")!;
|
|
232
|
-
if (!merged[
|
|
233
|
-
Object.assign(merged[
|
|
289
|
+
if (!merged[p]) merged[p] = {};
|
|
290
|
+
Object.assign(merged[p], methods);
|
|
234
291
|
}
|
|
235
292
|
}
|
|
236
293
|
}
|
|
@@ -379,6 +436,148 @@ function renderSchemaAsTable(schema: Record<string, unknown>, spec: OpenAPISpec,
|
|
|
379
436
|
return rows.join("\n");
|
|
380
437
|
}
|
|
381
438
|
|
|
439
|
+
function renderWorkflowIndex(index: ApiSpecIndex): string {
|
|
440
|
+
const workflows = index.guidedWorkflows?.workflows ?? [];
|
|
441
|
+
if (workflows.length === 0) {
|
|
442
|
+
return "# Guided Workflows\n\nNo workflows available.\n";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const rows = workflows.map(w => `| ${w.id} | ${w.name} | ${w.domain} | ${w.complexity} | ${w.steps.length} |`);
|
|
446
|
+
|
|
447
|
+
return [
|
|
448
|
+
"# Guided API Workflows",
|
|
449
|
+
"",
|
|
450
|
+
`${workflows.length} step-by-step workflows. Read \`xcsh://api-spec/workflows/{id}\` for details.`,
|
|
451
|
+
"",
|
|
452
|
+
"| ID | Name | Domain | Complexity | Steps |",
|
|
453
|
+
"|----|------|--------|------------|-------|",
|
|
454
|
+
...rows,
|
|
455
|
+
"",
|
|
456
|
+
].join("\n");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function renderWorkflowDetail(id: string, index: ApiSpecIndex): string {
|
|
460
|
+
const workflow = index.guidedWorkflows?.workflows.find(w => w.id === id);
|
|
461
|
+
if (!workflow) {
|
|
462
|
+
const available = index.guidedWorkflows?.workflows.map(w => `- \`${w.id}\` — ${w.name}`) ?? [];
|
|
463
|
+
return [`# Workflow not found: ${id}`, "", "Available workflows:", ...available, ""].join("\n");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const sections: string[] = [
|
|
467
|
+
`# ${workflow.name}`,
|
|
468
|
+
"",
|
|
469
|
+
`Complexity: ${workflow.complexity} | Domain: ${workflow.domain} | Steps: ${workflow.steps.length}`,
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
if (workflow.prerequisites.length > 0) {
|
|
473
|
+
sections.push("", "**Prerequisites:**", ...workflow.prerequisites.map(p => `- ${p}`));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (const step of workflow.steps) {
|
|
477
|
+
sections.push("", `## Step ${step.order}: ${step.name}`, "");
|
|
478
|
+
sections.push(step.description);
|
|
479
|
+
if (step.resource) sections.push(`Resource: \`${step.resource}\``);
|
|
480
|
+
if (step.required_fields?.length) sections.push(`Required fields: ${step.required_fields.join(", ")}`);
|
|
481
|
+
if (step.depends_on?.length) sections.push(`Depends on: step ${step.depends_on.join(", step ")}`);
|
|
482
|
+
if (step.tips?.length) sections.push("", "**Tips:**", ...step.tips.map(t => `- ${t}`));
|
|
483
|
+
if (step.verification?.length) sections.push("", "**Verify:**", ...step.verification.map(v => `- ${v}`));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
sections.push("");
|
|
487
|
+
return sections.join("\n");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function renderErrorIndex(index: ApiSpecIndex): string {
|
|
491
|
+
const er = index.errorResolution;
|
|
492
|
+
if (!er) {
|
|
493
|
+
return "# Error Resolution\n\nNo error resolution data available.\n";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const httpRows = Object.entries(er.http_errors).map(([code, e]) => `| HTTP | ${code} | ${e.name} |`);
|
|
497
|
+
const resourceRows = Object.keys(er.resource_errors).map(r => `| Resource | ${r} | ${r} errors |`);
|
|
498
|
+
|
|
499
|
+
return [
|
|
500
|
+
"# API Error Resolution",
|
|
501
|
+
"",
|
|
502
|
+
"Read `xcsh://api-spec/errors/{code}` for HTTP errors or `xcsh://api-spec/errors/{resource}` for resource-specific errors.",
|
|
503
|
+
"",
|
|
504
|
+
"| Type | Code/Resource | Name |",
|
|
505
|
+
"|------|---------------|------|",
|
|
506
|
+
...httpRows,
|
|
507
|
+
...resourceRows,
|
|
508
|
+
"",
|
|
509
|
+
].join("\n");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderErrorDetail(key: string, index: ApiSpecIndex): string {
|
|
513
|
+
const er = index.errorResolution;
|
|
514
|
+
if (!er) return `# Error: ${key}\n\nNo error resolution data available.\n`;
|
|
515
|
+
|
|
516
|
+
const httpError = er.http_errors[key];
|
|
517
|
+
if (httpError) {
|
|
518
|
+
const sections: string[] = [
|
|
519
|
+
`# HTTP ${httpError.code} — ${httpError.name}`,
|
|
520
|
+
"",
|
|
521
|
+
httpError.description,
|
|
522
|
+
"",
|
|
523
|
+
"## Common Causes",
|
|
524
|
+
...httpError.common_causes.map(c => `- ${c}`),
|
|
525
|
+
"",
|
|
526
|
+
"## Diagnostic Steps",
|
|
527
|
+
];
|
|
528
|
+
for (const ds of httpError.diagnostic_steps) {
|
|
529
|
+
sections.push(`${ds.step}. **${ds.action}** — ${ds.description}`);
|
|
530
|
+
if (ds.command) sections.push(` \`${ds.command}\``);
|
|
531
|
+
}
|
|
532
|
+
sections.push("", "## Prevention", ...httpError.prevention.map(p => `- ${p}`));
|
|
533
|
+
if (httpError.related_errors?.length) {
|
|
534
|
+
sections.push("", `Related errors: ${httpError.related_errors.join(", ")}`);
|
|
535
|
+
}
|
|
536
|
+
sections.push("");
|
|
537
|
+
return sections.join("\n");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const resourceErrors = er.resource_errors[key];
|
|
541
|
+
if (resourceErrors) {
|
|
542
|
+
const rows = resourceErrors.map(e => `| ${e.error_code} | ${e.pattern} | ${e.resolution} |`);
|
|
543
|
+
return [
|
|
544
|
+
`# ${key} — Common Errors`,
|
|
545
|
+
"",
|
|
546
|
+
"| Error Code | Pattern | Resolution |",
|
|
547
|
+
"|------------|---------|------------|",
|
|
548
|
+
...rows,
|
|
549
|
+
"",
|
|
550
|
+
].join("\n");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return [
|
|
554
|
+
`# Error not found: ${key}`,
|
|
555
|
+
"",
|
|
556
|
+
"Use `xcsh://api-spec/errors/` to see available error codes and resources.",
|
|
557
|
+
"",
|
|
558
|
+
].join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function renderGlossary(index: ApiSpecIndex): string {
|
|
562
|
+
const ac = index.acronyms;
|
|
563
|
+
if (!ac) return "# API Glossary\n\nNo glossary data available.\n";
|
|
564
|
+
|
|
565
|
+
const sections = ["# API Glossary", ""];
|
|
566
|
+
for (const cat of ac.categories) {
|
|
567
|
+
const items = ac.acronyms.filter(a => a.category === cat);
|
|
568
|
+
if (items.length === 0) continue;
|
|
569
|
+
sections.push(`## ${cat}`, "");
|
|
570
|
+
sections.push("| Acronym | Expansion |");
|
|
571
|
+
sections.push("|---------|-----------|");
|
|
572
|
+
for (const a of items) {
|
|
573
|
+
sections.push(`| ${a.acronym} | ${a.expansion} |`);
|
|
574
|
+
}
|
|
575
|
+
sections.push("");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return sections.join("\n");
|
|
579
|
+
}
|
|
580
|
+
|
|
382
581
|
function renderUnknownDomain(requested: string, index: ApiSpecIndex): string {
|
|
383
582
|
const suggestions = index.domains
|
|
384
583
|
.filter(d => d.domain.includes(requested) || requested.includes(d.domain.slice(0, 3)))
|
|
@@ -9,6 +9,17 @@ export interface ApiSpecDomainResource {
|
|
|
9
9
|
readonly name: string;
|
|
10
10
|
readonly description: string;
|
|
11
11
|
readonly schemaComponents?: readonly string[];
|
|
12
|
+
readonly apiPaths?: readonly string[];
|
|
13
|
+
readonly tier?: string;
|
|
14
|
+
readonly icon?: string;
|
|
15
|
+
readonly descriptionShort?: string;
|
|
16
|
+
readonly supportsLogs?: boolean;
|
|
17
|
+
readonly supportsMetrics?: boolean;
|
|
18
|
+
readonly dependencies?: {
|
|
19
|
+
readonly required: readonly string[];
|
|
20
|
+
readonly optional: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
readonly relationshipHints?: readonly string[];
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
export interface ApiSpecDomainEntry {
|
|
@@ -23,12 +34,90 @@ export interface ApiSpecDomainEntry {
|
|
|
23
34
|
readonly resources: readonly ApiSpecDomainResource[];
|
|
24
35
|
readonly useCases?: readonly string[];
|
|
25
36
|
readonly relatedDomains?: readonly string[];
|
|
37
|
+
readonly icon?: string;
|
|
38
|
+
readonly descriptionMedium?: string;
|
|
39
|
+
readonly isPreview?: boolean;
|
|
40
|
+
readonly requiresTier?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ApiSpecGuidedWorkflows {
|
|
44
|
+
readonly version: string;
|
|
45
|
+
readonly total_workflows: number;
|
|
46
|
+
readonly domains: readonly string[];
|
|
47
|
+
readonly workflows: readonly ApiSpecGuidedWorkflow[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ApiSpecGuidedWorkflow {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly description: string;
|
|
54
|
+
readonly complexity: string;
|
|
55
|
+
readonly estimated_steps: number;
|
|
56
|
+
readonly prerequisites: readonly string[];
|
|
57
|
+
readonly steps: readonly ApiSpecGuidedWorkflowStep[];
|
|
58
|
+
readonly domain: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ApiSpecGuidedWorkflowStep {
|
|
62
|
+
readonly order: number;
|
|
63
|
+
readonly action: string;
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly description: string;
|
|
66
|
+
readonly resource?: string;
|
|
67
|
+
readonly required_fields?: readonly string[];
|
|
68
|
+
readonly tips?: readonly string[];
|
|
69
|
+
readonly optional?: boolean;
|
|
70
|
+
readonly depends_on?: readonly number[];
|
|
71
|
+
readonly verification?: readonly string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ApiSpecHttpError {
|
|
75
|
+
readonly code: number;
|
|
76
|
+
readonly name: string;
|
|
77
|
+
readonly description: string;
|
|
78
|
+
readonly common_causes: readonly string[];
|
|
79
|
+
readonly diagnostic_steps: readonly {
|
|
80
|
+
readonly step: number;
|
|
81
|
+
readonly action: string;
|
|
82
|
+
readonly description: string;
|
|
83
|
+
readonly command?: string;
|
|
84
|
+
}[];
|
|
85
|
+
readonly prevention: readonly string[];
|
|
86
|
+
readonly related_errors?: readonly number[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ApiSpecResourceErrorEntry {
|
|
90
|
+
readonly error_code: number;
|
|
91
|
+
readonly pattern: string;
|
|
92
|
+
readonly resolution: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ApiSpecErrorResolution {
|
|
96
|
+
readonly version: string;
|
|
97
|
+
readonly http_errors: Record<string, ApiSpecHttpError>;
|
|
98
|
+
readonly resource_errors: Record<string, readonly ApiSpecResourceErrorEntry[]>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ApiSpecAcronym {
|
|
102
|
+
readonly acronym: string;
|
|
103
|
+
readonly expansion: string;
|
|
104
|
+
readonly category: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ApiSpecAcronyms {
|
|
108
|
+
readonly version: string;
|
|
109
|
+
readonly categories: readonly string[];
|
|
110
|
+
readonly acronyms: readonly ApiSpecAcronym[];
|
|
26
111
|
}
|
|
27
112
|
|
|
28
113
|
export interface ApiSpecIndex {
|
|
29
114
|
readonly version: string;
|
|
30
115
|
readonly timestamp: string;
|
|
31
116
|
readonly domains: readonly ApiSpecDomainEntry[];
|
|
117
|
+
readonly criticalResources?: readonly string[];
|
|
118
|
+
readonly guidedWorkflows?: ApiSpecGuidedWorkflows;
|
|
119
|
+
readonly errorResolution?: ApiSpecErrorResolution;
|
|
120
|
+
readonly acronyms?: ApiSpecAcronyms;
|
|
32
121
|
}
|
|
33
122
|
|
|
34
123
|
export interface OpenAPIPathOperation {
|
|
@@ -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.31.0",
|
|
21
|
+
"commit": "a0a4cd1c5d9256ab214c2e5ade08aeecccdb2a48",
|
|
22
|
+
"shortCommit": "a0a4cd1",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.31.0",
|
|
25
|
+
"commitDate": "2026-05-01T20:50:25Z",
|
|
26
|
+
"buildDate": "2026-05-01T21:15:11.839Z",
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/a0a4cd1c5d9256ab214c2e5ade08aeecccdb2a48",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.31.0"
|
|
33
33
|
};
|
|
@@ -11,9 +11,16 @@
|
|
|
11
11
|
* - xcsh://api-spec/ - API specification index
|
|
12
12
|
* - xcsh://api-spec/{domain} - Domain detail
|
|
13
13
|
* - xcsh://api-spec/{domain}?resource={name} - Resource spec
|
|
14
|
+
* - xcsh://api-spec/workflows/ - Guided API workflows
|
|
15
|
+
* - xcsh://api-spec/errors/ - Error resolution
|
|
16
|
+
* - xcsh://api-spec/glossary/ - Acronym glossary
|
|
17
|
+
* - xcsh://api-catalog/ - API operation catalog
|
|
18
|
+
* - xcsh://api-catalog/{category} - Category operations with curl templates
|
|
14
19
|
*/
|
|
15
20
|
import * as path from "node:path";
|
|
16
21
|
import type { ContextStatus } from "../services/f5xc-context";
|
|
22
|
+
import { type ApiCatalogResolver, createApiCatalogResolver } from "./api-catalog-resolve";
|
|
23
|
+
import type { ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";
|
|
17
24
|
import { type ApiSpecResolver, createApiSpecResolver } from "./api-spec-resolve";
|
|
18
25
|
import type { ApiSpecIndex } from "./api-spec-types";
|
|
19
26
|
import { getRuntimeBuildInfo, type RuntimeBuildInfo, renderAboutDoc } from "./build-info-runtime";
|
|
@@ -23,17 +30,20 @@ import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
|
23
30
|
const SCHEME_PREFIX = "xcsh://";
|
|
24
31
|
const ABOUT_ROUTE = "about";
|
|
25
32
|
const API_SPEC_HOST = "api-spec";
|
|
33
|
+
const API_CATALOG_HOST = "api-catalog";
|
|
26
34
|
|
|
27
35
|
const EMPTY_INDEX: ApiSpecIndex = { version: "unknown", timestamp: "", domains: [] };
|
|
36
|
+
const EMPTY_CATALOG_INDEX: ApiCatalogIndex = {
|
|
37
|
+
version: "unknown",
|
|
38
|
+
displayName: "F5 Distributed Cloud",
|
|
39
|
+
service: "f5xc",
|
|
40
|
+
categoryCount: 0,
|
|
41
|
+
auth: { type: "", headerName: "", headerTemplate: "", tokenSource: "", baseUrlSource: "" },
|
|
42
|
+
defaults: {},
|
|
43
|
+
};
|
|
28
44
|
|
|
29
45
|
let _apiSpecCache: { index: ApiSpecIndex; blobs: Record<string, string>; version: string } | null = null;
|
|
30
46
|
|
|
31
|
-
/**
|
|
32
|
-
* Lazily loads the generated API spec index. Uses require() instead of
|
|
33
|
-
* top-level import because the generated file may not exist in all
|
|
34
|
-
* contexts (tarball install, type-check without build). The try-catch
|
|
35
|
-
* falls back to an empty index so the handler degrades gracefully.
|
|
36
|
-
*/
|
|
37
47
|
function loadApiSpecs(): { index: ApiSpecIndex; blobs: Record<string, string>; version: string } {
|
|
38
48
|
if (_apiSpecCache) return _apiSpecCache;
|
|
39
49
|
try {
|
|
@@ -50,31 +60,51 @@ function loadApiSpecs(): { index: ApiSpecIndex; blobs: Record<string, string>; v
|
|
|
50
60
|
return _apiSpecCache;
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
let _apiCatalogCache: {
|
|
64
|
+
index: ApiCatalogIndex;
|
|
65
|
+
summaries: readonly ApiCatalogCategorySummary[];
|
|
66
|
+
blobs: Record<string, string>;
|
|
67
|
+
} | null = null;
|
|
68
|
+
|
|
69
|
+
function loadApiCatalog(): {
|
|
70
|
+
index: ApiCatalogIndex;
|
|
71
|
+
summaries: readonly ApiCatalogCategorySummary[];
|
|
72
|
+
blobs: Record<string, string>;
|
|
73
|
+
} {
|
|
74
|
+
if (_apiCatalogCache) return _apiCatalogCache;
|
|
75
|
+
try {
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
|
+
const mod = require("./api-catalog-index.generated");
|
|
78
|
+
_apiCatalogCache = {
|
|
79
|
+
index: mod.API_CATALOG_INDEX ?? EMPTY_CATALOG_INDEX,
|
|
80
|
+
summaries: mod.API_CATALOG_CATEGORY_SUMMARIES ?? [],
|
|
81
|
+
blobs: mod.API_CATALOG_BLOBS ?? {},
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
_apiCatalogCache = { index: EMPTY_CATALOG_INDEX, summaries: [], blobs: {} };
|
|
85
|
+
}
|
|
86
|
+
return _apiCatalogCache;
|
|
87
|
+
}
|
|
88
|
+
|
|
53
89
|
export interface InternalDocsProtocolOptions {
|
|
54
|
-
/** Override runtime build-info resolution. Primarily for tests. */
|
|
55
90
|
readonly resolveBuildInfo?: () => Promise<RuntimeBuildInfo>;
|
|
56
|
-
/** Sync getter returning the current context status (or null if unconfigured / unavailable). */
|
|
57
91
|
readonly getContextStatus?: () => ContextStatus | null;
|
|
58
|
-
/** Override the API spec resolver. Primarily for tests. */
|
|
59
92
|
readonly apiSpecResolver?: ApiSpecResolver;
|
|
93
|
+
readonly apiCatalogResolver?: ApiCatalogResolver;
|
|
60
94
|
}
|
|
61
95
|
|
|
62
|
-
/**
|
|
63
|
-
* Handler for the xcsh:// internal documentation protocol.
|
|
64
|
-
*
|
|
65
|
-
* Resolves documentation file names to their content, lists available docs,
|
|
66
|
-
* and serves the runtime identity doc at xcsh://about.
|
|
67
|
-
*/
|
|
68
96
|
export class InternalDocsProtocolHandler implements ProtocolHandler {
|
|
69
97
|
readonly scheme = "xcsh";
|
|
70
98
|
readonly #resolveBuildInfo: () => Promise<RuntimeBuildInfo>;
|
|
71
99
|
readonly #getContextStatus: (() => ContextStatus | null) | undefined;
|
|
72
100
|
#apiSpecResolver: ApiSpecResolver | null;
|
|
101
|
+
#apiCatalogResolver: ApiCatalogResolver | null;
|
|
73
102
|
|
|
74
103
|
constructor(options: InternalDocsProtocolOptions = {}) {
|
|
75
104
|
this.#resolveBuildInfo = options.resolveBuildInfo ?? getRuntimeBuildInfo;
|
|
76
105
|
this.#getContextStatus = options.getContextStatus;
|
|
77
106
|
this.#apiSpecResolver = options.apiSpecResolver ?? null;
|
|
107
|
+
this.#apiCatalogResolver = options.apiCatalogResolver ?? null;
|
|
78
108
|
}
|
|
79
109
|
|
|
80
110
|
#getApiSpecResolver(): ApiSpecResolver {
|
|
@@ -85,6 +115,14 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
|
|
|
85
115
|
return this.#apiSpecResolver;
|
|
86
116
|
}
|
|
87
117
|
|
|
118
|
+
#getApiCatalogResolver(): ApiCatalogResolver {
|
|
119
|
+
if (!this.#apiCatalogResolver) {
|
|
120
|
+
const catalog = loadApiCatalog();
|
|
121
|
+
this.#apiCatalogResolver = createApiCatalogResolver(catalog.index, catalog.summaries, catalog.blobs);
|
|
122
|
+
}
|
|
123
|
+
return this.#apiCatalogResolver;
|
|
124
|
+
}
|
|
125
|
+
|
|
88
126
|
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
89
127
|
const host = url.rawHost || url.hostname;
|
|
90
128
|
|
|
@@ -92,6 +130,10 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
|
|
|
92
130
|
return this.#getApiSpecResolver().resolve(url);
|
|
93
131
|
}
|
|
94
132
|
|
|
133
|
+
if (host === API_CATALOG_HOST) {
|
|
134
|
+
return this.#getApiCatalogResolver().resolve(url);
|
|
135
|
+
}
|
|
136
|
+
|
|
95
137
|
const pathname = url.rawPathname ?? url.pathname;
|
|
96
138
|
const filename = host ? (pathname && pathname !== "/" ? host + pathname : host) : "";
|
|
97
139
|
|
|
@@ -108,14 +150,17 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
|
|
|
108
150
|
}
|
|
109
151
|
|
|
110
152
|
const specs = loadApiSpecs();
|
|
153
|
+
const catalog = loadApiCatalog();
|
|
111
154
|
const syntheticEntry = `- [${ABOUT_ROUTE}](${SCHEME_PREFIX}${ABOUT_ROUTE}) — identity and build fingerprint`;
|
|
112
155
|
const apiSpecEntry = `- [${API_SPEC_HOST}/](${SCHEME_PREFIX}${API_SPEC_HOST}/) — F5 XC API specifications (${specs.index.domains.length} domains, v${specs.version})`;
|
|
156
|
+
const apiCatalogEntry = `- [${API_CATALOG_HOST}/](${SCHEME_PREFIX}${API_CATALOG_HOST}/) — F5 XC API operation catalog (${catalog.summaries.length} categories, v${catalog.index.version})`;
|
|
113
157
|
const listing = [
|
|
114
158
|
syntheticEntry,
|
|
115
159
|
apiSpecEntry,
|
|
160
|
+
apiCatalogEntry,
|
|
116
161
|
...EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](${SCHEME_PREFIX}${f})`),
|
|
117
162
|
].join("\n");
|
|
118
|
-
const totalCount = EMBEDDED_DOC_FILENAMES.length +
|
|
163
|
+
const totalCount = EMBEDDED_DOC_FILENAMES.length + 3;
|
|
119
164
|
const content = `# Documentation\n\n${totalCount} files available:\n\n${listing}\n`;
|
|
120
165
|
|
|
121
166
|
return {
|
|
@@ -197,6 +197,14 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
|
|
|
197
197
|
2. Read `xcsh://api-spec/{domain}` to find the resource and operations
|
|
198
198
|
3. Read `xcsh://api-spec/{domain}?resource={name}` for full endpoint specification
|
|
199
199
|
Never guess API paths or request schemas.
|
|
200
|
+
Also available: `xcsh://api-spec/workflows/` (step-by-step guides),
|
|
201
|
+
`xcsh://api-spec/errors/{code}` (error resolution),
|
|
202
|
+
`xcsh://api-spec/glossary/` (acronym reference).
|
|
203
|
+
|
|
204
|
+
- `xcsh://api-catalog/` — Pre-built API operation catalog with curl templates.
|
|
205
|
+
**MUST NOT** read proactively. When building API requests:
|
|
206
|
+
1. Read `xcsh://api-catalog/?search={term}` to find the right operation
|
|
207
|
+
2. Read `xcsh://api-catalog/{category}` for full operation details and curl template
|
|
200
208
|
|
|
201
209
|
In `bash`, URIs auto-resolve to filesystem paths (e.g., `python skill://my-skill/scripts/init.py`).
|
|
202
210
|
|