@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.30.4",
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.30.4",
52
- "@f5xc-salesdemos/pi-agent-core": "18.30.4",
53
- "@f5xc-salesdemos/pi-ai": "18.30.4",
54
- "@f5xc-salesdemos/pi-natives": "18.30.4",
55
- "@f5xc-salesdemos/pi-tui": "18.30.4",
56
- "@f5xc-salesdemos/pi-utils": "18.30.4",
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"?: Array<{ name: string; description: string }>;
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.62";
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 schemaComponents = findResourceSchemaComponents(r.name, specJson.paths ?? {});
153
- const scStr = schemaComponents.length > 0 ? `, schemaComponents: ${JSON.stringify(schemaComponents)}` : "";
154
- return `\t\t\t{ name: ${JSON.stringify(r.name)}, description: ${JSON.stringify(r.description)}${scStr} },`;
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
- ].join("\n");
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 rows = index.domains.map(
103
- d => `| ${d.domain} | ${d.category} | ${d.resources.length} | ${d.pathCount} | ${d.descriptionShort} |`,
104
- );
105
-
106
- return [
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
- ].join("\n");
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 resourceRows = entry.resources.map(r => `| ${r.name} | ${r.description} |`);
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
- const sections = [
131
- `# ${entry.title} F5 XC API`,
132
- "",
133
- `Category: ${entry.category} | Paths: ${entry.pathCount} | Complexity: ${entry.complexity}`,
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
- // Index-guided lookup: use pre-computed schemaComponents from the enriched index
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, methods] of Object.entries(paths)) {
258
+ for (const [pathKey, pathMethods] of Object.entries(paths)) {
203
259
  if (!result[pathKey]) result[pathKey] = {};
204
- Object.assign(result[pathKey], methods);
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 [path, methods] of Object.entries(paths)) {
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[path]) merged[path] = {};
233
- Object.assign(merged[path], methods);
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.30.4",
21
- "commit": "79427021521f12bb4843a1940bb541ae54b34078",
22
- "shortCommit": "7942702",
20
+ "version": "18.31.0",
21
+ "commit": "a0a4cd1c5d9256ab214c2e5ade08aeecccdb2a48",
22
+ "shortCommit": "a0a4cd1",
23
23
  "branch": "main",
24
- "tag": "v18.30.4",
25
- "commitDate": "2026-05-01T14:58:08Z",
26
- "buildDate": "2026-05-01T15:20:53.327Z",
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/79427021521f12bb4843a1940bb541ae54b34078",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.30.4"
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
  };
@@ -21,6 +21,8 @@
21
21
  */
22
22
 
23
23
  export * from "./agent-protocol";
24
+ export * from "./api-catalog-resolve";
25
+ export * from "./api-catalog-types";
24
26
  export * from "./api-spec-resolve";
25
27
  export * from "./api-spec-types";
26
28
  export * from "./artifact-protocol";
@@ -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 + 2;
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