standout 0.5.31 → 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.
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
@@ -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.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",