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 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
- await confirmPrivacy();
345
- const data = await gatherFullEnrichment();
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
- else if (args.includes("--mcp")) {
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
- const jobId = args.find((a) => !a.startsWith("-"));
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
- // Resolve to a USER (not an Organization) profile
472
- for (const candidate of remoteCandidates) {
473
- const profile = (await fetchGitHub(`https://api.github.com/users/${sanitize(candidate)}`));
474
- if (profile?.type === "User" && profile.login) {
475
- githubUsername = profile.login;
476
- 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
+ }
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
- if (!linkedinUrl) {
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
- enrichment_status: "ready",
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.31",
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": "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",