@f5-sales-demo/xcsh 19.63.6 → 19.63.7
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": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.63.
|
|
4
|
+
"version": "19.63.7",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
58
58
|
"@mozilla/readability": "^0.6",
|
|
59
|
-
"@f5-sales-demo/xcsh-stats": "19.63.
|
|
60
|
-
"@f5-sales-demo/pi-agent-core": "19.63.
|
|
61
|
-
"@f5-sales-demo/pi-ai": "19.63.
|
|
62
|
-
"@f5-sales-demo/pi-natives": "19.63.
|
|
63
|
-
"@f5-sales-demo/pi-resource-management": "19.63.
|
|
64
|
-
"@f5-sales-demo/pi-tui": "19.63.
|
|
65
|
-
"@f5-sales-demo/pi-utils": "19.63.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.63.7",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.63.7",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.63.7",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.63.7",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.63.7",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.63.7",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.63.7",
|
|
66
66
|
"@sinclair/typebox": "^0.34",
|
|
67
67
|
"@xterm/headless": "^6.0",
|
|
68
68
|
"ajv": "^8.20",
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.63.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.63.7",
|
|
21
|
+
"commit": "60a30a905669791dd0c2f600254d6b95c85ecd49",
|
|
22
|
+
"shortCommit": "60a30a9",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.63.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.63.7",
|
|
25
|
+
"commitDate": "2026-07-19T03:29:22Z",
|
|
26
|
+
"buildDate": "2026-07-19T03:49:08.769Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.63.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/60a30a905669791dd0c2f600254d6b95c85ecd49",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.63.7"
|
|
33
33
|
};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { logger } from "@f5-sales-demo/pi-utils";
|
|
2
|
-
import { $ } from "bun";
|
|
1
|
+
import { $which, logger } from "@f5-sales-demo/pi-utils";
|
|
3
2
|
|
|
4
3
|
import type { UserProfile } from "./user-profile";
|
|
5
4
|
|
|
@@ -17,14 +16,255 @@ export interface ProfileCollector {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
// ---------------------------------------------------------------------------
|
|
20
|
-
//
|
|
19
|
+
// Bounded CLI runner
|
|
20
|
+
//
|
|
21
|
+
// Collectors shell out to external CLIs (sf, gh, git, id, …). These run in a
|
|
22
|
+
// fire-and-forget background refresh, so a hung CLI must never leave a promise
|
|
23
|
+
// pending forever. Bun's `$` cannot be cancelled, so we spawn directly with an
|
|
24
|
+
// AbortSignal timeout that actually kills the child on expiry.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const CLI_TIMEOUT_MS = 15_000;
|
|
28
|
+
|
|
29
|
+
export interface CliResult {
|
|
30
|
+
exitCode: number;
|
|
31
|
+
stdout: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runCli(cmd: string[], timeoutMs = CLI_TIMEOUT_MS): Promise<CliResult> {
|
|
35
|
+
try {
|
|
36
|
+
const proc = Bun.spawn(cmd, {
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "ignore",
|
|
39
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
40
|
+
});
|
|
41
|
+
const stdout = await new Response(proc.stdout).text();
|
|
42
|
+
const exitCode = await proc.exited;
|
|
43
|
+
return { exitCode, stdout };
|
|
44
|
+
} catch (err: unknown) {
|
|
45
|
+
logger.debug("profile collector CLI failed or timed out", { cmd: cmd[0], error: err });
|
|
46
|
+
return { exitCode: -1, stdout: "" };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Shared helpers (pure — unit-tested directly, no I/O)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Split a full name into given + family. The first whitespace token is the
|
|
56
|
+
* given name; everything after it is the family name. Blank input yields {}.
|
|
57
|
+
*/
|
|
58
|
+
export function splitFullName(full: string): { givenName?: string; familyName?: string } {
|
|
59
|
+
const trimmed = full.trim();
|
|
60
|
+
if (!trimmed) return {};
|
|
61
|
+
const parts = trimmed.split(/\s+/);
|
|
62
|
+
const out: { givenName?: string; familyName?: string } = { givenName: parts[0] };
|
|
63
|
+
if (parts.length > 1) out.familyName = parts.slice(1).join(" ");
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Salesforce
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const SALESFORCE_SOQL_FIELDS =
|
|
72
|
+
"Id, Username, FirstName, LastName, Email, Title, Department, Division, CompanyName, " +
|
|
73
|
+
"ManagerId, Manager.Name, Manager.Email, Street, City, State, PostalCode, Country, Phone, MobilePhone";
|
|
74
|
+
|
|
75
|
+
/** Map a single Salesforce `User` SOQL record onto profile fields. */
|
|
76
|
+
export function parseSalesforceUserRecord(rec: Record<string, unknown>): Partial<UserProfile> {
|
|
77
|
+
const profile: Partial<UserProfile> = {};
|
|
78
|
+
|
|
79
|
+
if (rec.FirstName) profile.givenName = rec.FirstName as string;
|
|
80
|
+
if (rec.LastName) profile.familyName = rec.LastName as string;
|
|
81
|
+
if (rec.Email) profile.email = rec.Email as string;
|
|
82
|
+
|
|
83
|
+
const phone = (rec.Phone || rec.MobilePhone) as string | undefined;
|
|
84
|
+
if (phone) profile.telephone = phone;
|
|
85
|
+
|
|
86
|
+
if (rec.Title) profile.jobTitle = rec.Title as string;
|
|
87
|
+
if (rec.Department) profile.department = rec.Department as string;
|
|
88
|
+
if (rec.Division) profile.division = rec.Division as string;
|
|
89
|
+
|
|
90
|
+
profile.worksFor = { name: (rec.CompanyName as string) || "F5" };
|
|
91
|
+
|
|
92
|
+
const mgr = rec.Manager as Record<string, unknown> | undefined;
|
|
93
|
+
if (mgr && (mgr.Name || mgr.Email)) {
|
|
94
|
+
profile.manager = {};
|
|
95
|
+
if (mgr.Name) Object.assign(profile.manager, splitFullName(mgr.Name as string));
|
|
96
|
+
if (mgr.Email) profile.manager.email = mgr.Email as string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const street = rec.Street as string | undefined;
|
|
100
|
+
const city = rec.City as string | undefined;
|
|
101
|
+
const state = rec.State as string | undefined;
|
|
102
|
+
const postalCode = rec.PostalCode as string | undefined;
|
|
103
|
+
const country = rec.Country as string | undefined;
|
|
104
|
+
if (street || city || state || postalCode || country) {
|
|
105
|
+
profile.address = {};
|
|
106
|
+
if (street) profile.address.streetAddress = street;
|
|
107
|
+
if (city) profile.address.addressLocality = city;
|
|
108
|
+
if (state) profile.address.addressRegion = state;
|
|
109
|
+
if (postalCode) profile.address.postalCode = postalCode;
|
|
110
|
+
if (country) profile.address.addressCountry = country;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (rec.Id) profile.identifiers = { ...profile.identifiers, salesforceId: rec.Id as string };
|
|
114
|
+
|
|
115
|
+
return profile;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const salesforceCollector: ProfileCollector = {
|
|
119
|
+
id: "salesforce",
|
|
120
|
+
name: "Salesforce",
|
|
121
|
+
authoritativeFields: ["givenName", "familyName", "email", "jobTitle", "department", "division", "worksFor"],
|
|
122
|
+
|
|
123
|
+
async available(): Promise<boolean> {
|
|
124
|
+
if (!$which("sf")) return false;
|
|
125
|
+
const proc = await runCli(["sf", "org", "display", "--json"]);
|
|
126
|
+
if (proc.exitCode !== 0) return false;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(proc.stdout) as Record<string, unknown>;
|
|
129
|
+
const result = parsed.result as Record<string, unknown> | undefined;
|
|
130
|
+
return typeof result?.username === "string" && result.username.length > 0;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async collect(): Promise<Partial<UserProfile>> {
|
|
137
|
+
try {
|
|
138
|
+
const orgProc = await runCli(["sf", "org", "display", "--json"]);
|
|
139
|
+
if (orgProc.exitCode !== 0) return {};
|
|
140
|
+
const orgData = JSON.parse(orgProc.stdout) as Record<string, unknown>;
|
|
141
|
+
const orgResult = orgData.result as Record<string, unknown> | undefined;
|
|
142
|
+
const username = orgResult?.username as string | undefined;
|
|
143
|
+
if (!username) return {};
|
|
144
|
+
|
|
145
|
+
const soql = `SELECT ${SALESFORCE_SOQL_FIELDS} FROM User WHERE Username = '${username}'`;
|
|
146
|
+
const queryProc = await runCli(["sf", "data", "query", "--query", soql, "--json"]);
|
|
147
|
+
if (queryProc.exitCode !== 0) return {};
|
|
148
|
+
|
|
149
|
+
const queryData = JSON.parse(queryProc.stdout) as Record<string, unknown>;
|
|
150
|
+
const queryResult = queryData.result as Record<string, unknown> | undefined;
|
|
151
|
+
const records = queryResult?.records as Record<string, unknown>[] | undefined;
|
|
152
|
+
const rec = records?.[0];
|
|
153
|
+
if (!rec) return {};
|
|
154
|
+
|
|
155
|
+
return parseSalesforceUserRecord(rec);
|
|
156
|
+
} catch (err: unknown) {
|
|
157
|
+
logger.debug("salesforce collector failed", { error: err });
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// GitHub
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/** Map `gh api user` JSON output onto profile fields. */
|
|
168
|
+
export function parseGithubUserJson(stdout: string): Partial<UserProfile> {
|
|
169
|
+
let data: Record<string, unknown>;
|
|
170
|
+
try {
|
|
171
|
+
data = JSON.parse(stdout) as Record<string, unknown>;
|
|
172
|
+
} catch {
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const profile: Partial<UserProfile> = {};
|
|
177
|
+
const sameAs: string[] = [];
|
|
178
|
+
|
|
179
|
+
const login = data.login as string | undefined;
|
|
180
|
+
if (login) {
|
|
181
|
+
profile.identifiers = { ...profile.identifiers, github: login };
|
|
182
|
+
sameAs.push(`https://github.com/${login}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const name = data.name as string | undefined;
|
|
186
|
+
if (name) Object.assign(profile, splitFullName(name));
|
|
187
|
+
|
|
188
|
+
const email = data.email as string | undefined;
|
|
189
|
+
if (email) profile.email = email;
|
|
190
|
+
|
|
191
|
+
const bio = data.bio as string | undefined;
|
|
192
|
+
if (bio) profile.description = bio;
|
|
193
|
+
|
|
194
|
+
const blog = data.blog as string | undefined;
|
|
195
|
+
if (blog) {
|
|
196
|
+
profile.url = blog;
|
|
197
|
+
sameAs.push(blog);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const twitterUsername = data.twitter_username as string | undefined;
|
|
201
|
+
if (twitterUsername) {
|
|
202
|
+
profile.identifiers = { ...profile.identifiers, twitter: twitterUsername };
|
|
203
|
+
sameAs.push(`https://x.com/${twitterUsername}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (sameAs.length > 0) profile.sameAs = sameAs;
|
|
207
|
+
|
|
208
|
+
return profile;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const githubCollector: ProfileCollector = {
|
|
212
|
+
id: "github",
|
|
213
|
+
name: "GitHub",
|
|
214
|
+
|
|
215
|
+
async available(): Promise<boolean> {
|
|
216
|
+
if (!$which("gh")) return false;
|
|
217
|
+
const proc = await runCli(["gh", "auth", "status"]);
|
|
218
|
+
return proc.exitCode === 0;
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async collect(): Promise<Partial<UserProfile>> {
|
|
222
|
+
const proc = await runCli(["gh", "api", "user"]);
|
|
223
|
+
if (proc.exitCode !== 0) return {};
|
|
224
|
+
return parseGithubUserJson(proc.stdout);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Git (local config)
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
const gitCollector: ProfileCollector = {
|
|
233
|
+
id: "git",
|
|
234
|
+
name: "Git",
|
|
235
|
+
|
|
236
|
+
async available(): Promise<boolean> {
|
|
237
|
+
return Boolean($which("git"));
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async collect(): Promise<Partial<UserProfile>> {
|
|
241
|
+
const profile: Partial<UserProfile> = {};
|
|
242
|
+
|
|
243
|
+
const nameProc = await runCli(["git", "config", "--get", "user.name"]);
|
|
244
|
+
if (nameProc.exitCode === 0) {
|
|
245
|
+
const name = nameProc.stdout.trim();
|
|
246
|
+
if (name) Object.assign(profile, splitFullName(name));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const emailProc = await runCli(["git", "config", "--get", "user.email"]);
|
|
250
|
+
if (emailProc.exitCode === 0) {
|
|
251
|
+
const email = emailProc.stdout.trim();
|
|
252
|
+
if (email) profile.email = email;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return profile;
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// System (identity + UI languages)
|
|
21
261
|
// ---------------------------------------------------------------------------
|
|
22
262
|
|
|
23
263
|
async function detectDarwinLanguages(): Promise<string[]> {
|
|
24
|
-
const proc = await
|
|
264
|
+
const proc = await runCli(["defaults", "read", "NSGlobalDomain", "AppleLanguages"]);
|
|
25
265
|
if (proc.exitCode !== 0) return [];
|
|
26
266
|
|
|
27
|
-
const raw = proc.stdout.
|
|
267
|
+
const raw = proc.stdout.trim();
|
|
28
268
|
const inner = raw.replace(/^\(\s*/, "").replace(/\s*\)$/, "");
|
|
29
269
|
return inner
|
|
30
270
|
.split(",")
|
|
@@ -56,6 +296,22 @@ function detectLinuxLanguages(): string[] {
|
|
|
56
296
|
return languages;
|
|
57
297
|
}
|
|
58
298
|
|
|
299
|
+
/** Best-effort full name from the OS account record (macOS `id -F`, Linux GECOS). */
|
|
300
|
+
async function detectSystemFullName(): Promise<string> {
|
|
301
|
+
if (process.platform === "darwin") {
|
|
302
|
+
const proc = await runCli(["id", "-F"]);
|
|
303
|
+
return proc.exitCode === 0 ? proc.stdout.trim() : "";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Linux: GECOS field (comma-separated) of the passwd entry for the current user.
|
|
307
|
+
const user = process.env.USER || process.env.LOGNAME;
|
|
308
|
+
if (!user || !$which("getent")) return "";
|
|
309
|
+
const proc = await runCli(["getent", "passwd", user]);
|
|
310
|
+
if (proc.exitCode !== 0) return "";
|
|
311
|
+
const gecos = proc.stdout.trim().split(":")[6] ?? "";
|
|
312
|
+
return gecos.split(",")[0]?.trim() ?? "";
|
|
313
|
+
}
|
|
314
|
+
|
|
59
315
|
const systemCollector: ProfileCollector = {
|
|
60
316
|
id: "system",
|
|
61
317
|
name: "System",
|
|
@@ -65,23 +321,32 @@ const systemCollector: ProfileCollector = {
|
|
|
65
321
|
},
|
|
66
322
|
|
|
67
323
|
async collect(): Promise<Partial<UserProfile>> {
|
|
324
|
+
const profile: Partial<UserProfile> = {};
|
|
325
|
+
try {
|
|
326
|
+
const fullName = await detectSystemFullName();
|
|
327
|
+
if (fullName) Object.assign(profile, splitFullName(fullName));
|
|
328
|
+
} catch (err: unknown) {
|
|
329
|
+
logger.debug("system collector: name detection failed", { error: err });
|
|
330
|
+
}
|
|
68
331
|
try {
|
|
69
332
|
const languages = process.platform === "darwin" ? await detectDarwinLanguages() : detectLinuxLanguages();
|
|
70
|
-
|
|
71
|
-
if (languages.length === 0) return {};
|
|
72
|
-
return { knowsLanguage: languages };
|
|
333
|
+
if (languages.length > 0) profile.knowsLanguage = languages;
|
|
73
334
|
} catch (err: unknown) {
|
|
74
|
-
logger.debug("system collector failed", { error: err });
|
|
75
|
-
return {};
|
|
335
|
+
logger.debug("system collector: language detection failed", { error: err });
|
|
76
336
|
}
|
|
337
|
+
return profile;
|
|
77
338
|
},
|
|
78
339
|
};
|
|
79
340
|
|
|
80
341
|
// ---------------------------------------------------------------------------
|
|
81
342
|
// Registry
|
|
343
|
+
//
|
|
344
|
+
// Order encodes seed priority: `mergeProfile` is first-wins for scalar fields,
|
|
345
|
+
// so higher-trust identity sources come first (Salesforce → GitHub → git →
|
|
346
|
+
// system). Plugins may append more via `registerProfileCollector`.
|
|
82
347
|
// ---------------------------------------------------------------------------
|
|
83
348
|
|
|
84
|
-
const _collectors: ProfileCollector[] = [systemCollector];
|
|
349
|
+
const _collectors: ProfileCollector[] = [salesforceCollector, githubCollector, gitCollector, systemCollector];
|
|
85
350
|
|
|
86
351
|
export const PROFILE_COLLECTORS: readonly ProfileCollector[] = _collectors;
|
|
87
352
|
|
|
@@ -45,7 +45,7 @@ export interface UserProfile {
|
|
|
45
45
|
description?: string;
|
|
46
46
|
image?: string;
|
|
47
47
|
sameAs?: string[];
|
|
48
|
-
identifiers?: { github?: string; twitter?: string };
|
|
48
|
+
identifiers?: { github?: string; twitter?: string; salesforceId?: string };
|
|
49
49
|
/** User-authored: short role label, e.g. 'SE', 'AE', 'CSM', 'SA'. Set manually. */
|
|
50
50
|
role?: string;
|
|
51
51
|
/**
|
|
@@ -65,7 +65,7 @@ export interface UserProfile {
|
|
|
65
65
|
/** User-authored: quarterly quota target in dollars. Used for coverage ratio calculations. */
|
|
66
66
|
quota?: number;
|
|
67
67
|
observations?: UserProfileObservation[];
|
|
68
|
-
sources?: { github?: string; system?: string; conversation?: string };
|
|
68
|
+
sources?: { github?: string; system?: string; conversation?: string; git?: string; salesforce?: string };
|
|
69
69
|
updatedAt?: string;
|
|
70
70
|
/** Tracks which collector ID authoritatively owns each top-level field. */
|
|
71
71
|
_fieldOwnership?: Record<string, string>;
|
|
@@ -120,28 +120,59 @@ export function mergeProfile(target: UserProfile, source: Partial<UserProfile>):
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
/** Outcome of a single collector during a seed run — surfaced to the session. */
|
|
124
|
+
export interface SeedCollectorResult {
|
|
125
|
+
id: string;
|
|
126
|
+
name: string;
|
|
127
|
+
status: "collected" | "unavailable" | "error";
|
|
128
|
+
/** Profile fields the collector contributed (only when status === "collected"). */
|
|
129
|
+
fields?: string[];
|
|
130
|
+
/** Error message when status === "error". */
|
|
131
|
+
error?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SeedResult {
|
|
135
|
+
profile: UserProfile;
|
|
136
|
+
results: SeedCollectorResult[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function seedProfile(): Promise<SeedResult> {
|
|
124
140
|
const profile = await loadProfile();
|
|
125
141
|
if (!profile.sources) profile.sources = {};
|
|
126
142
|
|
|
143
|
+
const results: SeedCollectorResult[] = [];
|
|
144
|
+
|
|
127
145
|
for (const collector of PROFILE_COLLECTORS) {
|
|
128
146
|
try {
|
|
129
147
|
const isAvailable = await collector.available();
|
|
130
148
|
if (!isAvailable) {
|
|
131
149
|
logger.debug(`Profile collector '${collector.id}' not available, skipping`);
|
|
150
|
+
results.push({ id: collector.id, name: collector.name, status: "unavailable" });
|
|
132
151
|
continue;
|
|
133
152
|
}
|
|
134
153
|
const partial = await collector.collect();
|
|
135
154
|
mergeProfile(profile, partial);
|
|
136
155
|
(profile.sources as Record<string, string>)[collector.id] = new Date().toISOString();
|
|
137
156
|
logger.debug(`Profile collector '${collector.id}' completed`);
|
|
157
|
+
results.push({
|
|
158
|
+
id: collector.id,
|
|
159
|
+
name: collector.name,
|
|
160
|
+
status: "collected",
|
|
161
|
+
fields: Object.keys(partial),
|
|
162
|
+
});
|
|
138
163
|
} catch (err: unknown) {
|
|
139
164
|
logger.debug(`Profile collector '${collector.id}' failed`, { error: err });
|
|
165
|
+
results.push({
|
|
166
|
+
id: collector.id,
|
|
167
|
+
name: collector.name,
|
|
168
|
+
status: "error",
|
|
169
|
+
error: err instanceof Error ? err.message : String(err),
|
|
170
|
+
});
|
|
140
171
|
}
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
await saveProfile(profile);
|
|
144
|
-
return profile;
|
|
175
|
+
return { profile, results };
|
|
145
176
|
}
|
|
146
177
|
|
|
147
178
|
const META_FIELDS = new Set(["sources", "observations", "updatedAt", "_fieldOwnership"]);
|
|
@@ -219,17 +250,44 @@ function hasValues(obj: Record<string, unknown> | undefined): boolean {
|
|
|
219
250
|
return Object.values(obj).some(v => v !== undefined && v !== null && v !== "");
|
|
220
251
|
}
|
|
221
252
|
|
|
253
|
+
const SOURCE_LABELS: Record<string, string> = {
|
|
254
|
+
github: "GitHub",
|
|
255
|
+
system: "System",
|
|
256
|
+
conversation: "Conversation",
|
|
257
|
+
git: "Git",
|
|
258
|
+
salesforce: "Salesforce",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
function titleCase(id: string): string {
|
|
262
|
+
return id.charAt(0).toUpperCase() + id.slice(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Render a per-collector seed report so a no-op seed is diagnosable in-session
|
|
267
|
+
* (which sources ran, which were unavailable, which errored) instead of
|
|
268
|
+
* silently returning an empty template.
|
|
269
|
+
*/
|
|
270
|
+
export function renderSeedReport(results: SeedCollectorResult[]): string {
|
|
271
|
+
if (results.length === 0) return "";
|
|
272
|
+
const lines = ["\n## Seed Report\n"];
|
|
273
|
+
for (const r of results) {
|
|
274
|
+
if (r.status === "collected") {
|
|
275
|
+
const fields = r.fields && r.fields.length > 0 ? ` — ${r.fields.join(", ")}` : " — no fields";
|
|
276
|
+
lines.push(`- **${r.name}:** collected${fields}`);
|
|
277
|
+
} else if (r.status === "unavailable") {
|
|
278
|
+
lines.push(`- **${r.name}:** unavailable (skipped)`);
|
|
279
|
+
} else {
|
|
280
|
+
lines.push(`- **${r.name}:** error — ${r.error ?? "unknown error"}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
222
286
|
export function renderProfileMarkdown(profile: UserProfile): string {
|
|
223
287
|
const sections: string[] = [];
|
|
224
288
|
|
|
225
289
|
sections.push("# User Profile\n");
|
|
226
|
-
|
|
227
|
-
const isEmpty = !profile.givenName && !profile.familyName && !profile.email && !profile.jobTitle;
|
|
228
|
-
if (isEmpty) {
|
|
229
|
-
sections.push("No profile data yet. Use `xcsh://user?seed=true` to populate from GitHub and system sources.\n");
|
|
230
|
-
sections.push("Profile facts can also be added progressively during conversation.\n");
|
|
231
|
-
return sections.join("\n");
|
|
232
|
-
}
|
|
290
|
+
const headerOnly = sections.length;
|
|
233
291
|
|
|
234
292
|
// Identity
|
|
235
293
|
const identityLines: string[] = [];
|
|
@@ -347,14 +405,24 @@ export function renderProfileMarkdown(profile: UserProfile): string {
|
|
|
347
405
|
}
|
|
348
406
|
}
|
|
349
407
|
|
|
350
|
-
//
|
|
408
|
+
// No content sections were produced — show the seed hint instead of an
|
|
409
|
+
// empty shell (and never render an orphan Sources footer).
|
|
410
|
+
if (sections.length === headerOnly) {
|
|
411
|
+
sections.push(
|
|
412
|
+
"No profile data yet. Use `xcsh://user?seed=true` to populate from Salesforce, GitHub, git, and system sources.\n",
|
|
413
|
+
);
|
|
414
|
+
sections.push("Profile facts can also be added progressively during conversation.\n");
|
|
415
|
+
return sections.join("\n");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Sources — render every recorded source timestamp, not just a fixed subset.
|
|
351
419
|
if (profile.sources && hasValues(profile.sources as unknown as Record<string, unknown>)) {
|
|
352
420
|
sections.push("\n---\n");
|
|
353
421
|
sections.push("**Sources:**");
|
|
354
422
|
const srcLines: string[] = [];
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
423
|
+
for (const [id, ts] of Object.entries(profile.sources as Record<string, string>)) {
|
|
424
|
+
if (ts) srcLines.push(`${SOURCE_LABELS[id] ?? titleCase(id)}: ${ts}`);
|
|
425
|
+
}
|
|
358
426
|
sections.push(srcLines.join(" | "));
|
|
359
427
|
if (profile.updatedAt) sections.push(`\n*Last updated: ${profile.updatedAt}*`);
|
|
360
428
|
}
|
|
@@ -47,7 +47,8 @@ import extensionApiContent from "./extension-api.md" with { type: "text" };
|
|
|
47
47
|
import { createTerraformResolver, type TerraformResolver } from "./terraform-resolve";
|
|
48
48
|
import type { TerraformIndex } from "./terraform-types";
|
|
49
49
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
50
|
-
import {
|
|
50
|
+
import type { UserProfile } from "./user-profile";
|
|
51
|
+
import { loadProfile, renderProfileMarkdown, renderSeedReport, seedProfile } from "./user-profile";
|
|
51
52
|
|
|
52
53
|
const SCHEME_PREFIX = "xcsh://";
|
|
53
54
|
const ABOUT_ROUTE = "about";
|
|
@@ -386,8 +387,16 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
|
|
|
386
387
|
const params = new URLSearchParams(url.search);
|
|
387
388
|
const shouldSeed = params.get("seed") === "true";
|
|
388
389
|
|
|
389
|
-
|
|
390
|
-
|
|
390
|
+
let profile: UserProfile;
|
|
391
|
+
let seedReport = "";
|
|
392
|
+
if (shouldSeed) {
|
|
393
|
+
const seeded = await seedProfile();
|
|
394
|
+
profile = seeded.profile;
|
|
395
|
+
seedReport = renderSeedReport(seeded.results);
|
|
396
|
+
} else {
|
|
397
|
+
profile = await loadProfile();
|
|
398
|
+
}
|
|
399
|
+
const content = renderProfileMarkdown(profile) + seedReport;
|
|
391
400
|
|
|
392
401
|
const hasOwnership = profile._fieldOwnership && Object.keys(profile._fieldOwnership).length > 0;
|
|
393
402
|
const notes: string[] = [
|