standout 0.5.30 → 0.5.32

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.
@@ -0,0 +1,3 @@
1
+ import { type BrowserContext } from "playwright-core";
2
+ export declare const NAV_TIMEOUT_MS = 20000;
3
+ export declare function scrapeFromChromeSession<T>(extract: (context: BrowserContext) => Promise<T | null>): Promise<T | null>;
@@ -0,0 +1,227 @@
1
+ import { chromium } from "playwright-core";
2
+ import { existsSync, readdirSync, statSync, mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, } from "fs";
3
+ import { homedir, platform, tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { spawn } from "child_process";
6
+ const DEBUG_PORT_WAIT_MS = 10000;
7
+ export const NAV_TIMEOUT_MS = 20000;
8
+ const TOTAL_TIMEOUT_MS = 40000;
9
+ function wait(ms) {
10
+ return new Promise((r) => setTimeout(r, ms));
11
+ }
12
+ function findChromeExecutable() {
13
+ const os = platform();
14
+ const candidates = os === "darwin"
15
+ ? [
16
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
17
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
18
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
19
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
20
+ ]
21
+ : os === "linux"
22
+ ? [
23
+ "/usr/bin/google-chrome",
24
+ "/usr/bin/google-chrome-stable",
25
+ "/usr/bin/chromium",
26
+ "/usr/bin/chromium-browser",
27
+ "/snap/bin/chromium",
28
+ ]
29
+ : os === "win32"
30
+ ? [
31
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
32
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
33
+ ]
34
+ : [];
35
+ for (const path of candidates) {
36
+ if (existsSync(path))
37
+ return path;
38
+ }
39
+ return null;
40
+ }
41
+ function findChromeProfileRoot() {
42
+ const os = platform();
43
+ const home = homedir();
44
+ const candidates = os === "darwin"
45
+ ? [
46
+ join(home, "Library", "Application Support", "Google", "Chrome"),
47
+ join(home, "Library", "Application Support", "Chromium"),
48
+ ]
49
+ : os === "linux"
50
+ ? [
51
+ join(home, ".config", "google-chrome"),
52
+ join(home, ".config", "chromium"),
53
+ ]
54
+ : os === "win32"
55
+ ? [join(home, "AppData", "Local", "Google", "Chrome", "User Data")]
56
+ : [];
57
+ for (const path of candidates) {
58
+ if (existsSync(path) && existsSync(join(path, "Local State")))
59
+ return path;
60
+ }
61
+ return null;
62
+ }
63
+ function listProfilesByMtime(profileRoot) {
64
+ try {
65
+ return readdirSync(profileRoot)
66
+ .filter((d) => existsSync(join(profileRoot, d, "Cookies")))
67
+ .map((d) => ({
68
+ name: d,
69
+ mtime: statSync(join(profileRoot, d, "Cookies")).mtimeMs,
70
+ }))
71
+ .sort((a, b) => b.mtime - a.mtime)
72
+ .map((p) => p.name);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
78
+ function prepareProfileCopy(profileRoot, profileName) {
79
+ try {
80
+ const tempRoot = mkdtempSync(join(tmpdir(), "standout-chrome-"));
81
+ mkdirSync(join(tempRoot, "Default"), { recursive: true });
82
+ const localState = join(profileRoot, "Local State");
83
+ if (existsSync(localState)) {
84
+ copyFileSync(localState, join(tempRoot, "Local State"));
85
+ }
86
+ const sourceDir = join(profileRoot, profileName);
87
+ for (const name of ["Cookies", "Cookies-journal", "Preferences"]) {
88
+ const src = join(sourceDir, name);
89
+ if (existsSync(src)) {
90
+ copyFileSync(src, join(tempRoot, "Default", name));
91
+ }
92
+ }
93
+ return tempRoot;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ async function waitForDebugPort(userDataDir, timeoutMs) {
100
+ const deadline = Date.now() + timeoutMs;
101
+ const portFile = join(userDataDir, "DevToolsActivePort");
102
+ while (Date.now() < deadline) {
103
+ if (existsSync(portFile)) {
104
+ try {
105
+ const [port] = readFileSync(portFile, "utf-8").trim().split("\n");
106
+ if (port && /^\d+$/.test(port))
107
+ return parseInt(port, 10);
108
+ }
109
+ catch {
110
+ // keep polling
111
+ }
112
+ }
113
+ await wait(200);
114
+ }
115
+ return null;
116
+ }
117
+ async function spawnChrome(executablePath, userDataDir) {
118
+ const os = platform();
119
+ const args = [
120
+ "--headless=new",
121
+ `--user-data-dir=${userDataDir}`,
122
+ "--remote-debugging-port=0",
123
+ "--no-first-run",
124
+ "--no-default-browser-check",
125
+ "--disable-sync",
126
+ "--disable-features=AutofillServerCommunication",
127
+ ];
128
+ const cleanupFns = [];
129
+ if (os === "darwin") {
130
+ // Use `open -n -a` to force a new Chrome process instance on macOS
131
+ // (direct binary launch gets intercepted by the singleton running Chrome).
132
+ const appBundle = executablePath.replace(/\.app\/Contents\/MacOS\/.*$/, ".app");
133
+ const child = spawn("open", ["-n", "-a", appBundle, "--args", ...args], {
134
+ detached: true,
135
+ stdio: "ignore",
136
+ });
137
+ child.unref();
138
+ cleanupFns.push(() => {
139
+ try {
140
+ spawn("pkill", ["-f", `user-data-dir=${userDataDir}`]);
141
+ }
142
+ catch {
143
+ // skip
144
+ }
145
+ });
146
+ }
147
+ else {
148
+ const child = spawn(executablePath, args, {
149
+ detached: true,
150
+ stdio: "ignore",
151
+ });
152
+ child.unref();
153
+ cleanupFns.push(() => {
154
+ try {
155
+ if (child.pid)
156
+ process.kill(child.pid);
157
+ }
158
+ catch {
159
+ // skip
160
+ }
161
+ });
162
+ }
163
+ const port = await waitForDebugPort(userDataDir, DEBUG_PORT_WAIT_MS);
164
+ return {
165
+ port,
166
+ cleanup: () => cleanupFns.forEach((fn) => fn()),
167
+ };
168
+ }
169
+ async function withTimeout(promise, ms) {
170
+ return Promise.race([
171
+ promise,
172
+ new Promise((resolve) => setTimeout(() => resolve(null), ms)),
173
+ ]);
174
+ }
175
+ async function withProfileContext(executablePath, profileRoot, profileName, extract) {
176
+ const tempProfile = prepareProfileCopy(profileRoot, profileName);
177
+ if (!tempProfile)
178
+ return null;
179
+ const { port, cleanup } = await spawnChrome(executablePath, tempProfile);
180
+ let browser = null;
181
+ try {
182
+ if (!port)
183
+ return null;
184
+ browser = await chromium.connectOverCDP(`http://localhost:${port}`);
185
+ const contexts = browser.contexts();
186
+ const context = contexts[0] ?? (await browser.newContext());
187
+ return await extract(context);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ finally {
193
+ if (browser)
194
+ await browser.close().catch(() => undefined);
195
+ cleanup();
196
+ await wait(500);
197
+ try {
198
+ rmSync(tempProfile, { recursive: true, force: true });
199
+ }
200
+ catch {
201
+ // skip
202
+ }
203
+ }
204
+ }
205
+ // Headless, silent read of the user's logged-in Chrome session. Copies each
206
+ // Chrome profile (newest-cookie first) into a throwaway dir, opens it headless,
207
+ // and hands the browser context to `extract`. Returns the first non-null result;
208
+ // any failure (no Chrome, no session, checkpoint, timeout) resolves to null.
209
+ export async function scrapeFromChromeSession(extract) {
210
+ const executablePath = findChromeExecutable();
211
+ if (!executablePath)
212
+ return null;
213
+ const profileRoot = findChromeProfileRoot();
214
+ if (!profileRoot)
215
+ return null;
216
+ const profiles = listProfilesByMtime(profileRoot);
217
+ if (profiles.length === 0)
218
+ return null;
219
+ return withTimeout((async () => {
220
+ for (const profile of profiles) {
221
+ const result = await withProfileContext(executablePath, profileRoot, profile, extract).catch(() => null);
222
+ if (result)
223
+ return result;
224
+ }
225
+ return null;
226
+ })(), TOTAL_TIMEOUT_MS);
227
+ }
package/dist/cli.js CHANGED
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import Anthropic from "@anthropic-ai/sdk";
3
3
  import chalk from "chalk";
4
+ import http from "node:http";
4
5
  import { createInterface } from "readline";
5
6
  import { markWrappedShared, STANDOUT_API_URL } from "./api.js";
7
+ import { capPayload } from "./payload.js";
6
8
  import { gatherFullEnrichment, gatherProfileBasics, gatherUsageStats, } from "./gather.js";
7
9
  import { ensureHeapHeadroom } from "./heap.js";
8
10
  import { startMcpServer } from "./mcp.js";
9
11
  import { configureProxyFromEnv } from "./proxy.js";
10
12
  import { SYSTEM_PROMPT } from "./prompt.js";
11
13
  import { TOOLS, executeTool } from "./tools.js";
14
+ import { aggregateView } from "./wrapped/aggregate.js";
12
15
  import { renderWrappedAll } from "./wrapped/render.js";
13
16
  import { createWrapped } from "./wrapped-client.js";
14
17
  import { openWrapped } from "./wrapped-share.js";
@@ -23,6 +26,114 @@ function profileChatEnabled() {
23
26
  return true;
24
27
  return process.argv.slice(2).includes("--chat");
25
28
  }
29
+ // Local-only mode: compute and render the wrapped entirely on this machine. No
30
+ // profile is uploaded, no wrapped row is created, no leaderboard entry is made,
31
+ // and gathering skips every network call. Enable with --local / --no-upload or
32
+ // STANDOUT_LOCAL=1.
33
+ function localOnlyEnabled() {
34
+ const env = process.env.STANDOUT_LOCAL;
35
+ if (env === "1" || env === "true")
36
+ return true;
37
+ const args = process.argv.slice(2);
38
+ return args.includes("--local") || args.includes("--no-upload");
39
+ }
40
+ function printBanner() {
41
+ console.log("\n ┌────────────────────────────────┐");
42
+ console.log(" │ are you really tokenmaxxing? │");
43
+ console.log(" │ Powered by Standout (YC P26) │");
44
+ console.log(" └────────────────────────────────┘\n");
45
+ }
46
+ // First step of the default flow: an arrow-key menu to pick full (cloud) vs
47
+ // local-only. Non-interactive shells (CI, agents) can't answer, so they keep the
48
+ // full flow — pass --local / STANDOUT_LOCAL=1 to force local without a prompt.
49
+ const MODE_OPTIONS = [
50
+ {
51
+ key: "full",
52
+ title: "Full",
53
+ tag: " (recommended)",
54
+ lines: [
55
+ "Your full AI wrapped: LLM-written cards, your score, and a leaderboard rank.",
56
+ "Reads your local stats plus your public GitHub / LinkedIn / X.",
57
+ ],
58
+ },
59
+ {
60
+ key: "local",
61
+ title: "Local only",
62
+ tag: " — private & offline",
63
+ lines: [
64
+ "Stays entirely on your computer. Nothing uploaded, nothing saved.",
65
+ "Just your local AI-tool stats — no LLM cards, score, or ranking.",
66
+ ],
67
+ },
68
+ ];
69
+ function renderModeMenu(selected) {
70
+ const out = [
71
+ "",
72
+ chalk.white(" How do you want to run Standout?"),
73
+ "",
74
+ ];
75
+ MODE_OPTIONS.forEach((opt, i) => {
76
+ const active = i === selected;
77
+ const pointer = active ? chalk.hex("#ad5cff")("❯ ") : " ";
78
+ const title = active
79
+ ? chalk.bold.white(opt.title) + chalk.dim(opt.tag)
80
+ : chalk.dim(opt.title + opt.tag);
81
+ out.push(`${pointer}${title}`);
82
+ for (const line of opt.lines)
83
+ out.push(chalk.dim(` ${line}`));
84
+ out.push("");
85
+ });
86
+ out.push(chalk.dim(" ↑/↓ to move · enter to select"));
87
+ return out.join("\n");
88
+ }
89
+ async function chooseMode() {
90
+ if (!process.stdin.isTTY)
91
+ return "full";
92
+ let selected = 0;
93
+ const draw = () => {
94
+ const text = renderModeMenu(selected);
95
+ process.stderr.write(text + "\n");
96
+ return text.split("\n").length;
97
+ };
98
+ return new Promise((resolve) => {
99
+ const stdin = process.stdin;
100
+ stdin.setRawMode(true);
101
+ stdin.resume();
102
+ stdin.setEncoding("utf8");
103
+ let lines = draw();
104
+ const redraw = () => {
105
+ // Jump back to the menu's first line and clear it before redrawing.
106
+ process.stderr.write(`\x1b[${lines}A\x1b[0J`);
107
+ lines = draw();
108
+ };
109
+ const cleanup = () => {
110
+ stdin.setRawMode(false);
111
+ stdin.pause();
112
+ stdin.removeListener("data", onData);
113
+ };
114
+ const onData = (data) => {
115
+ if (data === "\x03") {
116
+ cleanup();
117
+ process.stderr.write("\n Cancelled.\n");
118
+ process.exit(0);
119
+ }
120
+ else if (data === "\r" || data === "\n") {
121
+ cleanup();
122
+ process.stderr.write("\n");
123
+ resolve(MODE_OPTIONS[selected].key);
124
+ }
125
+ else if (data === "\x1b[A" || data === "k") {
126
+ selected = (selected + MODE_OPTIONS.length - 1) % MODE_OPTIONS.length;
127
+ redraw();
128
+ }
129
+ else if (data === "\x1b[B" || data === "j") {
130
+ selected = (selected + 1) % MODE_OPTIONS.length;
131
+ redraw();
132
+ }
133
+ };
134
+ stdin.on("data", onData);
135
+ });
136
+ }
26
137
  async function confirmPrivacy() {
27
138
  process.stderr.write("\nDiscover your Claude Code, Codex, and Cursor stats, then see how you rank.\n" +
28
139
  "You'll see your cards first, then review before submitting.\n\n" +
@@ -125,6 +236,94 @@ async function maybeShareWrapped(wrapped) {
125
236
  process.stderr.write(` (${err instanceof Error ? err.message : err})\n\n`);
126
237
  }
127
238
  }
239
+ // Fully offline path: gather only local signals and render the wrapped deck in
240
+ // the terminal without ever contacting Standout. Reads only local fields, so the
241
+ // deck looks the same minus the server-computed bits — the LLM-written
242
+ // archetype/zinger (sensible defaults are used) and the leaderboard
243
+ // rank/percentiles (omitted, since those need the server cohort).
244
+ async function renderLocalTerminal(prefetched) {
245
+ const view = aggregateView({
246
+ profile: prefetched,
247
+ computed: {},
248
+ share_url: "",
249
+ });
250
+ await renderWrappedAll(view);
251
+ }
252
+ // Local path: gather on-device, then open the real wrapped UI on localhost. The
253
+ // gathered profile is held only in this process and served from an ephemeral
254
+ // in-memory HTTP server — nothing is written to disk, no DB row, no leaderboard
255
+ // entry. The page renders the card image via a stateless endpoint (also not
256
+ // stored) and shows the share options. `--terminal` forces the fully-offline
257
+ // ASCII deck instead.
258
+ async function runLocal() {
259
+ const terminalOnly = process.argv.slice(2).includes("--terminal");
260
+ const stop = startLoadingLine("Putting together your AI highlights");
261
+ let prefetched;
262
+ try {
263
+ prefetched = await gatherFullEnrichment({
264
+ localOnly: true,
265
+ verbose: false,
266
+ });
267
+ }
268
+ finally {
269
+ stop();
270
+ }
271
+ // Always page the deck in the terminal first.
272
+ await renderLocalTerminal(prefetched);
273
+ if (terminalOnly) {
274
+ process.stderr.write("\n Local run complete. To publish your wrapped and see how you rank: npx standout\n\n");
275
+ return;
276
+ }
277
+ // Then open the full wrapped (card image + share options) on localhost.
278
+ // Trim the bulkiest raw fields so the in-memory body stays well under the
279
+ // render endpoint's request cap, same as the upload path.
280
+ const { payload } = capPayload(prefetched);
281
+ const body = JSON.stringify(payload);
282
+ const server = http.createServer((req, res) => {
283
+ // The wrapped page (https) reads this over http://localhost, which browsers
284
+ // treat as a trustworthy origin. Allow any origin for the simple GET.
285
+ res.setHeader("Access-Control-Allow-Origin", "*");
286
+ if (req.url && req.url.startsWith("/profile.json")) {
287
+ res.setHeader("Content-Type", "application/json");
288
+ res.end(body);
289
+ return;
290
+ }
291
+ res.statusCode = 404;
292
+ res.end("not found");
293
+ });
294
+ await new Promise((resolve, reject) => {
295
+ server.on("error", reject);
296
+ server.listen(0, "127.0.0.1", resolve);
297
+ });
298
+ const address = server.address();
299
+ const port = address && typeof address === "object" ? address.port : null;
300
+ if (!port) {
301
+ server.close();
302
+ process.stderr.write("\n Couldn't start the local server, so the browser view is unavailable.\n" +
303
+ " Your wrapped is shown above. Re-run to try the browser view: npx standout --local\n\n");
304
+ return;
305
+ }
306
+ const url = `${STANDOUT_API_URL}/wrapped/live?port=${port}`;
307
+ process.stderr.write(`\n Your wrapped is ready — nothing was uploaded or saved.\n` +
308
+ ` ${url}\n\n` +
309
+ ` Opening your browser. Keep this terminal open while you view or share.\n` +
310
+ ` Press Ctrl+C when you're done.\n\n`);
311
+ try {
312
+ await openWrapped(url);
313
+ }
314
+ catch {
315
+ process.stderr.write(` Couldn't open the browser automatically. Open this link manually:\n ${url}\n\n`);
316
+ }
317
+ // Hold the process open (the in-memory server keeps the data available) until
318
+ // the user exits; then drop everything.
319
+ await new Promise((resolve) => {
320
+ process.on("SIGINT", () => {
321
+ server.close();
322
+ process.stderr.write("\n Closed. Nothing was saved.\n");
323
+ resolve();
324
+ });
325
+ });
326
+ }
128
327
  async function runAgent(jobId) {
129
328
  // The wrapped render + share now run non-interactively (no TTY required), so
130
329
  // the command completes when invoked from a Claude Code Bash call. Only the
@@ -132,10 +331,6 @@ async function runAgent(jobId) {
132
331
  if (profileChatEnabled()) {
133
332
  ensureInteractive("The interactive profile chat");
134
333
  }
135
- console.log("\n ┌────────────────────────────────┐");
136
- console.log(" │ are you really tokenmaxxing? │");
137
- console.log(" │ Powered by Standout (YC P26) │");
138
- console.log(" └────────────────────────────────┘\n");
139
334
  // Only frame the token as a job in the application flow. Otherwise it may be a
140
335
  // leaderboard group (`npx standout stanford`), confirmed after the wrapped runs.
141
336
  if (jobId && profileChatEnabled()) {
@@ -340,19 +535,37 @@ async function main() {
340
535
  const proxy = configureProxyFromEnv();
341
536
  if (proxy)
342
537
  process.stderr.write(` (using proxy ${proxy})\n`);
538
+ const localOnly = localOnlyEnabled();
343
539
  if (args.includes("--gather")) {
344
- await confirmPrivacy();
345
- const data = await gatherFullEnrichment();
540
+ // Local-only gather skips the upload-consent gate (nothing leaves the box).
541
+ if (!localOnly)
542
+ await confirmPrivacy();
543
+ const data = await gatherFullEnrichment({ localOnly });
346
544
  process.stdout.write(JSON.stringify(data, null, 2));
545
+ return;
347
546
  }
348
- else if (args.includes("--mcp")) {
547
+ if (args.includes("--mcp")) {
349
548
  await startMcpServer();
549
+ return;
550
+ }
551
+ const jobId = args.find((a) => !a.startsWith("-"));
552
+ printBanner();
553
+ // Pick the mode. An explicit --local/--no-upload/STANDOUT_LOCAL forces local;
554
+ // a group/job arg (`npx standout stanford`) is an explicit full-flow intent; a
555
+ // bare `npx standout` asks the user as the first step.
556
+ let mode;
557
+ if (localOnly)
558
+ mode = "local";
559
+ else if (jobId)
560
+ mode = "full";
561
+ else
562
+ mode = await chooseMode();
563
+ if (mode === "local") {
564
+ await runLocal();
350
565
  }
351
566
  else {
352
- const jobId = args.find((a) => !a.startsWith("-"));
353
- if (jobId) {
567
+ if (jobId)
354
568
  process.env.STANDOUT_JOB_ID = jobId;
355
- }
356
569
  await runAgent(jobId);
357
570
  }
358
571
  }
package/dist/gather.d.ts CHANGED
@@ -25,7 +25,7 @@ export interface GatheredProfile {
25
25
  };
26
26
  linkedin: {
27
27
  url: string | null;
28
- url_resolution_source: "email" | "ai_search" | null;
28
+ url_resolution_source: "email" | "ai_search" | "chrome_session" | null;
29
29
  profile: Record<string, unknown> | null;
30
30
  company_enrichments: Record<string, Record<string, unknown>>;
31
31
  };
@@ -72,6 +72,7 @@ export interface FullEnrichmentOptions {
72
72
  aiUsage?: AiUsage;
73
73
  profileBasics?: ProfileBasics;
74
74
  verbose?: boolean;
75
+ localOnly?: boolean;
75
76
  }
76
77
  export declare function emptyGatheredProfile(readiness?: GatherReadiness): GatheredProfile;
77
78
  export declare function gatherUsageStats(): Promise<Pick<GatheredProfile, "ai_usage" | "readiness">>;
package/dist/gather.js CHANGED
@@ -6,6 +6,8 @@ import { STANDOUT_API_URL } from "./api.js";
6
6
  import { gatherAiUsage } from "./ai-usage.js";
7
7
  import { gatherDevEnvironment } from "./dev-env.js";
8
8
  import { scrapeTwitterHandle } from "./twitter-scrape.js";
9
+ import { scrapeLinkedinUrl } from "./linkedin-scrape.js";
10
+ import { resolveDisplayName } from "./identity.js";
9
11
  import { redactSecrets } from "./redact.js";
10
12
  export function emptyGatheredProfile(readiness = {
11
13
  usage_stats_status: "unavailable",
@@ -443,6 +445,7 @@ function trimNpmPackage(p) {
443
445
  };
444
446
  }
445
447
  export async function gather(options = {}) {
448
+ const localOnly = options.localOnly === true;
446
449
  const profileBasics = options.profileBasics?.readiness.profile_basics_status === "ready"
447
450
  ? options.profileBasics
448
451
  : undefined;
@@ -466,24 +469,32 @@ export async function gather(options = {}) {
466
469
  remoteCandidates.push(match[1]);
467
470
  }
468
471
  }
469
- // Resolve to a USER (not an Organization) profile
470
- for (const candidate of remoteCandidates) {
471
- const profile = (await fetchGitHub(`https://api.github.com/users/${sanitize(candidate)}`));
472
- if (profile?.type === "User" && profile.login) {
473
- githubUsername = profile.login;
474
- break;
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
+ }
475
486
  }
476
487
  }
477
488
  }
478
489
  // Fallback: search GitHub by email (public profile email match)
479
- if (!githubUsername && email) {
490
+ if (!githubUsername && email && !localOnly) {
480
491
  const searchResult = (await fetchGitHub(`https://api.github.com/search/users?q=${encodeURIComponent(email)}+in:email`));
481
492
  if (searchResult?.items?.[0]) {
482
493
  githubUsername = searchResult.items[0].login;
483
494
  }
484
495
  }
485
496
  // Fallback: search commits by author-email (catches users with private email)
486
- if (!githubUsername && email) {
497
+ if (!githubUsername && email && !localOnly) {
487
498
  const commitSearch = (await fetchGitHub(`https://api.github.com/search/commits?q=author-email:${encodeURIComponent(email)}`));
488
499
  const login = commitSearch?.items?.[0]?.author?.login;
489
500
  if (login)
@@ -495,7 +506,7 @@ export async function gather(options = {}) {
495
506
  gatherLog(options, "Gathering GitHub data...\n");
496
507
  const canUseCachedGithubBasics = Boolean(profileBasics?.identity.github_username) &&
497
508
  profileBasics?.identity.github_username === githubUsername;
498
- const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername
509
+ const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername && !localOnly
499
510
  ? await Promise.all([
500
511
  canUseCachedGithubBasics
501
512
  ? Promise.resolve(profileBasics?.github.profile ?? null)
@@ -518,7 +529,7 @@ export async function gather(options = {}) {
518
529
  const repoContents = {};
519
530
  const readmes = {};
520
531
  const commitMessages = {};
521
- if (safeUsername) {
532
+ if (safeUsername && !localOnly) {
522
533
  const contentPromises = topRepos.map(async (repo) => {
523
534
  const repoName = repo.name;
524
535
  const contents = (await fetchGitHub(`https://api.github.com/repos/${safeUsername}/${repoName}/contents`));
@@ -553,7 +564,7 @@ export async function gather(options = {}) {
553
564
  }
554
565
  // Phase 2c: AI tool usage + dev environment (run in background during LinkedIn)
555
566
  gatherLog(options, "Scanning AI tool usage and dev environment...\n");
556
- const githubEnrichPromise = safeUsername
567
+ const githubEnrichPromise = safeUsername && !localOnly
557
568
  ? enrichApi("github", safeUsername).catch(() => null)
558
569
  : Promise.resolve(null);
559
570
  const aiUsagePromise = options.aiUsage
@@ -581,7 +592,7 @@ export async function gather(options = {}) {
581
592
  let linkedinResolutionSource = null;
582
593
  let linkedinProfile = null;
583
594
  const companyEnrichments = {};
584
- if (safeEmail) {
595
+ if (safeEmail && !localOnly) {
585
596
  const emailResult = await enrichApi("email_to_linkedin", email);
586
597
  if (emailResult && typeof emailResult === "object") {
587
598
  linkedinUrl =
@@ -592,7 +603,7 @@ export async function gather(options = {}) {
592
603
  }
593
604
  }
594
605
  // Fallback: name + signals via AI web search when email lookup comes up empty
595
- if (!linkedinUrl && ghProfile && name) {
606
+ if (!linkedinUrl && ghProfile && name && !localOnly) {
596
607
  gatherLog(options, "Resolving LinkedIn via name + signals...\n");
597
608
  const profile = ghProfile;
598
609
  const signals = [];
@@ -627,6 +638,24 @@ export async function gather(options = {}) {
627
638
  linkedinResolutionSource = "ai_search";
628
639
  }
629
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
+ }
630
659
  if (linkedinUrl) {
631
660
  linkedinProfile = await enrichApi("linkedin_profile", linkedinUrl);
632
661
  // Company enrichment
@@ -660,7 +689,7 @@ export async function gather(options = {}) {
660
689
  }
661
690
  }
662
691
  // Fallback: scrape the user's logged-in Chrome session for an X handle.
663
- if (!twitterHandle) {
692
+ if (!twitterHandle && !localOnly) {
664
693
  gatherLog(options, "Checking for an X/Twitter session in Chrome...\n");
665
694
  try {
666
695
  const scraped = await scrapeTwitterHandle();
@@ -673,7 +702,7 @@ export async function gather(options = {}) {
673
702
  }
674
703
  const safeTwitter = twitterHandle ? sanitize(twitterHandle) : null;
675
704
  let twitterProfile = null;
676
- if (safeTwitter) {
705
+ if (safeTwitter && !localOnly) {
677
706
  twitterProfile = await enrichApi("twitter", safeTwitter);
678
707
  }
679
708
  const twitterUrl = safeTwitter ? `https://x.com/${safeTwitter}` : null;
@@ -821,14 +850,22 @@ export async function gather(options = {}) {
821
850
  githubEnrichPromise,
822
851
  ]);
823
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
+ });
824
860
  return {
825
861
  readiness: {
826
862
  usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
827
863
  profile_basics_status: "ready",
828
- enrichment_status: "ready",
864
+ // Local-only mode performs no network enrichment.
865
+ enrichment_status: localOnly ? "unavailable" : "ready",
829
866
  },
830
867
  identity: {
831
- name: name || null,
868
+ name: resolvedName,
832
869
  email: email || null,
833
870
  github_username: githubUsername,
834
871
  twitter_username: twitterHandle,
@@ -0,0 +1,8 @@
1
+ export declare function linkedinFullName(profile: Record<string, unknown> | null | undefined): string | null;
2
+ export declare function resolveDisplayName(opts: {
3
+ gitName: string | null | undefined;
4
+ githubName: unknown;
5
+ linkedinProfile: Record<string, unknown> | null | undefined;
6
+ githubUsername: string | null | undefined;
7
+ twitterHandle: string | null | undefined;
8
+ }): string | null;
@@ -0,0 +1,29 @@
1
+ function cleanName(value) {
2
+ return typeof value === "string" && value.trim() ? value.trim() : null;
3
+ }
4
+ // LinkedIn enrichment exposes the display name as `full_name`, falling back to
5
+ // first/last when only those are present.
6
+ export function linkedinFullName(profile) {
7
+ if (!profile)
8
+ return null;
9
+ const full = cleanName(profile.full_name);
10
+ if (full)
11
+ return full;
12
+ const joined = [cleanName(profile.first_name), cleanName(profile.last_name)]
13
+ .filter(Boolean)
14
+ .join(" ")
15
+ .trim();
16
+ return joined || null;
17
+ }
18
+ // Identity name fallback chain. The self-set git name stays primary; when it's
19
+ // missing we fall back to real names (GitHub, then LinkedIn) and finally to
20
+ // handles (GitHub, then Twitter) so a wrapped is never anonymous when any
21
+ // identity signal exists.
22
+ export function resolveDisplayName(opts) {
23
+ return (cleanName(opts.gitName) ??
24
+ cleanName(opts.githubName) ??
25
+ linkedinFullName(opts.linkedinProfile) ??
26
+ cleanName(opts.githubUsername) ??
27
+ cleanName(opts.twitterHandle) ??
28
+ null);
29
+ }
@@ -0,0 +1,2 @@
1
+ export declare function normalizeLinkedinUrl(raw: string): string | null;
2
+ export declare function scrapeLinkedinUrl(): Promise<string | null>;
@@ -0,0 +1,56 @@
1
+ import { NAV_TIMEOUT_MS, scrapeFromChromeSession } from "./browser-session.js";
2
+ // LinkedIn bounces unauthenticated / new-device sessions to one of these.
3
+ const BLOCKED_RE = /\/(login|uas\/login|checkpoint|authwall)/i;
4
+ // Reduce any LinkedIn profile URL to the canonical vanity form, or null if it
5
+ // isn't a real member profile (e.g. the `/in/me` self-redirect target itself).
6
+ export function normalizeLinkedinUrl(raw) {
7
+ try {
8
+ const url = new URL(raw);
9
+ if (!/(^|\.)linkedin\.com$/i.test(url.hostname))
10
+ return null;
11
+ const match = url.pathname.match(/\/in\/([^/]+)/i);
12
+ const vanity = match?.[1] ? decodeURIComponent(match[1]).trim() : null;
13
+ if (!vanity || vanity.toLowerCase() === "me")
14
+ return null;
15
+ return `https://www.linkedin.com/in/${vanity}/`;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ async function extractLinkedinUrl(context) {
22
+ const cookies = await context.cookies(["https://www.linkedin.com"]);
23
+ if (!cookies.find((c) => c.name === "li_at"))
24
+ return null;
25
+ const page = await context.newPage();
26
+ try {
27
+ // `/in/me/` redirects to the logged-in member's own vanity profile.
28
+ await page.goto("https://www.linkedin.com/in/me/", {
29
+ waitUntil: "domcontentloaded",
30
+ timeout: NAV_TIMEOUT_MS,
31
+ });
32
+ if (BLOCKED_RE.test(page.url()))
33
+ return null;
34
+ const fromRedirect = normalizeLinkedinUrl(page.url());
35
+ if (fromRedirect)
36
+ return fromRedirect;
37
+ // Backup signal if the redirect didn't land on a vanity URL.
38
+ try {
39
+ const canonical = page.locator('link[rel="canonical"]').first();
40
+ await canonical.waitFor({ timeout: 3000 });
41
+ const href = await canonical.getAttribute("href");
42
+ if (href)
43
+ return normalizeLinkedinUrl(href);
44
+ }
45
+ catch {
46
+ // skip
47
+ }
48
+ return null;
49
+ }
50
+ finally {
51
+ await page.close().catch(() => undefined);
52
+ }
53
+ }
54
+ export async function scrapeLinkedinUrl() {
55
+ return scrapeFromChromeSession(extractLinkedinUrl);
56
+ }
@@ -1,267 +1,48 @@
1
- import { chromium } from "playwright-core";
2
- import { existsSync, readdirSync, statSync, mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, } from "fs";
3
- import { homedir, platform, tmpdir } from "os";
4
- import { join } from "path";
5
- import { spawn } from "child_process";
6
- const DEBUG_PORT_WAIT_MS = 10000;
7
- const NAV_TIMEOUT_MS = 20000;
8
- const TOTAL_TIMEOUT_MS = 40000;
9
- function wait(ms) {
10
- return new Promise((r) => setTimeout(r, ms));
11
- }
12
- function findChromeExecutable() {
13
- const os = platform();
14
- const candidates = os === "darwin"
15
- ? [
16
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
17
- "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
18
- "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
19
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
20
- ]
21
- : os === "linux"
22
- ? [
23
- "/usr/bin/google-chrome",
24
- "/usr/bin/google-chrome-stable",
25
- "/usr/bin/chromium",
26
- "/usr/bin/chromium-browser",
27
- "/snap/bin/chromium",
28
- ]
29
- : os === "win32"
30
- ? [
31
- "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
32
- "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
33
- ]
34
- : [];
35
- for (const path of candidates) {
36
- if (existsSync(path))
37
- return path;
38
- }
39
- return null;
40
- }
41
- function findChromeProfileRoot() {
42
- const os = platform();
43
- const home = homedir();
44
- const candidates = os === "darwin"
45
- ? [
46
- join(home, "Library", "Application Support", "Google", "Chrome"),
47
- join(home, "Library", "Application Support", "Chromium"),
48
- ]
49
- : os === "linux"
50
- ? [
51
- join(home, ".config", "google-chrome"),
52
- join(home, ".config", "chromium"),
53
- ]
54
- : os === "win32"
55
- ? [join(home, "AppData", "Local", "Google", "Chrome", "User Data")]
56
- : [];
57
- for (const path of candidates) {
58
- if (existsSync(path) && existsSync(join(path, "Local State")))
59
- return path;
60
- }
61
- return null;
62
- }
63
- function listProfilesByMtime(profileRoot) {
64
- try {
65
- return readdirSync(profileRoot)
66
- .filter((d) => existsSync(join(profileRoot, d, "Cookies")))
67
- .map((d) => ({
68
- name: d,
69
- mtime: statSync(join(profileRoot, d, "Cookies")).mtimeMs,
70
- }))
71
- .sort((a, b) => b.mtime - a.mtime)
72
- .map((p) => p.name);
73
- }
74
- catch {
75
- return [];
76
- }
77
- }
78
- function prepareProfileCopy(profileRoot, profileName) {
79
- try {
80
- const tempRoot = mkdtempSync(join(tmpdir(), "standout-chrome-"));
81
- mkdirSync(join(tempRoot, "Default"), { recursive: true });
82
- const localState = join(profileRoot, "Local State");
83
- if (existsSync(localState)) {
84
- copyFileSync(localState, join(tempRoot, "Local State"));
85
- }
86
- const sourceDir = join(profileRoot, profileName);
87
- for (const name of ["Cookies", "Cookies-journal", "Preferences"]) {
88
- const src = join(sourceDir, name);
89
- if (existsSync(src)) {
90
- copyFileSync(src, join(tempRoot, "Default", name));
91
- }
92
- }
93
- return tempRoot;
94
- }
95
- catch {
96
- return null;
97
- }
98
- }
99
- async function waitForDebugPort(userDataDir, timeoutMs) {
100
- const deadline = Date.now() + timeoutMs;
101
- const portFile = join(userDataDir, "DevToolsActivePort");
102
- while (Date.now() < deadline) {
103
- if (existsSync(portFile)) {
104
- try {
105
- const [port] = readFileSync(portFile, "utf-8").trim().split("\n");
106
- if (port && /^\d+$/.test(port))
107
- return parseInt(port, 10);
108
- }
109
- catch {
110
- // keep polling
111
- }
112
- }
113
- await wait(200);
114
- }
115
- return null;
116
- }
117
- async function spawnChrome(executablePath, userDataDir) {
118
- const os = platform();
119
- const args = [
120
- "--headless=new",
121
- `--user-data-dir=${userDataDir}`,
122
- "--remote-debugging-port=0",
123
- "--no-first-run",
124
- "--no-default-browser-check",
125
- "--disable-sync",
126
- "--disable-features=AutofillServerCommunication",
127
- ];
128
- const cleanupFns = [];
129
- if (os === "darwin") {
130
- // Use `open -n -a` to force a new Chrome process instance on macOS
131
- // (direct binary launch gets intercepted by the singleton running Chrome).
132
- const appBundle = executablePath.replace(/\.app\/Contents\/MacOS\/.*$/, ".app");
133
- const child = spawn("open", ["-n", "-a", appBundle, "--args", ...args], {
134
- detached: true,
135
- stdio: "ignore",
136
- });
137
- child.unref();
138
- cleanupFns.push(() => {
139
- try {
140
- spawn("pkill", ["-f", `user-data-dir=${userDataDir}`]);
141
- }
142
- catch {
143
- // skip
144
- }
145
- });
146
- }
147
- else {
148
- const child = spawn(executablePath, args, {
149
- detached: true,
150
- stdio: "ignore",
151
- });
152
- child.unref();
153
- cleanupFns.push(() => {
154
- try {
155
- if (child.pid)
156
- process.kill(child.pid);
157
- }
158
- catch {
159
- // skip
160
- }
161
- });
162
- }
163
- const port = await waitForDebugPort(userDataDir, DEBUG_PORT_WAIT_MS);
164
- return {
165
- port,
166
- cleanup: () => cleanupFns.forEach((fn) => fn()),
167
- };
168
- }
169
- async function extractHandleFromProfile(executablePath, profileRoot, profileName) {
170
- const tempProfile = prepareProfileCopy(profileRoot, profileName);
171
- if (!tempProfile)
1
+ import { NAV_TIMEOUT_MS, scrapeFromChromeSession } from "./browser-session.js";
2
+ async function extractTwitterHandle(context) {
3
+ const cookies = await context.cookies([
4
+ "https://x.com",
5
+ "https://twitter.com",
6
+ ]);
7
+ if (!cookies.find((c) => c.name === "auth_token"))
172
8
  return null;
173
- const { port, cleanup } = await spawnChrome(executablePath, tempProfile);
174
- let browser = null;
9
+ const page = await context.newPage();
175
10
  try {
176
- if (!port)
177
- return null;
178
- browser = await chromium.connectOverCDP(`http://localhost:${port}`);
179
- const contexts = browser.contexts();
180
- const context = contexts[0] ?? (await browser.newContext());
181
- const cookies = await context.cookies([
182
- "https://x.com",
183
- "https://twitter.com",
184
- ]);
185
- const authToken = cookies.find((c) => c.name === "auth_token");
186
- if (!authToken)
11
+ await page.goto("https://x.com/home", {
12
+ waitUntil: "domcontentloaded",
13
+ timeout: NAV_TIMEOUT_MS,
14
+ });
15
+ if (/\/(login|i\/flow\/login)/.test(page.url()))
187
16
  return null;
188
- const page = await context.newPage();
189
17
  try {
190
- await page.goto("https://x.com/home", {
191
- waitUntil: "domcontentloaded",
192
- timeout: NAV_TIMEOUT_MS,
193
- });
194
- const url = page.url();
195
- if (/\/(login|i\/flow\/login)/.test(url))
196
- return null;
197
- try {
198
- const link = page.locator('[data-testid="AppTabBar_Profile_Link"]');
199
- await link.first().waitFor({ timeout: 5000 });
200
- const href = await link.first().getAttribute("href");
201
- if (href) {
202
- const handle = href.replace(/^\//, "").trim();
203
- if (handle && !handle.includes("/"))
204
- return handle;
205
- }
206
- }
207
- catch {
208
- // fall through
209
- }
210
- try {
211
- const nameElem = page.locator('[data-testid="UserName"] span:has-text("@")');
212
- const text = await nameElem.first().innerText({ timeout: 3000 });
213
- const match = text.match(/@([A-Za-z0-9_]+)/);
214
- if (match)
215
- return match[1];
216
- }
217
- catch {
218
- // skip
18
+ const link = page.locator('[data-testid="AppTabBar_Profile_Link"]');
19
+ await link.first().waitFor({ timeout: 5000 });
20
+ const href = await link.first().getAttribute("href");
21
+ if (href) {
22
+ const handle = href.replace(/^\//, "").trim();
23
+ if (handle && !handle.includes("/"))
24
+ return handle;
219
25
  }
220
- return null;
221
- }
222
- finally {
223
- await page.close().catch(() => undefined);
224
26
  }
225
- }
226
- catch {
227
- return null;
228
- }
229
- finally {
230
- if (browser) {
231
- await browser.close().catch(() => undefined);
27
+ catch {
28
+ // fall through
232
29
  }
233
- cleanup();
234
- await wait(500);
235
30
  try {
236
- rmSync(tempProfile, { recursive: true, force: true });
31
+ const nameElem = page.locator('[data-testid="UserName"] span:has-text("@")');
32
+ const text = await nameElem.first().innerText({ timeout: 3000 });
33
+ const match = text.match(/@([A-Za-z0-9_]+)/);
34
+ if (match)
35
+ return match[1];
237
36
  }
238
37
  catch {
239
38
  // skip
240
39
  }
40
+ return null;
41
+ }
42
+ finally {
43
+ await page.close().catch(() => undefined);
241
44
  }
242
- }
243
- async function withTimeout(promise, ms) {
244
- return Promise.race([
245
- promise,
246
- new Promise((resolve) => setTimeout(() => resolve(null), ms)),
247
- ]);
248
45
  }
249
46
  export async function scrapeTwitterHandle() {
250
- const executablePath = findChromeExecutable();
251
- if (!executablePath)
252
- return null;
253
- const profileRoot = findChromeProfileRoot();
254
- if (!profileRoot)
255
- return null;
256
- const profiles = listProfilesByMtime(profileRoot);
257
- if (profiles.length === 0)
258
- return null;
259
- return withTimeout((async () => {
260
- for (const profile of profiles) {
261
- const handle = await extractHandleFromProfile(executablePath, profileRoot, profile).catch(() => null);
262
- if (handle)
263
- return handle;
264
- }
265
- return null;
266
- })(), TOTAL_TIMEOUT_MS);
47
+ return scrapeFromChromeSession(extractTwitterHandle);
267
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "standout",
3
- "version": "0.5.30",
3
+ "version": "0.5.32",
4
4
  "description": "Build your developer profile with AI. One command, zero friction.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsc",
21
- "dev": "tsx src/cli.ts",
21
+ "dev": "tsc && node dist/cli.js",
22
22
  "test": "tsx --test tests/*.test.ts",
23
23
  "preview:tiers": "tsx src/wrapped/preview.ts",
24
24
  "typecheck": "tsc --noEmit",