standout 0.5.33 → 0.5.35

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.
Files changed (55) hide show
  1. package/dist/cli.js +7431 -559
  2. package/package.json +9 -3
  3. package/dist/ai-usage.d.ts +0 -164
  4. package/dist/ai-usage.js +0 -1306
  5. package/dist/api.d.ts +0 -10
  6. package/dist/api.js +0 -94
  7. package/dist/browser-session.d.ts +0 -3
  8. package/dist/browser-session.js +0 -227
  9. package/dist/cli.d.ts +0 -2
  10. package/dist/cursor.d.ts +0 -57
  11. package/dist/cursor.js +0 -562
  12. package/dist/dev-env.d.ts +0 -28
  13. package/dist/dev-env.js +0 -264
  14. package/dist/gather.d.ts +0 -82
  15. package/dist/gather.js +0 -922
  16. package/dist/heap.d.ts +0 -1
  17. package/dist/heap.js +0 -25
  18. package/dist/identity.d.ts +0 -8
  19. package/dist/identity.js +0 -29
  20. package/dist/linkedin-scrape.d.ts +0 -2
  21. package/dist/linkedin-scrape.js +0 -56
  22. package/dist/mcp.d.ts +0 -1
  23. package/dist/mcp.js +0 -25
  24. package/dist/payload.d.ts +0 -8
  25. package/dist/payload.js +0 -163
  26. package/dist/prompt.d.ts +0 -1
  27. package/dist/prompt.js +0 -247
  28. package/dist/proxy.d.ts +0 -1
  29. package/dist/proxy.js +0 -27
  30. package/dist/redact.d.ts +0 -1
  31. package/dist/redact.js +0 -37
  32. package/dist/tools.d.ts +0 -4
  33. package/dist/tools.js +0 -182
  34. package/dist/twitter-scrape.d.ts +0 -1
  35. package/dist/twitter-scrape.js +0 -48
  36. package/dist/wrapped/aggregate.d.ts +0 -6
  37. package/dist/wrapped/aggregate.js +0 -811
  38. package/dist/wrapped/chrono-critters.d.ts +0 -8
  39. package/dist/wrapped/chrono-critters.js +0 -32
  40. package/dist/wrapped/index.d.ts +0 -3
  41. package/dist/wrapped/index.js +0 -2
  42. package/dist/wrapped/mascots.d.ts +0 -2
  43. package/dist/wrapped/mascots.js +0 -35
  44. package/dist/wrapped/preview.d.ts +0 -1
  45. package/dist/wrapped/preview.js +0 -97
  46. package/dist/wrapped/render.d.ts +0 -14
  47. package/dist/wrapped/render.js +0 -1025
  48. package/dist/wrapped/tiers.d.ts +0 -9
  49. package/dist/wrapped/tiers.js +0 -73
  50. package/dist/wrapped/types.d.ts +0 -189
  51. package/dist/wrapped/types.js +0 -4
  52. package/dist/wrapped-client.d.ts +0 -16
  53. package/dist/wrapped-client.js +0 -166
  54. package/dist/wrapped-share.d.ts +0 -1
  55. package/dist/wrapped-share.js +0 -6
package/dist/gather.js DELETED
@@ -1,922 +0,0 @@
1
- import { execSync } from "child_process";
2
- import { existsSync, readFileSync, statSync } from "fs";
3
- import { join } from "path";
4
- import { homedir } from "os";
5
- import { STANDOUT_API_URL } from "./api.js";
6
- import { gatherAiUsage } from "./ai-usage.js";
7
- import { gatherDevEnvironment } from "./dev-env.js";
8
- import { scrapeTwitterHandle } from "./twitter-scrape.js";
9
- import { scrapeLinkedinUrl } from "./linkedin-scrape.js";
10
- import { resolveDisplayName } from "./identity.js";
11
- import { redactSecrets } from "./redact.js";
12
- export function emptyGatheredProfile(readiness = {
13
- usage_stats_status: "unavailable",
14
- profile_basics_status: "unavailable",
15
- enrichment_status: "unavailable",
16
- }) {
17
- return {
18
- readiness,
19
- identity: {
20
- name: null,
21
- email: null,
22
- github_username: null,
23
- twitter_username: null,
24
- remotes: [],
25
- },
26
- github: {
27
- profile: null,
28
- repos: [],
29
- starred: [],
30
- following: [],
31
- events: [],
32
- prs_authored: [],
33
- contribution_graph: null,
34
- repo_contents: {},
35
- readmes: {},
36
- commit_messages: {},
37
- npm_packages: [],
38
- enrichment: null,
39
- },
40
- linkedin: {
41
- url: null,
42
- url_resolution_source: null,
43
- profile: null,
44
- company_enrichments: {},
45
- },
46
- twitter: {
47
- handle: null,
48
- url: null,
49
- profile: null,
50
- },
51
- local: {
52
- repos: [],
53
- commit_patterns: [],
54
- collaborators: [],
55
- },
56
- ai_coding: {
57
- total_commits_since_2024: 0,
58
- ai_assisted_commits: 0,
59
- lines_added_since_2024: 0,
60
- lines_deleted_since_2024: 0,
61
- median_commit_size: 0,
62
- biggest_commit_size: 0,
63
- commit_subjects: [],
64
- claude_md_contents: null,
65
- claude_dirs: [],
66
- other_ai_tools: [],
67
- cursorrules: null,
68
- },
69
- ai_usage: {
70
- claude_code: null,
71
- codex: null,
72
- cursor: null,
73
- },
74
- dev_environment: {
75
- agents_md: [],
76
- claude_settings: [],
77
- claude_commands: [],
78
- claude_skills: [],
79
- cursor_rules: [],
80
- vscode_extensions: [],
81
- global_npm_packages: [],
82
- global_brew_packages: [],
83
- global_pip_packages: [],
84
- tools: [],
85
- },
86
- };
87
- }
88
- function hasAnyUsage(aiUsage) {
89
- return Boolean(aiUsage.claude_code || aiUsage.codex || aiUsage.cursor);
90
- }
91
- function gatherLog(options, message) {
92
- if (options.verbose === false)
93
- return;
94
- process.stderr.write(message);
95
- }
96
- export async function gatherUsageStats() {
97
- try {
98
- const aiUsage = await gatherAiUsage();
99
- return {
100
- ai_usage: aiUsage,
101
- readiness: {
102
- usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
103
- profile_basics_status: "unavailable",
104
- enrichment_status: "unavailable",
105
- },
106
- };
107
- }
108
- catch {
109
- return {
110
- ai_usage: {
111
- claude_code: null,
112
- codex: null,
113
- cursor: null,
114
- },
115
- readiness: {
116
- usage_stats_status: "error",
117
- profile_basics_status: "unavailable",
118
- enrichment_status: "unavailable",
119
- },
120
- };
121
- }
122
- }
123
- export async function gatherProfileBasics() {
124
- const base = emptyGatheredProfile({
125
- usage_stats_status: "unavailable",
126
- profile_basics_status: "scanning",
127
- enrichment_status: "unavailable",
128
- });
129
- try {
130
- const name = execSafe("git config user.name");
131
- const email = execSafe("git config user.email");
132
- const remotes = execSafe("git remote -v").split("\n").filter(Boolean);
133
- const remoteCandidates = [];
134
- for (const remote of remotes) {
135
- const match = remote.match(/github\.com[:/]([a-zA-Z0-9_-]+)\/[a-zA-Z0-9_.-]+/);
136
- if (match && !remoteCandidates.includes(match[1])) {
137
- remoteCandidates.push(match[1]);
138
- }
139
- }
140
- let githubUsername = null;
141
- for (const candidate of remoteCandidates) {
142
- const profile = (await fetchGitHub(`https://api.github.com/users/${sanitize(candidate)}`));
143
- if (profile?.type === "User" && profile.login) {
144
- githubUsername = profile.login;
145
- break;
146
- }
147
- }
148
- const safeUsername = githubUsername ? sanitize(githubUsername) : null;
149
- const [ghProfile, ghRepos] = safeUsername
150
- ? await Promise.all([
151
- fetchGitHub(`https://api.github.com/users/${safeUsername}`),
152
- fetchGitHub(`https://api.github.com/users/${safeUsername}/repos?per_page=100&sort=pushed`),
153
- ])
154
- : [null, []];
155
- base.readiness.profile_basics_status = "ready";
156
- base.identity = {
157
- name: name || null,
158
- email: email || null,
159
- github_username: githubUsername,
160
- twitter_username: ghProfile && typeof ghProfile === "object"
161
- ? (ghProfile.twitter_username ??
162
- null)
163
- : null,
164
- remotes,
165
- };
166
- base.github.profile = ghProfile
167
- ? trimProfile(ghProfile)
168
- : null;
169
- base.github.repos = (Array.isArray(ghRepos) ? ghRepos : [])
170
- .slice(0, 30)
171
- .map(trimRepo);
172
- return {
173
- identity: base.identity,
174
- github: base.github,
175
- local: base.local,
176
- readiness: base.readiness,
177
- };
178
- }
179
- catch {
180
- base.readiness.profile_basics_status = "error";
181
- return {
182
- identity: base.identity,
183
- github: base.github,
184
- local: base.local,
185
- readiness: base.readiness,
186
- };
187
- }
188
- }
189
- export async function gatherFullEnrichment(options = {}) {
190
- return gather(options);
191
- }
192
- function execSafe(cmd, timeout = 10000) {
193
- try {
194
- return execSync(cmd, {
195
- timeout,
196
- maxBuffer: 1024 * 1024,
197
- encoding: "utf-8",
198
- stdio: ["pipe", "pipe", "pipe"],
199
- }).trim();
200
- }
201
- catch {
202
- return "";
203
- }
204
- }
205
- // First chunk of a repo's README (or CLAUDE.md fallback) — seeds the one-line
206
- // project blurb. Markdown headers/links are kept; the LLM handles the prose.
207
- function readRepoReadme(repoPath) {
208
- for (const name of ["README.md", "README", "readme.md", "CLAUDE.md"]) {
209
- const p = join(repoPath, name);
210
- if (!existsSync(p))
211
- continue;
212
- try {
213
- const text = readFileSync(p, "utf-8").trim();
214
- // Redact first, THEN slice — slicing first can cut a secret mid-pattern so
215
- // it no longer matches and its head leaks into the excerpt.
216
- if (text.length > 0)
217
- return redactSecrets(text).slice(0, 1200);
218
- }
219
- catch {
220
- // unreadable — try the next candidate
221
- }
222
- }
223
- return null;
224
- }
225
- // All GitHub traffic goes through the Standout-hosted proxy so users don't need a GITHUB_TOKEN.
226
- function toProxyUrl(githubUrl) {
227
- const apiMatch = githubUrl.match(/^https:\/\/api\.github\.com(\/.*)$/);
228
- if (apiMatch) {
229
- return `${STANDOUT_API_URL}/api/public/agent-github/api${apiMatch[1]}`;
230
- }
231
- const rawMatch = githubUrl.match(/^https:\/\/raw\.githubusercontent\.com(\/.*)$/);
232
- if (rawMatch) {
233
- return `${STANDOUT_API_URL}/api/public/agent-github/raw${rawMatch[1]}`;
234
- }
235
- return null;
236
- }
237
- async function fetchGitHub(url, acceptPreview) {
238
- const proxied = toProxyUrl(url);
239
- if (!proxied)
240
- return null;
241
- const headers = { Accept: "application/json" };
242
- if (acceptPreview)
243
- headers["x-github-accept-preview"] = acceptPreview;
244
- try {
245
- const res = await fetch(proxied, { headers });
246
- if (res.status === 403 || res.status === 429) {
247
- process.stderr.write("GitHub proxy rate limit hit. Try again in a few minutes.\n");
248
- return null;
249
- }
250
- if (!res.ok)
251
- return null;
252
- return await res.json();
253
- }
254
- catch {
255
- return null;
256
- }
257
- }
258
- async function fetchGitHubText(url) {
259
- const proxied = toProxyUrl(url);
260
- if (!proxied)
261
- return null;
262
- try {
263
- const res = await fetch(proxied);
264
- if (!res.ok)
265
- return null;
266
- return await res.text();
267
- }
268
- catch {
269
- return null;
270
- }
271
- }
272
- async function fetchJson(url) {
273
- try {
274
- const res = await fetch(url);
275
- if (!res.ok)
276
- return null;
277
- return await res.json();
278
- }
279
- catch {
280
- return null;
281
- }
282
- }
283
- async function enrichApi(type, value, context) {
284
- try {
285
- const res = await fetch(`${STANDOUT_API_URL}/api/public/agent-enrich`, {
286
- method: "POST",
287
- headers: { "Content-Type": "application/json" },
288
- body: JSON.stringify(context ? { type, value, context } : { type, value }),
289
- });
290
- if (!res.ok)
291
- return null;
292
- return (await res.json());
293
- }
294
- catch {
295
- return null;
296
- }
297
- }
298
- function sanitize(input) {
299
- return input.replace(/[^a-zA-Z0-9@._+\-]/g, "");
300
- }
301
- // Co-author signatures left by AI coding tools (Claude, Copilot, Cursor, etc.).
302
- const AI_COAUTHOR_RE = /claude|anthropic|copilot|cursor|devin-ai|aider|codeium|windsurf|sourcegraph|tabnine|google-labs-jules/i;
303
- function findGitRepos() {
304
- const dirs = [
305
- "Document",
306
- "Documents",
307
- "Projects",
308
- "projects",
309
- "code",
310
- "Code",
311
- "dev",
312
- "Dev",
313
- "src",
314
- "work",
315
- "Work",
316
- "workspace",
317
- ];
318
- const home = homedir();
319
- // Dedupe by inode (dev:ino), not path string — on case-insensitive filesystems
320
- // "work" and "Work" are the SAME directory but realpath keeps their distinct
321
- // casing, so a path/realpath-based dedupe would scan + count them twice.
322
- const inodeKey = (p) => {
323
- try {
324
- const s = statSync(p);
325
- return `${s.dev}:${s.ino}`;
326
- }
327
- catch {
328
- return null;
329
- }
330
- };
331
- const seenRoots = new Set();
332
- const searchPaths = [];
333
- for (const d of dirs) {
334
- const p = join(home, d);
335
- if (!existsSync(p))
336
- continue;
337
- const key = inodeKey(p);
338
- if (key && seenRoots.has(key))
339
- continue;
340
- if (key)
341
- seenRoots.add(key);
342
- searchPaths.push(p);
343
- }
344
- if (searchPaths.length === 0)
345
- return [];
346
- const result = execSafe(`find ${searchPaths.join(" ")} -maxdepth 3 -name ".git" -type d`, 15000);
347
- const raw = result ? result.split("\n").filter(Boolean) : [];
348
- // Dedupe repos by inode, then order by most-recently-active (.git mtime) so the
349
- // downstream cap keeps the repos you actually work in, not whatever the
350
- // filesystem happened to list first (e.g. old clones in ~/Documents).
351
- const seen = new Set();
352
- const withMtime = [];
353
- for (const gitDir of raw) {
354
- let mtime = 0;
355
- let key = null;
356
- try {
357
- const s = statSync(gitDir);
358
- mtime = s.mtimeMs;
359
- key = `${s.dev}:${s.ino}`;
360
- }
361
- catch {
362
- // unreadable — fall back to the path string as the dedupe key
363
- }
364
- const dedupe = key ?? gitDir;
365
- if (seen.has(dedupe))
366
- continue;
367
- seen.add(dedupe);
368
- withMtime.push({ path: gitDir, mtime });
369
- }
370
- return withMtime.sort((a, b) => b.mtime - a.mtime).map((r) => r.path);
371
- }
372
- // Trim helpers — keep only fields the LLM needs
373
- function trimRepo(r) {
374
- return {
375
- name: r.name,
376
- language: r.language,
377
- description: r.description,
378
- stars: r.stargazers_count ?? r.stars,
379
- pushed_at: r.pushed_at,
380
- fork: r.fork,
381
- default_branch: r.default_branch,
382
- topics: r.topics,
383
- homepage: r.homepage,
384
- created_at: r.created_at,
385
- };
386
- }
387
- function trimStarred(r) {
388
- return {
389
- full_name: r.full_name,
390
- description: r.description,
391
- language: r.language,
392
- stars: r.stargazers_count,
393
- };
394
- }
395
- function trimFollowing(u) {
396
- return { login: u.login, bio: u.bio };
397
- }
398
- function trimEvent(e) {
399
- const repo = e.repo;
400
- const payload = e.payload;
401
- const pr = payload?.pull_request;
402
- return {
403
- type: e.type,
404
- repo: repo?.name,
405
- created_at: e.created_at,
406
- ...(pr ? { pr_title: pr.title, pr_merged: pr.merged } : {}),
407
- };
408
- }
409
- function trimPr(pr) {
410
- const repoUrl = pr.repository_url;
411
- const pullRequest = pr.pull_request;
412
- return {
413
- title: pr.title,
414
- url: pr.html_url,
415
- repo: repoUrl?.split("/").slice(-2).join("/"),
416
- state: pr.state,
417
- created_at: pr.created_at,
418
- merged_at: pullRequest?.merged_at ?? null,
419
- };
420
- }
421
- function trimProfile(p) {
422
- return {
423
- name: p.name,
424
- bio: p.bio,
425
- location: p.location,
426
- company: p.company,
427
- blog: p.blog,
428
- twitter: p.twitter_username ?? p.twitter,
429
- public_repos: p.public_repos,
430
- followers: p.followers,
431
- following: p.following,
432
- created_at: p.created_at,
433
- hireable: p.hireable,
434
- };
435
- }
436
- function trimContent(f) {
437
- return { name: f.name, type: f.type };
438
- }
439
- function trimNpmPackage(p) {
440
- const pkg = p.package;
441
- return {
442
- name: pkg?.name,
443
- version: pkg?.version,
444
- description: pkg?.description,
445
- };
446
- }
447
- export async function gather(options = {}) {
448
- const localOnly = options.localOnly === true;
449
- const profileBasics = options.profileBasics?.readiness.profile_basics_status === "ready"
450
- ? options.profileBasics
451
- : undefined;
452
- // Phase 1: Git identity
453
- const name = profileBasics
454
- ? (profileBasics.identity.name ?? "")
455
- : execSafe("git config user.name");
456
- const email = profileBasics
457
- ? (profileBasics.identity.email ?? "")
458
- : execSafe("git config user.email");
459
- const remotes = profileBasics
460
- ? profileBasics.identity.remotes
461
- : execSafe("git remote -v").split("\n").filter(Boolean);
462
- // Extract GitHub username candidates from remotes (may be an org, not the user)
463
- let githubUsername = profileBasics?.identity.github_username ?? null;
464
- if (!profileBasics) {
465
- const remoteCandidates = [];
466
- for (const remote of remotes) {
467
- const match = remote.match(/github\.com[:/]([a-zA-Z0-9_-]+)\/[a-zA-Z0-9_.-]+/);
468
- if (match && !remoteCandidates.includes(match[1])) {
469
- remoteCandidates.push(match[1]);
470
- }
471
- }
472
- if (localOnly) {
473
- // No network to verify User vs Org — take the first remote candidate as a
474
- // best-effort handle. Only used to label identity; the deck falls back to
475
- // the git name regardless.
476
- githubUsername = remoteCandidates[0] ?? null;
477
- }
478
- else {
479
- // Resolve to a USER (not an Organization) profile
480
- for (const candidate of remoteCandidates) {
481
- const profile = (await fetchGitHub(`https://api.github.com/users/${sanitize(candidate)}`));
482
- if (profile?.type === "User" && profile.login) {
483
- githubUsername = profile.login;
484
- break;
485
- }
486
- }
487
- }
488
- }
489
- // Fallback: search GitHub by email (public profile email match)
490
- if (!githubUsername && email && !localOnly) {
491
- const searchResult = (await fetchGitHub(`https://api.github.com/search/users?q=${encodeURIComponent(email)}+in:email`));
492
- if (searchResult?.items?.[0]) {
493
- githubUsername = searchResult.items[0].login;
494
- }
495
- }
496
- // Fallback: search commits by author-email (catches users with private email)
497
- if (!githubUsername && email && !localOnly) {
498
- const commitSearch = (await fetchGitHub(`https://api.github.com/search/commits?q=author-email:${encodeURIComponent(email)}`));
499
- const login = commitSearch?.items?.[0]?.author?.login;
500
- if (login)
501
- githubUsername = login;
502
- }
503
- const safeUsername = githubUsername ? sanitize(githubUsername) : null;
504
- const safeEmail = email ? sanitize(email) : null;
505
- // Phase 2: GitHub API (parallelized)
506
- gatherLog(options, "Gathering GitHub data...\n");
507
- const canUseCachedGithubBasics = Boolean(profileBasics?.identity.github_username) &&
508
- profileBasics?.identity.github_username === githubUsername;
509
- const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername && !localOnly
510
- ? await Promise.all([
511
- canUseCachedGithubBasics
512
- ? Promise.resolve(profileBasics?.github.profile ?? null)
513
- : fetchGitHub(`https://api.github.com/users/${safeUsername}`),
514
- canUseCachedGithubBasics
515
- ? Promise.resolve(profileBasics?.github.repos ?? [])
516
- : fetchGitHub(`https://api.github.com/users/${safeUsername}/repos?per_page=100&sort=pushed`),
517
- fetchGitHub(`https://api.github.com/users/${safeUsername}/starred?per_page=100`),
518
- fetchGitHub(`https://api.github.com/users/${safeUsername}/following?per_page=100`),
519
- fetchGitHub(`https://api.github.com/users/${safeUsername}/events?per_page=30`),
520
- fetchGitHub(`https://api.github.com/search/issues?q=author:${safeUsername}+type:pr&per_page=20`),
521
- fetchJson(`https://github-contributions-api.jogruber.de/v4/${safeUsername}?y=last`),
522
- fetchJson(`https://registry.npmjs.org/-/v1/search?text=maintainer:${safeUsername}&size=20`),
523
- ])
524
- : [null, [], [], [], [], { items: [] }, null, { objects: [] }];
525
- const repos = Array.isArray(ghRepos) ? ghRepos : [];
526
- const nonForkRepos = repos.filter((r) => !r.fork && r.language);
527
- // Phase 2b: Repo contents, READMEs, commit messages (top repos)
528
- const topRepos = nonForkRepos.slice(0, 5);
529
- const repoContents = {};
530
- const readmes = {};
531
- const commitMessages = {};
532
- if (safeUsername && !localOnly) {
533
- const contentPromises = topRepos.map(async (repo) => {
534
- const repoName = repo.name;
535
- const contents = (await fetchGitHub(`https://api.github.com/repos/${safeUsername}/${repoName}/contents`));
536
- if (Array.isArray(contents)) {
537
- repoContents[repoName] = contents.map(trimContent);
538
- }
539
- });
540
- const readmeRepos = nonForkRepos.slice(0, 3);
541
- const readmePromises = readmeRepos.map(async (repo) => {
542
- const repoName = repo.name;
543
- const defaultBranch = repo.default_branch || "main";
544
- const text = await fetchGitHubText(`https://raw.githubusercontent.com/${safeUsername}/${repoName}/${defaultBranch}/README.md`);
545
- if (text)
546
- readmes[repoName] = text.slice(0, 3000);
547
- });
548
- const commitRepos = nonForkRepos.slice(0, 3);
549
- const commitPromises = commitRepos.map(async (repo) => {
550
- const repoName = repo.name;
551
- const commits = (await fetchGitHub(`https://api.github.com/repos/${safeUsername}/${repoName}/commits?per_page=10`));
552
- if (Array.isArray(commits)) {
553
- commitMessages[repoName] = commits
554
- .map((c) => c.commit?.message || "")
555
- .filter(Boolean)
556
- .map((m) => m.split("\n")[0].slice(0, 120));
557
- }
558
- });
559
- await Promise.all([
560
- ...contentPromises,
561
- ...readmePromises,
562
- ...commitPromises,
563
- ]);
564
- }
565
- // Phase 2c: AI tool usage + dev environment (run in background during LinkedIn)
566
- gatherLog(options, "Scanning AI tool usage and dev environment...\n");
567
- const githubEnrichPromise = safeUsername && !localOnly
568
- ? enrichApi("github", safeUsername).catch(() => null)
569
- : Promise.resolve(null);
570
- const aiUsagePromise = options.aiUsage
571
- ? Promise.resolve(options.aiUsage)
572
- : gatherAiUsage().catch(() => ({
573
- claude_code: null,
574
- codex: null,
575
- cursor: null,
576
- }));
577
- const devEnvPromise = gatherDevEnvironment().catch(() => ({
578
- agents_md: [],
579
- claude_settings: [],
580
- claude_commands: [],
581
- claude_skills: [],
582
- cursor_rules: [],
583
- vscode_extensions: [],
584
- global_npm_packages: [],
585
- global_brew_packages: [],
586
- global_pip_packages: [],
587
- tools: [],
588
- }));
589
- // Phase 3: LinkedIn enrichment
590
- gatherLog(options, "Checking LinkedIn...\n");
591
- let linkedinUrl = null;
592
- let linkedinResolutionSource = null;
593
- let linkedinProfile = null;
594
- const companyEnrichments = {};
595
- if (safeEmail && !localOnly) {
596
- const emailResult = await enrichApi("email_to_linkedin", email);
597
- if (emailResult && typeof emailResult === "object") {
598
- linkedinUrl =
599
- emailResult
600
- .linkedin_profile_url || null;
601
- if (linkedinUrl)
602
- linkedinResolutionSource = "email";
603
- }
604
- }
605
- // Fallback: name + signals via AI web search when email lookup comes up empty
606
- if (!linkedinUrl && ghProfile && name && !localOnly) {
607
- gatherLog(options, "Resolving LinkedIn via name + signals...\n");
608
- const profile = ghProfile;
609
- const signals = [];
610
- if (profile.company)
611
- signals.push(`Company: ${profile.company}`);
612
- if (profile.location)
613
- signals.push(`Location: ${profile.location}`);
614
- if (profile.bio)
615
- signals.push(`Bio: ${profile.bio}`);
616
- if (profile.blog)
617
- signals.push(`Website/blog: ${profile.blog}`);
618
- if (safeUsername)
619
- signals.push(`GitHub handle: ${safeUsername}`);
620
- const topLangs = nonForkRepos
621
- .slice(0, 5)
622
- .map((r) => r.language)
623
- .filter(Boolean);
624
- if (topLangs.length)
625
- signals.push(`Top GitHub languages: ${[...new Set(topLangs)].join(", ")}`);
626
- const topRepoNames = nonForkRepos
627
- .slice(0, 3)
628
- .map((r) => r.name);
629
- if (topRepoNames.length)
630
- signals.push(`Notable GitHub repos: ${topRepoNames.join(", ")}`);
631
- const resolveResult = await enrichApi("resolve_linkedin", name, {
632
- email_domain: email ? email.split("@")[1] : undefined,
633
- signals,
634
- });
635
- const resolved = resolveResult;
636
- if (resolved?.linkedin_url) {
637
- linkedinUrl = resolved.linkedin_url;
638
- linkedinResolutionSource = "ai_search";
639
- }
640
- }
641
- // Fallback: read a logged-in LinkedIn session from the local Chrome profile
642
- // (headless, silent). Same technique as the X handle scrape — the only anchor
643
- // for users with no git email/name and no GitHub remote. Reads the local
644
- // browser but resolves a URL the enrich proxy would then look up, so it's
645
- // skipped in local-only mode too.
646
- if (!linkedinUrl && !localOnly) {
647
- gatherLog(options, "Checking for a LinkedIn session in Chrome...\n");
648
- try {
649
- const scraped = await scrapeLinkedinUrl();
650
- if (scraped) {
651
- linkedinUrl = scraped;
652
- linkedinResolutionSource = "chrome_session";
653
- }
654
- }
655
- catch {
656
- // silent
657
- }
658
- }
659
- if (linkedinUrl) {
660
- linkedinProfile = await enrichApi("linkedin_profile", linkedinUrl);
661
- // Company enrichment
662
- if (linkedinProfile &&
663
- Array.isArray(linkedinProfile.experiences)) {
664
- const experiences = linkedinProfile.experiences;
665
- const companyUrls = [
666
- ...new Set(experiences.map((e) => e.company_linkedin_profile_url).filter(Boolean)),
667
- ].slice(0, 5);
668
- await Promise.all(companyUrls.map(async (url) => {
669
- const data = await enrichApi("company", url);
670
- if (data)
671
- companyEnrichments[url] = data;
672
- }));
673
- }
674
- }
675
- // Phase 3b: Twitter/X handle + optional enrichment
676
- let twitterHandle = profileBasics?.identity.twitter_username ?? null;
677
- if (ghProfile && typeof ghProfile === "object") {
678
- const raw = ghProfile
679
- .twitter_username ??
680
- ghProfile.twitter;
681
- if (!twitterHandle && typeof raw === "string" && raw.trim()) {
682
- twitterHandle = raw.trim();
683
- }
684
- }
685
- if (!twitterHandle && linkedinProfile) {
686
- const socialHandles = linkedinProfile.social_handles;
687
- if (socialHandles && typeof socialHandles.twitter === "string") {
688
- twitterHandle = socialHandles.twitter;
689
- }
690
- }
691
- // Fallback: scrape the user's logged-in Chrome session for an X handle.
692
- if (!twitterHandle && !localOnly) {
693
- gatherLog(options, "Checking for an X/Twitter session in Chrome...\n");
694
- try {
695
- const scraped = await scrapeTwitterHandle();
696
- if (scraped)
697
- twitterHandle = scraped;
698
- }
699
- catch {
700
- // silent
701
- }
702
- }
703
- const safeTwitter = twitterHandle ? sanitize(twitterHandle) : null;
704
- let twitterProfile = null;
705
- if (safeTwitter && !localOnly) {
706
- twitterProfile = await enrichApi("twitter", safeTwitter);
707
- }
708
- const twitterUrl = safeTwitter ? `https://x.com/${safeTwitter}` : null;
709
- // Phase 4: Local machine analysis
710
- gatherLog(options, "Scanning local repos...\n");
711
- const gitRepoPaths = findGitRepos();
712
- const localRepos = [];
713
- // Aggregated line stats across all local repos (since 2024).
714
- let linesAdded = 0;
715
- let linesDeleted = 0;
716
- const commitSizes = [];
717
- for (const gitDir of gitRepoPaths.slice(0, 30)) {
718
- const repoPath = gitDir.replace(/\/\.git$/, "");
719
- if (!safeEmail)
720
- continue;
721
- const count = parseInt(execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --oneline | wc -l`), 10);
722
- const lastCommit = execSafe(`git -C "${repoPath}" log --author="${safeEmail}" -1 --format="%ai %s"`);
723
- if (count > 0) {
724
- localRepos.push({
725
- path: repoPath.replace(homedir(), "~"),
726
- name: repoPath.split("/").filter(Boolean).pop() ?? repoPath,
727
- commit_count: count || 0,
728
- last_commit: lastCommit,
729
- readme_excerpt: readRepoReadme(repoPath),
730
- });
731
- // Per-commit added/deleted lines (since 2024). The %H line marks a commit;
732
- // numstat lines are "added\tdeleted\tfile" ("-" for binary).
733
- const numstat = execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --since="2024-01-01" --pretty=tformat:"%H" --numstat`);
734
- let cur = 0;
735
- let inCommit = false;
736
- for (const line of numstat.split("\n")) {
737
- if (/^[0-9a-f]{40}$/.test(line)) {
738
- if (inCommit)
739
- commitSizes.push(cur);
740
- cur = 0;
741
- inCommit = true;
742
- continue;
743
- }
744
- const t = line.split("\t");
745
- if (t.length >= 2) {
746
- const a = t[0] === "-" ? 0 : parseInt(t[0], 10) || 0;
747
- const d = t[1] === "-" ? 0 : parseInt(t[1], 10) || 0;
748
- linesAdded += a;
749
- linesDeleted += d;
750
- cur += a + d;
751
- }
752
- }
753
- if (inCommit)
754
- commitSizes.push(cur);
755
- }
756
- }
757
- commitSizes.sort((a, b) => a - b);
758
- const medianCommitSize = commitSizes.length
759
- ? commitSizes[Math.floor(commitSizes.length / 2)]
760
- : 0;
761
- const biggestCommitSize = commitSizes.length
762
- ? commitSizes[commitSizes.length - 1]
763
- : 0;
764
- // Commit patterns from largest local repo
765
- const commitPatterns = [];
766
- const collaborators = [];
767
- if (localRepos.length > 0 && safeEmail) {
768
- const sorted = [...localRepos].sort((a, b) => b.commit_count - a.commit_count);
769
- const repoPath = sorted[0].path.replace("~", homedir());
770
- const patterns = execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --format="%ai" --since="2024-01-01"`);
771
- if (patterns) {
772
- commitPatterns.push(...patterns.split("\n").filter(Boolean).slice(0, 200));
773
- }
774
- const collabs = execSafe(`git -C "${repoPath}" log --format="%aN <%aE>" --since="2024-01-01" | sort | uniq -c | sort -rn | head -10`);
775
- if (collabs) {
776
- collaborators.push(...collabs.split("\n").filter(Boolean));
777
- }
778
- }
779
- // Phase 5: AI coding tool analysis
780
- gatherLog(options, "Analyzing AI coding tools...\n");
781
- let totalCommits = 0;
782
- let aiAssistedCommits = 0;
783
- // Count commits since 2024 across ALL local repos (matching the git email),
784
- // deduped by SHA so worktrees/clones of the same repo count once. A commit is
785
- // AI-assisted when it carries a co-author trailer from a known AI coding tool —
786
- // matched case-insensitively via git's trailer parser, not a literal grep.
787
- if (safeEmail) {
788
- const fieldSep = String.fromCharCode(0x1f);
789
- const recordSep = String.fromCharCode(0x1e);
790
- const allCommitShas = new Set();
791
- const aiCommitShas = new Set();
792
- for (const gitDir of gitRepoPaths.slice(0, 30)) {
793
- const repoPath = gitDir.replace(/\/\.git$/, "");
794
- const log = execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --since="2024-01-01" --format="%H%x1f%(trailers:key=Co-authored-by,valueonly)%x1e"`);
795
- for (const entry of log.split(recordSep)) {
796
- const trimmed = entry.trim();
797
- if (!trimmed)
798
- continue;
799
- const sepIdx = trimmed.indexOf(fieldSep);
800
- const sha = sepIdx === -1 ? trimmed : trimmed.slice(0, sepIdx);
801
- const coauthors = sepIdx === -1 ? "" : trimmed.slice(sepIdx + 1);
802
- if (!sha)
803
- continue;
804
- allCommitShas.add(sha);
805
- if (coauthors && AI_COAUTHOR_RE.test(coauthors))
806
- aiCommitShas.add(sha);
807
- }
808
- }
809
- totalCommits = allCommitShas.size;
810
- aiAssistedCommits = aiCommitShas.size;
811
- }
812
- // Recent commit subjects (last 90 days) — feed the "what you shipped" summary.
813
- let commitSubjects = [];
814
- if (safeEmail) {
815
- const subjectsRaw = execSafe(`git log --author="${safeEmail}" --since="90 days ago" --no-merges --format="%s"`);
816
- commitSubjects = subjectsRaw
817
- ? subjectsRaw
818
- .split("\n")
819
- .map((s) => s.trim())
820
- .filter(Boolean)
821
- .slice(0, 200)
822
- .map((s) => redactSecrets(s).slice(0, 160))
823
- : [];
824
- }
825
- const claudeMd = existsSync("CLAUDE.md")
826
- ? readFileSync("CLAUDE.md", "utf-8").slice(0, 3000)
827
- : null;
828
- const claudeDirsRaw = execSafe(`find ${homedir()} -maxdepth 3 -path "*/.claude" -type d | head -5`);
829
- const claudeDirs = claudeDirsRaw
830
- ? claudeDirsRaw
831
- .split("\n")
832
- .filter(Boolean)
833
- .map((d) => d.replace(homedir(), "~"))
834
- : [];
835
- const otherAiTools = [];
836
- const aiToolPaths = [".cursor", ".continue", ".github/copilot", ".codeium"];
837
- for (const tool of aiToolPaths) {
838
- if (existsSync(join(homedir(), tool))) {
839
- otherAiTools.push(tool);
840
- }
841
- }
842
- let cursorrules = null;
843
- if (existsSync(".cursorrules")) {
844
- cursorrules = readFileSync(".cursorrules", "utf-8").slice(0, 2000);
845
- }
846
- // Phase 6: Await AI usage + dev environment + GitHub enrichment
847
- const [aiUsage, devEnvironment, githubEnrichment] = await Promise.all([
848
- aiUsagePromise,
849
- devEnvPromise,
850
- githubEnrichPromise,
851
- ]);
852
- gatherLog(options, "Done gathering.\n");
853
- const resolvedName = resolveDisplayName({
854
- gitName: name,
855
- githubName: ghProfile?.name,
856
- linkedinProfile: linkedinProfile,
857
- githubUsername,
858
- twitterHandle,
859
- });
860
- return {
861
- readiness: {
862
- usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
863
- profile_basics_status: "ready",
864
- // Local-only mode performs no network enrichment.
865
- enrichment_status: localOnly ? "unavailable" : "ready",
866
- },
867
- identity: {
868
- name: resolvedName,
869
- email: email || null,
870
- github_username: githubUsername,
871
- twitter_username: twitterHandle,
872
- remotes,
873
- },
874
- github: {
875
- profile: ghProfile
876
- ? trimProfile(ghProfile)
877
- : null,
878
- repos: repos.slice(0, 50).map(trimRepo),
879
- starred: (Array.isArray(ghStarred) ? ghStarred : []).map(trimStarred),
880
- following: (Array.isArray(ghFollowing) ? ghFollowing : []).map(trimFollowing),
881
- events: (Array.isArray(ghEvents) ? ghEvents : []).map(trimEvent),
882
- prs_authored: (ghPrs?.items || []).map(trimPr),
883
- contribution_graph: ghContrib || null,
884
- repo_contents: repoContents,
885
- readmes,
886
- commit_messages: commitMessages,
887
- npm_packages: (ghNpm?.objects || []).map(trimNpmPackage),
888
- enrichment: githubEnrichment,
889
- },
890
- linkedin: {
891
- url: linkedinUrl,
892
- url_resolution_source: linkedinResolutionSource,
893
- profile: linkedinProfile,
894
- company_enrichments: companyEnrichments,
895
- },
896
- twitter: {
897
- handle: twitterHandle,
898
- url: twitterUrl,
899
- profile: twitterProfile,
900
- },
901
- local: {
902
- repos: localRepos,
903
- commit_patterns: commitPatterns,
904
- collaborators,
905
- },
906
- ai_coding: {
907
- total_commits_since_2024: totalCommits,
908
- ai_assisted_commits: aiAssistedCommits,
909
- lines_added_since_2024: linesAdded,
910
- lines_deleted_since_2024: linesDeleted,
911
- median_commit_size: medianCommitSize,
912
- biggest_commit_size: biggestCommitSize,
913
- commit_subjects: commitSubjects,
914
- claude_md_contents: claudeMd,
915
- claude_dirs: claudeDirs,
916
- other_ai_tools: otherAiTools,
917
- cursorrules,
918
- },
919
- ai_usage: aiUsage,
920
- dev_environment: devEnvironment,
921
- };
922
- }