@f5xc-salesdemos/xcsh 18.38.2 → 18.40.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/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.40.0] - 2026-05-05
6
+
7
+ ### Added
8
+
9
+ - `use_tooling_api` parameter for `sf_query` tool to query Tooling API metadata objects (ApexTrigger, ApexClass, CustomField)
10
+ - `all_rows` parameter for `sf_query` tool to include deleted/archived records in query results
11
+ - Incomplete results warning when SOQL query returns `done: false`, recommending `sf data export bulk`
12
+ - `#testApi` injection seam on `SfSetupTool`, `SfQueryTool`, `SfOrgDisplayTool` for mock-based unit testing
13
+ - `collectAllOrgs()` shared helper that normalizes and deduplicates orgs from `sf org list --json` result arrays
14
+ - Salesforce unit test suite expanded from 58 to 91 tests covering tool execute() paths, error handling, security whitelisting, schema params, and formatter edge cases
15
+
16
+ ### Fixed
17
+
18
+ - Fixed duplicate orgs in `sf_setup status` and `sf_setup list_orgs` output caused by `sf org list --json` returning the same org in both `other` and `nonScratchOrgs` arrays
19
+ - Fixed relationship column pollution in `flattenRecord` where null relationships (e.g., `Account: null`) created a bare `Account` column alongside `Account.Name` from non-null records
20
+ - Fixed aggregate SOQL columns rendering as `expr0`/`expr1` by adding aliases (`TotalDeals`, `TotalAmount`) to template queries in sf-query prompt
21
+ - Fixed duplicate org entries in welcome-screen Salesforce status check (`welcome-checks.ts`)
22
+ - Fixed pre-existing biome lint warning in `glab/formatters.ts` (string concatenation -> template literal)
23
+ - Fixed pre-existing test timeouts in `model-registry-runtime-provider.test.ts` and `interactive-mode-lsp-startup.test.ts` by increasing timeout for integration-level tests that shell out to CLIs
24
+ - Fixed profile caching test contaminating real `~/.sf/config.json` with mock data by adding HOME isolation
25
+
26
+ ### Changed
27
+
28
+ - Moved Salesforce user profile cache from `~/.sf/config.json` (xcsh.user.* keys squatting in sf CLI namespace) to `~/.xcsh/sf-profile.json` (standalone xcsh-owned file). Removes namespace collision risk and sf CLI sync leakage to `.sfdx/`.
29
+ - Eliminated 3x copy-pasted org-list normalization blocks in `sf.ts` by extracting `collectAllOrgs()` helper
30
+ - Updated `sf-query.md` template queries with SOQL aliases, `LIMIT 50` clauses, and `Owner.Name` instead of raw `OwnerId`
31
+ - Updated `sf-setup.md` to clarify `set_default` action requires `org` parameter with a valid alias pattern
32
+
5
33
  ## [18.30.4] - 2026-05-01
6
34
 
7
35
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.38.2",
4
+ "version": "18.40.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.38.2",
52
- "@f5xc-salesdemos/pi-agent-core": "18.38.2",
53
- "@f5xc-salesdemos/pi-ai": "18.38.2",
54
- "@f5xc-salesdemos/pi-natives": "18.38.2",
55
- "@f5xc-salesdemos/pi-tui": "18.38.2",
56
- "@f5xc-salesdemos/pi-utils": "18.38.2",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.40.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.40.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.40.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.40.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.40.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.40.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -1339,6 +1339,17 @@ export const SETTINGS_SCHEMA = {
1339
1339
  },
1340
1340
  },
1341
1341
 
1342
+ "salesforce.enabled": {
1343
+ type: "boolean",
1344
+ default: true,
1345
+ ui: {
1346
+ tab: "tools",
1347
+ label: "Salesforce CLI",
1348
+ description:
1349
+ "Enable sf_* tools for Salesforce org management, SOQL queries, and pipeline reporting via sf CLI",
1350
+ },
1351
+ },
1352
+
1342
1353
  "web_search.enabled": {
1343
1354
  type: "boolean",
1344
1355
  default: true,
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.38.2",
21
- "commit": "00adc74a7c09a600140ce7b87be1ba50aaa93a56",
22
- "shortCommit": "00adc74",
20
+ "version": "18.40.0",
21
+ "commit": "888fc5b0eb94508573e6749ccde9407eb82c348b",
22
+ "shortCommit": "888fc5b",
23
23
  "branch": "main",
24
- "tag": "v18.38.2",
25
- "commitDate": "2026-05-04T20:26:29Z",
26
- "buildDate": "2026-05-04T20:50:41.251Z",
24
+ "tag": "v18.40.0",
25
+ "commitDate": "2026-05-05T03:53:27Z",
26
+ "buildDate": "2026-05-05T04:35:21.252Z",
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/00adc74a7c09a600140ce7b87be1ba50aaa93a56",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.38.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/888fc5b0eb94508573e6749ccde9407eb82c348b",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.40.0"
33
33
  };
@@ -232,3 +232,101 @@ export async function checkGitLabStatus(cwd: string): Promise<WelcomeGitLabStatu
232
232
  return { state: "not_configured" };
233
233
  }
234
234
  }
235
+
236
+ export type SalesforceCheckState = "connected" | "auth_error" | "session_expired" | "not_configured";
237
+
238
+ export interface WelcomeSalesforceStatus {
239
+ state: SalesforceCheckState;
240
+ username?: string;
241
+ orgAlias?: string;
242
+ instanceUrl?: string;
243
+ }
244
+
245
+ /** Idempotent startup check: sf installed -> org list -> default org -> display status. */
246
+ export async function checkSalesforceStatus(_cwd: string): Promise<WelcomeSalesforceStatus | undefined> {
247
+ try {
248
+ if (!$which("sf")) return undefined;
249
+
250
+ // Suppress telemetry consent nag (idempotent)
251
+ await $`sf config set disable-telemetry true --global`.quiet().nothrow();
252
+
253
+ // Step 1: Get org list
254
+ const listResult = await $`sf org list --json`.quiet().nothrow();
255
+ if (listResult.exitCode !== 0) return { state: "auth_error" };
256
+
257
+ let listData: {
258
+ result?: {
259
+ nonScratchOrgs?: unknown[];
260
+ sandboxes?: unknown[];
261
+ scratchOrgs?: unknown[];
262
+ devHubs?: unknown[];
263
+ other?: unknown[];
264
+ };
265
+ };
266
+ try {
267
+ listData = JSON.parse(listResult.text());
268
+ } catch {
269
+ return { state: "auth_error" };
270
+ }
271
+
272
+ const r = listData.result ?? {};
273
+ const seen = new Set<string>();
274
+ const allRawOrgs = (
275
+ [
276
+ ...(r.nonScratchOrgs ?? []),
277
+ ...(r.sandboxes ?? []),
278
+ ...(r.scratchOrgs ?? []),
279
+ ...(r.devHubs ?? []),
280
+ ...(r.other ?? []),
281
+ ] as Record<string, unknown>[]
282
+ ).filter(org => {
283
+ const id = String(org.orgId ?? org.orgid ?? "");
284
+ if (!id || seen.has(id)) return false;
285
+ seen.add(id);
286
+ return true;
287
+ });
288
+
289
+ if (allRawOrgs.length === 0) return { state: "auth_error" };
290
+
291
+ // Step 2: Find default org (normalize raw CLI fields)
292
+ const defaultRaw = allRawOrgs.find(
293
+ org =>
294
+ (typeof org.defaultMarker === "string" && org.defaultMarker.includes("(U)")) ||
295
+ org.isDefaultUsername === true,
296
+ );
297
+
298
+ if (!defaultRaw) {
299
+ return { state: "not_configured", username: allRawOrgs[0]?.username as string | undefined };
300
+ }
301
+
302
+ const alias = (defaultRaw.alias ?? defaultRaw.username) as string;
303
+
304
+ // Step 3: Display org details
305
+ const displayResult = await $`sf org display --target-org ${alias} --json`.quiet().nothrow();
306
+ if (displayResult.exitCode !== 0) {
307
+ return { state: "session_expired", username: defaultRaw.username as string | undefined, orgAlias: alias };
308
+ }
309
+
310
+ let displayData: { result?: Record<string, unknown> };
311
+ try {
312
+ displayData = JSON.parse(displayResult.text());
313
+ } catch {
314
+ return { state: "session_expired", username: defaultRaw.username as string | undefined, orgAlias: alias };
315
+ }
316
+
317
+ const result = displayData.result;
318
+ if (!result || result.connectedStatus !== "Connected") {
319
+ return { state: "session_expired", username: defaultRaw.username as string | undefined, orgAlias: alias };
320
+ }
321
+
322
+ return {
323
+ state: "connected",
324
+ username: result.username as string | undefined,
325
+ orgAlias: alias,
326
+ instanceUrl: result.instanceUrl as string | undefined,
327
+ };
328
+ } catch (err) {
329
+ logger.warn("Salesforce startup check failed", { error: String(err) });
330
+ return { state: "auth_error" };
331
+ }
332
+ }
@@ -2,7 +2,7 @@ import { type Component, padding, truncateToWidth, visibleWidth } from "@f5xc-sa
2
2
  import { APP_NAME } from "@f5xc-salesdemos/pi-utils";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
  import { formatStatusIcon } from "../../services/f5xc-context-indicators";
5
- import type { ModelStatus, WelcomeContextStatus, WelcomeGitLabStatus } from "./welcome-checks";
5
+ import type { ModelStatus, WelcomeContextStatus, WelcomeGitLabStatus, WelcomeSalesforceStatus } from "./welcome-checks";
6
6
 
7
7
  export interface UpdateStatus {
8
8
  available: boolean;
@@ -22,6 +22,7 @@ export class WelcomeComponent implements Component {
22
22
  private updateStatus?: UpdateStatus,
23
23
  private changelogStatus?: ChangelogStatus,
24
24
  private gitlabStatus?: WelcomeGitLabStatus,
25
+ private salesforceStatus?: WelcomeSalesforceStatus,
25
26
  ) {}
26
27
  invalidate(): void {}
27
28
  setModelStatus(status: ModelStatus): void {
@@ -39,6 +40,9 @@ export class WelcomeComponent implements Component {
39
40
  setGitLabStatus(status: WelcomeGitLabStatus | undefined): void {
40
41
  this.gitlabStatus = status;
41
42
  }
43
+ setSalesforceStatus(status: WelcomeSalesforceStatus | undefined): void {
44
+ this.salesforceStatus = status;
45
+ }
42
46
 
43
47
  render(termWidth: number): string[] {
44
48
  const minLeftCol = 48;
@@ -137,6 +141,9 @@ export class WelcomeComponent implements Component {
137
141
  if (this.gitlabStatus) {
138
142
  lines.push(" GitLab", ...this.#renderGitLabStatus());
139
143
  }
144
+ if (this.salesforceStatus) {
145
+ lines.push(" Salesforce", ...this.#renderSalesforceStatus());
146
+ }
140
147
  if (this.#showUpdateSection()) {
141
148
  lines.push(" Update Available", ...this.#renderUpdateStatus());
142
149
  }
@@ -168,6 +175,13 @@ export class WelcomeComponent implements Component {
168
175
  lines.push(...this.#renderGitLabStatus());
169
176
  lines.push("");
170
177
  }
178
+ if (this.salesforceStatus) {
179
+ lines.push(separator);
180
+ lines.push("");
181
+ lines.push(` ${theme.bold(theme.fg("contentAccent", "Salesforce"))}`);
182
+ lines.push(...this.#renderSalesforceStatus());
183
+ lines.push("");
184
+ }
171
185
  if (this.#showUpdateSection()) {
172
186
  lines.push(separator);
173
187
  lines.push("");
@@ -215,7 +229,7 @@ export class WelcomeComponent implements Component {
215
229
  const p = provider ?? "unknown";
216
230
  switch (state) {
217
231
  case "connected":
218
- return [` ${formatStatusIcon("connected")} ${theme.fg("muted", p)} ${theme.fg("dim", "\u2014 connected")}`];
232
+ return [` ${formatStatusIcon("connected")} ${theme.fg("muted", p)}`];
219
233
  case "auth_error":
220
234
  return [
221
235
  ` ${formatStatusIcon("error")} ${theme.fg("muted", p)} ${theme.fg("error", "\u2014 connection failed")}`,
@@ -235,7 +249,7 @@ export class WelcomeComponent implements Component {
235
249
  const n = name ?? "(unknown)";
236
250
  switch (state) {
237
251
  case "connected":
238
- return [` ${formatStatusIcon("connected")} ${theme.fg("muted", n)} ${theme.fg("dim", "\u2014 connected")}`];
252
+ return [` ${formatStatusIcon("connected")} ${theme.fg("muted", n)}`];
239
253
  case "auth_error":
240
254
  return [
241
255
  ` ${formatStatusIcon("error")} ${theme.fg("muted", n)} ${theme.fg("error", "\u2014 token invalid")}`,
@@ -265,9 +279,7 @@ export class WelcomeComponent implements Component {
265
279
  const { state, project } = this.gitlabStatus;
266
280
  switch (state) {
267
281
  case "connected":
268
- return [
269
- ` ${formatStatusIcon("connected")} ${theme.fg("muted", project ?? "configured")} ${theme.fg("dim", "\u2014 connected")}`,
270
- ];
282
+ return [` ${formatStatusIcon("connected")} ${theme.fg("muted", project ?? "configured")}`];
271
283
  case "auth_error":
272
284
  return [
273
285
  ` ${formatStatusIcon("error")} ${theme.fg("error", "Not authenticated")}`,
@@ -288,6 +300,32 @@ export class WelcomeComponent implements Component {
288
300
  }
289
301
  }
290
302
 
303
+ #renderSalesforceStatus(): string[] {
304
+ if (!this.salesforceStatus) return [];
305
+ const { state, username, orgAlias } = this.salesforceStatus;
306
+ switch (state) {
307
+ case "connected":
308
+ return [
309
+ ` ${formatStatusIcon("connected")} ${theme.fg("muted", orgAlias ?? "org")}${username ? ` ${theme.fg("dim", `(${username})`)}` : ""}`,
310
+ ];
311
+ case "not_configured":
312
+ return [
313
+ ` ${formatStatusIcon("warning")} ${theme.fg("warning", "Authenticated (no default org)")}`,
314
+ ` ${theme.fg("dim", "Run")} ${theme.fg("contentAccent", "sf_setup")} ${theme.fg("dim", "with action set_default")}`,
315
+ ];
316
+ case "auth_error":
317
+ return [
318
+ ` ${formatStatusIcon("error")} ${theme.fg("error", "Not authenticated")}`,
319
+ ` ${theme.fg("dim", "Run")} ${theme.fg("contentAccent", "sf org login web --set-default --alias SFDC")}`,
320
+ ];
321
+ case "session_expired":
322
+ return [
323
+ ` ${formatStatusIcon("warning")} ${theme.fg("muted", orgAlias ?? "org")} ${theme.fg("warning", "— session expired")}`,
324
+ ` ${theme.fg("dim", "Re-authenticate with")} ${theme.fg("contentAccent", "sf org login web --set-default")}`,
325
+ ];
326
+ }
327
+ }
328
+
291
329
  #f5ColorLine(line: string): string {
292
330
  const red = "\x1b[38;5;160m";
293
331
  const white = "\x1b[1;37m";
@@ -50,7 +50,7 @@ import type { PythonExecutionComponent } from "./components/python-execution";
50
50
  import { StatusLineComponent } from "./components/status-line";
51
51
  import type { ToolExecutionHandle } from "./components/tool-execution";
52
52
  import { type ChangelogStatus, type UpdateStatus, WelcomeComponent } from "./components/welcome";
53
- import { checkGitLabStatus, runWelcomeChecks } from "./components/welcome-checks";
53
+ import { checkGitLabStatus, checkSalesforceStatus, runWelcomeChecks } from "./components/welcome-checks";
54
54
  import { BtwController } from "./controllers/btw-controller";
55
55
  import { CommandController } from "./controllers/command-controller";
56
56
  import { EventController } from "./controllers/event-controller";
@@ -310,12 +310,13 @@ export class InteractiveMode implements InteractiveModeContext {
310
310
  getProjectDir(),
311
311
  );
312
312
 
313
- // Run blocking welcome screen status checks (model + context + gitlab) in parallel
314
- const [welcomeResult, gitlabStatus] = await Promise.all([
313
+ // Run blocking welcome screen status checks (model + context + gitlab + salesforce) in parallel
314
+ const [welcomeResult, gitlabStatus, salesforceStatus] = await Promise.all([
315
315
  logger.time("InteractiveMode.init:welcomeChecks", () =>
316
316
  runWelcomeChecks(this.session.model, this.session.modelRegistry.authStorage),
317
317
  ),
318
318
  checkGitLabStatus(getProjectDir()).catch(() => undefined),
319
+ checkSalesforceStatus(getProjectDir()).catch(() => undefined),
319
320
  ]);
320
321
 
321
322
  const startupQuiet = settings.get("startup.quiet");
@@ -336,6 +337,7 @@ export class InteractiveMode implements InteractiveModeContext {
336
337
  this.#initialUpdateStatus,
337
338
  this.#changelogStatus,
338
339
  gitlabStatus,
340
+ salesforceStatus,
339
341
  );
340
342
 
341
343
  // Setup UI layout
@@ -0,0 +1,7 @@
1
+ Display safe metadata about a Salesforce org via sf CLI.
2
+
3
+ <instruction>
4
+ Returns only safe fields: username, orgId, instanceUrl, connectedStatus, alias.
5
+ NEVER return access tokens, client IDs, refresh tokens, or the raw sf org display JSON.
6
+ Use to verify org connectivity before running queries.
7
+ </instruction>
@@ -0,0 +1,24 @@
1
+ Execute SOQL queries against Salesforce via sf CLI. Returns structured results as markdown tables.
2
+
3
+ <instruction>
4
+ Use for pipeline reporting, case management, account intelligence, and ad-hoc data queries.
5
+
6
+ Common query templates (substitute {userId} from cached xcsh.user.id in ~/.sf/config.json):
7
+
8
+ Pipeline summary:
9
+ SELECT StageName, COUNT(Id) TotalDeals, SUM(Amount) TotalAmount FROM Opportunity WHERE IsClosed = false GROUP BY StageName ORDER BY SUM(Amount) DESC LIMIT 50
10
+
11
+ My open deals:
12
+ SELECT Name, StageName, Amount, CloseDate, Account.Name FROM Opportunity WHERE OwnerId = '{userId}' AND IsClosed = false ORDER BY CloseDate LIMIT 50
13
+
14
+ Open cases:
15
+ SELECT CaseNumber, Subject, Status, Priority, Account.Name, CreatedDate FROM Case WHERE IsClosed = false ORDER BY Priority, CreatedDate DESC LIMIT 50
16
+
17
+ Account overview:
18
+ SELECT Name, Industry, AnnualRevenue, Type, Owner.Name FROM Account WHERE Type = 'Customer' ORDER BY AnnualRevenue DESC LIMIT 50
19
+
20
+ Results with relationship fields (e.g., Account.Name) are automatically flattened into dot-notation columns.
21
+ If the query returns more than 10,000 records, suggest using sf data export bulk instead.
22
+ Set use_tooling_api to true when querying metadata objects (ApexTrigger, ApexClass, CustomField).
23
+ Set all_rows to true to include deleted or archived records in results.
24
+ </instruction>
@@ -0,0 +1,10 @@
1
+ Salesforce onboarding wizard via sf CLI. Check installation, detect authentication, guide login, extract user profile.
2
+
3
+ <instruction>
4
+ Actions: "check" (verify sf installed), "status" (auth + default org + profile), "login" (detect auth and prompt user with login command), "list_orgs" (show all orgs), "set_default" (switch default org — requires org parameter with a valid alias), "profile" (extract and cache user profile via SOQL).
5
+
6
+ When the user first asks about Salesforce, pipeline, cases, or accounts and no org is authenticated, run check, then status, then login if needed, then profile after auth is confirmed. The login action shows the user the exact command to run (sf org login web --set-default --alias SFDC for workstations, or echo "$SFDX_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin=- --set-default --alias f5 for containers).
7
+
8
+ The login action does NOT execute authentication. It detects state and tells the user what command to run.
9
+ The profile action runs a SOQL query against the User object and caches results as xcsh.user.* keys in ~/.sf/config.json.
10
+ </instruction>
@@ -65,7 +65,7 @@ export function formatIssueDetail(issue: GlabIssue): string {
65
65
  for (const note of humanNotes) {
66
66
  lines.push("");
67
67
  lines.push(`**@${note.author.username}** (${formatDate(note.created_at)}):`);
68
- lines.push("> " + note.body.split("\n").join("\n> "));
68
+ lines.push(`> ${note.body.split("\n").join("\n> ")}`);
69
69
  }
70
70
  }
71
71
 
@@ -53,6 +53,7 @@ import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue"
53
53
  import { ResolveTool } from "./resolve";
54
54
  import { reportFindingTool } from "./review";
55
55
  import { SearchToolBm25Tool } from "./search-tool-bm25";
56
+ import { SfOrgDisplayTool, SfQueryTool, SfSetupTool } from "./sf";
56
57
  import { loadSshTool } from "./ssh";
57
58
  import { SubmitResultTool } from "./submit-result";
58
59
  import { type TodoPhase, TodoWriteTool } from "./todo-write";
@@ -235,6 +236,9 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
235
236
  glab_issue_list: GlabIssueListTool.createIf,
236
237
  glab_issue_view: GlabIssueViewTool.createIf,
237
238
  glab_search: GlabSearchTool.createIf,
239
+ sf_setup: SfSetupTool.createIf,
240
+ sf_query: SfQueryTool.createIf,
241
+ sf_org_display: SfOrgDisplayTool.createIf,
238
242
  find: s => new FindTool(s),
239
243
  grep: s => new GrepTool(s),
240
244
  lsp: LspTool.createIf,
@@ -405,6 +409,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
405
409
  if (name === "grep") return session.settings.get("grep.enabled");
406
410
  if (name.startsWith("gh_")) return session.settings.get("github.enabled");
407
411
  if (name.startsWith("glab_")) return session.settings.get("gitlab.enabled");
412
+ if (name.startsWith("sf_")) return session.settings.get("salesforce.enabled");
408
413
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
409
414
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
410
415
  if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
@@ -0,0 +1,44 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { isEnoent } from "@f5xc-salesdemos/pi-utils";
4
+ import type { SfUserProfile } from "./types";
5
+
6
+ export function getProfilePath(): string {
7
+ const home = process.env.HOME || process.env.USERPROFILE || "";
8
+ return path.join(home, ".xcsh", "sf-profile.json");
9
+ }
10
+
11
+ export async function loadUserProfile(): Promise<SfUserProfile | null> {
12
+ try {
13
+ const raw = await fs.readFile(getProfilePath(), "utf8");
14
+ const profile = JSON.parse(raw) as SfUserProfile;
15
+ if (
16
+ !profile.userId ||
17
+ !profile.username ||
18
+ !profile.firstName ||
19
+ !profile.lastName ||
20
+ !profile.email ||
21
+ !profile.fetchedAt
22
+ ) {
23
+ return null;
24
+ }
25
+ return profile;
26
+ } catch (err) {
27
+ if (isEnoent(err)) return null;
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export async function saveUserProfile(profile: SfUserProfile): Promise<void> {
33
+ const profilePath = getProfilePath();
34
+ await fs.mkdir(path.dirname(profilePath), { recursive: true });
35
+ await fs.writeFile(profilePath, JSON.stringify(profile, null, 2), "utf8");
36
+ }
37
+
38
+ export async function clearUserProfile(): Promise<void> {
39
+ try {
40
+ await fs.unlink(getProfilePath());
41
+ } catch (err) {
42
+ if (!isEnoent(err)) throw err;
43
+ }
44
+ }
@@ -0,0 +1,104 @@
1
+ import type { SfJsonResult, SfRawResult } from "./types";
2
+
3
+ export type { SfRawResult } from "./types";
4
+
5
+ export class SfNotFoundError extends Error {
6
+ constructor() {
7
+ super("Salesforce CLI (sf) is not installed. Install with: brew install sf");
8
+ this.name = "SfNotFoundError";
9
+ }
10
+ }
11
+
12
+ export class SfAuthError extends Error {
13
+ constructor() {
14
+ super("No authenticated Salesforce orgs found. Run: sf org login web --set-default --alias SFDC");
15
+ this.name = "SfAuthError";
16
+ }
17
+ }
18
+
19
+ export class SfSessionExpiredError extends Error {
20
+ constructor() {
21
+ super("Salesforce session expired. Re-authenticate with: sf org login web --set-default --alias SFDC");
22
+ this.name = "SfSessionExpiredError";
23
+ }
24
+ }
25
+
26
+ export class SfNoDefaultOrgError extends Error {
27
+ constructor() {
28
+ super(
29
+ "Authenticated orgs exist but no default is set. Run sf_setup with action 'set_default' to choose a default org.",
30
+ );
31
+ this.name = "SfNoDefaultOrgError";
32
+ }
33
+ }
34
+
35
+ export class SfExecError extends Error {
36
+ constructor(
37
+ message: string,
38
+ readonly exitCode: number,
39
+ ) {
40
+ super(`sf CLI error (exit ${exitCode}): ${message}`);
41
+ this.name = "SfExecError";
42
+ }
43
+ }
44
+
45
+ export class SfQueryError extends SfExecError {
46
+ constructor(
47
+ message: string,
48
+ readonly query: string,
49
+ ) {
50
+ super(message, 1);
51
+ this.name = "SfQueryError";
52
+ }
53
+ }
54
+
55
+ export function detectSfError(message: string, exitCode: number, query?: string): Error {
56
+ const lower = message.toLowerCase();
57
+ if (lower.includes("invalid_session_id")) {
58
+ return new SfSessionExpiredError();
59
+ }
60
+ if (lower.includes("no default org")) {
61
+ return new SfNoDefaultOrgError();
62
+ }
63
+ if (lower.includes("no orgs found")) {
64
+ return new SfAuthError();
65
+ }
66
+ if ((lower.includes("malformed_query") || lower.includes("invalid_field")) && query !== undefined) {
67
+ return new SfQueryError(message, query);
68
+ }
69
+ return new SfExecError(message, exitCode);
70
+ }
71
+
72
+ export function parseSfJsonOutput(raw: string): SfJsonResult {
73
+ try {
74
+ return JSON.parse(raw) as SfJsonResult;
75
+ } catch {
76
+ throw new SfExecError("Failed to parse sf CLI JSON output", 1);
77
+ }
78
+ }
79
+
80
+ export interface SfExecApi {
81
+ exec(command: string, args: string[], options?: { signal?: AbortSignal }): Promise<SfRawResult>;
82
+ }
83
+
84
+ export async function execSfJson(
85
+ api: SfExecApi,
86
+ args: string[],
87
+ signal?: AbortSignal,
88
+ query?: string,
89
+ ): Promise<SfJsonResult> {
90
+ const result = await api.exec("sf", [...args, "--json"], { signal });
91
+ const parsed = parseSfJsonOutput(result.stdout);
92
+ if (parsed.status !== 0 && parsed.message !== undefined) {
93
+ throw detectSfError(parsed.message, parsed.status, query);
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ export async function execSfRaw(api: SfExecApi, args: string[], signal?: AbortSignal): Promise<SfRawResult> {
99
+ const result = await api.exec("sf", args, { signal });
100
+ if (result.exitCode !== 0) {
101
+ throw detectSfError(result.stderr || result.stdout, result.exitCode);
102
+ }
103
+ return result;
104
+ }
@@ -0,0 +1,150 @@
1
+ import type { SfOrg, SfQueryResult, SfUserProfile } from "./types";
2
+
3
+ export function formatOrgTable(orgs: SfOrg[]): string {
4
+ if (orgs.length === 0) {
5
+ return "No authenticated orgs found.";
6
+ }
7
+
8
+ const header = "| Alias | Username | Org ID | Instance | Status |";
9
+ const divider = "|-------|----------|--------|----------|--------|";
10
+
11
+ const rows = orgs.map(org => {
12
+ const alias = org.alias
13
+ ? org.isDefault
14
+ ? `${org.alias} (default)`
15
+ : org.alias
16
+ : org.isDefault
17
+ ? "(none) (default)"
18
+ : "(none)";
19
+ return `| ${alias} | ${org.username} | ${org.orgId} | ${org.instanceUrl} | ${org.connectedStatus} |`;
20
+ });
21
+
22
+ return [header, divider, ...rows].join("\n");
23
+ }
24
+
25
+ export function formatOrgDetail(org: SfOrg): string {
26
+ const lines: string[] = [];
27
+
28
+ lines.push(`**${org.alias || org.username}**`);
29
+ lines.push(`Username: ${org.username}`);
30
+ lines.push(`Org ID: ${org.orgId}`);
31
+ lines.push(`Instance: ${org.instanceUrl}`);
32
+ lines.push(`Status: ${org.connectedStatus}`);
33
+
34
+ if (org.isDefault) {
35
+ lines.push("Default: yes");
36
+ }
37
+
38
+ if (org.isSandbox) {
39
+ lines.push("Type: Sandbox");
40
+ }
41
+
42
+ return lines.join("\n");
43
+ }
44
+
45
+ export function flattenRecord(record: Record<string, unknown>): Record<string, unknown> {
46
+ const result: Record<string, unknown> = {};
47
+
48
+ for (const [key, value] of Object.entries(record)) {
49
+ if (key === "attributes") {
50
+ continue;
51
+ }
52
+
53
+ if (value === null) {
54
+ continue;
55
+ }
56
+
57
+ if (typeof value === "object" && !Array.isArray(value)) {
58
+ const nested = value as Record<string, unknown>;
59
+ for (const [nestedKey, nestedValue] of Object.entries(nested)) {
60
+ if (nestedKey === "attributes") {
61
+ continue;
62
+ }
63
+ result[`${key}.${nestedKey}`] = nestedValue;
64
+ }
65
+ continue;
66
+ }
67
+
68
+ result[key] = value;
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ export function formatQueryResults(result: SfQueryResult): string {
75
+ if (result.records.length === 0) {
76
+ return "No records found.";
77
+ }
78
+
79
+ const flatRecords = result.records.map(r => flattenRecord(r as Record<string, unknown>));
80
+
81
+ const allColumns = Array.from(
82
+ flatRecords.reduce((cols, record) => {
83
+ for (const key of Object.keys(record)) {
84
+ cols.add(key);
85
+ }
86
+ return cols;
87
+ }, new Set<string>()),
88
+ );
89
+
90
+ const header = `| ${allColumns.join(" | ")} |`;
91
+ const divider = `| ${allColumns.map(() => "---").join(" | ")} |`;
92
+
93
+ const rows = flatRecords.map(record => {
94
+ const cells = allColumns.map(col => {
95
+ const val = record[col];
96
+ return val === null || val === undefined ? "" : String(val);
97
+ });
98
+ return `| ${cells.join(" | ")} |`;
99
+ });
100
+
101
+ return `${result.totalSize} records returned.\n\n${[header, divider, ...rows].join("\n")}`;
102
+ }
103
+
104
+ export function formatUserProfile(profile: SfUserProfile): string {
105
+ const lines: string[] = [];
106
+
107
+ lines.push(`**${profile.firstName} ${profile.lastName}** (${profile.username})`);
108
+
109
+ if (profile.title) {
110
+ lines.push(`Title: ${profile.title}`);
111
+ }
112
+
113
+ if (profile.department) {
114
+ lines.push(`Department: ${profile.department}`);
115
+ }
116
+
117
+ if (profile.division) {
118
+ lines.push(`Division: ${profile.division}`);
119
+ }
120
+
121
+ if (profile.role) {
122
+ lines.push(`Role: ${profile.role}`);
123
+ }
124
+
125
+ if (profile.profile) {
126
+ lines.push(`Profile: ${profile.profile}`);
127
+ }
128
+
129
+ if (profile.aboutMe) {
130
+ lines.push(`About: ${profile.aboutMe}`);
131
+ }
132
+
133
+ if (profile.managerName) {
134
+ const managerLine = profile.managerEmail
135
+ ? `Manager: ${profile.managerName} (${profile.managerEmail})`
136
+ : `Manager: ${profile.managerName}`;
137
+ lines.push(managerLine);
138
+ }
139
+
140
+ if (profile.phone) {
141
+ lines.push(`Phone: ${profile.phone}`);
142
+ }
143
+
144
+ const locationParts = [profile.city, profile.state, profile.country].filter(Boolean);
145
+ if (locationParts.length > 0) {
146
+ lines.push(`Location: ${locationParts.join(", ")}`);
147
+ }
148
+
149
+ return lines.join("\n");
150
+ }
@@ -0,0 +1,67 @@
1
+ export interface SfUserProfile {
2
+ userId: string;
3
+ username: string;
4
+ firstName: string;
5
+ lastName: string;
6
+ email: string;
7
+ title?: string;
8
+ department?: string;
9
+ division?: string;
10
+ role?: string;
11
+ profile?: string;
12
+ aboutMe?: string;
13
+ companyName?: string;
14
+ managerId?: string;
15
+ managerName?: string;
16
+ managerEmail?: string;
17
+ phone?: string;
18
+ street?: string;
19
+ city?: string;
20
+ state?: string;
21
+ postalCode?: string;
22
+ country?: string;
23
+ fetchedAt: string;
24
+ }
25
+
26
+ export interface SfOrg {
27
+ alias?: string;
28
+ username: string;
29
+ orgId: string;
30
+ instanceUrl: string;
31
+ connectedStatus: string;
32
+ isDefault: boolean;
33
+ isSandbox: boolean;
34
+ }
35
+
36
+ export interface SfQueryResult<T = Record<string, unknown>> {
37
+ totalSize: number;
38
+ done: boolean;
39
+ records: T[];
40
+ }
41
+
42
+ export interface SfOrgListResult {
43
+ nonScratchOrgs: SfOrg[];
44
+ sandboxes: SfOrg[];
45
+ scratchOrgs: SfOrg[];
46
+ devHubs: SfOrg[];
47
+ other: SfOrg[];
48
+ }
49
+
50
+ export interface SfJsonResult {
51
+ status: number;
52
+ result: unknown;
53
+ message?: string;
54
+ warnings?: string[];
55
+ }
56
+
57
+ export interface SfRawResult {
58
+ stdout: string;
59
+ stderr: string;
60
+ exitCode: number;
61
+ }
62
+
63
+ export const SF_ORG_SAFE_FIELDS = ["username", "orgId", "instanceUrl", "connectedStatus", "alias"] as const;
64
+
65
+ export const USER_PROFILE_SOQL = `SELECT Id, Username, FirstName, LastName, Email, Title, Department, Division, CompanyName, AboutMe, ManagerId, Manager.Name, Manager.Email, UserRole.Name, Profile.Name, Street, City, State, PostalCode, Country, Phone, MobilePhone FROM User WHERE Username = '{username}'`;
66
+
67
+ export const ORG_ALIAS_PATTERN = /^[a-zA-Z0-9._@-]+$/;
@@ -0,0 +1,405 @@
1
+ import type {
2
+ AgentTool,
3
+ AgentToolContext,
4
+ AgentToolResult,
5
+ AgentToolUpdateCallback,
6
+ } from "@f5xc-salesdemos/pi-agent-core";
7
+ import { $which, prompt } from "@f5xc-salesdemos/pi-utils";
8
+ import { type Static, Type } from "@sinclair/typebox";
9
+ import sfOrgDisplayDescription from "../prompts/tools/sf-org-display.md" with { type: "text" };
10
+ import sfQueryDescription from "../prompts/tools/sf-query.md" with { type: "text" };
11
+ import sfSetupDescription from "../prompts/tools/sf-setup.md" with { type: "text" };
12
+ import type { ToolSession } from ".";
13
+ import { loadUserProfile, saveUserProfile } from "./sf/config";
14
+ import type { SfExecApi } from "./sf/exec";
15
+ import { execSfJson, execSfRaw } from "./sf/exec";
16
+ import { formatOrgDetail, formatOrgTable, formatQueryResults, formatUserProfile } from "./sf/formatters";
17
+ import type { SfOrg, SfQueryResult, SfRawResult, SfUserProfile } from "./sf/types";
18
+ import { ORG_ALIAS_PATTERN, USER_PROFILE_SOQL } from "./sf/types";
19
+
20
+ function makeExecApi(cwd: string): SfExecApi {
21
+ return {
22
+ async exec(command: string, args: string[], _options?: { signal?: AbortSignal }): Promise<SfRawResult> {
23
+ // Never pass signal to Bun.spawn and never pre-check signal.aborted.
24
+ // sf commands finish in 1-5s. Passing the signal or pre-checking causes
25
+ // false cancellations when xcsh's AbortSignal fires between multi-turn
26
+ // tool calls (the signal is stale from a prior turn).
27
+ const child = Bun.spawn([command, ...args], {
28
+ cwd,
29
+ stdin: "ignore",
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ if (!child.stdout || !child.stderr) {
34
+ return { stdout: "", stderr: "Failed to capture output", exitCode: 1 };
35
+ }
36
+ const [stdout, stderr, exitCode] = await Promise.all([
37
+ new Response(child.stdout).text(),
38
+ new Response(child.stderr).text(),
39
+ child.exited,
40
+ ]);
41
+ return {
42
+ stdout: stdout.trim(),
43
+ stderr: stderr.trim(),
44
+ exitCode: exitCode ?? 0,
45
+ };
46
+ },
47
+ };
48
+ }
49
+
50
+ // ─── Schemas ─────────────────────────────────────────────────────────────
51
+
52
+ const sfSetupSchema = Type.Object({
53
+ action: Type.Union(
54
+ [
55
+ Type.Literal("check"),
56
+ Type.Literal("status"),
57
+ Type.Literal("login"),
58
+ Type.Literal("list_orgs"),
59
+ Type.Literal("set_default"),
60
+ Type.Literal("profile"),
61
+ ],
62
+ { description: "Onboarding action to perform" },
63
+ ),
64
+ org: Type.Optional(Type.String({ description: "Org alias (used with set_default)" })),
65
+ });
66
+
67
+ const sfQuerySchema = Type.Object({
68
+ query: Type.String({ description: "SOQL query to execute" }),
69
+ target_org: Type.Optional(Type.String({ description: "Org alias or username to query against" })),
70
+ use_tooling_api: Type.Optional(
71
+ Type.Boolean({ description: "Use Tooling API to query metadata objects like ApexTrigger" }),
72
+ ),
73
+ all_rows: Type.Optional(Type.Boolean({ description: "Include deleted records in results" })),
74
+ });
75
+
76
+ const sfOrgDisplaySchema = Type.Object({
77
+ target_org: Type.Optional(Type.String({ description: "Org alias or username to display" })),
78
+ });
79
+
80
+ type SfSetupInput = Static<typeof sfSetupSchema>;
81
+ type SfQueryInput = Static<typeof sfQuerySchema>;
82
+ type SfOrgDisplayInput = Static<typeof sfOrgDisplaySchema>;
83
+
84
+ interface SfToolDetails {
85
+ orgs?: SfOrg[];
86
+ queryResult?: SfQueryResult;
87
+ profile?: SfUserProfile;
88
+ }
89
+
90
+ function textResult(text: string, details?: SfToolDetails): AgentToolResult<SfToolDetails> {
91
+ return { content: [{ type: "text", text }], details };
92
+ }
93
+
94
+ // ─── Helpers ─────────────────────────────────────────────────────────────
95
+
96
+ export function normalizeOrg(raw: Record<string, unknown>): SfOrg {
97
+ return {
98
+ alias: raw.alias as string | undefined,
99
+ username: raw.username as string,
100
+ orgId: (raw.orgId ?? raw.orgid) as string,
101
+ instanceUrl: raw.instanceUrl as string,
102
+ connectedStatus: (raw.connectedStatus ?? "Unknown") as string,
103
+ isDefault: Boolean(raw.isDefaultUsername) || String(raw.defaultMarker ?? "").includes("(U)"),
104
+ isSandbox: Boolean(raw.isSandbox),
105
+ };
106
+ }
107
+
108
+ function normalizeOrgList(rawOrgs: Record<string, unknown>[]): SfOrg[] {
109
+ return (rawOrgs ?? []).map(normalizeOrg);
110
+ }
111
+
112
+ export function collectAllOrgs(orgList: Record<string, unknown[]>): SfOrg[] {
113
+ const all = [
114
+ ...normalizeOrgList((orgList.nonScratchOrgs ?? []) as Record<string, unknown>[]),
115
+ ...normalizeOrgList((orgList.scratchOrgs ?? []) as Record<string, unknown>[]),
116
+ ...normalizeOrgList((orgList.sandboxes ?? []) as Record<string, unknown>[]),
117
+ ...normalizeOrgList((orgList.devHubs ?? []) as Record<string, unknown>[]),
118
+ ...normalizeOrgList((orgList.other ?? []) as Record<string, unknown>[]),
119
+ ];
120
+ const seen = new Set<string>();
121
+ return all.filter(org => {
122
+ if (seen.has(org.orgId)) return false;
123
+ seen.add(org.orgId);
124
+ return true;
125
+ });
126
+ }
127
+
128
+ function extractRelationshipField(
129
+ record: Record<string, unknown>,
130
+ relationship: string,
131
+ field: string,
132
+ ): string | undefined {
133
+ const related = record[relationship];
134
+ if (related && typeof related === "object" && !Array.isArray(related)) {
135
+ const value = (related as Record<string, unknown>)[field];
136
+ if (value) return String(value);
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ // ─── SfSetupTool ─────────────────────────────────────────────────────────
142
+
143
+ export class SfSetupTool implements AgentTool<typeof sfSetupSchema, SfToolDetails> {
144
+ readonly name = "sf_setup";
145
+ readonly label = "Salesforce Setup";
146
+ readonly description = prompt.render(sfSetupDescription);
147
+ readonly parameters = sfSetupSchema;
148
+
149
+ #testApi?: SfExecApi;
150
+ constructor(
151
+ readonly session: ToolSession,
152
+ testApi?: SfExecApi,
153
+ ) {
154
+ this.#testApi = testApi;
155
+ }
156
+
157
+ static createIf(session: ToolSession): SfSetupTool | null {
158
+ if (!$which("sf")) return null;
159
+ return new SfSetupTool(session);
160
+ }
161
+
162
+ async execute(
163
+ _toolCallId: string,
164
+ params: SfSetupInput,
165
+ signal?: AbortSignal,
166
+ _onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
167
+ _context?: AgentToolContext,
168
+ ): Promise<AgentToolResult<SfToolDetails>> {
169
+ const api = this.#testApi ?? makeExecApi(this.session.cwd);
170
+
171
+ switch (params.action) {
172
+ case "check": {
173
+ const result = await execSfRaw(api, ["--version"], signal);
174
+ return textResult(`sf is installed: ${result.stdout}`);
175
+ }
176
+
177
+ case "status": {
178
+ const orgResult = await execSfJson(api, ["org", "list"], signal);
179
+ const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
180
+ let output = formatOrgTable(allOrgs);
181
+
182
+ const cached = await loadUserProfile();
183
+ if (cached) {
184
+ output += `\n\nCached user profile: **${cached.firstName} ${cached.lastName}** (${cached.username}), fetched ${cached.fetchedAt}`;
185
+ }
186
+
187
+ return textResult(output, { orgs: allOrgs });
188
+ }
189
+
190
+ case "login": {
191
+ const orgResult = await execSfJson(api, ["org", "list"], signal);
192
+ const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
193
+ if (allOrgs.length > 0) {
194
+ return textResult("Already authenticated. Use 'profile' action to extract your user data.", {
195
+ orgs: allOrgs,
196
+ });
197
+ }
198
+ return textResult(
199
+ "No authenticated orgs found.\n\nRun one of these commands to authenticate:\n" +
200
+ "- **Workstation**: `sf org login web --set-default --alias SFDC`\n" +
201
+ '- **Container**: `echo "$SFDX_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin=- --set-default --alias f5`\n\n' +
202
+ "After authenticating, call sf_setup with action 'status' to confirm.",
203
+ );
204
+ }
205
+
206
+ case "list_orgs": {
207
+ const orgResult = await execSfJson(api, ["org", "list"], signal);
208
+ const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
209
+ return textResult(formatOrgTable(allOrgs), { orgs: allOrgs });
210
+ }
211
+
212
+ case "set_default": {
213
+ if (!params.org) {
214
+ return textResult("Error: org parameter is required for set_default action.");
215
+ }
216
+ if (!ORG_ALIAS_PATTERN.test(params.org)) {
217
+ return textResult(
218
+ `Error: invalid org alias "${params.org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
219
+ );
220
+ }
221
+ await execSfRaw(api, ["config", "set", "target-org", params.org, "--global"], signal);
222
+ return textResult(`Default org set to: **${params.org}**`);
223
+ }
224
+
225
+ case "profile": {
226
+ // Step 1: Get the current user's username from org display
227
+ const orgInfo = await execSfJson(api, ["org", "display"], signal);
228
+ const orgResult = orgInfo.result as Record<string, unknown>;
229
+ const username = orgResult.username as string;
230
+ if (!username) {
231
+ return textResult("Could not determine username from org display. Ensure a default org is set.");
232
+ }
233
+
234
+ // Step 2: Build and run SOQL query for user profile
235
+ const soql = USER_PROFILE_SOQL.replace("{username}", username);
236
+ const queryResult = await execSfJson(api, ["data", "query", "--query", soql], signal, soql);
237
+ const queryData = queryResult.result as SfQueryResult<Record<string, unknown>>;
238
+
239
+ if (!queryData.records || queryData.records.length === 0) {
240
+ return textResult(`No user record found for username: ${username}`);
241
+ }
242
+
243
+ const record = queryData.records[0];
244
+
245
+ // Step 3: Map SOQL fields to SfUserProfile
246
+ const profile: SfUserProfile = {
247
+ userId: String(record.Id ?? ""),
248
+ username: String(record.Username ?? ""),
249
+ firstName: String(record.FirstName ?? ""),
250
+ lastName: String(record.LastName ?? ""),
251
+ email: String(record.Email ?? ""),
252
+ title: record.Title ? String(record.Title) : undefined,
253
+ department: record.Department ? String(record.Department) : undefined,
254
+ division: record.Division ? String(record.Division) : undefined,
255
+ companyName: record.CompanyName ? String(record.CompanyName) : undefined,
256
+ aboutMe: record.AboutMe ? String(record.AboutMe) : undefined,
257
+ managerId: record.ManagerId ? String(record.ManagerId) : undefined,
258
+ managerName: extractRelationshipField(record, "Manager", "Name"),
259
+ managerEmail: extractRelationshipField(record, "Manager", "Email"),
260
+ role: extractRelationshipField(record, "UserRole", "Name"),
261
+ profile: extractRelationshipField(record, "Profile", "Name"),
262
+ phone: record.Phone || record.MobilePhone ? String(record.Phone ?? record.MobilePhone) : undefined,
263
+ street: record.Street ? String(record.Street) : undefined,
264
+ city: record.City ? String(record.City) : undefined,
265
+ state: record.State ? String(record.State) : undefined,
266
+ postalCode: record.PostalCode ? String(record.PostalCode) : undefined,
267
+ country: record.Country ? String(record.Country) : undefined,
268
+ fetchedAt: new Date().toISOString(),
269
+ };
270
+
271
+ // Step 4: Cache the profile
272
+ await saveUserProfile(profile);
273
+
274
+ return textResult(formatUserProfile(profile), { profile });
275
+ }
276
+
277
+ default:
278
+ return textResult(`Unknown action: ${params.action}`);
279
+ }
280
+ }
281
+ }
282
+
283
+ // ─── SfQueryTool ─────────────────────────────────────────────────────────
284
+
285
+ export class SfQueryTool implements AgentTool<typeof sfQuerySchema, SfToolDetails> {
286
+ readonly name = "sf_query";
287
+ readonly label = "Salesforce Query";
288
+ readonly description = prompt.render(sfQueryDescription);
289
+ readonly parameters = sfQuerySchema;
290
+
291
+ #testApi?: SfExecApi;
292
+ constructor(
293
+ readonly session: ToolSession,
294
+ testApi?: SfExecApi,
295
+ ) {
296
+ this.#testApi = testApi;
297
+ }
298
+
299
+ static createIf(session: ToolSession): SfQueryTool | null {
300
+ if (!$which("sf")) return null;
301
+ return new SfQueryTool(session);
302
+ }
303
+
304
+ async execute(
305
+ _toolCallId: string,
306
+ params: SfQueryInput,
307
+ signal?: AbortSignal,
308
+ _onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
309
+ _context?: AgentToolContext,
310
+ ): Promise<AgentToolResult<SfToolDetails>> {
311
+ const api = this.#testApi ?? makeExecApi(this.session.cwd);
312
+
313
+ if (params.target_org && !ORG_ALIAS_PATTERN.test(params.target_org)) {
314
+ return textResult(
315
+ `Error: invalid org alias "${params.target_org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
316
+ );
317
+ }
318
+
319
+ const args = ["data", "query", "--query", params.query];
320
+ if (params.target_org) {
321
+ args.push("--target-org", params.target_org);
322
+ }
323
+ if (params.use_tooling_api) {
324
+ args.push("--use-tooling-api");
325
+ }
326
+ if (params.all_rows) {
327
+ args.push("--all-rows");
328
+ }
329
+
330
+ const result = await execSfJson(api, args, signal, params.query);
331
+ const queryData = result.result as SfQueryResult<Record<string, unknown>>;
332
+
333
+ const queryResult: SfQueryResult = {
334
+ totalSize: queryData.totalSize ?? 0,
335
+ done: queryData.done ?? true,
336
+ records: queryData.records ?? [],
337
+ };
338
+
339
+ let output = formatQueryResults(queryResult);
340
+ if (!queryResult.done) {
341
+ output +=
342
+ "\n\n**Warning**: Results are incomplete. The query returned more records than the API limit. Use `sf data export bulk` for the full dataset.";
343
+ }
344
+ return textResult(output, { queryResult });
345
+ }
346
+ }
347
+
348
+ // ─── SfOrgDisplayTool ────────────────────────────────────────────────────
349
+
350
+ export class SfOrgDisplayTool implements AgentTool<typeof sfOrgDisplaySchema, SfToolDetails> {
351
+ readonly name = "sf_org_display";
352
+ readonly label = "Salesforce Org Display";
353
+ readonly description = prompt.render(sfOrgDisplayDescription);
354
+ readonly parameters = sfOrgDisplaySchema;
355
+
356
+ #testApi?: SfExecApi;
357
+ constructor(
358
+ readonly session: ToolSession,
359
+ testApi?: SfExecApi,
360
+ ) {
361
+ this.#testApi = testApi;
362
+ }
363
+
364
+ static createIf(session: ToolSession): SfOrgDisplayTool | null {
365
+ if (!$which("sf")) return null;
366
+ return new SfOrgDisplayTool(session);
367
+ }
368
+
369
+ async execute(
370
+ _toolCallId: string,
371
+ params: SfOrgDisplayInput,
372
+ signal?: AbortSignal,
373
+ _onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
374
+ _context?: AgentToolContext,
375
+ ): Promise<AgentToolResult<SfToolDetails>> {
376
+ const api = this.#testApi ?? makeExecApi(this.session.cwd);
377
+
378
+ if (params.target_org && !ORG_ALIAS_PATTERN.test(params.target_org)) {
379
+ return textResult(
380
+ `Error: invalid org alias "${params.target_org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
381
+ );
382
+ }
383
+
384
+ const args = ["org", "display"];
385
+ if (params.target_org) {
386
+ args.push("--target-org", params.target_org);
387
+ }
388
+
389
+ const result = await execSfJson(api, args, signal);
390
+ const raw = result.result as Record<string, unknown>;
391
+
392
+ // SECURITY: only extract whitelisted fields
393
+ const org: SfOrg = {
394
+ username: String(raw.username ?? ""),
395
+ orgId: String(raw.id ?? raw.orgId ?? ""),
396
+ instanceUrl: String(raw.instanceUrl ?? ""),
397
+ connectedStatus: String(raw.connectedStatus ?? "Connected"),
398
+ alias: raw.alias ? String(raw.alias) : undefined,
399
+ isDefault: false,
400
+ isSandbox: Boolean(raw.isSandbox ?? false),
401
+ };
402
+
403
+ return textResult(formatOrgDetail(org), { orgs: [org] });
404
+ }
405
+ }