@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 +7 -7
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/pipeline-report/benchmark.ts +405 -0
- package/src/tools/render-utils.ts +41 -0
- package/src/tools/renderers.ts +4 -0
- package/src/tools/sf-renderer.ts +272 -0
- package/src/tools/sf.ts +140 -96
- package/src/tools/xcsh-api-renderer.ts +8 -33
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.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.
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
51
|
+
"@f5xc-salesdemos/xcsh-stats": "18.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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.69.0",
|
|
21
|
+
"commit": "309ca5b1b28a5530f01b83381e69f8a6c94f83c8",
|
|
22
|
+
"shortCommit": "309ca5b",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
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
|
+
}
|
package/src/tools/renderers.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 {
|
|
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
|
-
|
|
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)
|