@f5xc-salesdemos/xcsh 18.41.1 → 18.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.41.1",
4
+ "version": "18.43.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.41.1",
52
- "@f5xc-salesdemos/pi-agent-core": "18.41.1",
53
- "@f5xc-salesdemos/pi-ai": "18.41.1",
54
- "@f5xc-salesdemos/pi-natives": "18.41.1",
55
- "@f5xc-salesdemos/pi-tui": "18.41.1",
56
- "@f5xc-salesdemos/pi-utils": "18.41.1",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.43.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.43.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.43.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.43.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.43.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.43.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -412,6 +412,8 @@ function formatFieldConstraints(prop: Record<string, unknown>): string {
412
412
  if (c.maxItems != null) parts.push(`maxItems: ${c.maxItems}`);
413
413
  if (c.format) parts.push(`format: ${c.format}`);
414
414
  if (Array.isArray(c.enum)) parts.push(`enum: ${(c.enum as string[]).join(", ")}`);
415
+ const meta = c.metadata as Record<string, unknown> | undefined;
416
+ if (meta?.note) parts.push(`note: ${String(meta.note)}`);
415
417
  return parts.join(", ");
416
418
  }
417
419
 
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.41.1",
21
- "commit": "7dc812d262a4be86c13968193803ffe2fb19b91b",
22
- "shortCommit": "7dc812d",
20
+ "version": "18.43.0",
21
+ "commit": "3b728b77f3756587bfb90eb1e264dc60890ef4bd",
22
+ "shortCommit": "3b728b7",
23
23
  "branch": "main",
24
- "tag": "v18.41.1",
25
- "commitDate": "2026-05-05T15:09:44Z",
26
- "buildDate": "2026-05-05T15:33:02.304Z",
24
+ "tag": "v18.43.0",
25
+ "commitDate": "2026-05-05T18:56:20Z",
26
+ "buildDate": "2026-05-05T19:16:23.079Z",
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/7dc812d262a4be86c13968193803ffe2fb19b91b",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.41.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/3b728b77f3756587bfb90eb1e264dc60890ef4bd",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.43.0"
33
33
  };
@@ -32,8 +32,10 @@ export * from "./local-protocol";
32
32
  export * from "./mcp-protocol";
33
33
  export * from "./memory-protocol";
34
34
  export * from "./parse";
35
+ export * from "./profile-collectors";
35
36
  export * from "./router";
36
37
  export * from "./rule-protocol";
37
38
  export * from "./skill-protocol";
38
39
  export type * from "./types";
40
+ export * from "./user-profile";
39
41
  export * from "./xcsh-protocol";
@@ -0,0 +1,228 @@
1
+ import { $which, logger } from "@f5xc-salesdemos/pi-utils";
2
+ import { $ } from "bun";
3
+
4
+ import type { UserProfile } from "./user-profile";
5
+
6
+ export interface ProfileCollector {
7
+ /** Unique identifier for this collector */
8
+ readonly id: string;
9
+ /** Human-readable name */
10
+ readonly name: string;
11
+ /** Check if this collector can run (binary exists, platform ok, etc.) */
12
+ available(): Promise<boolean>;
13
+ /** Run the collector and return partial profile fields to merge */
14
+ collect(): Promise<Partial<UserProfile>>;
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Salesforce
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const salesforceCollector: ProfileCollector = {
22
+ id: "salesforce",
23
+ name: "Salesforce",
24
+
25
+ async available(): Promise<boolean> {
26
+ if (!$which("sf")) return false;
27
+ try {
28
+ const proc = await $`sf org display --json`.quiet().nothrow();
29
+ if (proc.exitCode !== 0) return false;
30
+ const parsed = JSON.parse(proc.stdout.toString()) as Record<string, unknown>;
31
+ const result = parsed.result as Record<string, unknown> | undefined;
32
+ return typeof result?.username === "string" && result.username.length > 0;
33
+ } catch {
34
+ return false;
35
+ }
36
+ },
37
+
38
+ async collect(): Promise<Partial<UserProfile>> {
39
+ try {
40
+ // Get username
41
+ const orgProc = await $`sf org display --json`.quiet().nothrow();
42
+ if (orgProc.exitCode !== 0) return {};
43
+ const orgData = JSON.parse(orgProc.stdout.toString()) as Record<string, unknown>;
44
+ const orgResult = orgData.result as Record<string, unknown> | undefined;
45
+ const username = orgResult?.username as string | undefined;
46
+ if (!username) return {};
47
+
48
+ // Build and run SOQL
49
+ const 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}'`;
50
+ const queryProc = await $`sf data query --query ${soql} --json`.quiet().nothrow();
51
+ if (queryProc.exitCode !== 0) return {};
52
+
53
+ const queryData = JSON.parse(queryProc.stdout.toString()) as Record<string, unknown>;
54
+ const queryResult = queryData.result as Record<string, unknown> | undefined;
55
+ const records = queryResult?.records as Record<string, unknown>[] | undefined;
56
+ const rec = records?.[0];
57
+ if (!rec) return {};
58
+
59
+ // Map fields
60
+ const profile: Partial<UserProfile> = {};
61
+
62
+ if (rec.FirstName) profile.givenName = rec.FirstName as string;
63
+ if (rec.LastName) profile.familyName = rec.LastName as string;
64
+ if (rec.Email) profile.email = rec.Email as string;
65
+
66
+ const phone = (rec.Phone || rec.MobilePhone) as string | undefined;
67
+ if (phone) profile.telephone = phone;
68
+
69
+ if (rec.Title) profile.jobTitle = rec.Title as string;
70
+ if (rec.Department) profile.department = rec.Department as string;
71
+ if (rec.Division) profile.division = rec.Division as string;
72
+
73
+ const companyName = (rec.CompanyName as string) || "F5";
74
+ profile.worksFor = { name: companyName };
75
+
76
+ // Manager
77
+ const mgr = rec.Manager as Record<string, unknown> | undefined;
78
+ if (mgr) {
79
+ const mgrName = mgr.Name as string | undefined;
80
+ const mgrEmail = mgr.Email as string | undefined;
81
+ if (mgrName || mgrEmail) {
82
+ profile.manager = {};
83
+ if (mgrName) {
84
+ const parts = mgrName.split(" ");
85
+ profile.manager.givenName = parts[0];
86
+ if (parts.length > 1) profile.manager.familyName = parts.slice(1).join(" ");
87
+ }
88
+ if (mgrEmail) profile.manager.email = mgrEmail;
89
+ }
90
+ }
91
+
92
+ // Address
93
+ const street = rec.Street as string | undefined;
94
+ const city = rec.City as string | undefined;
95
+ const state = rec.State as string | undefined;
96
+ const postalCode = rec.PostalCode as string | undefined;
97
+ const country = rec.Country as string | undefined;
98
+ if (street || city || state || postalCode || country) {
99
+ profile.address = {};
100
+ if (street) profile.address.streetAddress = street;
101
+ if (city) profile.address.addressLocality = city;
102
+ if (state) profile.address.addressRegion = state;
103
+ if (postalCode) profile.address.postalCode = postalCode;
104
+ if (country) profile.address.addressCountry = country;
105
+ }
106
+
107
+ // Identifiers
108
+ if (rec.Id) {
109
+ profile.identifiers = { salesforceId: rec.Id as string };
110
+ }
111
+
112
+ return profile;
113
+ } catch (err: unknown) {
114
+ logger.debug("salesforce collector failed", { error: err });
115
+ return {};
116
+ }
117
+ },
118
+ };
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // GitHub
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const githubCollector: ProfileCollector = {
125
+ id: "github",
126
+ name: "GitHub",
127
+
128
+ async available(): Promise<boolean> {
129
+ if (!$which("gh")) return false;
130
+ try {
131
+ const proc = await $`gh auth status`.quiet().nothrow();
132
+ return proc.exitCode === 0;
133
+ } catch {
134
+ return false;
135
+ }
136
+ },
137
+
138
+ async collect(): Promise<Partial<UserProfile>> {
139
+ try {
140
+ const proc = await $`gh api user`.quiet().nothrow();
141
+ if (proc.exitCode !== 0) return {};
142
+
143
+ const data = JSON.parse(proc.stdout.toString()) as Record<string, unknown>;
144
+ const profile: Partial<UserProfile> = {};
145
+ const sameAs: string[] = [];
146
+
147
+ const login = data.login as string | undefined;
148
+ if (login) {
149
+ profile.identifiers = { ...profile.identifiers, github: login };
150
+ sameAs.push(`https://github.com/${login}`);
151
+ }
152
+
153
+ const name = data.name as string | undefined;
154
+ if (name) {
155
+ const parts = name.split(" ");
156
+ if (parts.length >= 2) {
157
+ profile.givenName = parts[0];
158
+ profile.familyName = parts.slice(1).join(" ");
159
+ }
160
+ }
161
+
162
+ const email = data.email as string | undefined;
163
+ if (email) profile.email = email;
164
+
165
+ const bio = data.bio as string | undefined;
166
+ if (bio) profile.description = bio;
167
+
168
+ const blog = data.blog as string | undefined;
169
+ if (blog) {
170
+ profile.url = blog;
171
+ sameAs.push(blog);
172
+ }
173
+
174
+ const twitterUsername = data.twitter_username as string | undefined;
175
+ if (twitterUsername) {
176
+ profile.identifiers = { ...profile.identifiers, twitter: twitterUsername };
177
+ sameAs.push(`https://x.com/${twitterUsername}`);
178
+ }
179
+
180
+ if (sameAs.length > 0) profile.sameAs = sameAs;
181
+
182
+ return profile;
183
+ } catch (err: unknown) {
184
+ logger.debug("github collector failed", { error: err });
185
+ return {};
186
+ }
187
+ },
188
+ };
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // System (macOS)
192
+ // ---------------------------------------------------------------------------
193
+
194
+ const systemCollector: ProfileCollector = {
195
+ id: "system",
196
+ name: "System",
197
+
198
+ async available(): Promise<boolean> {
199
+ return process.platform === "darwin";
200
+ },
201
+
202
+ async collect(): Promise<Partial<UserProfile>> {
203
+ try {
204
+ const proc = await $`defaults read NSGlobalDomain AppleLanguages`.quiet().nothrow();
205
+ if (proc.exitCode !== 0) return {};
206
+
207
+ const raw = proc.stdout.toString().trim();
208
+ // Plist array format: (\n "en-US",\n "fr-FR"\n)
209
+ const inner = raw.replace(/^\(\s*/, "").replace(/\s*\)$/, "");
210
+ const languages = inner
211
+ .split(",")
212
+ .map(s => s.trim().replace(/^"/, "").replace(/"$/, ""))
213
+ .filter(s => s.length > 0);
214
+
215
+ if (languages.length === 0) return {};
216
+ return { knowsLanguage: languages };
217
+ } catch (err: unknown) {
218
+ logger.debug("system collector failed", { error: err });
219
+ return {};
220
+ }
221
+ },
222
+ };
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Registry
226
+ // ---------------------------------------------------------------------------
227
+
228
+ export const PROFILE_COLLECTORS: readonly ProfileCollector[] = [salesforceCollector, githubCollector, systemCollector];
@@ -0,0 +1,287 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import { isEnoent, logger } from "@f5xc-salesdemos/pi-utils";
4
+ import { PROFILE_COLLECTORS } from "./profile-collectors";
5
+
6
+ export interface UserProfileObservation {
7
+ key: string;
8
+ value: string;
9
+ source?: string;
10
+ observedAt?: string;
11
+ }
12
+
13
+ export interface UserProfile {
14
+ givenName?: string;
15
+ familyName?: string;
16
+ additionalName?: string;
17
+ email?: string;
18
+ telephone?: string;
19
+ jobTitle?: string;
20
+ department?: string;
21
+ division?: string;
22
+ worksFor?: { name?: string; url?: string };
23
+ manager?: { givenName?: string; familyName?: string; email?: string };
24
+ address?: {
25
+ streetAddress?: string;
26
+ addressLocality?: string;
27
+ addressRegion?: string;
28
+ postalCode?: string;
29
+ addressCountry?: string;
30
+ };
31
+ birthDate?: string;
32
+ birthPlace?: {
33
+ addressLocality?: string;
34
+ addressRegion?: string;
35
+ addressCountry?: string;
36
+ };
37
+ nationality?: string;
38
+ gender?: string;
39
+ knowsLanguage?: string[];
40
+ spouse?: { givenName?: string; familyName?: string };
41
+ children?: Array<{ givenName?: string; familyName?: string; birthDate?: string }>;
42
+ parent?: Array<{ givenName?: string; familyName?: string }>;
43
+ sibling?: Array<{ givenName?: string; familyName?: string }>;
44
+ url?: string;
45
+ description?: string;
46
+ image?: string;
47
+ sameAs?: string[];
48
+ identifiers?: { github?: string; twitter?: string; salesforceId?: string };
49
+ observations?: UserProfileObservation[];
50
+ sources?: { salesforce?: string; github?: string; system?: string; conversation?: string };
51
+ updatedAt?: string;
52
+ }
53
+
54
+ const PROFILE_PATH = path.join(os.homedir(), ".xcsh", "user-profile.json");
55
+
56
+ export async function loadProfile(): Promise<UserProfile> {
57
+ try {
58
+ return (await Bun.file(PROFILE_PATH).json()) as UserProfile;
59
+ } catch (err: unknown) {
60
+ if (isEnoent(err)) return {};
61
+ logger.warn("Failed to load user profile", { error: err });
62
+ return {};
63
+ }
64
+ }
65
+
66
+ export async function saveProfile(profile: UserProfile): Promise<void> {
67
+ profile.updatedAt = new Date().toISOString();
68
+ await Bun.write(PROFILE_PATH, JSON.stringify(profile, null, 2));
69
+ }
70
+
71
+ export function mergeProfile(target: UserProfile, source: Partial<UserProfile>): void {
72
+ for (const [key, value] of Object.entries(source)) {
73
+ if (value === undefined || value === null) continue;
74
+ const k = key as keyof UserProfile;
75
+ if (k === "sources" || k === "observations" || k === "updatedAt") continue;
76
+ if (k === "sameAs") {
77
+ // Merge arrays without duplicates
78
+ if (!target.sameAs) target.sameAs = [];
79
+ for (const url of value as string[]) {
80
+ if (!target.sameAs.includes(url)) target.sameAs.push(url);
81
+ }
82
+ continue;
83
+ }
84
+ if (k === "knowsLanguage") {
85
+ if (!target.knowsLanguage) target.knowsLanguage = value as string[];
86
+ continue;
87
+ }
88
+ // For objects (worksFor, manager, address, etc.), merge sub-fields
89
+ if (typeof value === "object" && !Array.isArray(value)) {
90
+ const existing = target[k] as Record<string, unknown> | undefined;
91
+ if (!existing) {
92
+ (target as Record<string, unknown>)[k] = value;
93
+ }
94
+ continue;
95
+ }
96
+ // For simple values, don't overwrite
97
+ if (target[k] === undefined || target[k] === null) {
98
+ (target as Record<string, unknown>)[k] = value;
99
+ }
100
+ }
101
+ }
102
+
103
+ export async function seedProfile(): Promise<UserProfile> {
104
+ const profile = await loadProfile();
105
+ if (!profile.sources) profile.sources = {};
106
+
107
+ for (const collector of PROFILE_COLLECTORS) {
108
+ try {
109
+ const isAvailable = await collector.available();
110
+ if (!isAvailable) {
111
+ logger.debug(`Profile collector '${collector.id}' not available, skipping`);
112
+ continue;
113
+ }
114
+ const partial = await collector.collect();
115
+ mergeProfile(profile, partial);
116
+ (profile.sources as Record<string, string>)[collector.id] = new Date().toISOString();
117
+ logger.debug(`Profile collector '${collector.id}' completed`);
118
+ } catch (err: unknown) {
119
+ logger.debug(`Profile collector '${collector.id}' failed`, { error: err });
120
+ }
121
+ }
122
+
123
+ await saveProfile(profile);
124
+ return profile;
125
+ }
126
+
127
+ function formatAddress(addr: NonNullable<UserProfile["address"]>): string {
128
+ const parts: string[] = [];
129
+ if (addr.streetAddress) parts.push(addr.streetAddress);
130
+ const cityState = [addr.addressLocality, addr.addressRegion].filter(Boolean).join(", ");
131
+ if (cityState) parts.push(cityState);
132
+ if (addr.postalCode) parts.push(addr.postalCode);
133
+ if (addr.addressCountry) parts.push(addr.addressCountry);
134
+ return parts.join(", ");
135
+ }
136
+
137
+ function hasValues(obj: Record<string, unknown> | undefined): boolean {
138
+ if (!obj) return false;
139
+ return Object.values(obj).some(v => v !== undefined && v !== null && v !== "");
140
+ }
141
+
142
+ export function renderProfileMarkdown(profile: UserProfile): string {
143
+ const sections: string[] = [];
144
+
145
+ sections.push("# User Profile\n");
146
+
147
+ const isEmpty = !profile.givenName && !profile.familyName && !profile.email && !profile.jobTitle;
148
+ if (isEmpty) {
149
+ sections.push(
150
+ "No profile data yet. Use `xcsh://user?seed=true` to populate from Salesforce, GitHub, and system sources.\n",
151
+ );
152
+ sections.push("Profile facts can also be added progressively during conversation.\n");
153
+ return sections.join("\n");
154
+ }
155
+
156
+ // Identity
157
+ const identityLines: string[] = [];
158
+ const fullName = [profile.givenName, profile.additionalName, profile.familyName].filter(Boolean).join(" ");
159
+ if (fullName) identityLines.push(`- **Name:** ${fullName}`);
160
+ if (profile.email) identityLines.push(`- **Email:** ${profile.email}`);
161
+ if (profile.telephone) identityLines.push(`- **Phone:** ${profile.telephone}`);
162
+ if (profile.image) identityLines.push(`- **Avatar:** ${profile.image}`);
163
+ if (identityLines.length > 0) {
164
+ sections.push("## Identity\n");
165
+ sections.push(identityLines.join("\n"));
166
+ }
167
+
168
+ // Employment
169
+ const employmentLines: string[] = [];
170
+ if (profile.jobTitle) employmentLines.push(`- **Title:** ${profile.jobTitle}`);
171
+ if (profile.department) employmentLines.push(`- **Department:** ${profile.department}`);
172
+ if (profile.division) employmentLines.push(`- **Division:** ${profile.division}`);
173
+ if (profile.worksFor?.name) {
174
+ const org = profile.worksFor.url ? `[${profile.worksFor.name}](${profile.worksFor.url})` : profile.worksFor.name;
175
+ employmentLines.push(`- **Organization:** ${org}`);
176
+ }
177
+ if (profile.manager) {
178
+ const mgrName = [profile.manager.givenName, profile.manager.familyName].filter(Boolean).join(" ");
179
+ const mgrLine = profile.manager.email ? `${mgrName} (${profile.manager.email})` : mgrName;
180
+ if (mgrLine) employmentLines.push(`- **Manager:** ${mgrLine}`);
181
+ }
182
+ if (employmentLines.length > 0) {
183
+ sections.push("\n## Employment\n");
184
+ sections.push(employmentLines.join("\n"));
185
+ }
186
+
187
+ // Address
188
+ if (hasValues(profile.address)) {
189
+ sections.push("\n## Address\n");
190
+ sections.push(`- ${formatAddress(profile.address!)}`);
191
+ }
192
+
193
+ // Demographics
194
+ const demoLines: string[] = [];
195
+ if (profile.birthDate) demoLines.push(`- **Birth Date:** ${profile.birthDate}`);
196
+ if (profile.birthPlace && hasValues(profile.birthPlace)) {
197
+ const bp = [
198
+ profile.birthPlace.addressLocality,
199
+ profile.birthPlace.addressRegion,
200
+ profile.birthPlace.addressCountry,
201
+ ]
202
+ .filter(Boolean)
203
+ .join(", ");
204
+ if (bp) demoLines.push(`- **Birth Place:** ${bp}`);
205
+ }
206
+ if (profile.nationality) demoLines.push(`- **Nationality:** ${profile.nationality}`);
207
+ if (profile.gender) demoLines.push(`- **Gender:** ${profile.gender}`);
208
+ if (profile.knowsLanguage && profile.knowsLanguage.length > 0) {
209
+ demoLines.push(`- **Languages:** ${profile.knowsLanguage.join(", ")}`);
210
+ }
211
+ if (demoLines.length > 0) {
212
+ sections.push("\n## Demographics\n");
213
+ sections.push(demoLines.join("\n"));
214
+ }
215
+
216
+ // Family
217
+ const familyLines: string[] = [];
218
+ if (profile.spouse) {
219
+ const spouseName = [profile.spouse.givenName, profile.spouse.familyName].filter(Boolean).join(" ");
220
+ if (spouseName) familyLines.push(`- **Spouse:** ${spouseName}`);
221
+ }
222
+ if (profile.children && profile.children.length > 0) {
223
+ for (const child of profile.children) {
224
+ const childName = [child.givenName, child.familyName].filter(Boolean).join(" ");
225
+ const childLine = child.birthDate ? `${childName} (b. ${child.birthDate})` : childName;
226
+ if (childLine) familyLines.push(`- **Child:** ${childLine}`);
227
+ }
228
+ }
229
+ if (profile.parent && profile.parent.length > 0) {
230
+ for (const p of profile.parent) {
231
+ const pName = [p.givenName, p.familyName].filter(Boolean).join(" ");
232
+ if (pName) familyLines.push(`- **Parent:** ${pName}`);
233
+ }
234
+ }
235
+ if (profile.sibling && profile.sibling.length > 0) {
236
+ for (const s of profile.sibling) {
237
+ const sName = [s.givenName, s.familyName].filter(Boolean).join(" ");
238
+ if (sName) familyLines.push(`- **Sibling:** ${sName}`);
239
+ }
240
+ }
241
+ if (familyLines.length > 0) {
242
+ sections.push("\n## Family\n");
243
+ sections.push(familyLines.join("\n"));
244
+ }
245
+
246
+ // Online Presence
247
+ const onlineLines: string[] = [];
248
+ if (profile.url) onlineLines.push(`- **Website:** ${profile.url}`);
249
+ if (profile.description) onlineLines.push(`- **Bio:** ${profile.description}`);
250
+ if (profile.identifiers?.github) onlineLines.push(`- **GitHub:** ${profile.identifiers.github}`);
251
+ if (profile.identifiers?.twitter) onlineLines.push(`- **Twitter/X:** ${profile.identifiers.twitter}`);
252
+ if (profile.identifiers?.salesforceId) onlineLines.push(`- **Salesforce ID:** ${profile.identifiers.salesforceId}`);
253
+ if (profile.sameAs && profile.sameAs.length > 0) {
254
+ for (const link of profile.sameAs) {
255
+ onlineLines.push(`- **Profile:** ${link}`);
256
+ }
257
+ }
258
+ if (onlineLines.length > 0) {
259
+ sections.push("\n## Online Presence\n");
260
+ sections.push(onlineLines.join("\n"));
261
+ }
262
+
263
+ // Observations
264
+ if (profile.observations && profile.observations.length > 0) {
265
+ sections.push("\n## Observations\n");
266
+ sections.push("| Key | Value | Source | Observed |");
267
+ sections.push("|-----|-------|--------|----------|");
268
+ for (const obs of profile.observations) {
269
+ sections.push(`| ${obs.key} | ${obs.value} | ${obs.source ?? ""} | ${obs.observedAt ?? ""} |`);
270
+ }
271
+ }
272
+
273
+ // Sources
274
+ if (profile.sources && hasValues(profile.sources as unknown as Record<string, unknown>)) {
275
+ sections.push("\n---\n");
276
+ sections.push("**Sources:**");
277
+ const srcLines: string[] = [];
278
+ if (profile.sources.salesforce) srcLines.push(`Salesforce: ${profile.sources.salesforce}`);
279
+ if (profile.sources.github) srcLines.push(`GitHub: ${profile.sources.github}`);
280
+ if (profile.sources.system) srcLines.push(`System: ${profile.sources.system}`);
281
+ if (profile.sources.conversation) srcLines.push(`Conversation: ${profile.sources.conversation}`);
282
+ sections.push(srcLines.join(" | "));
283
+ if (profile.updatedAt) sections.push(`\n*Last updated: ${profile.updatedAt}*`);
284
+ }
285
+
286
+ return sections.join("\n");
287
+ }
@@ -16,6 +16,8 @@
16
16
  * - xcsh://api-spec/glossary/ - Acronym glossary
17
17
  * - xcsh://api-catalog/ - API operation catalog
18
18
  * - xcsh://api-catalog/{category} - Category operations with curl templates
19
+ * - xcsh://user - Human user profile
20
+ * - xcsh://user?seed=true - Seed profile from sources and render
19
21
  */
20
22
  import * as path from "node:path";
21
23
  import { logger } from "@f5xc-salesdemos/pi-utils";
@@ -27,11 +29,13 @@ import type { ApiSpecIndex, OpenAPISpec } from "./api-spec-types";
27
29
  import { getRuntimeBuildInfo, type RuntimeBuildInfo, renderAboutDoc } from "./build-info-runtime";
28
30
  import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
29
31
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
32
+ import { loadProfile, renderProfileMarkdown, seedProfile } from "./user-profile";
30
33
 
31
34
  const SCHEME_PREFIX = "xcsh://";
32
35
  const ABOUT_ROUTE = "about";
33
36
  const API_SPEC_HOST = "api-spec";
34
37
  const API_CATALOG_HOST = "api-catalog";
38
+ const USER_ROUTE = "user";
35
39
 
36
40
  const EMPTY_INDEX: ApiSpecIndex = { version: "unavailable", timestamp: "", domains: [] };
37
41
  const EMPTY_CATALOG_INDEX: ApiCatalogIndex = {
@@ -163,6 +167,10 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
163
167
  return this.#getApiCatalogResolver().resolve(url);
164
168
  }
165
169
 
170
+ if (host === USER_ROUTE) {
171
+ return this.#resolveUserProfile(url);
172
+ }
173
+
166
174
  const pathname = url.rawPathname ?? url.pathname;
167
175
  const filename = host ? (pathname && pathname !== "/" ? host + pathname : host) : "";
168
176
 
@@ -173,6 +181,22 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
173
181
  return this.#readDoc(filename, url);
174
182
  }
175
183
 
184
+ async #resolveUserProfile(url: InternalUrl): Promise<InternalResource> {
185
+ const params = new URLSearchParams(url.search);
186
+ const shouldSeed = params.get("seed") === "true";
187
+
188
+ const profile = shouldSeed ? await seedProfile() : await loadProfile();
189
+ const content = renderProfileMarkdown(profile);
190
+
191
+ return {
192
+ url: url.href,
193
+ content,
194
+ contentType: "text/markdown",
195
+ size: Buffer.byteLength(content, "utf-8"),
196
+ sourcePath: `xcsh://${USER_ROUTE}`,
197
+ };
198
+ }
199
+
176
200
  async #listDocs(url: InternalUrl): Promise<InternalResource> {
177
201
  if (EMBEDDED_DOC_FILENAMES.length === 0) {
178
202
  throw new Error("No documentation files found");
@@ -183,13 +207,15 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
183
207
  const syntheticEntry = `- [${ABOUT_ROUTE}](${SCHEME_PREFIX}${ABOUT_ROUTE}) — identity and build fingerprint`;
184
208
  const apiSpecEntry = `- [${API_SPEC_HOST}/](${SCHEME_PREFIX}${API_SPEC_HOST}/) — F5 XC API specifications (${specs.index.domains.length} domains, v${specs.version})`;
185
209
  const apiCatalogEntry = `- [${API_CATALOG_HOST}/](${SCHEME_PREFIX}${API_CATALOG_HOST}/) — F5 XC API operation catalog (${catalog.summaries.length} categories, v${catalog.index.version})`;
210
+ const userEntry = `- [${USER_ROUTE}](${SCHEME_PREFIX}${USER_ROUTE}) — human user profile`;
186
211
  const listing = [
187
212
  syntheticEntry,
188
213
  apiSpecEntry,
189
214
  apiCatalogEntry,
215
+ userEntry,
190
216
  ...EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](${SCHEME_PREFIX}${f})`),
191
217
  ].join("\n");
192
- const totalCount = EMBEDDED_DOC_FILENAMES.length + 3;
218
+ const totalCount = EMBEDDED_DOC_FILENAMES.length + 4;
193
219
  const content = `# Documentation\n\n${totalCount} files available:\n\n${listing}\n`;
194
220
 
195
221
  return {
@@ -46,19 +46,19 @@ You **MUST** use specialized tools instead of bash for ALL file operations:
46
46
  |---|---|
47
47
  |`cat file`, `head -n N file`|`read(path="file", limit=N)`|
48
48
  |`cat -n file \|sed -n '50,150p'`|`read(path="file", offset=50, limit=100)`|
49
- <!-- markdownlint-disable MD055 MD056 -->
49
+ <!-- markdownlint-disable MD055 MD056 -→
50
50
  {{#if hasGrep}}|`grep -A 20 'pat' file`|`grep(pattern="pat", path="file", post=20)`|
51
51
  |`grep -rn 'pat' dir/`|`grep(pattern="pat", path="dir/")`|
52
52
  |`rg 'pattern' dir/`|`grep(pattern="pattern", path="dir/")`|{{/if}}
53
53
  {{#if hasFind}}|`find dir -name '*.ts'`|`find(pattern="dir/**/*.ts")`|{{/if}}
54
- <!-- markdownlint-enable MD055 MD056 -->
54
+ <!-- markdownlint-enable MD055 MD056 -→
55
55
  |`ls dir/`|`read(path="dir/")`|
56
56
  |`cat <<'EOF' > file`|`write(path="file", content="…")`|
57
57
  |`sed -i 's/old/new/' file`|`edit(path="file", edits=[…])`|
58
58
 
59
- <!-- markdownlint-disable MD055 MD056 -->
59
+ <!-- markdownlint-disable MD055 MD056 -→
60
60
  {{#if hasAstEdit}}|`sed -i 's/oldFn(/newFn(/' src/*.ts`|`ast_edit({ops:[{pat:"oldFn($$$A)", out:"newFn($$$A)"}], path:"src/"})`|{{/if}}
61
- <!-- markdownlint-enable MD055 MD056 -->
61
+ <!-- markdownlint-enable MD055 MD056 -→
62
62
  {{#if hasAstGrep}}- You **MUST** use `ast_grep` for structural code search instead of bash `grep`/`awk`/`perl` pipelines{{/if}}
63
63
  {{#if hasAstEdit}}- You **MUST** use `ast_edit` for structural rewrites instead of bash `sed`/`awk`/`perl` pipelines{{/if}}
64
64
  - You **MUST NOT** use Bash for these operations like read, grep, find, edit, write, where specialized tools exist.
@@ -3,7 +3,7 @@ Execute SOQL queries against Salesforce via sf CLI. Returns structured results a
3
3
  <instruction>
4
4
  Use for pipeline reporting, case management, account intelligence, and ad-hoc data queries.
5
5
 
6
- Common query templates (substitute {userId} from cached xcsh.user.id in ~/.sf/config.json):
6
+ Common query templates (substitute {userId} from user profile — read `xcsh://user` to get identifiers.salesforceId):
7
7
 
8
8
  Pipeline summary:
9
9
  SELECT StageName, COUNT(Id) TotalDeals, SUM(Amount) TotalAmount FROM Opportunity WHERE IsClosed = false GROUP BY StageName ORDER BY SUM(Amount) DESC LIMIT 50
@@ -1,10 +1,10 @@
1
- Salesforce onboarding wizard via sf CLI. Check installation, detect authentication, guide login, extract user profile.
1
+ Salesforce onboarding wizard via sf CLI. Check installation, detect authentication, guide login.
2
2
 
3
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).
4
+ Actions: "check" (verify sf installed), "status" (auth + default org), "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).
5
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).
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. 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
7
 
8
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.
9
+ User profile data is managed by the central profile builder via `xcsh://user?seed=true`.
10
10
  </instruction>
@@ -1,4 +1,4 @@
1
- import type { SfOrg, SfQueryResult, SfUserProfile } from "./types";
1
+ import type { SfOrg, SfQueryResult } from "./types";
2
2
 
3
3
  export function formatOrgTable(orgs: SfOrg[]): string {
4
4
  if (orgs.length === 0) {
@@ -100,51 +100,3 @@ export function formatQueryResults(result: SfQueryResult): string {
100
100
 
101
101
  return `${result.totalSize} records returned.\n\n${[header, divider, ...rows].join("\n")}`;
102
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
- }
@@ -1,28 +1,3 @@
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
1
  export interface SfOrg {
27
2
  alias?: string;
28
3
  username: string;
@@ -62,6 +37,4 @@ export interface SfRawResult {
62
37
 
63
38
  export const SF_ORG_SAFE_FIELDS = ["username", "orgId", "instanceUrl", "connectedStatus", "alias"] as const;
64
39
 
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
40
  export const ORG_ALIAS_PATTERN = /^[a-zA-Z0-9._@-]+$/;
package/src/tools/sf.ts CHANGED
@@ -6,16 +6,16 @@ import type {
6
6
  } from "@f5xc-salesdemos/pi-agent-core";
7
7
  import { $which, prompt } from "@f5xc-salesdemos/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
+ import { loadProfile } from "../internal-urls/user-profile";
9
10
  import sfOrgDisplayDescription from "../prompts/tools/sf-org-display.md" with { type: "text" };
10
11
  import sfQueryDescription from "../prompts/tools/sf-query.md" with { type: "text" };
11
12
  import sfSetupDescription from "../prompts/tools/sf-setup.md" with { type: "text" };
12
13
  import type { ToolSession } from ".";
13
- import { loadUserProfile, saveUserProfile } from "./sf/config";
14
14
  import type { SfExecApi } from "./sf/exec";
15
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";
16
+ import { formatOrgDetail, formatOrgTable, formatQueryResults } from "./sf/formatters";
17
+ import type { SfOrg, SfQueryResult, SfRawResult } from "./sf/types";
18
+ import { ORG_ALIAS_PATTERN } from "./sf/types";
19
19
 
20
20
  function makeExecApi(cwd: string): SfExecApi {
21
21
  return {
@@ -57,7 +57,6 @@ const sfSetupSchema = Type.Object({
57
57
  Type.Literal("login"),
58
58
  Type.Literal("list_orgs"),
59
59
  Type.Literal("set_default"),
60
- Type.Literal("profile"),
61
60
  ],
62
61
  { description: "Onboarding action to perform" },
63
62
  ),
@@ -84,7 +83,6 @@ type SfOrgDisplayInput = Static<typeof sfOrgDisplaySchema>;
84
83
  interface SfToolDetails {
85
84
  orgs?: SfOrg[];
86
85
  queryResult?: SfQueryResult;
87
- profile?: SfUserProfile;
88
86
  }
89
87
 
90
88
  function textResult(text: string, details?: SfToolDetails): AgentToolResult<SfToolDetails> {
@@ -125,19 +123,6 @@ export function collectAllOrgs(orgList: Record<string, unknown[]>): SfOrg[] {
125
123
  });
126
124
  }
127
125
 
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
126
  // ─── SfSetupTool ─────────────────────────────────────────────────────────
142
127
 
143
128
  export class SfSetupTool implements AgentTool<typeof sfSetupSchema, SfToolDetails> {
@@ -179,9 +164,10 @@ export class SfSetupTool implements AgentTool<typeof sfSetupSchema, SfToolDetail
179
164
  const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
180
165
  let output = formatOrgTable(allOrgs);
181
166
 
182
- const cached = await loadUserProfile();
183
- if (cached) {
184
- output += `\n\nCached user profile: **${cached.firstName} ${cached.lastName}** (${cached.username}), fetched ${cached.fetchedAt}`;
167
+ const userProfile = await loadProfile();
168
+ if (userProfile.givenName || userProfile.familyName) {
169
+ const name = [userProfile.givenName, userProfile.familyName].filter(Boolean).join(" ");
170
+ output += `\n\nUser profile: **${name}** (${userProfile.email ?? "no email"})`;
185
171
  }
186
172
 
187
173
  return textResult(output, { orgs: allOrgs });
@@ -191,7 +177,7 @@ export class SfSetupTool implements AgentTool<typeof sfSetupSchema, SfToolDetail
191
177
  const orgResult = await execSfJson(api, ["org", "list"], signal);
192
178
  const allOrgs = collectAllOrgs(orgResult.result as Record<string, unknown[]>);
193
179
  if (allOrgs.length > 0) {
194
- return textResult("Already authenticated. Use 'profile' action to extract your user data.", {
180
+ return textResult("Already authenticated. Use 'status' action to see your orgs and profile.", {
195
181
  orgs: allOrgs,
196
182
  });
197
183
  }
@@ -221,59 +207,6 @@ export class SfSetupTool implements AgentTool<typeof sfSetupSchema, SfToolDetail
221
207
  await execSfRaw(api, ["config", "set", "target-org", params.org, "--global"], signal);
222
208
  return textResult(`Default org set to: **${params.org}**`);
223
209
  }
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
210
  default:
278
211
  return textResult(`Unknown action: ${params.action}`);
279
212
  }
@@ -1,44 +0,0 @@
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
- }