@f5xc-salesdemos/xcsh 18.33.3 → 18.35.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.33.3",
4
+ "version": "18.35.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.33.3",
52
- "@f5xc-salesdemos/pi-agent-core": "18.33.3",
53
- "@f5xc-salesdemos/pi-ai": "18.33.3",
54
- "@f5xc-salesdemos/pi-natives": "18.33.3",
55
- "@f5xc-salesdemos/pi-tui": "18.33.3",
56
- "@f5xc-salesdemos/pi-utils": "18.33.3",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.35.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.35.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.35.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.35.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.35.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.35.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -3,7 +3,6 @@
3
3
  import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
- import { gzipSync } from "node:zlib";
7
6
  import { $ } from "bun";
8
7
 
9
8
  interface SpecPathOperation {
@@ -81,9 +80,16 @@ const REPO = "f5xc-salesdemos/api-specs-enriched";
81
80
  const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/api-spec-index.generated.ts");
82
81
  const catalogOutputPath = path.resolve(import.meta.dir, "../src/internal-urls/api-catalog-index.generated.ts");
83
82
 
83
+ function githubHeaders(): Record<string, string> {
84
+ const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
85
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
86
+ if (token) headers.Authorization = `Bearer ${token}`;
87
+ return headers;
88
+ }
89
+
84
90
  async function resolveLatestTag(): Promise<string> {
85
91
  const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
86
- headers: { Accept: "application/vnd.github+json" },
92
+ headers: githubHeaders(),
87
93
  });
88
94
  if (!response.ok) {
89
95
  throw new Error(`Failed to fetch latest release from ${REPO}: ${response.status} ${response.statusText}`);
@@ -203,7 +209,7 @@ if (catalog) {
203
209
  }
204
210
 
205
211
  const domainEntries: string[] = [];
206
- const blobEntries: string[] = [];
212
+ const specDataEntries: string[] = [];
207
213
  let processedCount = 0;
208
214
  let skippedCount = 0;
209
215
 
@@ -220,8 +226,6 @@ for (const entry of rawIndex.specifications) {
220
226
  paths?: Record<string, Record<string, SpecPathOperation>>;
221
227
  [k: string]: unknown;
222
228
  };
223
- const compressed = gzipSync(Buffer.from(specContent));
224
- const b64 = compressed.toString("base64");
225
229
 
226
230
  const resources = (entry["x-f5xc-primary-resources"] ?? []).map(r => {
227
231
  const upstreamSc = r.schema_components ?? [];
@@ -282,7 +286,7 @@ for (const entry of rawIndex.specifications) {
282
286
  .join("\n"),
283
287
  );
284
288
 
285
- blobEntries.push(`\t${JSON.stringify(entry.domain)}: ${JSON.stringify(b64)},`);
289
+ specDataEntries.push(`\t${JSON.stringify(entry.domain)}: ${JSON.stringify(specJson)},`);
286
290
  processedCount++;
287
291
  }
288
292
 
@@ -310,8 +314,8 @@ const output = [
310
314
  serializeEnrichment("acronyms", acronyms),
311
315
  `};`,
312
316
  "",
313
- `export const API_SPEC_BLOBS: Readonly<Record<string, string>> = {`,
314
- ...blobEntries,
317
+ `export const API_SPEC_DATA: Readonly<Record<string, unknown>> = {`,
318
+ ...specDataEntries,
315
319
  `};`,
316
320
  "",
317
321
  ]
@@ -328,13 +332,11 @@ console.log(
328
332
  // Generate API catalog index
329
333
  if (catalog) {
330
334
  const categories = (catalog.categories ?? []) as Array<{ name: string; displayName: string; operations: unknown[] }>;
331
- const catalogBlobEntries: string[] = [];
332
335
  const catalogIndexEntries: string[] = [];
333
336
 
337
+ const catalogDataEntries: string[] = [];
334
338
  for (const cat of categories) {
335
- const catJson = JSON.stringify(cat);
336
- const catCompressed = gzipSync(Buffer.from(catJson));
337
- catalogBlobEntries.push(`\t${JSON.stringify(cat.name)}: ${JSON.stringify(catCompressed.toString("base64"))},`);
339
+ catalogDataEntries.push(`\t${JSON.stringify(cat.name)}: ${JSON.stringify(cat)},`);
338
340
  catalogIndexEntries.push(
339
341
  `\t\t{ name: ${JSON.stringify(cat.name)}, displayName: ${JSON.stringify(cat.displayName)}, operationCount: ${cat.operations?.length ?? 0} },`,
340
342
  );
@@ -343,7 +345,7 @@ if (catalog) {
343
345
  const catalogOutput = [
344
346
  "// Auto-generated by scripts/generate-api-spec-index.ts - DO NOT EDIT",
345
347
  "",
346
- `import type { ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";`,
348
+ `import type { ApiCatalogCategory, ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";`,
347
349
  "",
348
350
  `export const API_CATALOG_INDEX: ApiCatalogIndex = {`,
349
351
  `\tversion: ${JSON.stringify(catalog.version ?? "unknown")},`,
@@ -358,8 +360,8 @@ if (catalog) {
358
360
  ...catalogIndexEntries,
359
361
  `];`,
360
362
  "",
361
- `export const API_CATALOG_BLOBS: Readonly<Record<string, string>> = {`,
362
- ...catalogBlobEntries,
363
+ `export const API_CATALOG_DATA: Readonly<Record<string, ApiCatalogCategory>> = {`,
364
+ ...catalogDataEntries,
363
365
  `};`,
364
366
  "",
365
367
  ].join("\n");
package/src/cli.ts CHANGED
@@ -93,4 +93,15 @@ export function runCli(argv: string[]): Promise<void> {
93
93
  return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp });
94
94
  }
95
95
 
96
+ if (process.env.XCSH_SMOKE_TEST_SPECS === "1") {
97
+ const specMod = require("./internal-urls/api-spec-index.generated") as { API_SPEC_INDEX?: { domains?: unknown[] } };
98
+ const catalogMod = require("./internal-urls/api-catalog-index.generated") as {
99
+ API_CATALOG_CATEGORY_SUMMARIES?: unknown[];
100
+ };
101
+ const domainCount = specMod.API_SPEC_INDEX?.domains?.length ?? 0;
102
+ const categoryCount = catalogMod.API_CATALOG_CATEGORY_SUMMARIES?.length ?? 0;
103
+ console.log(`api-specs: ${domainCount} domains, ${categoryCount} categories`);
104
+ process.exit(domainCount > 0 && categoryCount > 0 ? 0 : 1);
105
+ }
106
+
96
107
  await runCli(process.argv.slice(2));
@@ -1438,6 +1438,17 @@ export const SETTINGS_SCHEMA = {
1438
1438
  },
1439
1439
  },
1440
1440
 
1441
+ "bash.verbose": {
1442
+ type: "boolean",
1443
+ default: false,
1444
+ ui: {
1445
+ tab: "tools",
1446
+ label: "Bash Verbose",
1447
+ description:
1448
+ "Show standard command output panel. When disabled, shows a compact single-line summary with status indicator.",
1449
+ },
1450
+ },
1451
+
1441
1452
  // MCP
1442
1453
  "mcp.enableProjectConfig": {
1443
1454
  type: "boolean",
@@ -1,11 +1,7 @@
1
- import { gunzipSync } from "node:zlib";
2
- import { LRUCache } from "lru-cache";
3
1
  import type { ApiCatalogCategory, ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";
4
2
  import type { ApiSpecIndex } from "./api-spec-types";
5
3
  import type { InternalResource, InternalUrl } from "./types";
6
4
 
7
- const LRU_CAPACITY = 5;
8
-
9
5
  function normalizeSearchTerm(s: string): string {
10
6
  return s.toLowerCase().replace(/_/g, "-");
11
7
  }
@@ -17,22 +13,12 @@ export interface ApiCatalogResolver {
17
13
  export function createApiCatalogResolver(
18
14
  index: ApiCatalogIndex,
19
15
  categorySummaries: readonly ApiCatalogCategorySummary[],
20
- blobs: Record<string, string>,
16
+ data: Readonly<Record<string, ApiCatalogCategory>>,
21
17
  specIndex?: ApiSpecIndex,
22
18
  ): ApiCatalogResolver {
23
- const cache = new LRUCache<string, ApiCatalogCategory>({ max: LRU_CAPACITY });
24
-
25
- function decompress(category: string): ApiCatalogCategory {
26
- const cached = cache.get(category);
27
- if (cached) return cached;
28
-
29
- const blob = blobs[category];
30
- if (!blob) throw new Error(`No catalog blob for category: ${category}`);
31
-
32
- const buffer = Buffer.from(blob, "base64");
33
- const decompressed = gunzipSync(buffer);
34
- const cat = JSON.parse(decompressed.toString("utf-8")) as ApiCatalogCategory;
35
- cache.set(category, cat);
19
+ function lookup(category: string): ApiCatalogCategory {
20
+ const cat = data[category];
21
+ if (!cat) throw new Error(`No catalog data for category: ${category}`);
36
22
  return cat;
37
23
  }
38
24
 
@@ -52,7 +38,7 @@ export function createApiCatalogResolver(
52
38
  const catName = res.catalogCategories[0];
53
39
  if (categorySummaries.some(c => c.name === catName)) {
54
40
  try {
55
- const cat = decompress(catName);
41
+ const cat = lookup(catName);
56
42
  return makeResource(url, renderCatalogDetail(cat, index, { compact }));
57
43
  } catch {
58
44
  break;
@@ -75,7 +61,7 @@ export function createApiCatalogResolver(
75
61
  }
76
62
 
77
63
  try {
78
- const cat = decompress(category);
64
+ const cat = lookup(category);
79
65
  return makeResource(url, renderCatalogDetail(cat, index, { compact }));
80
66
  } catch (err) {
81
67
  const message = err instanceof Error ? err.message : String(err);
@@ -174,6 +160,13 @@ function formatRequiredFor(
174
160
  return ops.length > 0 ? ops.join(", ") : "--";
175
161
  }
176
162
 
163
+ function fieldMetadataFingerprint(metadata: Record<string, unknown>): string {
164
+ return JSON.stringify(metadata, (key, value) => {
165
+ if (key === "validatedAt" || key === "confidence" || key === "source") return undefined;
166
+ return value;
167
+ });
168
+ }
169
+
177
170
  function formatDefault(defaultVal: unknown, serverDefault: boolean | undefined): string {
178
171
  if (defaultVal == null && !serverDefault) return "--";
179
172
  let val = "--";
@@ -235,7 +228,7 @@ function renderCatalogDetail(cat: ApiCatalogCategory, index: ApiCatalogIndex, op
235
228
 
236
229
  // Field Constraints (Tier 2) — skipped in compact mode, deduped when identical across operations
237
230
  if (op.fieldMetadata && Object.keys(op.fieldMetadata).length > 0 && !options?.compact) {
238
- const currentFingerprint = JSON.stringify(op.fieldMetadata);
231
+ const currentFingerprint = fieldMetadataFingerprint(op.fieldMetadata as Record<string, unknown>);
239
232
  if (fieldConstraintsRenderedForOp && currentFingerprint === fieldConstraintsFingerprint) {
240
233
  sections.push("", "### Field Constraints");
241
234
  sections.push(`Same as ${fieldConstraintsRenderedForOp} — see above.`, "");
@@ -1,9 +1,6 @@
1
- import { gunzipSync } from "node:zlib";
2
- import { LRUCache } from "lru-cache";
3
1
  import type { ApiSpecDomainEntry, ApiSpecIndex, OpenAPIPathOperation, OpenAPISpec } from "./api-spec-types";
4
2
  import type { InternalResource, InternalUrl } from "./types";
5
3
 
6
- const LRU_CAPACITY = 5;
7
4
  const SCHEMA_RENDER_MAX_DEPTH = 3;
8
5
  const CRUD_OPERATION_SUFFIXES = [".API.Create", ".API.Replace", ".API.Get", ".API.List", ".API.Delete"];
9
6
 
@@ -21,28 +18,14 @@ export interface ApiSpecResolver {
21
18
  resolve(url: InternalUrl): Promise<InternalResource>;
22
19
  }
23
20
 
24
- export function createApiSpecResolver(index: ApiSpecIndex, blobs: Record<string, string>): ApiSpecResolver {
25
- const cache = new LRUCache<string, OpenAPISpec>({ max: LRU_CAPACITY });
26
-
27
- function decompress(domain: string): OpenAPISpec {
28
- const cached = cache.get(domain);
29
- if (cached) return cached;
30
-
31
- const blob = blobs[domain];
32
- if (!blob) {
33
- throw new Error(`No spec blob for domain: ${domain}`);
34
- }
35
-
36
- try {
37
- const buffer = Buffer.from(blob, "base64");
38
- const decompressed = gunzipSync(buffer);
39
- const spec = JSON.parse(decompressed.toString("utf-8")) as OpenAPISpec;
40
- cache.set(domain, spec);
41
- return spec;
42
- } catch (err) {
43
- const message = err instanceof Error ? err.message : String(err);
44
- throw new Error(`Failed to decompress spec for domain '${domain}': ${message}`);
45
- }
21
+ export function createApiSpecResolver(
22
+ index: ApiSpecIndex,
23
+ data: Readonly<Record<string, OpenAPISpec>>,
24
+ ): ApiSpecResolver {
25
+ function lookup(domain: string): OpenAPISpec {
26
+ const spec = data[domain];
27
+ if (!spec) throw new Error(`No spec data for domain: ${domain}`);
28
+ return spec;
46
29
  }
47
30
 
48
31
  return {
@@ -80,7 +63,7 @@ export function createApiSpecResolver(index: ApiSpecIndex, blobs: Record<string,
80
63
 
81
64
  if (resource) {
82
65
  const crud = url.searchParams.get("crud") === "true";
83
- const spec = decompress(domain);
66
+ const spec = lookup(domain);
84
67
  const matchingPaths = filterPathsByResource(spec, resource, entry);
85
68
  if (Object.keys(matchingPaths).length === 0) {
86
69
  return makeResource(url, renderUnknownResource(resource, entry, spec));
@@ -89,11 +72,11 @@ export function createApiSpecResolver(index: ApiSpecIndex, blobs: Record<string,
89
72
  }
90
73
 
91
74
  if (pathFilter) {
92
- const spec = decompress(domain);
75
+ const spec = lookup(domain);
93
76
  return makeResource(url, renderPathSpec(domain, pathFilter, spec));
94
77
  }
95
78
 
96
- const spec = decompress(domain);
79
+ const spec = lookup(domain);
97
80
  return makeResource(url, renderDomainDetail(domain, entry, spec));
98
81
  } catch (err) {
99
82
  const message = err instanceof Error ? err.message : String(err);
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.33.3",
21
- "commit": "ea081417a014b40c53422b3a51e062a13f035c5e",
22
- "shortCommit": "ea08141",
20
+ "version": "18.35.0",
21
+ "commit": "ec582a736845e44f023ae6975c86e5377b6ccbdf",
22
+ "shortCommit": "ec582a7",
23
23
  "branch": "main",
24
- "tag": "v18.33.3",
25
- "commitDate": "2026-05-03T05:25:43Z",
26
- "buildDate": "2026-05-03T05:47:37.045Z",
24
+ "tag": "v18.35.0",
25
+ "commitDate": "2026-05-03T21:42:09Z",
26
+ "buildDate": "2026-05-03T22:04:53.312Z",
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/ea081417a014b40c53422b3a51e062a13f035c5e",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.33.3"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/ec582a736845e44f023ae6975c86e5377b6ccbdf",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.35.0"
33
33
  };
@@ -21,9 +21,9 @@ import * as path from "node:path";
21
21
  import { logger } from "@f5xc-salesdemos/pi-utils";
22
22
  import type { ContextStatus } from "../services/f5xc-context";
23
23
  import { type ApiCatalogResolver, createApiCatalogResolver } from "./api-catalog-resolve";
24
- import type { ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";
24
+ import type { ApiCatalogCategory, ApiCatalogCategorySummary, ApiCatalogIndex } from "./api-catalog-types";
25
25
  import { type ApiSpecResolver, createApiSpecResolver } from "./api-spec-resolve";
26
- import type { ApiSpecIndex } from "./api-spec-types";
26
+ import type { ApiSpecIndex, OpenAPISpec } from "./api-spec-types";
27
27
  import { getRuntimeBuildInfo, type RuntimeBuildInfo, renderAboutDoc } from "./build-info-runtime";
28
28
  import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
29
29
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
@@ -43,14 +43,14 @@ const EMPTY_CATALOG_INDEX: ApiCatalogIndex = {
43
43
  defaults: {},
44
44
  };
45
45
 
46
- let _apiSpecCache: { index: ApiSpecIndex; blobs: Record<string, string>; version: string } | null = null;
46
+ let _apiSpecCache: { index: ApiSpecIndex; data: Readonly<Record<string, OpenAPISpec>>; version: string } | null = null;
47
47
 
48
- function loadApiSpecs(): { index: ApiSpecIndex; blobs: Record<string, string>; version: string } {
48
+ function loadApiSpecs(): { index: ApiSpecIndex; data: Readonly<Record<string, OpenAPISpec>>; version: string } {
49
49
  if (_apiSpecCache) return _apiSpecCache;
50
50
  try {
51
51
  const mod = require("./api-spec-index.generated") as {
52
52
  API_SPEC_INDEX?: ApiSpecIndex;
53
- API_SPEC_BLOBS?: Record<string, string>;
53
+ API_SPEC_DATA?: Readonly<Record<string, unknown>>;
54
54
  API_SPEC_VERSION?: string;
55
55
  };
56
56
  const index = mod.API_SPEC_INDEX ?? EMPTY_INDEX;
@@ -60,14 +60,14 @@ function loadApiSpecs(): { index: ApiSpecIndex; blobs: Record<string, string>; v
60
60
  }
61
61
  _apiSpecCache = {
62
62
  index,
63
- blobs: mod.API_SPEC_BLOBS ?? {},
63
+ data: (mod.API_SPEC_DATA ?? {}) as Readonly<Record<string, OpenAPISpec>>,
64
64
  version,
65
65
  };
66
66
  } catch (err) {
67
67
  logger.warn("api-spec index unavailable, embedded specs disabled", {
68
68
  error: err instanceof Error ? err.message : String(err),
69
69
  });
70
- _apiSpecCache = { index: EMPTY_INDEX, blobs: {}, version: "unavailable" };
70
+ _apiSpecCache = { index: EMPTY_INDEX, data: {}, version: "unavailable" };
71
71
  }
72
72
  return _apiSpecCache;
73
73
  }
@@ -75,20 +75,20 @@ function loadApiSpecs(): { index: ApiSpecIndex; blobs: Record<string, string>; v
75
75
  let _apiCatalogCache: {
76
76
  index: ApiCatalogIndex;
77
77
  summaries: readonly ApiCatalogCategorySummary[];
78
- blobs: Record<string, string>;
78
+ data: Readonly<Record<string, ApiCatalogCategory>>;
79
79
  } | null = null;
80
80
 
81
81
  function loadApiCatalog(): {
82
82
  index: ApiCatalogIndex;
83
83
  summaries: readonly ApiCatalogCategorySummary[];
84
- blobs: Record<string, string>;
84
+ data: Readonly<Record<string, ApiCatalogCategory>>;
85
85
  } {
86
86
  if (_apiCatalogCache) return _apiCatalogCache;
87
87
  try {
88
88
  const mod = require("./api-catalog-index.generated") as {
89
89
  API_CATALOG_INDEX?: ApiCatalogIndex;
90
90
  API_CATALOG_CATEGORY_SUMMARIES?: readonly ApiCatalogCategorySummary[];
91
- API_CATALOG_BLOBS?: Record<string, string>;
91
+ API_CATALOG_DATA?: Readonly<Record<string, ApiCatalogCategory>>;
92
92
  };
93
93
  const index = mod.API_CATALOG_INDEX ?? EMPTY_CATALOG_INDEX;
94
94
  const summaries = mod.API_CATALOG_CATEGORY_SUMMARIES ?? [];
@@ -98,13 +98,13 @@ function loadApiCatalog(): {
98
98
  _apiCatalogCache = {
99
99
  index,
100
100
  summaries,
101
- blobs: mod.API_CATALOG_BLOBS ?? {},
101
+ data: mod.API_CATALOG_DATA ?? {},
102
102
  };
103
103
  } catch (err) {
104
104
  logger.warn("api-catalog index unavailable, catalog disabled", {
105
105
  error: err instanceof Error ? err.message : String(err),
106
106
  });
107
- _apiCatalogCache = { index: EMPTY_CATALOG_INDEX, summaries: [], blobs: {} };
107
+ _apiCatalogCache = { index: EMPTY_CATALOG_INDEX, summaries: [], data: {} };
108
108
  }
109
109
  return _apiCatalogCache;
110
110
  }
@@ -133,7 +133,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
133
133
  #getApiSpecResolver(): ApiSpecResolver {
134
134
  if (!this.#apiSpecResolver) {
135
135
  const specs = loadApiSpecs();
136
- this.#apiSpecResolver = createApiSpecResolver(specs.index, specs.blobs);
136
+ this.#apiSpecResolver = createApiSpecResolver(specs.index, specs.data);
137
137
  }
138
138
  return this.#apiSpecResolver;
139
139
  }
@@ -145,7 +145,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
145
145
  this.#apiCatalogResolver = createApiCatalogResolver(
146
146
  catalog.index,
147
147
  catalog.summaries,
148
- catalog.blobs,
148
+ catalog.data,
149
149
  specs.index,
150
150
  );
151
151
  }
@@ -192,18 +192,19 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
192
192
  This document contains the authoritative repository URL, issues URL, and source location.
193
193
  For identity questions (source code, repo, version, who built this) — answer from `xcsh://about` alone. Do not call external GitHub tools.
194
194
  - `xcsh://api-spec/` — F5 XC API specifications (schema introspection, field types, validation).
195
- - `xcsh://api-catalog/` — F5 XC API operations with curl templates (CRUD execution).
195
+ - `xcsh://api-catalog/` — F5 XC API operations catalog (CRUD execution).
196
196
 
197
197
  When the user needs to **make an API call** (create, read, update, delete):
198
198
 
199
- 1. `xcsh://api-catalog/?search={term}` → find the operation
200
- 2. `xcsh://api-catalog/{category}?compact=true` get curl template, minimum payload,
201
- OneOf recommendations, and response summary
199
+ 1. `xcsh://api-catalog/?resource={resource_name}` → get endpoint path, method, minimum
200
+ payload JSON, required fields, and response summary
201
+ 2. Call `xcsh_api` tool with `method`, `path`, `params` (all `{placeholder}` substitutions), and `payload`
202
202
 
203
- For POST/PUT operations, the catalog includes a ready-to-use JSON payload.
204
- Customize the payload with user-specified values (name, namespace, etc.),
205
- substitute path parameters (`{namespace}`, `{name}`) with actual values,
206
- insert the payload as the `-d` body in the curl template, and execute.
203
+ The `xcsh_api` tool handles authentication, URL construction, and HTTP execution.
204
+ Never construct curl commands for F5 XC API calls — use `xcsh_api` instead.
205
+
206
+ If the resource name is unknown, search first:
207
+ `xcsh://api-catalog/?search={term}` → find the matching category, then read it.
207
208
 
208
209
  When the user needs **field-level validation rules** (constraints, patterns, enums):
209
210
 
@@ -215,8 +216,7 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
215
216
  If the domain is unknown, read `xcsh://api-spec/` first to identify it.
216
217
 
217
218
  **MUST NOT** read proactively.
218
- Never start at `xcsh://api-spec/` for CRUD operations — the catalog provides
219
- the same endpoint with curl template at a fraction of the token cost.
219
+ Never start at `xcsh://api-spec/` for CRUD operations — the catalog is faster.
220
220
  Never guess API paths or request schemas.
221
221
  Also available: `xcsh://api-spec/workflows/` (step-by-step guides),
222
222
  `xcsh://api-spec/errors/{code}` (error resolution), `xcsh://api-spec/glossary/` (acronym reference).
@@ -2,13 +2,14 @@ Executes bash command in shell session for terminal operations like git, bun, ca
2
2
 
3
3
  <instruction>
4
4
  - You **MUST** use `cwd` parameter to set working directory instead of `cd dir && …`
5
+ - Always provide a `description` parameter: a short, human-readable summary of what the command does (e.g. "Install dependencies", "Run test suite"). This is displayed in compact mode and during execution.
5
6
  - Prefer `env: { NAME: "…" }` for multiline, quote-heavy, or untrusted values instead of inlining them into shell syntax; reference them from the command as `$NAME`
6
7
  - Quote variable expansions like `"$NAME"` to preserve exact content and avoid shell parsing bugs
7
8
  - PTY mode is opt-in: set `pty: true` only when command expects a real terminal (for example `sudo`, `ssh` where you need input from the user); default is `false`
8
9
  - You **MUST** use `;` only when later commands should run regardless of earlier failures
9
10
  - `skill://` URIs are auto-resolved to filesystem paths before execution
10
- - `python skill://my-skill/scripts/init.py` runs the script from the skill directory
11
- - `skill://<name>/<relative-path>` resolves within the skill's base directory
11
+ - `python skill://my-skill/scripts/init.py` runs the script from the skill directory
12
+ - `skill://<name>/<relative-path>` resolves within the skill's base directory
12
13
  - Internal URLs are also auto-resolved to filesystem paths before execution.
13
14
  {{#if asyncEnabled}}
14
15
  - Use `async: true` for long-running commands when you don't need immediate output; the call returns a background job ID and the result is delivered automatically as a follow-up.
@@ -45,17 +46,22 @@ You **MUST** use specialized tools instead of bash for ALL file operations:
45
46
  |---|---|
46
47
  |`cat file`, `head -n N file`|`read(path="file", limit=N)`|
47
48
  |`cat -n file \|sed -n '50,150p'`|`read(path="file", offset=50, limit=100)`|
49
+ <!-- markdownlint-disable MD055 MD056 -->
48
50
  {{#if hasGrep}}|`grep -A 20 'pat' file`|`grep(pattern="pat", path="file", post=20)`|
49
51
  |`grep -rn 'pat' dir/`|`grep(pattern="pat", path="dir/")`|
50
52
  |`rg 'pattern' dir/`|`grep(pattern="pattern", path="dir/")`|{{/if}}
51
53
  {{#if hasFind}}|`find dir -name '*.ts'`|`find(pattern="dir/**/*.ts")`|{{/if}}
54
+ <!-- markdownlint-enable MD055 MD056 -->
52
55
  |`ls dir/`|`read(path="dir/")`|
53
56
  |`cat <<'EOF' > file`|`write(path="file", content="…")`|
54
57
  |`sed -i 's/old/new/' file`|`edit(path="file", edits=[…])`|
55
58
 
59
+ <!-- markdownlint-disable MD055 MD056 -->
56
60
  {{#if hasAstEdit}}|`sed -i 's/oldFn(/newFn(/' src/*.ts`|`ast_edit({ops:[{pat:"oldFn($$$A)", out:"newFn($$$A)"}], path:"src/"})`|{{/if}}
61
+ <!-- markdownlint-enable MD055 MD056 -->
57
62
  {{#if hasAstGrep}}- You **MUST** use `ast_grep` for structural code search instead of bash `grep`/`awk`/`perl` pipelines{{/if}}
58
63
  {{#if hasAstEdit}}- You **MUST** use `ast_edit` for structural rewrites instead of bash `sed`/`awk`/`perl` pipelines{{/if}}
64
+
59
65
  - You **MUST NOT** use Bash for these operations like read, grep, find, edit, write, where specialized tools exist.
60
66
  - You **MUST NOT** use `2>&1` | `2>/dev/null` pattern, stdout and stderr are already merged.
61
67
  - You **MUST NOT** use `| head -n 50` or `| tail -n 100` pattern, use `head` and `tail` parameters instead.
@@ -0,0 +1,9 @@
1
+ Execute an F5 Distributed Cloud API call directly.
2
+
3
+ Handles authentication, URL construction, and HTTP execution.
4
+ Requires `F5XC_API_URL` and `F5XC_API_TOKEN` environment variables.
5
+
6
+ Pass all path `{placeholder}` values via `params`, e.g. `{ namespace: "default", name: "example-lb", vh_name: "example-vh" }`.
7
+ Body is sent for all methods except GET when `payload` is provided — including DELETE operations that require a body.
8
+
9
+ Use this tool after reading the API catalog to get the endpoint path and payload structure.
package/src/tools/bash.ts CHANGED
@@ -9,6 +9,7 @@ import type { Component } from "@f5xc-salesdemos/pi-tui";
9
9
  import { ImageProtocol, TERMINAL, Text } from "@f5xc-salesdemos/pi-tui";
10
10
  import { $env, getProjectDir, isEnoent, prompt, setShellPwd } from "@f5xc-salesdemos/pi-utils";
11
11
  import { Type } from "@sinclair/typebox";
12
+ import { Settings } from "../config/settings";
12
13
  import { type BashResult, executeBash } from "../exec/bash-executor";
13
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
15
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
@@ -26,7 +27,7 @@ import { applyHeadTail } from "./bash-normalize";
26
27
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
27
28
  import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
28
29
  import { resolveToCwd } from "./path-utils";
29
- import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
30
+ import { formatToolWorkingDirectory, replaceTabs, truncateToWidth } from "./render-utils";
30
31
  import { ToolAbortError, ToolError } from "./tool-errors";
31
32
  import { toolResult } from "./tool-result";
32
33
  import { clampTimeout } from "./tool-timeouts";
@@ -41,6 +42,12 @@ const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
41
42
 
42
43
  const bashSchemaBase = Type.Object({
43
44
  command: Type.String({ description: "Command to execute" }),
45
+ description: Type.Optional(
46
+ Type.String({
47
+ description:
48
+ "Human-readable description of what this command does (e.g. 'Install dependencies', 'Run test suite')",
49
+ }),
50
+ ),
44
51
  env: Type.Optional(
45
52
  Type.Record(Type.String({ pattern: BASH_ENV_NAME_PATTERN.source }), Type.String(), {
46
53
  description:
@@ -71,6 +78,7 @@ type BashToolSchema = typeof bashSchemaBase | typeof bashSchemaWithAsync;
71
78
 
72
79
  export interface BashToolInput {
73
80
  command: string;
81
+ description?: string;
74
82
  env?: Record<string, string>;
75
83
  timeout?: number;
76
84
  cwd?: string;
@@ -680,6 +688,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
680
688
 
681
689
  interface BashRenderArgs {
682
690
  command?: string;
691
+ description?: string;
683
692
  env?: Record<string, string>;
684
693
  timeout?: number;
685
694
  cwd?: string;
@@ -711,10 +720,18 @@ function formatBashCommand(args: BashRenderArgs): string {
711
720
  return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`;
712
721
  }
713
722
 
723
+ function getBashVerboseSetting(): boolean {
724
+ try {
725
+ return Settings.instance.get("bash.verbose");
726
+ } catch {
727
+ return false;
728
+ }
729
+ }
730
+
714
731
  export const bashToolRenderer = {
715
732
  renderCall(args: BashRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
716
- const cmdText = formatBashCommand(args);
717
- const text = renderStatusLine({ icon: "pending", title: "Bash", description: cmdText }, uiTheme);
733
+ const summaryText = args.description ?? formatBashCommand(args);
734
+ const text = renderStatusLine({ icon: "pending", title: "Bash", description: summaryText }, uiTheme);
718
735
  return new Text(text, 0, 0);
719
736
  },
720
737
 
@@ -746,6 +763,30 @@ export const bashToolRenderer = {
746
763
  const displayOutput = output.trimEnd();
747
764
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
748
765
 
766
+ const rawOutputLines = displayOutput.split("\n");
767
+ const sixelLineMask =
768
+ TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
769
+ const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
770
+
771
+ // Collapsed mode: single status line when bash.verbose=false
772
+ const verbose = getBashVerboseSetting();
773
+ const hasAsyncDetails = details?.async != null;
774
+ const forceExpand = isError || hasAsyncDetails || hasSixelOutput;
775
+ if (!verbose && !expanded && !forceExpand && !options.isPartial) {
776
+ const rawCmd = args?.command;
777
+ const summaryText =
778
+ args?.description ?? (rawCmd && rawCmd.length > 60 ? `${rawCmd.slice(0, 60)}…` : rawCmd) ?? "…";
779
+ const line = renderStatusLine(
780
+ {
781
+ title: "Bash",
782
+ description: summaryText,
783
+ badge: { label: "ok", color: "success" },
784
+ },
785
+ uiTheme,
786
+ );
787
+ return [truncateToWidth(line, width)];
788
+ }
789
+
749
790
  // Build truncation warning
750
791
  const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
751
792
  const timeoutLine =
@@ -762,10 +803,6 @@ export const bashToolRenderer = {
762
803
 
763
804
  const outputLines: string[] = [];
764
805
  const hasOutput = displayOutput.trim().length > 0;
765
- const rawOutputLines = displayOutput.split("\n");
766
- const sixelLineMask =
767
- TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
768
- const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
769
806
  if (hasOutput) {
770
807
  if (hasSixelOutput) {
771
808
  outputLines.push(
@@ -56,6 +56,7 @@ import { loadSshTool } from "./ssh";
56
56
  import { SubmitResultTool } from "./submit-result";
57
57
  import { type TodoPhase, TodoWriteTool } from "./todo-write";
58
58
  import { WriteTool } from "./write";
59
+ import { XcshApiTool } from "./xcsh-api";
59
60
 
60
61
  // Exa MCP tools (22 tools)
61
62
 
@@ -244,6 +245,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
244
245
  web_search: s => new SearchTool(s),
245
246
  search_tool_bm25: SearchToolBm25Tool.createIf,
246
247
  write: s => new WriteTool(s),
248
+ xcsh_api: s => new XcshApiTool(s),
247
249
  };
248
250
 
249
251
  export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
@@ -0,0 +1,106 @@
1
+ import type { AgentTool, AgentToolResult } from "@f5xc-salesdemos/pi-agent-core";
2
+ import { prompt } from "@f5xc-salesdemos/pi-utils";
3
+ import { type Static, Type } from "@sinclair/typebox";
4
+ import xcshApiDescription from "../prompts/tools/xcsh-api.md" with { type: "text" };
5
+ import type { ToolSession } from ".";
6
+
7
+ const xcshApiSchema = Type.Object({
8
+ method: Type.Union(
9
+ [Type.Literal("GET"), Type.Literal("POST"), Type.Literal("PUT"), Type.Literal("PATCH"), Type.Literal("DELETE")],
10
+ { description: "HTTP method" },
11
+ ),
12
+ path: Type.String({ description: "API path, e.g. /api/config/namespaces/{namespace}/http_loadbalancers" }),
13
+ params: Type.Optional(
14
+ Type.Record(Type.String(), Type.String(), {
15
+ description:
16
+ "Path parameter substitutions, e.g. { namespace: 'default', name: 'example-lb', vh_name: 'example-vh' }",
17
+ }),
18
+ ),
19
+ payload: Type.Optional(Type.Unknown({ description: "JSON body for POST/PUT/PATCH/DELETE requests" })),
20
+ });
21
+
22
+ type XcshApiParams = Static<typeof xcshApiSchema>;
23
+
24
+ export interface XcshApiToolDetails {
25
+ status: number;
26
+ url: string;
27
+ method: string;
28
+ }
29
+
30
+ type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
31
+
32
+ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolDetails> {
33
+ readonly name = "xcsh_api";
34
+ readonly label = "API";
35
+ readonly description: string;
36
+ readonly parameters = xcshApiSchema;
37
+
38
+ constructor(_session: ToolSession) {
39
+ this.description = prompt.render(xcshApiDescription);
40
+ }
41
+
42
+ async execute(_toolCallId: string, params: XcshApiParams): Promise<XcshApiResult> {
43
+ const apiUrl = process.env.F5XC_API_URL;
44
+ if (!apiUrl) {
45
+ return {
46
+ content: [{ type: "text", text: "Error: F5XC_API_URL environment variable is not set." }],
47
+ isError: true,
48
+ };
49
+ }
50
+
51
+ const apiToken = process.env.F5XC_API_TOKEN;
52
+ if (!apiToken) {
53
+ return {
54
+ content: [{ type: "text", text: "Error: F5XC_API_TOKEN environment variable is not set." }],
55
+ isError: true,
56
+ };
57
+ }
58
+
59
+ let resolvedPath = params.path;
60
+ if (params.params) {
61
+ for (const [key, value] of Object.entries(params.params)) {
62
+ resolvedPath = resolvedPath.replaceAll(`{${key}}`, value);
63
+ }
64
+ }
65
+
66
+ const url = `${apiUrl.replace(/\/+$/, "")}${resolvedPath}`;
67
+ const headers: Record<string, string> = {
68
+ Authorization: `APIToken ${apiToken}`,
69
+ Accept: "application/json",
70
+ };
71
+
72
+ const init: RequestInit = { method: params.method, headers };
73
+
74
+ if (params.payload && params.method !== "GET") {
75
+ headers["Content-Type"] = "application/json";
76
+ init.body = JSON.stringify(params.payload);
77
+ }
78
+
79
+ try {
80
+ const response = await fetch(url, init);
81
+ const contentType = response.headers.get("content-type") ?? "";
82
+ let bodyText: string;
83
+
84
+ if (contentType.includes("application/json")) {
85
+ const json = await response.json();
86
+ bodyText = JSON.stringify(json, null, 2);
87
+ } else {
88
+ bodyText = await response.text();
89
+ }
90
+
91
+ const statusLine = `${response.status} ${response.statusText}`;
92
+
93
+ return {
94
+ content: [{ type: "text", text: `${statusLine}\n\n${bodyText}` }],
95
+ details: { status: response.status, url, method: params.method },
96
+ ...(response.status >= 400 ? { isError: true } : {}),
97
+ };
98
+ } catch (err) {
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ return {
101
+ content: [{ type: "text", text: `Request failed: ${message}` }],
102
+ isError: true,
103
+ };
104
+ }
105
+ }
106
+ }