standout 0.5.31 → 0.5.33
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/dist/cli.js +262 -10
- package/dist/gather.d.ts +1 -0
- package/dist/gather.js +30 -18
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import Anthropic from "@anthropic-ai/sdk";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
4
7
|
import { createInterface } from "readline";
|
|
5
8
|
import { markWrappedShared, STANDOUT_API_URL } from "./api.js";
|
|
9
|
+
import { capPayload } from "./payload.js";
|
|
6
10
|
import { gatherFullEnrichment, gatherProfileBasics, gatherUsageStats, } from "./gather.js";
|
|
7
11
|
import { ensureHeapHeadroom } from "./heap.js";
|
|
8
12
|
import { startMcpServer } from "./mcp.js";
|
|
9
13
|
import { configureProxyFromEnv } from "./proxy.js";
|
|
10
14
|
import { SYSTEM_PROMPT } from "./prompt.js";
|
|
11
15
|
import { TOOLS, executeTool } from "./tools.js";
|
|
16
|
+
import { aggregateView } from "./wrapped/aggregate.js";
|
|
12
17
|
import { renderWrappedAll } from "./wrapped/render.js";
|
|
13
18
|
import { createWrapped } from "./wrapped-client.js";
|
|
14
19
|
import { openWrapped } from "./wrapped-share.js";
|
|
@@ -23,6 +28,114 @@ function profileChatEnabled() {
|
|
|
23
28
|
return true;
|
|
24
29
|
return process.argv.slice(2).includes("--chat");
|
|
25
30
|
}
|
|
31
|
+
// Local-only mode: compute and render the wrapped entirely on this machine. No
|
|
32
|
+
// profile is uploaded, no wrapped row is created, no leaderboard entry is made,
|
|
33
|
+
// and gathering skips every network call. Enable with --local / --no-upload or
|
|
34
|
+
// STANDOUT_LOCAL=1.
|
|
35
|
+
function localOnlyEnabled() {
|
|
36
|
+
const env = process.env.STANDOUT_LOCAL;
|
|
37
|
+
if (env === "1" || env === "true")
|
|
38
|
+
return true;
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
return args.includes("--local") || args.includes("--no-upload");
|
|
41
|
+
}
|
|
42
|
+
function printBanner() {
|
|
43
|
+
console.log("\n ┌────────────────────────────────┐");
|
|
44
|
+
console.log(" │ are you really tokenmaxxing? │");
|
|
45
|
+
console.log(" │ Powered by Standout (YC P26) │");
|
|
46
|
+
console.log(" └────────────────────────────────┘\n");
|
|
47
|
+
}
|
|
48
|
+
// First step of the default flow: an arrow-key menu to pick full (cloud) vs
|
|
49
|
+
// local-only. Non-interactive shells (CI, agents) can't answer, so they keep the
|
|
50
|
+
// full flow — pass --local / STANDOUT_LOCAL=1 to force local without a prompt.
|
|
51
|
+
const MODE_OPTIONS = [
|
|
52
|
+
{
|
|
53
|
+
key: "full",
|
|
54
|
+
title: "Full",
|
|
55
|
+
tag: " (recommended)",
|
|
56
|
+
lines: [
|
|
57
|
+
"Your full AI wrapped: LLM-written cards, your score, and a leaderboard rank.",
|
|
58
|
+
"Reads your local stats plus your public GitHub / LinkedIn / X.",
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: "local",
|
|
63
|
+
title: "Local only",
|
|
64
|
+
tag: " — private",
|
|
65
|
+
lines: [
|
|
66
|
+
"Your stats stay on your computer — no account, no leaderboard, nothing saved.",
|
|
67
|
+
"Renders your wrapped right in the terminal (no LLM cards, score, or ranking).",
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
function renderModeMenu(selected) {
|
|
72
|
+
const out = [
|
|
73
|
+
"",
|
|
74
|
+
chalk.white(" How do you want to run Standout?"),
|
|
75
|
+
"",
|
|
76
|
+
];
|
|
77
|
+
MODE_OPTIONS.forEach((opt, i) => {
|
|
78
|
+
const active = i === selected;
|
|
79
|
+
const pointer = active ? chalk.hex("#ad5cff")("❯ ") : " ";
|
|
80
|
+
const title = active
|
|
81
|
+
? chalk.bold.white(opt.title) + chalk.dim(opt.tag)
|
|
82
|
+
: chalk.dim(opt.title + opt.tag);
|
|
83
|
+
out.push(`${pointer}${title}`);
|
|
84
|
+
for (const line of opt.lines)
|
|
85
|
+
out.push(chalk.dim(` ${line}`));
|
|
86
|
+
out.push("");
|
|
87
|
+
});
|
|
88
|
+
out.push(chalk.dim(" ↑/↓ to move · enter to select"));
|
|
89
|
+
return out.join("\n");
|
|
90
|
+
}
|
|
91
|
+
async function chooseMode() {
|
|
92
|
+
if (!process.stdin.isTTY)
|
|
93
|
+
return "full";
|
|
94
|
+
let selected = 0;
|
|
95
|
+
const draw = () => {
|
|
96
|
+
const text = renderModeMenu(selected);
|
|
97
|
+
process.stderr.write(text + "\n");
|
|
98
|
+
return text.split("\n").length;
|
|
99
|
+
};
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
const stdin = process.stdin;
|
|
102
|
+
stdin.setRawMode(true);
|
|
103
|
+
stdin.resume();
|
|
104
|
+
stdin.setEncoding("utf8");
|
|
105
|
+
let lines = draw();
|
|
106
|
+
const redraw = () => {
|
|
107
|
+
// Jump back to the menu's first line and clear it before redrawing.
|
|
108
|
+
process.stderr.write(`\x1b[${lines}A\x1b[0J`);
|
|
109
|
+
lines = draw();
|
|
110
|
+
};
|
|
111
|
+
const cleanup = () => {
|
|
112
|
+
stdin.setRawMode(false);
|
|
113
|
+
stdin.pause();
|
|
114
|
+
stdin.removeListener("data", onData);
|
|
115
|
+
};
|
|
116
|
+
const onData = (data) => {
|
|
117
|
+
if (data === "\x03") {
|
|
118
|
+
cleanup();
|
|
119
|
+
process.stderr.write("\n Cancelled.\n");
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
else if (data === "\r" || data === "\n") {
|
|
123
|
+
cleanup();
|
|
124
|
+
process.stderr.write("\n");
|
|
125
|
+
resolve(MODE_OPTIONS[selected].key);
|
|
126
|
+
}
|
|
127
|
+
else if (data === "\x1b[A" || data === "k") {
|
|
128
|
+
selected = (selected + MODE_OPTIONS.length - 1) % MODE_OPTIONS.length;
|
|
129
|
+
redraw();
|
|
130
|
+
}
|
|
131
|
+
else if (data === "\x1b[B" || data === "j") {
|
|
132
|
+
selected = (selected + 1) % MODE_OPTIONS.length;
|
|
133
|
+
redraw();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
stdin.on("data", onData);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
26
139
|
async function confirmPrivacy() {
|
|
27
140
|
process.stderr.write("\nDiscover your Claude Code, Codex, and Cursor stats, then see how you rank.\n" +
|
|
28
141
|
"You'll see your cards first, then review before submitting.\n\n" +
|
|
@@ -125,6 +238,131 @@ async function maybeShareWrapped(wrapped) {
|
|
|
125
238
|
process.stderr.write(` (${err instanceof Error ? err.message : err})\n\n`);
|
|
126
239
|
}
|
|
127
240
|
}
|
|
241
|
+
// Fully offline path: gather only local signals and render the wrapped deck in
|
|
242
|
+
// the terminal without ever contacting Standout. Reads only local fields, so the
|
|
243
|
+
// deck looks the same minus the server-computed bits — the LLM-written
|
|
244
|
+
// archetype/zinger (sensible defaults are used) and the leaderboard
|
|
245
|
+
// rank/percentiles (omitted, since those need the server cohort).
|
|
246
|
+
async function renderLocalTerminal(prefetched) {
|
|
247
|
+
const view = aggregateView({
|
|
248
|
+
profile: prefetched,
|
|
249
|
+
computed: {},
|
|
250
|
+
share_url: "",
|
|
251
|
+
});
|
|
252
|
+
await renderWrappedAll(view);
|
|
253
|
+
}
|
|
254
|
+
function profileName(prefetched) {
|
|
255
|
+
return prefetched.identity?.name || prefetched.identity?.email || "you";
|
|
256
|
+
}
|
|
257
|
+
// Render the shareable card PNG via the stateless endpoint. This is a
|
|
258
|
+
// server-to-server call (CLI -> Standout), so there's no browser, no CORS, and
|
|
259
|
+
// no cross-origin/private-network blocking. The endpoint stores nothing.
|
|
260
|
+
async function fetchCardPng(prefetched) {
|
|
261
|
+
try {
|
|
262
|
+
const { payload } = capPayload(prefetched);
|
|
263
|
+
const res = await fetch(`${STANDOUT_API_URL}/api/public/wrapped/local/share-card`, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
"Content-Type": "application/json",
|
|
267
|
+
"User-Agent": "Standout/0.4.0 (node)",
|
|
268
|
+
},
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
profile: payload,
|
|
271
|
+
name: profileName(prefetched),
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
if (!res.ok)
|
|
275
|
+
return null;
|
|
276
|
+
return Buffer.from(await res.arrayBuffer());
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Print a PNG inline using the terminal's image protocol. Returns false if the
|
|
283
|
+
// terminal doesn't support inline images (caller then falls back to the saved
|
|
284
|
+
// file path). Covers iTerm2-protocol terminals (iTerm2, WezTerm, VS Code, Hyper,
|
|
285
|
+
// Tabby) and Kitty-protocol terminals (Kitty, Ghostty).
|
|
286
|
+
function showImageInline(png) {
|
|
287
|
+
if (!process.stdout.isTTY)
|
|
288
|
+
return false;
|
|
289
|
+
const tp = process.env.TERM_PROGRAM || "";
|
|
290
|
+
const term = process.env.TERM || "";
|
|
291
|
+
const b64 = png.toString("base64");
|
|
292
|
+
const itermLike = tp === "iTerm.app" ||
|
|
293
|
+
tp === "WezTerm" ||
|
|
294
|
+
tp === "vscode" ||
|
|
295
|
+
tp === "Hyper" ||
|
|
296
|
+
tp === "Tabby" ||
|
|
297
|
+
process.env.LC_TERMINAL === "iTerm2";
|
|
298
|
+
if (itermLike) {
|
|
299
|
+
process.stdout.write(`\x1b]1337;File=inline=1;size=${png.length};width=44;preserveAspectRatio=1:${b64}\x07\n`);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
const kittyLike = !!process.env.KITTY_WINDOW_ID || term.includes("kitty") || tp === "ghostty";
|
|
303
|
+
if (kittyLike) {
|
|
304
|
+
const CHUNK = 4096;
|
|
305
|
+
for (let i = 0; i < b64.length; i += CHUNK) {
|
|
306
|
+
const part = b64.slice(i, i + CHUNK);
|
|
307
|
+
const more = i + CHUNK < b64.length ? 1 : 0;
|
|
308
|
+
const ctrl = i === 0 ? `a=T,f=100,m=${more}` : `m=${more}`;
|
|
309
|
+
process.stdout.write(`\x1b_G${ctrl};${part}\x1b\\`);
|
|
310
|
+
}
|
|
311
|
+
process.stdout.write("\n");
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
// Local path: gather on-device, page the deck, then render the real shareable
|
|
317
|
+
// card image right in the terminal. The card is rendered on the fly by a
|
|
318
|
+
// stateless endpoint (nothing is saved on our servers) and saved to a temp file
|
|
319
|
+
// so it can be attached when sharing. `--terminal` stays fully offline (deck
|
|
320
|
+
// only, no network, no image).
|
|
321
|
+
async function runLocal() {
|
|
322
|
+
const terminalOnly = process.argv.slice(2).includes("--terminal");
|
|
323
|
+
const stop = startLoadingLine("Putting together your AI highlights");
|
|
324
|
+
let prefetched;
|
|
325
|
+
try {
|
|
326
|
+
prefetched = await gatherFullEnrichment({
|
|
327
|
+
localOnly: true,
|
|
328
|
+
verbose: false,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
stop();
|
|
333
|
+
}
|
|
334
|
+
// Always page the deck in the terminal first.
|
|
335
|
+
await renderLocalTerminal(prefetched);
|
|
336
|
+
if (terminalOnly) {
|
|
337
|
+
process.stderr.write("\n Local run complete. To publish your wrapped and see how you rank: npx standout\n\n");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const stop2 = startLoadingLine("Rendering your shareable card");
|
|
341
|
+
let png;
|
|
342
|
+
try {
|
|
343
|
+
png = await fetchCardPng(prefetched);
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
stop2();
|
|
347
|
+
}
|
|
348
|
+
if (!png) {
|
|
349
|
+
process.stderr.write("\n (Couldn't render the shareable card image right now — your stats are above.)\n\n");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const file = join(tmpdir(), `standout-wrapped-${Date.now()}.png`);
|
|
353
|
+
writeFileSync(file, png);
|
|
354
|
+
process.stderr.write("\n");
|
|
355
|
+
const shown = showImageInline(png);
|
|
356
|
+
if (!shown) {
|
|
357
|
+
process.stderr.write(" (Your terminal can't show inline images — open the saved card below.)\n");
|
|
358
|
+
}
|
|
359
|
+
const post = encodeURIComponent("Check out my AI Wrapped 🤖 — get yours: npx standout");
|
|
360
|
+
process.stderr.write(`\n Saved card: ${file}\n\n` +
|
|
361
|
+
` Share it:\n` +
|
|
362
|
+
` X: https://x.com/intent/post?text=${post}\n` +
|
|
363
|
+
` LinkedIn: https://www.linkedin.com/feed/?shareActive=true&text=${post}\n\n` +
|
|
364
|
+
` (Card rendered on the fly — nothing was saved on our servers.)\n\n`);
|
|
365
|
+
}
|
|
128
366
|
async function runAgent(jobId) {
|
|
129
367
|
// The wrapped render + share now run non-interactively (no TTY required), so
|
|
130
368
|
// the command completes when invoked from a Claude Code Bash call. Only the
|
|
@@ -132,10 +370,6 @@ async function runAgent(jobId) {
|
|
|
132
370
|
if (profileChatEnabled()) {
|
|
133
371
|
ensureInteractive("The interactive profile chat");
|
|
134
372
|
}
|
|
135
|
-
console.log("\n ┌────────────────────────────────┐");
|
|
136
|
-
console.log(" │ are you really tokenmaxxing? │");
|
|
137
|
-
console.log(" │ Powered by Standout (YC P26) │");
|
|
138
|
-
console.log(" └────────────────────────────────┘\n");
|
|
139
373
|
// Only frame the token as a job in the application flow. Otherwise it may be a
|
|
140
374
|
// leaderboard group (`npx standout stanford`), confirmed after the wrapped runs.
|
|
141
375
|
if (jobId && profileChatEnabled()) {
|
|
@@ -340,19 +574,37 @@ async function main() {
|
|
|
340
574
|
const proxy = configureProxyFromEnv();
|
|
341
575
|
if (proxy)
|
|
342
576
|
process.stderr.write(` (using proxy ${proxy})\n`);
|
|
577
|
+
const localOnly = localOnlyEnabled();
|
|
343
578
|
if (args.includes("--gather")) {
|
|
344
|
-
|
|
345
|
-
|
|
579
|
+
// Local-only gather skips the upload-consent gate (nothing leaves the box).
|
|
580
|
+
if (!localOnly)
|
|
581
|
+
await confirmPrivacy();
|
|
582
|
+
const data = await gatherFullEnrichment({ localOnly });
|
|
346
583
|
process.stdout.write(JSON.stringify(data, null, 2));
|
|
584
|
+
return;
|
|
347
585
|
}
|
|
348
|
-
|
|
586
|
+
if (args.includes("--mcp")) {
|
|
349
587
|
await startMcpServer();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const jobId = args.find((a) => !a.startsWith("-"));
|
|
591
|
+
printBanner();
|
|
592
|
+
// Pick the mode. An explicit --local/--no-upload/STANDOUT_LOCAL forces local;
|
|
593
|
+
// a group/job arg (`npx standout stanford`) is an explicit full-flow intent; a
|
|
594
|
+
// bare `npx standout` asks the user as the first step.
|
|
595
|
+
let mode;
|
|
596
|
+
if (localOnly)
|
|
597
|
+
mode = "local";
|
|
598
|
+
else if (jobId)
|
|
599
|
+
mode = "full";
|
|
600
|
+
else
|
|
601
|
+
mode = await chooseMode();
|
|
602
|
+
if (mode === "local") {
|
|
603
|
+
await runLocal();
|
|
350
604
|
}
|
|
351
605
|
else {
|
|
352
|
-
|
|
353
|
-
if (jobId) {
|
|
606
|
+
if (jobId)
|
|
354
607
|
process.env.STANDOUT_JOB_ID = jobId;
|
|
355
|
-
}
|
|
356
608
|
await runAgent(jobId);
|
|
357
609
|
}
|
|
358
610
|
}
|
package/dist/gather.d.ts
CHANGED
|
@@ -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
|
@@ -445,6 +445,7 @@ function trimNpmPackage(p) {
|
|
|
445
445
|
};
|
|
446
446
|
}
|
|
447
447
|
export async function gather(options = {}) {
|
|
448
|
+
const localOnly = options.localOnly === true;
|
|
448
449
|
const profileBasics = options.profileBasics?.readiness.profile_basics_status === "ready"
|
|
449
450
|
? options.profileBasics
|
|
450
451
|
: undefined;
|
|
@@ -468,24 +469,32 @@ export async function gather(options = {}) {
|
|
|
468
469
|
remoteCandidates.push(match[1]);
|
|
469
470
|
}
|
|
470
471
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
+
}
|
|
477
486
|
}
|
|
478
487
|
}
|
|
479
488
|
}
|
|
480
489
|
// Fallback: search GitHub by email (public profile email match)
|
|
481
|
-
if (!githubUsername && email) {
|
|
490
|
+
if (!githubUsername && email && !localOnly) {
|
|
482
491
|
const searchResult = (await fetchGitHub(`https://api.github.com/search/users?q=${encodeURIComponent(email)}+in:email`));
|
|
483
492
|
if (searchResult?.items?.[0]) {
|
|
484
493
|
githubUsername = searchResult.items[0].login;
|
|
485
494
|
}
|
|
486
495
|
}
|
|
487
496
|
// Fallback: search commits by author-email (catches users with private email)
|
|
488
|
-
if (!githubUsername && email) {
|
|
497
|
+
if (!githubUsername && email && !localOnly) {
|
|
489
498
|
const commitSearch = (await fetchGitHub(`https://api.github.com/search/commits?q=author-email:${encodeURIComponent(email)}`));
|
|
490
499
|
const login = commitSearch?.items?.[0]?.author?.login;
|
|
491
500
|
if (login)
|
|
@@ -497,7 +506,7 @@ export async function gather(options = {}) {
|
|
|
497
506
|
gatherLog(options, "Gathering GitHub data...\n");
|
|
498
507
|
const canUseCachedGithubBasics = Boolean(profileBasics?.identity.github_username) &&
|
|
499
508
|
profileBasics?.identity.github_username === githubUsername;
|
|
500
|
-
const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername
|
|
509
|
+
const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername && !localOnly
|
|
501
510
|
? await Promise.all([
|
|
502
511
|
canUseCachedGithubBasics
|
|
503
512
|
? Promise.resolve(profileBasics?.github.profile ?? null)
|
|
@@ -520,7 +529,7 @@ export async function gather(options = {}) {
|
|
|
520
529
|
const repoContents = {};
|
|
521
530
|
const readmes = {};
|
|
522
531
|
const commitMessages = {};
|
|
523
|
-
if (safeUsername) {
|
|
532
|
+
if (safeUsername && !localOnly) {
|
|
524
533
|
const contentPromises = topRepos.map(async (repo) => {
|
|
525
534
|
const repoName = repo.name;
|
|
526
535
|
const contents = (await fetchGitHub(`https://api.github.com/repos/${safeUsername}/${repoName}/contents`));
|
|
@@ -555,7 +564,7 @@ export async function gather(options = {}) {
|
|
|
555
564
|
}
|
|
556
565
|
// Phase 2c: AI tool usage + dev environment (run in background during LinkedIn)
|
|
557
566
|
gatherLog(options, "Scanning AI tool usage and dev environment...\n");
|
|
558
|
-
const githubEnrichPromise = safeUsername
|
|
567
|
+
const githubEnrichPromise = safeUsername && !localOnly
|
|
559
568
|
? enrichApi("github", safeUsername).catch(() => null)
|
|
560
569
|
: Promise.resolve(null);
|
|
561
570
|
const aiUsagePromise = options.aiUsage
|
|
@@ -583,7 +592,7 @@ export async function gather(options = {}) {
|
|
|
583
592
|
let linkedinResolutionSource = null;
|
|
584
593
|
let linkedinProfile = null;
|
|
585
594
|
const companyEnrichments = {};
|
|
586
|
-
if (safeEmail) {
|
|
595
|
+
if (safeEmail && !localOnly) {
|
|
587
596
|
const emailResult = await enrichApi("email_to_linkedin", email);
|
|
588
597
|
if (emailResult && typeof emailResult === "object") {
|
|
589
598
|
linkedinUrl =
|
|
@@ -594,7 +603,7 @@ export async function gather(options = {}) {
|
|
|
594
603
|
}
|
|
595
604
|
}
|
|
596
605
|
// Fallback: name + signals via AI web search when email lookup comes up empty
|
|
597
|
-
if (!linkedinUrl && ghProfile && name) {
|
|
606
|
+
if (!linkedinUrl && ghProfile && name && !localOnly) {
|
|
598
607
|
gatherLog(options, "Resolving LinkedIn via name + signals...\n");
|
|
599
608
|
const profile = ghProfile;
|
|
600
609
|
const signals = [];
|
|
@@ -631,8 +640,10 @@ export async function gather(options = {}) {
|
|
|
631
640
|
}
|
|
632
641
|
// Fallback: read a logged-in LinkedIn session from the local Chrome profile
|
|
633
642
|
// (headless, silent). Same technique as the X handle scrape — the only anchor
|
|
634
|
-
// for users with no git email/name and no GitHub remote.
|
|
635
|
-
|
|
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) {
|
|
636
647
|
gatherLog(options, "Checking for a LinkedIn session in Chrome...\n");
|
|
637
648
|
try {
|
|
638
649
|
const scraped = await scrapeLinkedinUrl();
|
|
@@ -678,7 +689,7 @@ export async function gather(options = {}) {
|
|
|
678
689
|
}
|
|
679
690
|
}
|
|
680
691
|
// Fallback: scrape the user's logged-in Chrome session for an X handle.
|
|
681
|
-
if (!twitterHandle) {
|
|
692
|
+
if (!twitterHandle && !localOnly) {
|
|
682
693
|
gatherLog(options, "Checking for an X/Twitter session in Chrome...\n");
|
|
683
694
|
try {
|
|
684
695
|
const scraped = await scrapeTwitterHandle();
|
|
@@ -691,7 +702,7 @@ export async function gather(options = {}) {
|
|
|
691
702
|
}
|
|
692
703
|
const safeTwitter = twitterHandle ? sanitize(twitterHandle) : null;
|
|
693
704
|
let twitterProfile = null;
|
|
694
|
-
if (safeTwitter) {
|
|
705
|
+
if (safeTwitter && !localOnly) {
|
|
695
706
|
twitterProfile = await enrichApi("twitter", safeTwitter);
|
|
696
707
|
}
|
|
697
708
|
const twitterUrl = safeTwitter ? `https://x.com/${safeTwitter}` : null;
|
|
@@ -850,7 +861,8 @@ export async function gather(options = {}) {
|
|
|
850
861
|
readiness: {
|
|
851
862
|
usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
|
|
852
863
|
profile_basics_status: "ready",
|
|
853
|
-
|
|
864
|
+
// Local-only mode performs no network enrichment.
|
|
865
|
+
enrichment_status: localOnly ? "unavailable" : "ready",
|
|
854
866
|
},
|
|
855
867
|
identity: {
|
|
856
868
|
name: resolvedName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "standout",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.33",
|
|
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": "
|
|
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",
|