@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 +28 -0
- package/package.json +7 -7
- package/src/config/settings-schema.ts +11 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/welcome-checks.ts +98 -0
- package/src/modes/components/welcome.ts +44 -6
- package/src/modes/interactive-mode.ts +5 -3
- package/src/prompts/tools/sf-org-display.md +7 -0
- package/src/prompts/tools/sf-query.md +24 -0
- package/src/prompts/tools/sf-setup.md +10 -0
- package/src/tools/glab/formatters.ts +1 -1
- package/src/tools/index.ts +5 -0
- package/src/tools/sf/config.ts +44 -0
- package/src/tools/sf/exec.ts +104 -0
- package/src/tools/sf/formatters.ts +150 -0
- package/src/tools/sf/types.ts +67 -0
- package/src/tools/sf.ts +405 -0
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.
|
|
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.
|
|
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.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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.40.0",
|
|
21
|
+
"commit": "888fc5b0eb94508573e6749ccde9407eb82c348b",
|
|
22
|
+
"shortCommit": "888fc5b",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
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)}
|
|
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)}
|
|
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(
|
|
68
|
+
lines.push(`> ${note.body.split("\n").join("\n> ")}`);
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
package/src/tools/index.ts
CHANGED
|
@@ -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._@-]+$/;
|
package/src/tools/sf.ts
ADDED
|
@@ -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
|
+
}
|