@f5xc-salesdemos/xcsh 18.66.2 → 18.69.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.66.2",
4
+ "version": "18.69.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.66.2",
52
- "@f5xc-salesdemos/pi-agent-core": "18.66.2",
53
- "@f5xc-salesdemos/pi-ai": "18.66.2",
54
- "@f5xc-salesdemos/pi-natives": "18.66.2",
55
- "@f5xc-salesdemos/pi-tui": "18.66.2",
56
- "@f5xc-salesdemos/pi-utils": "18.66.2",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.69.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.69.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.69.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.69.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.69.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.69.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.66.2",
21
- "commit": "04c64b950d117fc3499dca62d084f30e41545d1e",
22
- "shortCommit": "04c64b9",
20
+ "version": "18.69.0",
21
+ "commit": "309ca5b1b28a5530f01b83381e69f8a6c94f83c8",
22
+ "shortCommit": "309ca5b",
23
23
  "branch": "main",
24
- "tag": "v18.66.2",
25
- "commitDate": "2026-05-18T07:27:11Z",
26
- "buildDate": "2026-05-18T07:51:19.009Z",
24
+ "tag": "v18.69.0",
25
+ "commitDate": "2026-05-18T23:00:56Z",
26
+ "buildDate": "2026-05-18T23:27:04.429Z",
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/04c64b950d117fc3499dca62d084f30e41545d1e",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.66.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/309ca5b1b28a5530f01b83381e69f8a6c94f83c8",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.69.0"
33
33
  };
@@ -0,0 +1,405 @@
1
+ /**
2
+ * pipeline-report/benchmark.ts
3
+ *
4
+ * Autoresearch benchmark harness for the pipeline report.
5
+ * Generates a real pipeline report against the live Salesforce org
6
+ * and scores it deterministically across 6 quality dimensions.
7
+ *
8
+ * Primary metric: report_quality_score (0–100, higher is better)
9
+ *
10
+ * OFF LIMITS to autoresearch modifications — this is the ground-truth evaluator.
11
+ */
12
+
13
+ import { loadSalesforceContext } from "../internal-urls/salesforce-context";
14
+ import { loadProfile } from "../internal-urls/user-profile";
15
+ import { generatePipelineReport } from "./generator";
16
+ import { renderPipelineReport } from "./renderer";
17
+ import type { PipelineReportData } from "./types";
18
+
19
+ // ─── Fiscal quarter helpers ───────────────────────────────────────────
20
+ // F5 fiscal year starts November 1. Quarters:
21
+ // Q1: Nov–Jan Q2: Feb–Apr Q3: May–Jul Q4: Aug–Oct
22
+
23
+ function fiscalQuarterDates(): { start: string; end: string } {
24
+ const now = new Date();
25
+ const m = now.getMonth(); // 0-indexed
26
+ const y = now.getFullYear();
27
+
28
+ let start: Date;
29
+ let end: Date;
30
+
31
+ if (m >= 10) {
32
+ // Nov–Dec → Q1 starts this year Nov, ends Jan next year
33
+ start = new Date(y, 10, 1);
34
+ end = new Date(y + 1, 1, 0);
35
+ } else if (m === 0) {
36
+ // Jan → still Q1 (Nov prev year – Jan this year)
37
+ start = new Date(y - 1, 10, 1);
38
+ end = new Date(y, 1, 0);
39
+ } else if (m <= 3) {
40
+ // Feb–Apr → Q2
41
+ start = new Date(y, 1, 1);
42
+ end = new Date(y, 4, 0);
43
+ } else if (m <= 6) {
44
+ // May–Jul → Q3
45
+ start = new Date(y, 4, 1);
46
+ end = new Date(y, 7, 0);
47
+ } else {
48
+ // Aug–Oct → Q4
49
+ start = new Date(y, 7, 1);
50
+ end = new Date(y, 10, 0);
51
+ }
52
+
53
+ const fmt = (d: Date) => d.toISOString().split("T")[0]!;
54
+ return { start: fmt(start), end: fmt(end) };
55
+ }
56
+
57
+ // ─── Scoring helpers ──────────────────────────────────────────────────
58
+
59
+ interface DimScore {
60
+ score: number;
61
+ max: number;
62
+ notes: string[];
63
+ }
64
+
65
+ function scoreSectionPresence(report: string): DimScore {
66
+ const checks: Array<{ re: RegExp; label: string; pts: number }> = [
67
+ { re: /^# F5 Distributed Cloud Pipeline Report/m, label: "report header", pts: 2 },
68
+ { re: /\*\*Generated:\*\*/m, label: "generated timestamp", pts: 2 },
69
+ { re: /\*\*Quarter:\*\*/m, label: "quarter range", pts: 2 },
70
+ { re: /\*\*Summary:\*\*/m, label: "executive summary", pts: 3 },
71
+ { re: /Closed.*Booked/m, label: "booked section", pts: 3 },
72
+ { re: /Open Pipeline.*Net New/m, label: "net new section", pts: 3 },
73
+ { re: /Open Pipeline.*Renewals/m, label: "renewals section", pts: 3 },
74
+ { re: /Forecast Summary/m, label: "forecast summary", pts: 3 },
75
+ { re: /\*\*Line items:\*\*/m, label: "line item count", pts: 1 },
76
+ { re: /\*\*SKUs:\*\*/m, label: "SKU count", pts: 1 },
77
+ { re: /\*\*Model:\*\*/m, label: "value model description", pts: 1 },
78
+ ];
79
+ let score = 0;
80
+ let max = 0;
81
+ const notes: string[] = [];
82
+ for (const c of checks) {
83
+ max += c.pts;
84
+ if (c.re.test(report)) {
85
+ score += c.pts;
86
+ } else {
87
+ notes.push(`Missing: ${c.label}`);
88
+ }
89
+ }
90
+ return { score, max, notes };
91
+ }
92
+
93
+ function scoreDollarFormatting(report: string): DimScore {
94
+ const notes: string[] = [];
95
+ let score = 0;
96
+ const max = 20;
97
+
98
+ // Compact dollar amounts in summaries ($1.2M, $45K)
99
+ if (/\$[\d,.]+[MK]\b/.test(report)) {
100
+ score += 5;
101
+ } else {
102
+ notes.push("No compact dollar amounts ($XM/$XK) in summary lines");
103
+ }
104
+
105
+ // Precise dollar amounts in tables (1,234.56)
106
+ if (/[\d,]+\.\d{2}/.test(report)) {
107
+ score += 5;
108
+ } else {
109
+ notes.push("No precise dollar amounts (X,XXX.XX) in tables");
110
+ }
111
+
112
+ // Em-dash (—) for zero values instead of literal $0
113
+ if (/\|\s*—\s*\|/.test(report)) {
114
+ score += 3;
115
+ }
116
+
117
+ // No unformatted large numbers in table context
118
+ const rawLarge = [...(report.matchAll(/(?<!\$)(?<!\d[,.])\b\d{5,}\b(?!\.\d{2})(?![MK%])/g) ?? [])].filter(m => {
119
+ const ctx = report.slice(Math.max(0, m.index! - 20), m.index! + m[0].length + 20);
120
+ return ctx.includes("|");
121
+ });
122
+ if (rawLarge.length === 0) {
123
+ score += 4;
124
+ } else {
125
+ notes.push(`${rawLarge.length} unformatted large number(s) in table context`);
126
+ }
127
+
128
+ // No undefined/null/NaN in output
129
+ const badValues = (report.match(/\bundefined\b|\bnull\b|\bNaN\b/g) ?? []).length;
130
+ if (badValues === 0) {
131
+ score += 3;
132
+ } else {
133
+ notes.push(`${badValues} occurrence(s) of undefined/null/NaN in output`);
134
+ }
135
+
136
+ return { score, max, notes };
137
+ }
138
+
139
+ function scoreDateFormatting(report: string): DimScore {
140
+ const notes: string[] = [];
141
+ let score = 0;
142
+ const max = 10;
143
+
144
+ // Generated timestamp: human-readable, not raw ISO
145
+ const generatedMatch = report.match(/\*\*Generated:\*\*\s*(.+)/);
146
+ if (generatedMatch) {
147
+ const d = generatedMatch[1]!.trim();
148
+ if (/^\d{4}-\d{2}-\d{2}T/.test(d)) {
149
+ notes.push("Generated date uses raw ISO 8601 (should be locale string)");
150
+ } else {
151
+ score += 3;
152
+ }
153
+ }
154
+
155
+ // Quarter line present and reasonably readable
156
+ const quarterMatch = report.match(/\*\*Quarter:\*\*\s*(.+)/);
157
+ if (quarterMatch) {
158
+ score += quarterMatch[1]!.includes("-") ? 3 : 4;
159
+ }
160
+
161
+ // No raw ISO timestamps (2026-05-18T...) in the report body
162
+ const rawTs = (report.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g) ?? []).length;
163
+ if (rawTs === 0) {
164
+ score += 3;
165
+ } else {
166
+ notes.push(`${rawTs} raw ISO timestamp(s) in report body`);
167
+ }
168
+
169
+ return { score, max, notes };
170
+ }
171
+
172
+ function scoreTableQuality(report: string): DimScore {
173
+ const notes: string[] = [];
174
+ let score = 0;
175
+ const max = 15;
176
+
177
+ const tables = report.match(/\|.+\|[\n\r]+\|[-:|]+\|/g) ?? [];
178
+ if (tables.length === 0) {
179
+ notes.push("No properly formatted markdown tables found");
180
+ return { score: 0, max, notes };
181
+ }
182
+ score += 3;
183
+
184
+ // All tables have separator rows (already implied by regex, this checks alignment chars)
185
+ const hasRightAlign = /---:\|/.test(report);
186
+ if (hasRightAlign) {
187
+ score += 3;
188
+ } else {
189
+ notes.push("No right-aligned columns (amounts should be right-aligned with ---:|)");
190
+ }
191
+
192
+ // Tables have left-aligned label columns
193
+ if (/:---\|/.test(report)) {
194
+ score += 3;
195
+ }
196
+
197
+ // Territory grouping separators (bold em-dash rows)
198
+ if (/\*\*.*—.*\*\*/.test(report)) {
199
+ score += 3;
200
+ }
201
+
202
+ // Total rows (bold)
203
+ if (/\|\s*\*\*Total\*\*\s*\|/.test(report)) {
204
+ score += 3;
205
+ } else {
206
+ notes.push("No bold Total rows in tables");
207
+ }
208
+
209
+ return { score, max, notes };
210
+ }
211
+
212
+ function scoreDataDensity(data: PipelineReportData, report: string): DimScore {
213
+ const notes: string[] = [];
214
+ let score = 0;
215
+ const max = 20;
216
+
217
+ if (data.lineItemCount > 0) {
218
+ score += 4;
219
+ } else {
220
+ notes.push("Zero line items processed");
221
+ }
222
+
223
+ const totalAccounts = data.netNew.accounts.length + data.booked.accounts.length + data.renewals.accounts.length;
224
+ if (totalAccounts > 0) {
225
+ score += 4;
226
+ } else {
227
+ notes.push("No accounts in any section");
228
+ }
229
+
230
+ const fc = data.forecast;
231
+ const fcCount = [fc.commit, fc.bestCase, fc.pipeline].filter(v => v > 0).length;
232
+ score += Math.min(5, fcCount * 2);
233
+ if (fcCount === 0) {
234
+ notes.push("No forecast category amounts populated");
235
+ }
236
+
237
+ if (data.skusFound.length > 0) {
238
+ score += 3;
239
+ } else {
240
+ notes.push("No SKUs discovered");
241
+ }
242
+
243
+ // Anomaly detection running (even 0 anomalies means the engine ran)
244
+ score += 2;
245
+ if (data.anomalies.length > 0 && /Data Quality/.test(report)) {
246
+ score += 2;
247
+ }
248
+
249
+ return { score, max, notes };
250
+ }
251
+
252
+ function scoreInformationValue(report: string): DimScore {
253
+ const notes: string[] = [];
254
+ let score = 0;
255
+ const max = 10;
256
+
257
+ // Quota total callouts (actionable for sales professionals)
258
+ if (/Quota Total/i.test(report)) {
259
+ score += 3;
260
+ } else {
261
+ notes.push("No quota total callouts");
262
+ }
263
+
264
+ // Forecast category breakdown visible (Commit, Best Case, Pipeline)
265
+ if (/Commit/m.test(report) && /Best Case/m.test(report)) {
266
+ score += 2;
267
+ }
268
+
269
+ // Platform vs Point product split (critical for overlay SE reporting)
270
+ if (/Platform.*Distributed Cloud/m.test(report) || /Point.*Shape/m.test(report)) {
271
+ score += 3;
272
+ } else {
273
+ notes.push("Platform/Point product split not visible");
274
+ }
275
+
276
+ // Territory context present
277
+ if (/Territories:/m.test(report) || /Territory/m.test(report)) {
278
+ score += 2;
279
+ }
280
+
281
+ return { score, max, notes };
282
+ }
283
+
284
+ // ─── Main ─────────────────────────────────────────────────────────────
285
+
286
+ const profile = await loadProfile();
287
+ const sfContext = await loadSalesforceContext();
288
+
289
+ if (!profile.identifiers?.salesforceId) {
290
+ console.error("ERROR: No salesforceId in user profile. Run: xcsh://salesforce?refresh=true");
291
+ process.exit(1);
292
+ }
293
+
294
+ if (!sfContext) {
295
+ console.error("ERROR: No cached Salesforce context. Run: xcsh://salesforce?refresh=true");
296
+ process.exit(1);
297
+ }
298
+
299
+ const userId = profile.identifiers.salesforceId;
300
+ const partnerId = profile.partner?.id;
301
+ const userIds = partnerId ? [userId, partnerId] : [userId];
302
+ const { start, end } = fiscalQuarterDates();
303
+
304
+ // Stale cutoff: 12 months ago to include non-stale pipeline beyond current quarter
305
+ const staleDate = new Date();
306
+ staleDate.setFullYear(staleDate.getFullYear() - 1);
307
+ const staleCutoff = staleDate.toISOString().split("T")[0]!;
308
+
309
+ const partnerName = profile.partner?.name;
310
+ const selfName = [profile.givenName, profile.familyName].filter(Boolean).join(" ").trim();
311
+ const teamMemberNames = partnerName && selfName ? [selfName, partnerName] : undefined;
312
+
313
+ const options = {
314
+ userIds,
315
+ orgAlias: sfContext.orgAlias,
316
+ quarterStart: start,
317
+ quarterEnd: end,
318
+ staleCutoff,
319
+ confirmedTerritories: profile.territories ?? sfContext.confirmedTerritories ?? sfContext.territories,
320
+ teamMemberNames,
321
+ };
322
+
323
+ // ─── Generate and time ────────────────────────────────────────────────
324
+
325
+ const genStart = performance.now();
326
+ const data = await generatePipelineReport(options);
327
+ const generateMs = Math.round(performance.now() - genStart);
328
+
329
+ const renderStart = performance.now();
330
+ const report = renderPipelineReport(data, sfContext.instanceUrl);
331
+ const renderMs = Math.round(performance.now() - renderStart);
332
+
333
+ const totalMs = generateMs + renderMs;
334
+
335
+ // ─── Score ────────────────────────────────────────────────────────────
336
+
337
+ const sections = scoreSectionPresence(report);
338
+ const dollars = scoreDollarFormatting(report);
339
+ const dates = scoreDateFormatting(report);
340
+ const tables = scoreTableQuality(report);
341
+ const density = scoreDataDensity(data, report);
342
+ const infoVal = scoreInformationValue(report);
343
+
344
+ const rawScore = sections.score + dollars.score + dates.score + tables.score + density.score + infoVal.score;
345
+ const rawMax = sections.max + dollars.max + dates.max + tables.max + density.max + infoVal.max;
346
+ const qualityScore = Math.round((rawScore / rawMax) * 100);
347
+
348
+ const dataCompletenessPct = Math.round(
349
+ ([
350
+ data.generated,
351
+ data.quarter.start,
352
+ data.quarter.end,
353
+ data.lineItemCount > 0,
354
+ data.skusFound.length > 0,
355
+ data.netNew.accounts.length > 0 || data.booked.accounts.length > 0,
356
+ data.forecast.commit > 0 || data.forecast.bestCase > 0 || data.forecast.pipeline > 0,
357
+ data.territories.length > 0,
358
+ ].filter(Boolean).length /
359
+ 8) *
360
+ 100,
361
+ );
362
+
363
+ // Baseline generator makes 3 queries: net new + booked (parallel) + renewals (sequential)
364
+ const queryCount = 3;
365
+
366
+ const totalAccounts = data.netNew.accounts.length + data.booked.accounts.length + data.renewals.accounts.length;
367
+
368
+ // ─── Output METRIC lines ──────────────────────────────────────────────
369
+
370
+ console.log(`METRIC report_quality_score=${qualityScore}`);
371
+ console.log(`METRIC query_count=${queryCount}`);
372
+ console.log(`METRIC total_time_ms=${totalMs}`);
373
+ console.log(`METRIC generate_time_ms=${generateMs}`);
374
+ console.log(`METRIC render_time_ms=${renderMs}`);
375
+ console.log(`METRIC data_completeness_pct=${dataCompletenessPct}`);
376
+ console.log(`METRIC section_score=${sections.score}`);
377
+ console.log(`METRIC dollar_format_score=${dollars.score}`);
378
+ console.log(`METRIC date_format_score=${dates.score}`);
379
+ console.log(`METRIC table_alignment_score=${tables.score}`);
380
+ console.log(`METRIC data_density_score=${density.score}`);
381
+ console.log(`METRIC info_value_score=${infoVal.score}`);
382
+ console.log(`METRIC line_item_count=${data.lineItemCount}`);
383
+ console.log(`METRIC account_count=${totalAccounts}`);
384
+ console.log(`METRIC report_length=${report.length}`);
385
+ console.log(
386
+ `ASI scoring_breakdown={"sections":${sections.score}/${sections.max},"dollars":${dollars.score}/${dollars.max},"dates":${dates.score}/${dates.max},"tables":${tables.score}/${tables.max},"density":${density.score}/${density.max},"infoValue":${infoVal.score}/${infoVal.max},"total":${rawScore}/${rawMax}}`,
387
+ );
388
+
389
+ const allNotes = [
390
+ ...sections.notes.map(n => `[sections] ${n}`),
391
+ ...dollars.notes.map(n => `[dollars] ${n}`),
392
+ ...dates.notes.map(n => `[dates] ${n}`),
393
+ ...tables.notes.map(n => `[tables] ${n}`),
394
+ ...density.notes.map(n => `[density] ${n}`),
395
+ ...infoVal.notes.map(n => `[info-value] ${n}`),
396
+ ];
397
+
398
+ if (allNotes.length > 0) {
399
+ console.log(`ASI deduction_notes=${JSON.stringify(allNotes)}`);
400
+ console.log("");
401
+ console.log("Deduction notes:");
402
+ for (const note of allNotes) {
403
+ console.log(` - ${note}`);
404
+ }
405
+ }
@@ -625,3 +625,44 @@ export function formatParseErrors(errors: string[]): string[] {
625
625
  : "Parse issues:";
626
626
  return [header, ...capped.map(err => `- ${err}`)];
627
627
  }
628
+
629
+ // =============================================================================
630
+ // JSON / Display Utilities (shared with tool renderers)
631
+ // =============================================================================
632
+
633
+ export function stripEmpty(obj: unknown): unknown {
634
+ if (Array.isArray(obj)) return obj.map(stripEmpty).filter(v => v != null);
635
+ if (obj && typeof obj === "object") {
636
+ const entries = Object.entries(obj as Record<string, unknown>);
637
+ if (entries.length === 0) return obj;
638
+ const out: Record<string, unknown> = {};
639
+ for (const [k, v] of entries) {
640
+ if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) continue;
641
+ const cleaned = stripEmpty(v);
642
+ if (cleaned != null) out[k] = cleaned;
643
+ }
644
+ return Object.keys(out).length > 0 ? out : null;
645
+ }
646
+ return obj;
647
+ }
648
+
649
+ export function formatTimestamp(iso: string): string {
650
+ return iso.replace("T", " ").replace(/:\d{2}(\.\d+)?Z$/, " UTC");
651
+ }
652
+
653
+ export function addSection(
654
+ sections: Array<{ label?: string; lines: string[] }>,
655
+ label: string,
656
+ lines: string[],
657
+ theme: Theme,
658
+ maxLines?: number,
659
+ ): void {
660
+ const titled = theme.fg("toolTitle", label);
661
+ if (maxLines && lines.length > maxLines) {
662
+ const truncated = lines.slice(0, maxLines);
663
+ truncated.push(theme.fg("dim", `… ${lines.length - maxLines} more lines`));
664
+ sections.push({ label: titled, lines: truncated });
665
+ } else {
666
+ sections.push({ label: titled, lines });
667
+ }
668
+ }
@@ -24,6 +24,7 @@ import { notebookToolRenderer } from "./notebook";
24
24
  import { pythonToolRenderer } from "./python";
25
25
  import { resolveToolRenderer } from "./resolve";
26
26
  import { searchToolBm25Renderer } from "./search-tool-bm25";
27
+ import { sfToolRenderer } from "./sf-renderer";
27
28
  import { sshToolRenderer } from "./ssh";
28
29
  import { todoWriteToolRenderer } from "./todo-write";
29
30
  import { writeToolRenderer } from "./write";
@@ -70,4 +71,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
70
71
  web_search: webSearchToolRenderer as ToolRenderer,
71
72
  write: writeToolRenderer as ToolRenderer,
72
73
  xcsh_api: xcshApiToolRenderer as ToolRenderer,
74
+ sf_setup: sfToolRenderer as ToolRenderer,
75
+ sf_query: sfToolRenderer as ToolRenderer,
76
+ sf_org_display: sfToolRenderer as ToolRenderer,
73
77
  };
@@ -0,0 +1,272 @@
1
+ /** TUI renderer for Salesforce tools — rich visual output at full parity with XC-API. */
2
+ import type { Component } from "@f5xc-salesdemos/pi-tui";
3
+ import { Text } from "@f5xc-salesdemos/pi-tui";
4
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
5
+ import type { Theme, ThemeColor } from "../modes/theme/theme";
6
+ import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
7
+ import { addSection, formatErrorMessage, replaceTabs } from "./render-utils";
8
+ import type { SfErrorType, SfToolDetails } from "./sf";
9
+ import { flattenRecord } from "./sf/formatters";
10
+ import type { SfOrg, SfQueryResult } from "./sf/types";
11
+
12
+ const TOOL_TITLE = "Salesforce";
13
+ const MAX_COL_WIDTH = 30;
14
+
15
+ type SfRenderArgs = {
16
+ action?: string;
17
+ query?: string;
18
+ target_org?: string;
19
+ };
20
+
21
+ const TOOL_ACTION_COLORS: Partial<Record<string, ThemeColor>> = {
22
+ sf_setup: "chromeAccent",
23
+ sf_query: "contentAccent",
24
+ };
25
+
26
+ const ERROR_GUIDANCE: Record<SfErrorType, string> = {
27
+ auth_required: "Authenticate with: sf org login web --set-default --alias SFDC",
28
+ session_expired: "Re-authenticate: sf org login web --set-default\nThen run sf_setup action 'status' to confirm",
29
+ no_default_org: "Run sf_setup with action set_default to choose a default org",
30
+ invalid_query:
31
+ "Check field names and object types. Use SELECT ... FROM EntityDefinition to discover available objects",
32
+ exec_error: "Check sf CLI is installed and configured correctly",
33
+ };
34
+
35
+ function orgStatusColor(status: string): ThemeColor {
36
+ const lower = status.toLowerCase();
37
+ if (lower === "connected") return "success";
38
+ if (lower.includes("expired")) return "error";
39
+ return "warning";
40
+ }
41
+
42
+ function truncateCell(value: string, maxWidth: number): string {
43
+ if (value.length <= maxWidth) return value;
44
+ return `${value.slice(0, maxWidth - 1)}…`;
45
+ }
46
+
47
+ function buildOrgRows(orgs: SfOrg[], uiTheme: Theme): string[] {
48
+ return orgs.map(org => {
49
+ const defaultBadge = org.isDefault ? ` ${uiTheme.fg("chromeAccent", "(default)")}` : "";
50
+ const aliasText = org.alias
51
+ ? uiTheme.fg("toolOutput", org.alias) + defaultBadge
52
+ : uiTheme.fg("dim", "(none)") + defaultBadge;
53
+ const username = uiTheme.fg("dim", org.username);
54
+ const status = uiTheme.fg(orgStatusColor(org.connectedStatus), org.connectedStatus);
55
+ const sandboxBadge = org.isSandbox ? ` ${uiTheme.fg("warning", "[sandbox]")}` : "";
56
+ return ` ${aliasText} ${username} ${status}${sandboxBadge}`;
57
+ });
58
+ }
59
+
60
+ function buildQueryTable(queryResult: SfQueryResult, uiTheme: Theme): string[] {
61
+ const records = queryResult.records as Record<string, unknown>[];
62
+ if (records.length === 0) return [uiTheme.fg("dim", " No records found.")];
63
+
64
+ const flatRecords = records.map(r => flattenRecord(r));
65
+ const allColumns = Array.from(
66
+ flatRecords.reduce((cols, record) => {
67
+ for (const key of Object.keys(record)) cols.add(key);
68
+ return cols;
69
+ }, new Set<string>()),
70
+ );
71
+
72
+ const colWidths = allColumns.map(col => {
73
+ const maxData = flatRecords.reduce((max, rec) => {
74
+ const val = rec[col];
75
+ return Math.max(max, val == null ? 0 : String(val).replace(/[\n\r\t]+/g, " ").length);
76
+ }, 0);
77
+ return Math.min(MAX_COL_WIDTH, Math.max(col.length, maxData));
78
+ });
79
+
80
+ const lines: string[] = [];
81
+
82
+ // Header
83
+ const headerCells = allColumns.map((col, i) => uiTheme.fg("toolTitle", col.padEnd(colWidths[i]!)));
84
+ lines.push(` ${headerCells.join(" ")}`);
85
+
86
+ // Separator
87
+ const sepCells = colWidths.map(w => uiTheme.fg("dim", "─".repeat(w)));
88
+ lines.push(` ${sepCells.join(" ")}`);
89
+
90
+ // Rows
91
+ for (const rec of flatRecords) {
92
+ const cells = allColumns.map((col, i) => {
93
+ const val = rec[col];
94
+ const raw = val == null ? "" : String(val).replace(/[\n\r\t]+/g, " ");
95
+ const cell = truncateCell(raw, colWidths[i]!).padEnd(colWidths[i]!);
96
+ return val == null || raw === "" ? uiTheme.fg("dim", cell) : uiTheme.fg("toolOutput", cell);
97
+ });
98
+ lines.push(` ${cells.join(" ")}`);
99
+ }
100
+
101
+ return lines;
102
+ }
103
+
104
+ function buildOrgKV(org: SfOrg, uiTheme: Theme): string[] {
105
+ const line = (label: string, value: string, valueColor: ThemeColor = "toolOutput") =>
106
+ ` ${uiTheme.fg("dim", label.padEnd(10))}${uiTheme.fg(valueColor, value)}`;
107
+
108
+ const lines: string[] = [];
109
+ if (org.alias) lines.push(line("alias:", org.alias));
110
+ lines.push(line("username:", org.username));
111
+ lines.push(line("org id:", org.orgId));
112
+ lines.push(line("instance:", org.instanceUrl));
113
+ lines.push(line("status:", org.connectedStatus, orgStatusColor(org.connectedStatus)));
114
+ if (org.isSandbox) lines.push(line("type:", "Sandbox", "warning"));
115
+ if (org.isDefault) lines.push(line("default:", "yes", "chromeAccent"));
116
+ return lines;
117
+ }
118
+
119
+ export const sfToolRenderer = {
120
+ renderCall(args: SfRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
121
+ const action = args.action ?? (args.query !== undefined ? "query" : "org");
122
+ const text = renderStatusLine(
123
+ {
124
+ icon: "pending",
125
+ title: TOOL_TITLE,
126
+ badge: { label: action, color: args.query !== undefined ? "contentAccent" : "chromeAccent" },
127
+ },
128
+ uiTheme,
129
+ );
130
+ return new Text(text, 0, 0);
131
+ },
132
+
133
+ renderResult(
134
+ result: { content: Array<{ type: string; text?: string }>; details?: SfToolDetails; isError?: boolean },
135
+ options: RenderResultOptions,
136
+ uiTheme: Theme,
137
+ _args?: SfRenderArgs,
138
+ ): Component {
139
+ const details = result.details;
140
+ const isError = result.isError === true;
141
+
142
+ // Fallback: error without structured details
143
+ if (isError && !details) {
144
+ const errorText = result.content?.find(c => c.type === "text")?.text;
145
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
146
+ }
147
+
148
+ const tool = details?.tool;
149
+ const action = details?.action;
150
+ const errorType = details?.errorType;
151
+ const sections: Array<{ label?: string; lines: string[] }> = [];
152
+
153
+ // --- Error path ---
154
+ if (isError) {
155
+ const errorText = result.content?.find(c => c.type === "text")?.text ?? "Unknown error";
156
+ addSection(sections, "Error", [uiTheme.fg("error", errorText)], uiTheme);
157
+
158
+ const badgeLabel = errorType ?? "error";
159
+ if (errorType) {
160
+ const guidance = ERROR_GUIDANCE[errorType];
161
+ const guidanceLines = guidance.split("\n").map(l => uiTheme.fg("warning", l));
162
+ addSection(sections, "Guidance", guidanceLines, uiTheme);
163
+ }
164
+
165
+ const header = renderStatusLine(
166
+ {
167
+ title: TOOL_TITLE,
168
+ titleColor: "contentAccent",
169
+ badge: { label: badgeLabel, color: "error" },
170
+ },
171
+ uiTheme,
172
+ );
173
+ const outputBlock = new CachedOutputBlock();
174
+ return {
175
+ render(width: number): string[] {
176
+ return outputBlock.render({ header, state: "error", sections, width }, uiTheme);
177
+ },
178
+ invalidate() {
179
+ outputBlock.invalidate();
180
+ },
181
+ };
182
+ }
183
+
184
+ // --- Success path ---
185
+ let badgeLabel = action ?? tool?.replace("sf_", "") ?? "sf";
186
+ const badgeColor: ThemeColor = tool ? (TOOL_ACTION_COLORS[tool] ?? "muted") : "muted";
187
+ const meta: string[] = [];
188
+
189
+ if (tool === "sf_setup") {
190
+ const orgs = details?.orgs;
191
+ if ((action === "status" || action === "list_orgs") && orgs !== undefined) {
192
+ const count = orgs.length;
193
+ meta.push(uiTheme.fg("dim", `${count} org${count !== 1 ? "s" : ""}`));
194
+ if (count === 0) {
195
+ addSection(sections, "Orgs", [uiTheme.fg("dim", "No authenticated orgs found.")], uiTheme);
196
+ } else {
197
+ addSection(sections, "Orgs", buildOrgRows(orgs, uiTheme), uiTheme);
198
+ }
199
+ } else {
200
+ const text = result.content?.find(c => c.type === "text")?.text ?? "";
201
+ addSection(
202
+ sections,
203
+ "Result",
204
+ text.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line))),
205
+ uiTheme,
206
+ );
207
+ }
208
+ } else if (tool === "sf_query") {
209
+ const queryResult = details?.queryResult;
210
+ if (queryResult) {
211
+ const count = queryResult.totalSize;
212
+ badgeLabel = "query";
213
+ meta.push(uiTheme.fg("dim", `${count} record${count !== 1 ? "s" : ""}`));
214
+ addSection(
215
+ sections,
216
+ `Results (${count} record${count !== 1 ? "s" : ""})`,
217
+ buildQueryTable(queryResult, uiTheme),
218
+ uiTheme,
219
+ );
220
+ if (!queryResult.done) {
221
+ addSection(
222
+ sections,
223
+ "Warning",
224
+ [uiTheme.fg("warning", "Results are incomplete. Use sf data export bulk for the full dataset.")],
225
+ uiTheme,
226
+ );
227
+ }
228
+ } else {
229
+ const text = result.content?.find(c => c.type === "text")?.text ?? "";
230
+ addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
231
+ }
232
+ } else if (tool === "sf_org_display") {
233
+ const org = details?.orgs?.[0];
234
+ if (org) {
235
+ badgeLabel = "org";
236
+ meta.push(uiTheme.fg("muted", org.alias ?? org.username));
237
+ meta.push(uiTheme.fg(orgStatusColor(org.connectedStatus), org.connectedStatus));
238
+ addSection(sections, "Summary", buildOrgKV(org, uiTheme), uiTheme);
239
+ } else {
240
+ const text = result.content?.find(c => c.type === "text")?.text ?? "";
241
+ addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
242
+ }
243
+ } else {
244
+ const text = result.content?.find(c => c.type === "text")?.text ?? "";
245
+ addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
246
+ }
247
+
248
+ const header = renderStatusLine(
249
+ {
250
+ title: TOOL_TITLE,
251
+ titleColor: "contentAccent",
252
+ badge: { label: badgeLabel, color: badgeColor },
253
+ meta: meta.length > 0 ? meta : undefined,
254
+ },
255
+ uiTheme,
256
+ );
257
+
258
+ const outputBlock = new CachedOutputBlock();
259
+ return {
260
+ render(width: number): string[] {
261
+ const state = options.isPartial ? "pending" : "success";
262
+ return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
263
+ },
264
+ invalidate() {
265
+ outputBlock.invalidate();
266
+ },
267
+ };
268
+ },
269
+
270
+ mergeCallAndResult: true,
271
+ inline: true,
272
+ };
package/src/tools/sf.ts CHANGED
@@ -12,7 +12,14 @@ import sfQueryDescription from "../prompts/tools/sf-query.md" with { type: "text
12
12
  import sfSetupDescription from "../prompts/tools/sf-setup.md" with { type: "text" };
13
13
  import type { ToolSession } from ".";
14
14
  import type { SfExecApi } from "./sf/exec";
15
- import { execSfJson, execSfRaw } from "./sf/exec";
15
+ import {
16
+ execSfJson,
17
+ execSfRaw,
18
+ SfAuthError,
19
+ SfNoDefaultOrgError,
20
+ SfQueryError,
21
+ SfSessionExpiredError,
22
+ } from "./sf/exec";
16
23
  import { formatOrgDetail, formatOrgTable, formatQueryResults } from "./sf/formatters";
17
24
  import type { SfOrg, SfQueryResult, SfRawResult } from "./sf/types";
18
25
  import { ORG_ALIAS_PATTERN } from "./sf/types";
@@ -80,15 +87,34 @@ type SfSetupInput = Static<typeof sfSetupSchema>;
80
87
  type SfQueryInput = Static<typeof sfQuerySchema>;
81
88
  type SfOrgDisplayInput = Static<typeof sfOrgDisplaySchema>;
82
89
 
83
- interface SfToolDetails {
90
+ export type SfErrorType = "auth_required" | "session_expired" | "no_default_org" | "invalid_query" | "exec_error";
91
+
92
+ export interface SfToolDetails {
93
+ tool: "sf_setup" | "sf_query" | "sf_org_display";
94
+ action?: string;
84
95
  orgs?: SfOrg[];
85
96
  queryResult?: SfQueryResult;
97
+ errorType?: SfErrorType;
86
98
  }
87
99
 
88
- function textResult(text: string, details?: SfToolDetails): AgentToolResult<SfToolDetails> {
100
+ type SfResult = AgentToolResult<SfToolDetails> & { isError?: boolean };
101
+
102
+ function textResult(text: string, details: SfToolDetails): SfResult {
89
103
  return { content: [{ type: "text", text }], details };
90
104
  }
91
105
 
106
+ function errorResult(text: string, details: SfToolDetails): SfResult {
107
+ return { content: [{ type: "text", text }], isError: true, details };
108
+ }
109
+
110
+ function detectErrorType(err: unknown): SfErrorType {
111
+ if (err instanceof SfAuthError) return "auth_required";
112
+ if (err instanceof SfSessionExpiredError) return "session_expired";
113
+ if (err instanceof SfNoDefaultOrgError) return "no_default_org";
114
+ if (err instanceof SfQueryError) return "invalid_query";
115
+ return "exec_error";
116
+ }
117
+
92
118
  // ─── Helpers ─────────────────────────────────────────────────────────────
93
119
 
94
120
  export function normalizeOrg(raw: Record<string, unknown>): SfOrg {
@@ -150,65 +176,76 @@ export class SfSetupTool implements AgentTool<typeof sfSetupSchema, SfToolDetail
150
176
  signal?: AbortSignal,
151
177
  _onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
152
178
  _context?: AgentToolContext,
153
- ): Promise<AgentToolResult<SfToolDetails>> {
179
+ ): Promise<SfResult> {
154
180
  const api = this.#testApi ?? makeExecApi(this.session.cwd);
181
+ const base = { tool: "sf_setup" as const, action: params.action };
155
182
 
156
- switch (params.action) {
157
- case "check": {
158
- const result = await execSfRaw(api, ["--version"], signal);
159
- return textResult(`sf is installed: ${result.stdout}`);
160
- }
161
-
162
- case "status": {
163
- const orgResult = await execSfJson(api, ["org", "list"], signal);
164
- const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
165
- let output = formatOrgTable(allOrgs);
166
-
167
- const userProfile = await loadProfile();
168
- if (userProfile.givenName || userProfile.familyName) {
169
- const name = [userProfile.givenName, userProfile.familyName].filter(Boolean).join(" ");
170
- output += `\n\nUser profile: **${name}** (${userProfile.email ?? "no email"})`;
183
+ try {
184
+ switch (params.action) {
185
+ case "check": {
186
+ const result = await execSfRaw(api, ["--version"], signal);
187
+ return textResult(`sf is installed: ${result.stdout}`, base);
171
188
  }
172
189
 
173
- return textResult(output, { orgs: allOrgs });
174
- }
190
+ case "status": {
191
+ const orgResult = await execSfJson(api, ["org", "list"], signal);
192
+ const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
193
+ let output = formatOrgTable(allOrgs);
175
194
 
176
- case "login": {
177
- const orgResult = await execSfJson(api, ["org", "list"], signal);
178
- const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
179
- if (allOrgs.length > 0) {
180
- return textResult("Already authenticated. Use 'status' action to see your orgs and profile.", {
181
- orgs: allOrgs,
182
- });
183
- }
184
- return textResult(
185
- "No authenticated orgs found.\n\nRun one of these commands to authenticate:\n" +
186
- "- **Workstation**: `sf org login web --set-default --alias SFDC`\n" +
187
- '- **Container**: `echo "$SFDX_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin=- --set-default --alias f5`\n\n' +
188
- "After authenticating, call sf_setup with action 'status' to confirm.",
189
- );
190
- }
195
+ const userProfile = await loadProfile();
196
+ if (userProfile.givenName || userProfile.familyName) {
197
+ const name = [userProfile.givenName, userProfile.familyName].filter(Boolean).join(" ");
198
+ output += `\n\nUser profile: **${name}** (${userProfile.email ?? "no email"})`;
199
+ }
191
200
 
192
- case "list_orgs": {
193
- const orgResult = await execSfJson(api, ["org", "list"], signal);
194
- const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
195
- return textResult(formatOrgTable(allOrgs), { orgs: allOrgs });
196
- }
197
-
198
- case "set_default": {
199
- if (!params.org) {
200
- return textResult("Error: org parameter is required for set_default action.");
201
+ return textResult(output, { ...base, orgs: allOrgs });
201
202
  }
202
- if (!ORG_ALIAS_PATTERN.test(params.org)) {
203
+
204
+ case "login": {
205
+ const orgResult = await execSfJson(api, ["org", "list"], signal);
206
+ const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
207
+ if (allOrgs.length > 0) {
208
+ return textResult("Already authenticated. Use 'status' action to see your orgs and profile.", {
209
+ ...base,
210
+ orgs: allOrgs,
211
+ });
212
+ }
203
213
  return textResult(
204
- `Error: invalid org alias "${params.org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
214
+ "No authenticated orgs found.\n\nRun one of these commands to authenticate:\n" +
215
+ "- **Workstation**: `sf org login web --set-default --alias SFDC`\n" +
216
+ '- **Container**: `echo "$SFDX_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin=- --set-default --alias f5`\n\n' +
217
+ "After authenticating, call sf_setup with action 'status' to confirm.",
218
+ base,
205
219
  );
206
220
  }
207
- await execSfRaw(api, ["config", "set", "target-org", params.org, "--global"], signal);
208
- return textResult(`Default org set to: **${params.org}**`);
221
+
222
+ case "list_orgs": {
223
+ const orgResult = await execSfJson(api, ["org", "list"], signal);
224
+ const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
225
+ return textResult(formatOrgTable(allOrgs), { ...base, orgs: allOrgs });
226
+ }
227
+
228
+ case "set_default": {
229
+ if (!params.org) {
230
+ return errorResult("Error: org parameter is required for set_default action.", base);
231
+ }
232
+ if (!ORG_ALIAS_PATTERN.test(params.org)) {
233
+ return errorResult(
234
+ `Error: invalid org alias "${params.org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
235
+ base,
236
+ );
237
+ }
238
+ await execSfRaw(api, ["config", "set", "target-org", params.org, "--global"], signal);
239
+ return textResult(`Default org set to: **${params.org}**`, base);
240
+ }
241
+
242
+ default:
243
+ return textResult(`Unknown action: ${params.action}`, base);
209
244
  }
210
- default:
211
- return textResult(`Unknown action: ${params.action}`);
245
+ } catch (err) {
246
+ const errorType = detectErrorType(err);
247
+ const message = err instanceof Error ? err.message : String(err);
248
+ return errorResult(message, { ...base, errorType });
212
249
  }
213
250
  }
214
251
  }
@@ -240,41 +277,42 @@ export class SfQueryTool implements AgentTool<typeof sfQuerySchema, SfToolDetail
240
277
  signal?: AbortSignal,
241
278
  _onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
242
279
  _context?: AgentToolContext,
243
- ): Promise<AgentToolResult<SfToolDetails>> {
280
+ ): Promise<SfResult> {
244
281
  const api = this.#testApi ?? makeExecApi(this.session.cwd);
282
+ const base = { tool: "sf_query" as const, action: "query" };
245
283
 
246
284
  if (params.target_org && !ORG_ALIAS_PATTERN.test(params.target_org)) {
247
- return textResult(
285
+ return errorResult(
248
286
  `Error: invalid org alias "${params.target_org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
287
+ base,
249
288
  );
250
289
  }
251
290
 
252
291
  const args = ["data", "query", "--query", params.query];
253
- if (params.target_org) {
254
- args.push("--target-org", params.target_org);
255
- }
256
- if (params.use_tooling_api) {
257
- args.push("--use-tooling-api");
258
- }
259
- if (params.all_rows) {
260
- args.push("--all-rows");
261
- }
262
-
263
- const result = await execSfJson(api, args, signal, params.query);
264
- const queryData = result.result as SfQueryResult<Record<string, unknown>>;
265
-
266
- const queryResult: SfQueryResult = {
267
- totalSize: queryData.totalSize ?? 0,
268
- done: queryData.done ?? true,
269
- records: queryData.records ?? [],
270
- };
292
+ if (params.target_org) args.push("--target-org", params.target_org);
293
+ if (params.use_tooling_api) args.push("--use-tooling-api");
294
+ if (params.all_rows) args.push("--all-rows");
295
+
296
+ try {
297
+ const result = await execSfJson(api, args, signal, params.query);
298
+ const queryData = result.result as SfQueryResult<Record<string, unknown>>;
299
+ const queryResult: SfQueryResult = {
300
+ totalSize: queryData.totalSize ?? 0,
301
+ done: queryData.done ?? true,
302
+ records: queryData.records ?? [],
303
+ };
271
304
 
272
- let output = formatQueryResults(queryResult);
273
- if (!queryResult.done) {
274
- output +=
275
- "\n\n**Warning**: Results are incomplete. The query returned more records than the API limit. Use `sf data export bulk` for the full dataset.";
305
+ let output = formatQueryResults(queryResult);
306
+ if (!queryResult.done) {
307
+ output +=
308
+ "\n\n**Warning**: Results are incomplete. The query returned more records than the API limit. Use `sf data export bulk` for the full dataset.";
309
+ }
310
+ return textResult(output, { ...base, queryResult });
311
+ } catch (err) {
312
+ const errorType = detectErrorType(err);
313
+ const message = err instanceof Error ? err.message : String(err);
314
+ return errorResult(message, { ...base, errorType });
276
315
  }
277
- return textResult(output, { queryResult });
278
316
  }
279
317
  }
280
318
 
@@ -305,34 +343,40 @@ export class SfOrgDisplayTool implements AgentTool<typeof sfOrgDisplaySchema, Sf
305
343
  signal?: AbortSignal,
306
344
  _onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
307
345
  _context?: AgentToolContext,
308
- ): Promise<AgentToolResult<SfToolDetails>> {
346
+ ): Promise<SfResult> {
309
347
  const api = this.#testApi ?? makeExecApi(this.session.cwd);
348
+ const base = { tool: "sf_org_display" as const };
310
349
 
311
350
  if (params.target_org && !ORG_ALIAS_PATTERN.test(params.target_org)) {
312
- return textResult(
351
+ return errorResult(
313
352
  `Error: invalid org alias "${params.target_org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
353
+ base,
314
354
  );
315
355
  }
316
356
 
317
357
  const args = ["org", "display"];
318
- if (params.target_org) {
319
- args.push("--target-org", params.target_org);
320
- }
358
+ if (params.target_org) args.push("--target-org", params.target_org);
359
+
360
+ try {
361
+ const result = await execSfJson(api, args, signal);
362
+ const raw = result.result as Record<string, unknown>;
363
+
364
+ // SECURITY: only extract whitelisted fields
365
+ const org: SfOrg = {
366
+ username: String(raw.username ?? ""),
367
+ orgId: String(raw.id ?? raw.orgId ?? ""),
368
+ instanceUrl: String(raw.instanceUrl ?? ""),
369
+ connectedStatus: String(raw.connectedStatus ?? "Connected"),
370
+ alias: raw.alias ? String(raw.alias) : undefined,
371
+ isDefault: false,
372
+ isSandbox: Boolean(raw.isSandbox ?? false),
373
+ };
321
374
 
322
- const result = await execSfJson(api, args, signal);
323
- const raw = result.result as Record<string, unknown>;
324
-
325
- // SECURITY: only extract whitelisted fields
326
- const org: SfOrg = {
327
- username: String(raw.username ?? ""),
328
- orgId: String(raw.id ?? raw.orgId ?? ""),
329
- instanceUrl: String(raw.instanceUrl ?? ""),
330
- connectedStatus: String(raw.connectedStatus ?? "Connected"),
331
- alias: raw.alias ? String(raw.alias) : undefined,
332
- isDefault: false,
333
- isSandbox: Boolean(raw.isSandbox ?? false),
334
- };
335
-
336
- return textResult(formatOrgDetail(org), { orgs: [org] });
375
+ return textResult(formatOrgDetail(org), { ...base, orgs: [org] });
376
+ } catch (err) {
377
+ const errorType = detectErrorType(err);
378
+ const message = err instanceof Error ? err.message : String(err);
379
+ return errorResult(message, { ...base, errorType });
380
+ }
337
381
  }
338
382
  }
@@ -5,7 +5,13 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
5
5
  import type { Theme, ThemeColor } from "../modes/theme/theme";
6
6
  import { highlightCode } from "../modes/theme/theme";
7
7
  import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
8
- import { formatErrorMessage, replaceTabs } from "./render-utils";
8
+ import {
9
+ formatErrorMessage,
10
+ formatTimestamp,
11
+ addSection as pushSection,
12
+ replaceTabs,
13
+ stripEmpty,
14
+ } from "./render-utils";
9
15
  import type { XcshApiToolDetails } from "./xcsh-api";
10
16
 
11
17
  const TOOL_TITLE = "XC-API";
@@ -31,30 +37,6 @@ function statusColor(status: number): ThemeColor {
31
37
  return status < 300 ? "success" : status < 400 ? "warning" : "error";
32
38
  }
33
39
 
34
- /**
35
- * Strip null, empty string, and empty array fields recursively.
36
- * Preserves empty objects `{}` — these are F5 XC protobuf oneof presence markers
37
- * (e.g. `use_origin_server_name: {}` means that option is selected).
38
- */
39
- function stripEmpty(obj: unknown): unknown {
40
- if (Array.isArray(obj)) return obj.map(stripEmpty).filter(v => v != null);
41
- if (obj && typeof obj === "object") {
42
- const entries = Object.entries(obj as Record<string, unknown>);
43
- // Preserve source-empty objects (F5 XC oneof presence markers)
44
- if (entries.length === 0) return obj;
45
- const out: Record<string, unknown> = {};
46
- for (const [k, v] of entries) {
47
- if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) continue;
48
- const cleaned = stripEmpty(v);
49
- if (cleaned != null) out[k] = cleaned;
50
- }
51
- return Object.keys(out).length > 0 ? out : null;
52
- }
53
- return obj;
54
- }
55
- function formatTimestamp(iso: string): string {
56
- return iso.replace("T", " ").replace(/:\d{2}(\.\d+)?Z$/, " UTC");
57
- }
58
40
  function stripProtobufPrefix(message: string): string {
59
41
  return message.replace(/^ves\.io\.schema\.\S+:\s*/i, "");
60
42
  }
@@ -247,14 +229,7 @@ export const xcshApiToolRenderer = {
247
229
  const sections: Array<{ label?: string; lines: string[] }> = [];
248
230
 
249
231
  const addSection = (label: string, lines: string[], maxLines?: number): void => {
250
- const titled = uiTheme.fg("toolTitle", label);
251
- if (maxLines && lines.length > maxLines) {
252
- const truncated = lines.slice(0, maxLines);
253
- truncated.push(uiTheme.fg("dim", `… ${lines.length - maxLines} more lines`));
254
- sections.push({ label: titled, lines: truncated });
255
- } else {
256
- sections.push({ label: titled, lines });
257
- }
232
+ pushSection(sections, label, lines, uiTheme, maxLines);
258
233
  };
259
234
 
260
235
  // Section: Request payload — show resolved body (actual JSON sent to API)