@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.6",
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.6",
60
- "@f5-sales-demo/pi-agent-core": "19.63.6",
61
- "@f5-sales-demo/pi-ai": "19.63.6",
62
- "@f5-sales-demo/pi-natives": "19.63.6",
63
- "@f5-sales-demo/pi-resource-management": "19.63.6",
64
- "@f5-sales-demo/pi-tui": "19.63.6",
65
- "@f5-sales-demo/pi-utils": "19.63.6",
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.6",
21
- "commit": "f24790e4f2617287c53171fdc6596bb257dfa499",
22
- "shortCommit": "f24790e",
20
+ "version": "19.63.7",
21
+ "commit": "60a30a905669791dd0c2f600254d6b95c85ecd49",
22
+ "shortCommit": "60a30a9",
23
23
  "branch": "main",
24
- "tag": "v19.63.6",
25
- "commitDate": "2026-07-17T15:34:43Z",
26
- "buildDate": "2026-07-17T15:57:30.152Z",
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/f24790e4f2617287c53171fdc6596bb257dfa499",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.63.6"
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
- // System (macOS)
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 $`defaults read NSGlobalDomain AppleLanguages`.quiet().nothrow();
264
+ const proc = await runCli(["defaults", "read", "NSGlobalDomain", "AppleLanguages"]);
25
265
  if (proc.exitCode !== 0) return [];
26
266
 
27
- const raw = proc.stdout.toString().trim();
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
- export async function seedProfile(): Promise<UserProfile> {
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
- // Sources
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
- if (profile.sources.github) srcLines.push(`GitHub: ${profile.sources.github}`);
356
- if (profile.sources.system) srcLines.push(`System: ${profile.sources.system}`);
357
- if (profile.sources.conversation) srcLines.push(`Conversation: ${profile.sources.conversation}`);
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 { loadProfile, renderProfileMarkdown, seedProfile } from "./user-profile";
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
- const profile = shouldSeed ? await seedProfile() : await loadProfile();
390
- const content = renderProfileMarkdown(profile);
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[] = [