@f5xc-salesdemos/xcsh 18.66.1 → 18.68.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.1",
4
+ "version": "18.68.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.1",
52
- "@f5xc-salesdemos/pi-agent-core": "18.66.1",
53
- "@f5xc-salesdemos/pi-ai": "18.66.1",
54
- "@f5xc-salesdemos/pi-natives": "18.66.1",
55
- "@f5xc-salesdemos/pi-tui": "18.66.1",
56
- "@f5xc-salesdemos/pi-utils": "18.66.1",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.68.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.68.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.68.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.68.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.68.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.68.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.1",
21
- "commit": "90d34ed7bee228bbc7e09126b5e9e71aee873861",
22
- "shortCommit": "90d34ed",
20
+ "version": "18.68.0",
21
+ "commit": "27ddc863792ce7bba1ab75066abeca95cafca4d4",
22
+ "shortCommit": "27ddc86",
23
23
  "branch": "main",
24
- "tag": "v18.66.1",
25
- "commitDate": "2026-05-18T06:44:43Z",
26
- "buildDate": "2026-05-18T07:08:42.105Z",
24
+ "tag": "v18.68.0",
25
+ "commitDate": "2026-05-18T17:51:04Z",
26
+ "buildDate": "2026-05-18T18:22:44.227Z",
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/90d34ed7bee228bbc7e09126b5e9e71aee873861",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.66.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/27ddc863792ce7bba1ab75066abeca95cafca4d4",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.68.0"
33
33
  };
@@ -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)
@@ -39,12 +39,14 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
39
39
  const v = theme.boxSharp.vertical;
40
40
  const cap = h.repeat(3);
41
41
  const lineWidth = Math.max(0, width);
42
- // Border colors: running/pending use accent, everything else uses dim (gray).
43
- // Error state uses dim the red toolErrorBg background is the error signal; no red border on generic tools.
44
- // borderColorOverride (from options) takes precedence for F5-branded tools (e.g. XC-API); use F5_TOOL_BORDER_COLOR.
42
+ // Border colors: error→error (red), warning→warning, running/pending→spinnerAccent, success→dim.
43
+ // borderColorOverride (from options) takes precedence for non-error states on F5-branded tools (e.g. XC-API);
44
+ // override is always cleared on error so all tools show a consistent error border; use F5_TOOL_BORDER_COLOR.
45
45
  const resolvedBorderColor: ThemeColor =
46
- borderColorOverride ??
47
- (state === "warning" ? "warning" : state === "running" || state === "pending" ? "spinnerAccent" : "dim");
46
+ state === "error"
47
+ ? "error"
48
+ : (borderColorOverride ??
49
+ (state === "warning" ? "warning" : state === "running" || state === "pending" ? "spinnerAccent" : "dim"));
48
50
  const border = (text: string) => theme.fg(resolvedBorderColor, text);
49
51
  const bgFn = (() => {
50
52
  if (!state || !applyBg) return undefined;