terminalhire 0.1.0

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,2264 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/github-auth.ts
13
+ var github_auth_exports = {};
14
+ __export(github_auth_exports, {
15
+ GITHUB_SCOPE: () => GITHUB_SCOPE,
16
+ deleteGitHubToken: () => deleteGitHubToken,
17
+ hasGitHubToken: () => hasGitHubToken,
18
+ readGitHubToken: () => readGitHubToken,
19
+ resolveStoredLogin: () => resolveStoredLogin,
20
+ runDeviceFlow: () => runDeviceFlow,
21
+ writeGitHubToken: () => writeGitHubToken
22
+ });
23
+ import {
24
+ createCipheriv,
25
+ createDecipheriv,
26
+ randomBytes
27
+ } from "crypto";
28
+ import {
29
+ readFileSync,
30
+ writeFileSync,
31
+ mkdirSync,
32
+ existsSync,
33
+ rmSync
34
+ } from "fs";
35
+ import { join } from "path";
36
+ import { homedir } from "os";
37
+ async function loadKey() {
38
+ try {
39
+ const kt = await import("keytar");
40
+ const stored = await kt.getPassword("terminalhire", "profile-key");
41
+ if (stored) return Buffer.from(stored, "hex");
42
+ const key2 = randomBytes(KEY_BYTES);
43
+ await kt.setPassword("terminalhire", "profile-key", key2.toString("hex"));
44
+ return key2;
45
+ } catch {
46
+ }
47
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
48
+ if (existsSync(KEY_FILE)) {
49
+ return Buffer.from(readFileSync(KEY_FILE, "utf8").trim(), "hex");
50
+ }
51
+ const key = randomBytes(KEY_BYTES);
52
+ writeFileSync(KEY_FILE, key.toString("hex"), { mode: 384, encoding: "utf8" });
53
+ return key;
54
+ }
55
+ function encrypt(plaintext, key) {
56
+ const iv = randomBytes(IV_BYTES);
57
+ const cipher = createCipheriv(ALGO, key, iv);
58
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
59
+ const tag = cipher.getAuthTag();
60
+ return { iv: iv.toString("hex"), tag: tag.toString("hex"), ciphertext: ct.toString("hex") };
61
+ }
62
+ function decrypt(blob, key) {
63
+ const decipher = createDecipheriv(ALGO, key, Buffer.from(blob.iv, "hex"));
64
+ decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
65
+ const plain = Buffer.concat([
66
+ decipher.update(Buffer.from(blob.ciphertext, "hex")),
67
+ decipher.final()
68
+ ]);
69
+ return plain.toString("utf8");
70
+ }
71
+ async function readGitHubToken() {
72
+ if (!existsSync(TOKEN_FILE)) return void 0;
73
+ try {
74
+ const key = await loadKey();
75
+ const raw = readFileSync(TOKEN_FILE, "utf8");
76
+ const blob = JSON.parse(raw);
77
+ return decrypt(blob, key);
78
+ } catch {
79
+ return void 0;
80
+ }
81
+ }
82
+ async function writeGitHubToken(token) {
83
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
84
+ const key = await loadKey();
85
+ const blob = encrypt(token, key);
86
+ writeFileSync(TOKEN_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
87
+ }
88
+ async function deleteGitHubToken() {
89
+ try {
90
+ rmSync(TOKEN_FILE);
91
+ } catch {
92
+ }
93
+ }
94
+ async function hasGitHubToken() {
95
+ return existsSync(TOKEN_FILE);
96
+ }
97
+ async function runDeviceFlow() {
98
+ if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") {
99
+ console.log("\n[mock] GitHub OAuth skipped (JPI_GITHUB_MOCK=1)");
100
+ console.log(`[mock] Using fixture profile: ${MOCK_LOGIN}`);
101
+ await writeGitHubToken(MOCK_TOKEN);
102
+ return MOCK_LOGIN;
103
+ }
104
+ const clientId = process.env["GITHUB_DEVICE_CLIENT_ID"] ?? process.env["GITHUB_CLIENT_ID"] ?? DEV_PLACEHOLDER_CLIENT_ID;
105
+ if (clientId === "Iv1.PLACEHOLDER_REGISTER_YOUR_APP") {
106
+ console.warn("\nWarning: GITHUB_CLIENT_ID env var looks like a placeholder.");
107
+ console.warn("Remove it to use the baked-in client ID, or set it to your own OAuth App Client ID.\n");
108
+ }
109
+ const deviceRes = await fetch(DEVICE_CODE_URL, {
110
+ method: "POST",
111
+ headers: {
112
+ Accept: "application/json",
113
+ "Content-Type": "application/x-www-form-urlencoded"
114
+ },
115
+ body: new URLSearchParams({ client_id: clientId, scope: GITHUB_SCOPE }).toString(),
116
+ signal: AbortSignal.timeout(15e3)
117
+ });
118
+ if (!deviceRes.ok) {
119
+ throw new Error(`GitHub device code request failed: HTTP ${deviceRes.status}`);
120
+ }
121
+ const deviceData = await deviceRes.json();
122
+ console.log("");
123
+ console.log(" GitHub sign-in (device flow)");
124
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
125
+ console.log(` 1. Open: ${deviceData.verification_uri}`);
126
+ console.log(` 2. Enter code: ${deviceData.user_code}`);
127
+ console.log(' 3. Authorize "jpi" (scope: read:user \u2014 public data only)');
128
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
129
+ console.log(" Waiting for authorization...");
130
+ console.log("");
131
+ let intervalSecs = deviceData.interval ?? 5;
132
+ const expiresAt = Date.now() + (deviceData.expires_in ?? 900) * 1e3;
133
+ const clientSecret = process.env["GITHUB_CLIENT_SECRET"];
134
+ while (Date.now() < expiresAt) {
135
+ await sleep(intervalSecs * 1e3);
136
+ const body = new URLSearchParams({
137
+ client_id: clientId,
138
+ device_code: deviceData.device_code,
139
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
140
+ });
141
+ if (clientSecret) body.set("client_secret", clientSecret);
142
+ const tokenRes = await fetch(ACCESS_TOKEN_URL, {
143
+ method: "POST",
144
+ headers: {
145
+ Accept: "application/json",
146
+ "Content-Type": "application/x-www-form-urlencoded"
147
+ },
148
+ body: body.toString(),
149
+ signal: AbortSignal.timeout(15e3)
150
+ });
151
+ if (!tokenRes.ok) {
152
+ throw new Error(`GitHub token poll failed: HTTP ${tokenRes.status}`);
153
+ }
154
+ const tokenData = await tokenRes.json();
155
+ if (tokenData.access_token) {
156
+ await writeGitHubToken(tokenData.access_token);
157
+ const login = await fetchAuthedLogin(tokenData.access_token);
158
+ console.log(` Authorized as: ${login}`);
159
+ return login;
160
+ }
161
+ if (tokenData.error === "authorization_pending") {
162
+ continue;
163
+ }
164
+ if (tokenData.error === "slow_down") {
165
+ intervalSecs = (tokenData.interval ?? intervalSecs) + 5;
166
+ continue;
167
+ }
168
+ if (tokenData.error === "expired_token") {
169
+ throw new Error("GitHub device code expired. Please run `terminalhire login` again.");
170
+ }
171
+ if (tokenData.error === "access_denied") {
172
+ throw new Error("GitHub authorization was denied by the user.");
173
+ }
174
+ throw new Error(
175
+ `GitHub device flow error: ${tokenData.error ?? "unknown"} \u2014 ${tokenData.error_description ?? ""}`
176
+ );
177
+ }
178
+ throw new Error("GitHub device code expired before authorization. Please run `terminalhire login` again.");
179
+ }
180
+ async function fetchAuthedLogin(token) {
181
+ if (token === MOCK_TOKEN) return MOCK_LOGIN;
182
+ const res = await fetch("https://api.github.com/user", {
183
+ headers: {
184
+ Authorization: `Bearer ${token}`,
185
+ Accept: "application/vnd.github+json",
186
+ "X-GitHub-Api-Version": "2022-11-28"
187
+ },
188
+ signal: AbortSignal.timeout(1e4)
189
+ });
190
+ if (!res.ok) throw new Error(`GitHub /user: HTTP ${res.status}`);
191
+ const data = await res.json();
192
+ return data.login;
193
+ }
194
+ async function resolveStoredLogin() {
195
+ if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") return MOCK_LOGIN;
196
+ const token = await readGitHubToken();
197
+ if (!token) return void 0;
198
+ try {
199
+ return await fetchAuthedLogin(token);
200
+ } catch {
201
+ return void 0;
202
+ }
203
+ }
204
+ function sleep(ms) {
205
+ return new Promise((resolve) => setTimeout(resolve, ms));
206
+ }
207
+ var TERMINALHIRE_DIR, TOKEN_FILE, KEY_FILE, ALGO, KEY_BYTES, IV_BYTES, GITHUB_SCOPE, DEVICE_CODE_URL, ACCESS_TOKEN_URL, DEV_PLACEHOLDER_CLIENT_ID, MOCK_TOKEN, MOCK_LOGIN;
208
+ var init_github_auth = __esm({
209
+ "src/github-auth.ts"() {
210
+ "use strict";
211
+ TERMINALHIRE_DIR = join(homedir(), ".terminalhire");
212
+ TOKEN_FILE = join(TERMINALHIRE_DIR, "github-token.enc");
213
+ KEY_FILE = join(TERMINALHIRE_DIR, "key");
214
+ ALGO = "aes-256-gcm";
215
+ KEY_BYTES = 32;
216
+ IV_BYTES = 12;
217
+ GITHUB_SCOPE = "read:user";
218
+ DEVICE_CODE_URL = "https://github.com/login/device/code";
219
+ ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
220
+ DEV_PLACEHOLDER_CLIENT_ID = "Ov23lignE2ZSBe0J3a6B";
221
+ MOCK_TOKEN = "mock-github-token-jpi-dev";
222
+ MOCK_LOGIN = "janedev";
223
+ }
224
+ });
225
+
226
+ // ../../packages/core/src/types.ts
227
+ var init_types = __esm({
228
+ "../../packages/core/src/types.ts"() {
229
+ "use strict";
230
+ }
231
+ });
232
+
233
+ // ../../packages/core/src/vocabulary.ts
234
+ function normalize(tokens) {
235
+ const result = /* @__PURE__ */ new Set();
236
+ for (const raw of tokens) {
237
+ const lower = raw.toLowerCase().trim();
238
+ if (VOCAB_SET.has(lower)) {
239
+ result.add(lower);
240
+ continue;
241
+ }
242
+ const mapped = SYNONYMS[lower];
243
+ if (mapped && VOCAB_SET.has(mapped)) {
244
+ result.add(mapped);
245
+ }
246
+ }
247
+ return Array.from(result);
248
+ }
249
+ var VOCABULARY, SYNONYMS, VOCAB_SET;
250
+ var init_vocabulary = __esm({
251
+ "../../packages/core/src/vocabulary.ts"() {
252
+ "use strict";
253
+ VOCABULARY = [
254
+ // Languages
255
+ "typescript",
256
+ "javascript",
257
+ "python",
258
+ "go",
259
+ "rust",
260
+ "java",
261
+ "ruby",
262
+ "elixir",
263
+ "scala",
264
+ "kotlin",
265
+ "swift",
266
+ "cpp",
267
+ "csharp",
268
+ "php",
269
+ "haskell",
270
+ "clojure",
271
+ "r",
272
+ // Frontend frameworks / libs
273
+ "react",
274
+ "nextjs",
275
+ "vue",
276
+ "nuxt",
277
+ "svelte",
278
+ "angular",
279
+ "solidjs",
280
+ "tailwind",
281
+ "css",
282
+ "html",
283
+ "graphql",
284
+ "trpc",
285
+ // Backend frameworks
286
+ "nodejs",
287
+ "express",
288
+ "fastify",
289
+ "nestjs",
290
+ "django",
291
+ "fastapi",
292
+ "flask",
293
+ "rails",
294
+ "spring",
295
+ "actix",
296
+ "gin",
297
+ "phoenix",
298
+ "laravel",
299
+ "dotnet",
300
+ // Infrastructure & DevOps
301
+ "kubernetes",
302
+ "docker",
303
+ "terraform",
304
+ "aws",
305
+ "gcp",
306
+ "azure",
307
+ "ci-cd",
308
+ "github-actions",
309
+ "linux",
310
+ "nginx",
311
+ "pulumi",
312
+ "ansible",
313
+ "prometheus",
314
+ "grafana",
315
+ "datadog",
316
+ "opentelemetry",
317
+ // Data & ML
318
+ "postgresql",
319
+ "mysql",
320
+ "sqlite",
321
+ "mongodb",
322
+ "redis",
323
+ "elasticsearch",
324
+ "kafka",
325
+ "rabbitmq",
326
+ "data-engineering",
327
+ "spark",
328
+ "airflow",
329
+ "dbt",
330
+ "ml",
331
+ "llm",
332
+ "pytorch",
333
+ "tensorflow",
334
+ "pandas",
335
+ "numpy",
336
+ // Domains / capabilities
337
+ "oauth",
338
+ "authentication",
339
+ "security",
340
+ "payments",
341
+ "billing",
342
+ "frontend",
343
+ "backend",
344
+ "devops",
345
+ "mobile",
346
+ "ios",
347
+ "android",
348
+ "api-design",
349
+ "microservices",
350
+ "websockets",
351
+ "testing",
352
+ "accessibility",
353
+ "seo",
354
+ "performance",
355
+ "observability",
356
+ "search",
357
+ "realtime"
358
+ ];
359
+ SYNONYMS = {
360
+ // Kubernetes aliases
361
+ "k8s": "kubernetes",
362
+ "kube": "kubernetes",
363
+ // Auth / identity
364
+ "passport": "authentication",
365
+ "oauth2": "oauth",
366
+ "oidc": "oauth",
367
+ "jwt": "authentication",
368
+ "saml": "authentication",
369
+ "auth0": "authentication",
370
+ "clerk": "authentication",
371
+ "nextauth": "authentication",
372
+ // Payments
373
+ "@stripe/stripe-js": "payments",
374
+ "stripe": "payments",
375
+ "braintree": "payments",
376
+ "paddle": "payments",
377
+ "lemonsqueezy": "payments",
378
+ "recurly": "billing",
379
+ "chargebee": "billing",
380
+ // Framework / lib aliases
381
+ "next": "nextjs",
382
+ "next.js": "nextjs",
383
+ "nuxt.js": "nuxt",
384
+ "vue.js": "vue",
385
+ "angular.js": "angular",
386
+ "angularjs": "angular",
387
+ "express.js": "express",
388
+ "expressjs": "express",
389
+ "fastapi": "fastapi",
390
+ "nest": "nestjs",
391
+ "nest.js": "nestjs",
392
+ "sveltekit": "svelte",
393
+ // Language aliases
394
+ "ts": "typescript",
395
+ "js": "javascript",
396
+ "py": "python",
397
+ "golang": "go",
398
+ "c++": "cpp",
399
+ "c#": "csharp",
400
+ ".net": "dotnet",
401
+ "asp.net": "dotnet",
402
+ // DB aliases
403
+ "postgres": "postgresql",
404
+ "pg": "postgresql",
405
+ "mongo": "mongodb",
406
+ "elastic": "elasticsearch",
407
+ // Cloud aliases
408
+ "amazon web services": "aws",
409
+ "google cloud": "gcp",
410
+ "google cloud platform": "gcp",
411
+ "microsoft azure": "azure",
412
+ // CI/CD aliases
413
+ "github actions": "github-actions",
414
+ "circle ci": "ci-cd",
415
+ "circleci": "ci-cd",
416
+ "jenkins": "ci-cd",
417
+ "gitlab ci": "ci-cd",
418
+ "travis": "ci-cd",
419
+ // Mobile
420
+ "react native": "mobile",
421
+ "flutter": "mobile",
422
+ "expo": "mobile",
423
+ // AI / ML
424
+ "openai": "llm",
425
+ "anthropic": "llm",
426
+ "langchain": "llm",
427
+ "llamaindex": "llm",
428
+ "hugging face": "ml",
429
+ "huggingface": "ml",
430
+ "scikit-learn": "ml",
431
+ "sklearn": "ml",
432
+ // Data pipeline
433
+ "apache kafka": "kafka",
434
+ "apache spark": "spark",
435
+ "apache airflow": "airflow",
436
+ // Misc
437
+ "tailwindcss": "tailwind",
438
+ "tw": "tailwind",
439
+ "gql": "graphql",
440
+ "ws": "websockets",
441
+ "socket.io": "websockets",
442
+ "jest": "testing",
443
+ "vitest": "testing",
444
+ "playwright": "testing",
445
+ "cypress": "testing"
446
+ };
447
+ VOCAB_SET = new Set(VOCABULARY);
448
+ }
449
+ });
450
+
451
+ // ../../packages/core/src/matcher.ts
452
+ function computeIdf(jobs) {
453
+ const docFreq = /* @__PURE__ */ new Map();
454
+ const N = jobs.length;
455
+ for (const job of jobs) {
456
+ const unique = new Set(job.tags);
457
+ for (const tag of unique) {
458
+ docFreq.set(tag, (docFreq.get(tag) ?? 0) + 1);
459
+ }
460
+ }
461
+ const idf = /* @__PURE__ */ new Map();
462
+ for (const [tag, df] of docFreq) {
463
+ idf.set(tag, Math.log((N + 1) / (df + 1)) + 1);
464
+ }
465
+ return idf;
466
+ }
467
+ function inferSeniority(title) {
468
+ for (const [re, level] of SENIORITY_PATTERNS) {
469
+ if (re.test(title)) return level;
470
+ }
471
+ return void 0;
472
+ }
473
+ function seniorityScore(fp, job) {
474
+ if (!fp.seniorityBand) return 1;
475
+ const jobLevel = inferSeniority(job.title);
476
+ if (!jobLevel) return 0.85;
477
+ const wanted = SENIORITY_RANK[fp.seniorityBand] ?? 1;
478
+ const got = SENIORITY_RANK[jobLevel] ?? 1;
479
+ const delta = Math.abs(wanted - got);
480
+ if (delta === 0) return 1;
481
+ if (delta === 1) return 0.5;
482
+ return 0.2;
483
+ }
484
+ function recencyScore(postedAt) {
485
+ if (!postedAt) return 0.75;
486
+ const ageDays = (Date.now() - new Date(postedAt).getTime()) / 864e5;
487
+ if (ageDays < 7) return 1;
488
+ if (ageDays < 30) return 0.9;
489
+ if (ageDays < 90) return 0.75;
490
+ return 0.6;
491
+ }
492
+ function passesFilters(fp, job) {
493
+ const prefs = fp.prefs;
494
+ if (!prefs) return true;
495
+ if (prefs.remoteOnly && !job.remote) return false;
496
+ if (prefs.roleTypes && prefs.roleTypes.length > 0 && !prefs.roleTypes.includes(job.roleType)) {
497
+ return false;
498
+ }
499
+ if (prefs.compFloorUsd !== void 0) {
500
+ if (job.compMax !== void 0 && job.compMax < prefs.compFloorUsd) return false;
501
+ }
502
+ return true;
503
+ }
504
+ function buildReason(matchedTags) {
505
+ if (matchedTags.length === 0) return "No direct skill overlap found.";
506
+ const top = matchedTags.slice(0, 3);
507
+ const rest = matchedTags.length - top.length;
508
+ const listed = top.join(", ");
509
+ if (rest === 0) return `Matched on ${listed}.`;
510
+ return `Matched on ${listed} + ${rest} more skill${rest > 1 ? "s" : ""}.`;
511
+ }
512
+ function match(fp, jobs, limit = 5) {
513
+ const idf = computeIdf(jobs);
514
+ const fpTagSet = new Set(fp.skillTags);
515
+ const maxTagScore = fp.skillTags.reduce((acc, t) => acc + (idf.get(t) ?? 1), 0);
516
+ const candidates = jobs.filter((j) => passesFilters(fp, j));
517
+ const scored = candidates.map((job) => {
518
+ const jobTagSet = new Set(job.tags);
519
+ const matched = [];
520
+ let tagScore2 = 0;
521
+ for (const tag of fpTagSet) {
522
+ if (jobTagSet.has(tag)) {
523
+ const w = idf.get(tag) ?? 1;
524
+ tagScore2 += w;
525
+ matched.push(tag);
526
+ }
527
+ }
528
+ const normTagScore = maxTagScore > 0 ? tagScore2 / maxTagScore : 0;
529
+ matched.sort((a, b) => (idf.get(b) ?? 1) - (idf.get(a) ?? 1));
530
+ const sScore = seniorityScore(fp, job);
531
+ const rScore = recencyScore(job.postedAt);
532
+ const score = normTagScore * 0.6 + sScore * 0.25 + rScore * 0.15;
533
+ return {
534
+ job,
535
+ score: Math.round(score * 1e3) / 1e3,
536
+ matchedTags: matched,
537
+ reason: buildReason(matched)
538
+ };
539
+ });
540
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit);
541
+ }
542
+ function matchOne(fp, job) {
543
+ const results = match(fp, [job], 1);
544
+ return results.length > 0 ? results[0] : null;
545
+ }
546
+ var SENIORITY_RANK, SENIORITY_PATTERNS;
547
+ var init_matcher = __esm({
548
+ "../../packages/core/src/matcher.ts"() {
549
+ "use strict";
550
+ SENIORITY_RANK = {
551
+ junior: 0,
552
+ mid: 1,
553
+ senior: 2,
554
+ staff: 3
555
+ };
556
+ SENIORITY_PATTERNS = [
557
+ [/\bstaff\b|\bprincipal\b|\bdistinguished\b/i, "staff"],
558
+ [/\bsenior\b|\bsr\.?\b/i, "senior"],
559
+ [/\bjunior\b|\bjr\.?\b|\bentry[\s-]?level\b/i, "junior"],
560
+ [/\bmid[\s-]?level\b|\bmid\b/i, "mid"]
561
+ ];
562
+ }
563
+ });
564
+
565
+ // ../../packages/core/src/feeds/greenhouse.ts
566
+ function tokenize(text) {
567
+ return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
568
+ }
569
+ function extractTags(job) {
570
+ const texts = [
571
+ job.title,
572
+ ...(job.departments ?? []).map((d) => d.name),
573
+ job.location?.name ?? "",
574
+ ...(job.offices ?? []).map((o) => o.name),
575
+ // mine the full HTML description for additional signal when present
576
+ ...job.content ? [job.content.replace(/<[^>]*>/g, " ")] : []
577
+ ].filter(Boolean);
578
+ const tokens = texts.flatMap(tokenize);
579
+ return normalize(tokens);
580
+ }
581
+ function inferRemote(location) {
582
+ const l = location.toLowerCase();
583
+ return l.includes("remote") || l.includes("anywhere") || l.includes("worldwide");
584
+ }
585
+ async function fetchSlug(slug) {
586
+ const url = `https://boards-api.greenhouse.io/v1/boards/${slug}/jobs?content=true`;
587
+ let res;
588
+ try {
589
+ res = await fetch(url, { headers: { Accept: "application/json" } });
590
+ } catch (err) {
591
+ console.warn(`[greenhouse] ${slug}: network error \u2014`, err);
592
+ return [];
593
+ }
594
+ if (!res.ok) {
595
+ console.warn(`[greenhouse] ${slug}: HTTP ${res.status} ${res.statusText}`);
596
+ return [];
597
+ }
598
+ let data;
599
+ try {
600
+ data = await res.json();
601
+ } catch (err) {
602
+ console.warn(`[greenhouse] ${slug}: JSON parse error \u2014`, err);
603
+ return [];
604
+ }
605
+ const jobs = data.jobs ?? [];
606
+ if (jobs.length === 0) {
607
+ console.warn(`[greenhouse] ${slug}: 0 jobs returned (board may be private or slug invalid)`);
608
+ } else {
609
+ console.info(`[greenhouse] ${slug}: ${jobs.length} jobs`);
610
+ }
611
+ return jobs.map((j) => ({
612
+ id: `greenhouse:${j.id}`,
613
+ source: "greenhouse",
614
+ title: j.title,
615
+ company: slug,
616
+ url: j.absolute_url,
617
+ remote: inferRemote(j.location?.name ?? ""),
618
+ location: j.location?.name,
619
+ tags: extractTags(j),
620
+ roleType: "full_time",
621
+ postedAt: j.updated_at,
622
+ applyMode: "direct",
623
+ raw: j
624
+ }));
625
+ }
626
+ var FALLBACK_SLUGS, greenhouse;
627
+ var init_greenhouse = __esm({
628
+ "../../packages/core/src/feeds/greenhouse.ts"() {
629
+ "use strict";
630
+ init_vocabulary();
631
+ FALLBACK_SLUGS = [
632
+ "stripe",
633
+ "linear",
634
+ "vercel",
635
+ "ramp",
636
+ "notion",
637
+ "airbnb",
638
+ "anthropic",
639
+ "figma",
640
+ "discord",
641
+ "brex",
642
+ "mercury",
643
+ "retool",
644
+ "vanta",
645
+ "plaid",
646
+ "gusto",
647
+ "scale",
648
+ "databricks",
649
+ "coinbase",
650
+ "robinhood",
651
+ "doordash"
652
+ ];
653
+ greenhouse = {
654
+ source: "greenhouse",
655
+ async fetch(opts) {
656
+ const slugs = opts?.slugs && opts.slugs.length > 0 ? opts.slugs : FALLBACK_SLUGS;
657
+ console.info(`[greenhouse] fetching ${slugs.length} slugs: ${slugs.join(", ")}`);
658
+ const results = await Promise.allSettled(slugs.map(fetchSlug));
659
+ const jobs = [];
660
+ let failures = 0;
661
+ for (const r of results) {
662
+ if (r.status === "fulfilled") {
663
+ jobs.push(...r.value);
664
+ } else {
665
+ failures++;
666
+ console.warn(`[greenhouse] slug fetch rejected:`, r.reason);
667
+ }
668
+ }
669
+ console.info(`[greenhouse] total: ${jobs.length} jobs, ${failures} slug failures`);
670
+ return jobs;
671
+ }
672
+ };
673
+ }
674
+ });
675
+
676
+ // ../../packages/core/src/feeds/ashby.ts
677
+ function tokenize2(text) {
678
+ return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
679
+ }
680
+ function extractTags2(job) {
681
+ const texts = [
682
+ job.title,
683
+ job.teamName ?? "",
684
+ job.locationName ?? "",
685
+ ...(job.secondaryLocations ?? []).map((l) => l.locationName ?? "")
686
+ ];
687
+ return normalize(texts.flatMap(tokenize2));
688
+ }
689
+ function mapEmploymentType(raw) {
690
+ if (!raw) return "full_time";
691
+ const lower = raw.toLowerCase();
692
+ if (lower.includes("contract") || lower.includes("contractor")) return "contract";
693
+ if (lower.includes("freelance")) return "freelance";
694
+ return "full_time";
695
+ }
696
+ function inferRemote2(job) {
697
+ if (job.isRemote === true) return true;
698
+ const loc = (job.locationName ?? "").toLowerCase();
699
+ return loc.includes("remote") || loc.includes("anywhere");
700
+ }
701
+ async function fetchSlug2(slug) {
702
+ const url = `https://api.ashbyhq.com/posting-api/job-board/${slug}`;
703
+ const res = await fetch(url, {
704
+ headers: { Accept: "application/json" }
705
+ });
706
+ if (!res.ok) {
707
+ throw new Error(`Ashby ${slug}: HTTP ${res.status}`);
708
+ }
709
+ const data = await res.json();
710
+ return (data.jobs ?? []).map((j) => {
711
+ const comp = j.compensation;
712
+ return {
713
+ id: `ashby:${j.id}`,
714
+ source: "ashby",
715
+ title: j.title,
716
+ company: slug,
717
+ url: j.applyUrl ?? `https://jobs.ashbyhq.com/${slug}/${j.id}`,
718
+ remote: inferRemote2(j),
719
+ location: j.locationName,
720
+ compMin: comp?.minValue,
721
+ compMax: comp?.maxValue,
722
+ tags: extractTags2(j),
723
+ roleType: mapEmploymentType(j.employmentType),
724
+ postedAt: j.publishedDate,
725
+ applyMode: "direct",
726
+ raw: j
727
+ };
728
+ });
729
+ }
730
+ var ashby;
731
+ var init_ashby = __esm({
732
+ "../../packages/core/src/feeds/ashby.ts"() {
733
+ "use strict";
734
+ init_vocabulary();
735
+ ashby = {
736
+ source: "ashby",
737
+ async fetch(opts) {
738
+ const slugs = opts?.slugs ?? [];
739
+ const results = await Promise.allSettled(slugs.map(fetchSlug2));
740
+ const jobs = [];
741
+ for (const r of results) {
742
+ if (r.status === "fulfilled") jobs.push(...r.value);
743
+ }
744
+ return jobs;
745
+ }
746
+ };
747
+ }
748
+ });
749
+
750
+ // ../../packages/core/src/feeds/himalayas.ts
751
+ function tokenize3(text) {
752
+ return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
753
+ }
754
+ function extractTags3(job) {
755
+ const texts = [
756
+ job.title,
757
+ ...job.tags ?? []
758
+ ];
759
+ return normalize(texts.flatMap(tokenize3));
760
+ }
761
+ function mapJobType(raw) {
762
+ if (!raw) return "full_time";
763
+ const lower = raw.toLowerCase();
764
+ if (lower.includes("contract")) return "contract";
765
+ if (lower.includes("freelance")) return "freelance";
766
+ return "full_time";
767
+ }
768
+ function buildUrl(job) {
769
+ if (job.applicationUrl) return job.applicationUrl;
770
+ if (job.url) return job.url;
771
+ const slug = job.slug ?? job.id ?? "unknown";
772
+ return `https://himalayas.app/jobs/${slug}`;
773
+ }
774
+ function buildId(job) {
775
+ return `himalayas:${job.id ?? job.slug ?? job.title}`;
776
+ }
777
+ var himalayas;
778
+ var init_himalayas = __esm({
779
+ "../../packages/core/src/feeds/himalayas.ts"() {
780
+ "use strict";
781
+ init_vocabulary();
782
+ himalayas = {
783
+ source: "himalayas",
784
+ async fetch(opts) {
785
+ const limit = opts?.limit ?? 100;
786
+ const url = `https://himalayas.app/jobs/api?limit=${limit}`;
787
+ const res = await fetch(url, {
788
+ headers: { Accept: "application/json" }
789
+ });
790
+ if (!res.ok) {
791
+ throw new Error(`Himalayas: HTTP ${res.status}`);
792
+ }
793
+ const data = await res.json();
794
+ return (data.jobs ?? []).map((j) => ({
795
+ id: buildId(j),
796
+ source: "himalayas",
797
+ title: j.title,
798
+ company: j.companyName ?? j.companySlug ?? "unknown",
799
+ url: buildUrl(j),
800
+ // Himalayas is a remote-only board
801
+ remote: true,
802
+ location: (j.locationRestrictions ?? []).join(", ") || "Remote",
803
+ compMin: j.salaryMin,
804
+ compMax: j.salaryMax,
805
+ tags: extractTags3(j),
806
+ roleType: mapJobType(j.jobType),
807
+ postedAt: j.pubDate ?? j.createdAt,
808
+ applyMode: "direct",
809
+ raw: j
810
+ }));
811
+ }
812
+ };
813
+ }
814
+ });
815
+
816
+ // ../../packages/core/src/feeds/wwr.ts
817
+ function tokenize4(text) {
818
+ return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
819
+ }
820
+ function stripHtml(html) {
821
+ return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
822
+ }
823
+ function inferRoleType(category) {
824
+ const lower = category.toLowerCase();
825
+ if (lower.includes("contract")) return "contract";
826
+ if (lower.includes("freelance")) return "freelance";
827
+ return "full_time";
828
+ }
829
+ function extractId(link) {
830
+ const match2 = link.match(/\/opening\/([^/\s]+)/);
831
+ return `wwr:${match2?.[1] ?? encodeURIComponent(link)}`;
832
+ }
833
+ function parseRss(xml) {
834
+ const items = [];
835
+ const itemBlocks = xml.match(/<item>([\s\S]*?)<\/item>/g) ?? [];
836
+ for (const block of itemBlocks) {
837
+ const get = (tag) => {
838
+ const cdataMatch = block.match(new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`, "i"));
839
+ if (cdataMatch) return cdataMatch[1].trim();
840
+ const plainMatch = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
841
+ return plainMatch?.[1].trim() ?? "";
842
+ };
843
+ const rawTitle = get("title");
844
+ const colonIdx = rawTitle.indexOf(":");
845
+ const company = colonIdx !== -1 ? rawTitle.slice(0, colonIdx).trim() : "Unknown";
846
+ const titleAfterColon = colonIdx !== -1 ? rawTitle.slice(colonIdx + 1).trim() : rawTitle;
847
+ const title = titleAfterColon.replace(/\s*\([^)]*\)\s*$/, "").trim();
848
+ items.push({
849
+ title,
850
+ link: get("link") || get("guid"),
851
+ pubDate: get("pubDate"),
852
+ category: get("category"),
853
+ description: get("description"),
854
+ company
855
+ });
856
+ }
857
+ return items;
858
+ }
859
+ function extractTags4(item) {
860
+ const text = [item.title, item.category, stripHtml(item.description)].join(" ");
861
+ return normalize(tokenize4(text));
862
+ }
863
+ var WWR_RSS_URL, wwr;
864
+ var init_wwr = __esm({
865
+ "../../packages/core/src/feeds/wwr.ts"() {
866
+ "use strict";
867
+ init_vocabulary();
868
+ WWR_RSS_URL = "https://weworkremotely.com/remote-jobs.rss";
869
+ wwr = {
870
+ source: "wwr",
871
+ async fetch(opts) {
872
+ const limit = opts?.limit ?? 200;
873
+ const res = await fetch(WWR_RSS_URL, {
874
+ headers: { Accept: "application/rss+xml, application/xml, text/xml" }
875
+ });
876
+ if (!res.ok) {
877
+ throw new Error(`WWR RSS: HTTP ${res.status}`);
878
+ }
879
+ const xml = await res.text();
880
+ const items = parseRss(xml).slice(0, limit);
881
+ return items.map((item) => ({
882
+ id: extractId(item.link),
883
+ source: "wwr",
884
+ title: item.title,
885
+ company: item.company,
886
+ url: item.link,
887
+ // WWR is a remote-only board
888
+ remote: true,
889
+ location: "Remote",
890
+ tags: extractTags4(item),
891
+ roleType: inferRoleType(item.category),
892
+ postedAt: item.pubDate ? new Date(item.pubDate).toISOString() : void 0,
893
+ applyMode: "direct",
894
+ raw: item
895
+ }));
896
+ }
897
+ };
898
+ }
899
+ });
900
+
901
+ // ../../packages/core/src/feeds/hn.ts
902
+ function tokenize5(text) {
903
+ return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
904
+ }
905
+ function stripHtml2(html) {
906
+ return html.replace(/<p>/gi, " ").replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/\s+/g, " ").trim();
907
+ }
908
+ function extractUrl(text) {
909
+ const match2 = text.match(/https?:\/\/[^\s<>"']+/);
910
+ return match2?.[0] ?? "";
911
+ }
912
+ function inferRemote3(text) {
913
+ const lower = text.toLowerCase();
914
+ return lower.includes("remote") || lower.includes("anywhere") || lower.includes("distributed");
915
+ }
916
+ function inferRoleType2(text) {
917
+ const lower = text.toLowerCase();
918
+ if (lower.includes("contract") || lower.includes("contractor")) return "contract";
919
+ if (lower.includes("freelance")) return "freelance";
920
+ return "full_time";
921
+ }
922
+ function parseComment(item) {
923
+ if (!item.text || item.text.trim().length < 20) return null;
924
+ const raw = stripHtml2(item.text);
925
+ if (!raw) return null;
926
+ const firstLine = raw.split(/\n/)[0];
927
+ const parts = firstLine.split("|").map((s) => s.trim());
928
+ const company = parts[0] ?? "Unknown";
929
+ const title = parts[1] ?? firstLine.slice(0, 80).trim();
930
+ const location = parts[2] ?? "";
931
+ if (company.toLowerCase().startsWith("note:") || company.toLowerCase().startsWith("ps:") || title.length < 3) {
932
+ return null;
933
+ }
934
+ const url = extractUrl(raw) || `https://news.ycombinator.com/item?id=${item.id}`;
935
+ const tags = extractTags5(raw);
936
+ if (tags.length === 0) return null;
937
+ return {
938
+ id: `hn:${item.id}`,
939
+ source: "hn",
940
+ title: title.slice(0, 120),
941
+ company: company.slice(0, 80),
942
+ url,
943
+ remote: inferRemote3(raw),
944
+ location: location || void 0,
945
+ tags,
946
+ roleType: inferRoleType2(raw),
947
+ postedAt: item.created_at,
948
+ applyMode: "direct",
949
+ raw: item
950
+ };
951
+ }
952
+ function extractTags5(text) {
953
+ return normalize(tokenize5(text));
954
+ }
955
+ var ALGOLIA_SEARCH, ALGOLIA_ITEMS, hn;
956
+ var init_hn = __esm({
957
+ "../../packages/core/src/feeds/hn.ts"() {
958
+ "use strict";
959
+ init_vocabulary();
960
+ ALGOLIA_SEARCH = "https://hn.algolia.com/api/v1/search?query=Ask+HN%3A+Who+is+Hiring%3F&tags=story,ask_hn&hitsPerPage=1";
961
+ ALGOLIA_ITEMS = "https://hn.algolia.com/api/v1/items/";
962
+ hn = {
963
+ source: "hn",
964
+ async fetch(opts) {
965
+ const limit = opts?.limit ?? 150;
966
+ const searchRes = await fetch(ALGOLIA_SEARCH, {
967
+ headers: { Accept: "application/json" }
968
+ });
969
+ if (!searchRes.ok) {
970
+ throw new Error(`HN Algolia search: HTTP ${searchRes.status}`);
971
+ }
972
+ const searchData = await searchRes.json();
973
+ const story = searchData.hits[0];
974
+ if (!story) {
975
+ throw new Error('HN: No "Who is Hiring" story found');
976
+ }
977
+ const itemRes = await fetch(`${ALGOLIA_ITEMS}${story.objectID}`, {
978
+ headers: { Accept: "application/json" }
979
+ });
980
+ if (!itemRes.ok) {
981
+ throw new Error(`HN Algolia item ${story.objectID}: HTTP ${itemRes.status}`);
982
+ }
983
+ const storyItem = await itemRes.json();
984
+ const comments = storyItem.children ?? [];
985
+ const jobs = [];
986
+ for (const comment of comments.slice(0, limit)) {
987
+ const job = parseComment(comment);
988
+ if (job) jobs.push(job);
989
+ }
990
+ return jobs;
991
+ }
992
+ };
993
+ }
994
+ });
995
+
996
+ // ../../packages/core/src/feeds/index.ts
997
+ async function aggregate(opts) {
998
+ const ghSlugs = opts?.slugs?.["greenhouse"] ?? DEFAULT_GREENHOUSE_SLUGS;
999
+ const ashbySlugs = opts?.slugs?.["ashby"] ?? DEFAULT_ASHBY_SLUGS;
1000
+ const limit = opts?.limit ?? 150;
1001
+ const settled = await Promise.allSettled([
1002
+ greenhouse.fetch({ slugs: ghSlugs, limit }),
1003
+ ashby.fetch({ slugs: ashbySlugs, limit }),
1004
+ himalayas.fetch({ limit }),
1005
+ wwr.fetch({ limit }),
1006
+ hn.fetch({ limit })
1007
+ ]);
1008
+ const seen = /* @__PURE__ */ new Set();
1009
+ const jobs = [];
1010
+ const sourceNames = ["greenhouse", "ashby", "himalayas", "wwr", "hn"];
1011
+ for (let i = 0; i < settled.length; i++) {
1012
+ const result = settled[i];
1013
+ if (result.status === "rejected") {
1014
+ console.warn(`[feeds] ${sourceNames[i]} failed:`, result.reason);
1015
+ continue;
1016
+ }
1017
+ for (const job of result.value) {
1018
+ if (!seen.has(job.id)) {
1019
+ seen.add(job.id);
1020
+ jobs.push(job);
1021
+ }
1022
+ }
1023
+ }
1024
+ return jobs;
1025
+ }
1026
+ var FEEDS, DEFAULT_GREENHOUSE_SLUGS, DEFAULT_ASHBY_SLUGS;
1027
+ var init_feeds = __esm({
1028
+ "../../packages/core/src/feeds/index.ts"() {
1029
+ "use strict";
1030
+ init_greenhouse();
1031
+ init_ashby();
1032
+ init_himalayas();
1033
+ init_wwr();
1034
+ init_hn();
1035
+ FEEDS = [greenhouse, ashby, himalayas, wwr, hn];
1036
+ DEFAULT_GREENHOUSE_SLUGS = [
1037
+ "stripe",
1038
+ "linear",
1039
+ "vercel",
1040
+ "ramp",
1041
+ "notion",
1042
+ "airbnb",
1043
+ "anthropic",
1044
+ "figma",
1045
+ "discord",
1046
+ "brex",
1047
+ "mercury",
1048
+ "retool",
1049
+ "vanta",
1050
+ "plaid",
1051
+ "gusto",
1052
+ "scale",
1053
+ "databricks",
1054
+ "coinbase",
1055
+ "robinhood",
1056
+ "doordash"
1057
+ ];
1058
+ DEFAULT_ASHBY_SLUGS = [
1059
+ "ramp",
1060
+ "notion",
1061
+ "linear",
1062
+ "vercel",
1063
+ "replit",
1064
+ "posthog"
1065
+ ];
1066
+ }
1067
+ });
1068
+
1069
+ // ../../packages/core/src/coastal.ts
1070
+ import { readFileSync as readFileSync2 } from "fs";
1071
+ import { join as join2 } from "path";
1072
+ import { fileURLToPath } from "url";
1073
+ function resolveDataPath() {
1074
+ try {
1075
+ const dir = fileURLToPath(new URL("../../../data", import.meta.url));
1076
+ return join2(dir, "coastal-roles.json");
1077
+ } catch {
1078
+ return join2(process.cwd(), "data", "coastal-roles.json");
1079
+ }
1080
+ }
1081
+ function loadCoastalRoles() {
1082
+ const filePath = resolveDataPath();
1083
+ try {
1084
+ const raw = readFileSync2(filePath, "utf-8");
1085
+ const parsed = JSON.parse(raw);
1086
+ if (!Array.isArray(parsed)) {
1087
+ console.warn("[coastal] coastal-roles.json is not an array \u2014 skipping");
1088
+ return [];
1089
+ }
1090
+ const valid = [];
1091
+ for (const entry of parsed) {
1092
+ if (typeof entry === "object" && entry !== null && typeof entry.id === "string" && entry.applyMode === "buyer-lead" && entry.buyer === "coastal") {
1093
+ valid.push(entry);
1094
+ } else {
1095
+ console.warn("[coastal] Skipping malformed role entry:", entry);
1096
+ }
1097
+ }
1098
+ return valid;
1099
+ } catch (err) {
1100
+ if (err.code === "ENOENT") {
1101
+ console.warn(`[coastal] data/coastal-roles.json not found at ${filePath} \u2014 no Coastal roles loaded`);
1102
+ } else {
1103
+ console.warn("[coastal] Failed to load coastal-roles.json:", err);
1104
+ }
1105
+ return [];
1106
+ }
1107
+ }
1108
+ var COASTAL_BUYER;
1109
+ var init_coastal = __esm({
1110
+ "../../packages/core/src/coastal.ts"() {
1111
+ "use strict";
1112
+ COASTAL_BUYER = {
1113
+ id: "coastal",
1114
+ legalName: "Coastal Recruiting LLC",
1115
+ matchCriteria: {
1116
+ roleTypes: ["full_time"]
1117
+ }
1118
+ };
1119
+ }
1120
+ });
1121
+
1122
+ // ../../packages/core/src/indexer.ts
1123
+ async function buildIndex(opts) {
1124
+ const includeCoastal = opts?.includeCoastal ?? true;
1125
+ const publicJobs = await aggregate(opts);
1126
+ const allJobs = [...publicJobs];
1127
+ if (includeCoastal) {
1128
+ const coastalJobs = loadCoastalRoles();
1129
+ const seen = new Set(publicJobs.map((j) => j.id));
1130
+ for (const job of coastalJobs) {
1131
+ if (!seen.has(job.id)) {
1132
+ seen.add(job.id);
1133
+ allJobs.push(job);
1134
+ }
1135
+ }
1136
+ }
1137
+ const jobs = allJobs.map(({ raw: _raw, ...rest }) => rest);
1138
+ return {
1139
+ builtAt: (/* @__PURE__ */ new Date()).toISOString(),
1140
+ jobs
1141
+ };
1142
+ }
1143
+ var init_indexer = __esm({
1144
+ "../../packages/core/src/indexer.ts"() {
1145
+ "use strict";
1146
+ init_feeds();
1147
+ init_coastal();
1148
+ }
1149
+ });
1150
+
1151
+ // ../../packages/core/src/github.ts
1152
+ function ghHeaders(token) {
1153
+ const headers = {
1154
+ Accept: "application/vnd.github+json",
1155
+ "X-GitHub-Api-Version": "2022-11-28"
1156
+ };
1157
+ if (token) headers["Authorization"] = `Bearer ${token}`;
1158
+ return headers;
1159
+ }
1160
+ async function ghFetch(path, token) {
1161
+ const url = `https://api.github.com${path}`;
1162
+ const res = await fetch(url, { headers: ghHeaders(token) });
1163
+ if (!res.ok) {
1164
+ throw new Error(`GitHub API ${path}: HTTP ${res.status} ${res.statusText}`);
1165
+ }
1166
+ return res.json();
1167
+ }
1168
+ async function fetchGitHubProfile(login, token) {
1169
+ const user = await ghFetch(`/users/${login}`, token);
1170
+ let repos = [];
1171
+ try {
1172
+ repos = await ghFetch(
1173
+ `/users/${login}/repos?sort=pushed&per_page=100`,
1174
+ token
1175
+ );
1176
+ } catch (err) {
1177
+ console.warn(`[github] ${login}: repos fetch failed, continuing \u2014`, err);
1178
+ }
1179
+ const langCount = {};
1180
+ for (const repo of repos) {
1181
+ if (repo.fork) continue;
1182
+ if (repo.language) {
1183
+ langCount[repo.language.toLowerCase()] = (langCount[repo.language.toLowerCase()] ?? 0) + 1;
1184
+ }
1185
+ }
1186
+ const topLanguages = Object.entries(langCount).sort(([, a], [, b]) => b - a).slice(0, 10).map(([lang]) => lang);
1187
+ const topicSet = /* @__PURE__ */ new Set();
1188
+ for (const repo of repos) {
1189
+ if (repo.fork) continue;
1190
+ for (const t of repo.topics ?? []) topicSet.add(t.toLowerCase());
1191
+ }
1192
+ const topics = Array.from(topicSet).slice(0, 30);
1193
+ let recentPRorgs;
1194
+ try {
1195
+ const q = encodeURIComponent(
1196
+ `type:pr is:merged author:${login} sort:updated`
1197
+ );
1198
+ const result = await ghFetch(
1199
+ `/search/issues?q=${q}&per_page=30`,
1200
+ token
1201
+ );
1202
+ const orgs = /* @__PURE__ */ new Set();
1203
+ for (const item of result.items ?? []) {
1204
+ const orgLogin = item.repository?.owner?.login;
1205
+ if (orgLogin && orgLogin !== login) orgs.add(orgLogin);
1206
+ }
1207
+ if (orgs.size > 0) recentPRorgs = Array.from(orgs);
1208
+ } catch {
1209
+ }
1210
+ return {
1211
+ login: user.login,
1212
+ name: user.name ?? void 0,
1213
+ publicEmail: user.email ?? void 0,
1214
+ avatarUrl: user.avatar_url,
1215
+ accountCreatedAt: user.created_at,
1216
+ publicRepos: user.public_repos,
1217
+ followers: user.followers,
1218
+ topLanguages,
1219
+ topics,
1220
+ recentPRorgs
1221
+ };
1222
+ }
1223
+ function inferSeniority2(p) {
1224
+ const ageMs = Date.now() - new Date(p.accountCreatedAt).getTime();
1225
+ const ageYears = ageMs / (1e3 * 60 * 60 * 24 * 365.25);
1226
+ if (ageYears >= 9 && (p.publicRepos >= 40 || p.followers >= 500)) return "staff";
1227
+ if (ageYears >= 5 && (p.publicRepos >= 20 || p.followers >= 100)) return "senior";
1228
+ if (ageYears >= 2 && p.publicRepos >= 5) return "mid";
1229
+ if (ageYears < 2 || p.publicRepos < 5) return "junior";
1230
+ return void 0;
1231
+ }
1232
+ function githubToFingerprint(p) {
1233
+ const rawTokens = [
1234
+ ...p.topLanguages,
1235
+ ...p.topics
1236
+ // recentPRorgs intentionally excluded — org names are not skill tags
1237
+ ];
1238
+ const skillTags = normalize(rawTokens);
1239
+ const seniorityBand = inferSeniority2(p);
1240
+ return { skillTags, seniorityBand };
1241
+ }
1242
+ var init_github = __esm({
1243
+ "../../packages/core/src/github.ts"() {
1244
+ "use strict";
1245
+ init_vocabulary();
1246
+ }
1247
+ });
1248
+
1249
+ // ../../packages/core/src/index.ts
1250
+ var src_exports = {};
1251
+ __export(src_exports, {
1252
+ COASTAL_BUYER: () => COASTAL_BUYER,
1253
+ DEFAULT_ASHBY_SLUGS: () => DEFAULT_ASHBY_SLUGS,
1254
+ DEFAULT_GREENHOUSE_SLUGS: () => DEFAULT_GREENHOUSE_SLUGS,
1255
+ FEEDS: () => FEEDS,
1256
+ SYNONYMS: () => SYNONYMS,
1257
+ VOCABULARY: () => VOCABULARY,
1258
+ aggregate: () => aggregate,
1259
+ ashby: () => ashby,
1260
+ buildIndex: () => buildIndex,
1261
+ buildReason: () => buildReason,
1262
+ fetchGitHubProfile: () => fetchGitHubProfile,
1263
+ githubToFingerprint: () => githubToFingerprint,
1264
+ greenhouse: () => greenhouse,
1265
+ himalayas: () => himalayas,
1266
+ hn: () => hn,
1267
+ loadCoastalRoles: () => loadCoastalRoles,
1268
+ match: () => match,
1269
+ matchOne: () => matchOne,
1270
+ normalize: () => normalize,
1271
+ wwr: () => wwr
1272
+ });
1273
+ var init_src = __esm({
1274
+ "../../packages/core/src/index.ts"() {
1275
+ "use strict";
1276
+ init_types();
1277
+ init_vocabulary();
1278
+ init_matcher();
1279
+ init_feeds();
1280
+ init_indexer();
1281
+ init_coastal();
1282
+ init_github();
1283
+ }
1284
+ });
1285
+
1286
+ // src/profile.ts
1287
+ var profile_exports = {};
1288
+ __export(profile_exports, {
1289
+ accumulateGitHubTags: () => accumulateGitHubTags,
1290
+ accumulateSession: () => accumulateSession,
1291
+ accumulateTags: () => accumulateTags,
1292
+ deleteProfile: () => deleteProfile,
1293
+ profileToFingerprint: () => profileToFingerprint,
1294
+ readProfile: () => readProfile,
1295
+ writeProfile: () => writeProfile
1296
+ });
1297
+ import {
1298
+ createCipheriv as createCipheriv2,
1299
+ createDecipheriv as createDecipheriv2,
1300
+ randomBytes as randomBytes2
1301
+ } from "crypto";
1302
+ import {
1303
+ readFileSync as readFileSync3,
1304
+ writeFileSync as writeFileSync2,
1305
+ mkdirSync as mkdirSync2,
1306
+ existsSync as existsSync2
1307
+ } from "fs";
1308
+ import { join as join3 } from "path";
1309
+ import { homedir as homedir2 } from "os";
1310
+ async function loadKey2() {
1311
+ try {
1312
+ const kt = await import("keytar");
1313
+ const stored = await kt.getPassword("terminalhire", "profile-key");
1314
+ if (stored) {
1315
+ return Buffer.from(stored, "hex");
1316
+ }
1317
+ const key2 = randomBytes2(KEY_BYTES2);
1318
+ await kt.setPassword("terminalhire", "profile-key", key2.toString("hex"));
1319
+ return key2;
1320
+ } catch {
1321
+ }
1322
+ mkdirSync2(TERMINALHIRE_DIR2, { recursive: true });
1323
+ if (existsSync2(KEY_FILE2)) {
1324
+ return Buffer.from(readFileSync3(KEY_FILE2, "utf8").trim(), "hex");
1325
+ }
1326
+ const key = randomBytes2(KEY_BYTES2);
1327
+ writeFileSync2(KEY_FILE2, key.toString("hex"), { mode: 384, encoding: "utf8" });
1328
+ return key;
1329
+ }
1330
+ function encrypt2(plaintext, key) {
1331
+ const iv = randomBytes2(IV_BYTES2);
1332
+ const cipher = createCipheriv2(ALGO2, key, iv);
1333
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
1334
+ const tag = cipher.getAuthTag();
1335
+ return {
1336
+ iv: iv.toString("hex"),
1337
+ tag: tag.toString("hex"),
1338
+ ciphertext: ct.toString("hex")
1339
+ };
1340
+ }
1341
+ function decrypt2(blob, key) {
1342
+ const decipher = createDecipheriv2(
1343
+ ALGO2,
1344
+ key,
1345
+ Buffer.from(blob.iv, "hex")
1346
+ );
1347
+ decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
1348
+ const plain = Buffer.concat([
1349
+ decipher.update(Buffer.from(blob.ciphertext, "hex")),
1350
+ decipher.final()
1351
+ ]);
1352
+ return plain.toString("utf8");
1353
+ }
1354
+ function blankProfile() {
1355
+ return {
1356
+ version: 3,
1357
+ skillTags: [],
1358
+ tagWeights: {},
1359
+ hasEmployerSessions: false,
1360
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1361
+ };
1362
+ }
1363
+ function recencyDecay(lastSeen) {
1364
+ const ageMs = Date.now() - new Date(lastSeen).getTime();
1365
+ return Math.pow(0.5, ageMs / DECAY_HALF_LIFE_MS);
1366
+ }
1367
+ function tagScore(w) {
1368
+ return w.count * recencyDecay(w.lastSeen);
1369
+ }
1370
+ function deriveSkillTags(tagWeights) {
1371
+ return Object.entries(tagWeights).filter(([, w]) => w.count >= 1).sort(([, a], [, b]) => tagScore(b) - tagScore(a)).map(([tag]) => tag);
1372
+ }
1373
+ function migrateTagWeights(profile) {
1374
+ if (!profile.tagWeights) {
1375
+ profile.tagWeights = {};
1376
+ }
1377
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1378
+ for (const tag of profile.skillTags) {
1379
+ if (!profile.tagWeights[tag]) {
1380
+ profile.tagWeights[tag] = { count: 1, firstSeen: now, lastSeen: now, sessions: 1 };
1381
+ }
1382
+ }
1383
+ }
1384
+ async function readProfile() {
1385
+ if (!existsSync2(PROFILE_FILE)) return blankProfile();
1386
+ try {
1387
+ const key = await loadKey2();
1388
+ const raw = readFileSync3(PROFILE_FILE, "utf8");
1389
+ const blob = JSON.parse(raw);
1390
+ const plaintext = decrypt2(blob, key);
1391
+ const parsed = JSON.parse(plaintext);
1392
+ migrateTagWeights(parsed);
1393
+ return parsed;
1394
+ } catch {
1395
+ return blankProfile();
1396
+ }
1397
+ }
1398
+ async function writeProfile(profile) {
1399
+ mkdirSync2(TERMINALHIRE_DIR2, { recursive: true });
1400
+ const key = await loadKey2();
1401
+ profile.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1402
+ profile.skillTags = deriveSkillTags(profile.tagWeights);
1403
+ const blob = encrypt2(JSON.stringify(profile), key);
1404
+ writeFileSync2(PROFILE_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
1405
+ }
1406
+ function accumulateSession(profile, tags, isEmployerContext2, inferredSeniority) {
1407
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1408
+ let filtered = normalize(tags);
1409
+ if (isEmployerContext2) {
1410
+ filtered = filtered.filter((t) => LANGUAGE_TAGS.has(t));
1411
+ profile.hasEmployerSessions = true;
1412
+ }
1413
+ for (const tag of filtered) {
1414
+ const existing = profile.tagWeights[tag];
1415
+ if (existing) {
1416
+ existing.count += 1;
1417
+ existing.sessions += 1;
1418
+ existing.lastSeen = now;
1419
+ } else {
1420
+ profile.tagWeights[tag] = { count: 1, firstSeen: now, lastSeen: now, sessions: 1 };
1421
+ }
1422
+ }
1423
+ if (inferredSeniority && !isEmployerContext2) {
1424
+ profile.seniority = inferredSeniority;
1425
+ }
1426
+ }
1427
+ async function accumulateTags(rawTokens, isEmployerContext2, inferredSeniority) {
1428
+ const profile = await readProfile();
1429
+ accumulateSession(profile, rawTokens, isEmployerContext2, inferredSeniority);
1430
+ await writeProfile(profile);
1431
+ }
1432
+ function accumulateGitHubTags(profile, tags) {
1433
+ accumulateSession(
1434
+ profile,
1435
+ tags,
1436
+ /* isEmployerContext */
1437
+ false
1438
+ );
1439
+ }
1440
+ async function deleteProfile() {
1441
+ const { rmSync: rmSync2 } = await import("fs");
1442
+ try {
1443
+ rmSync2(PROFILE_FILE);
1444
+ } catch {
1445
+ }
1446
+ try {
1447
+ rmSync2(KEY_FILE2);
1448
+ } catch {
1449
+ }
1450
+ }
1451
+ function profileToFingerprint(profile) {
1452
+ const rankedTags = Object.entries(profile.tagWeights).map(([tag, w]) => ({ tag, score: tagScore(w) })).filter(({ score }) => score >= MIN_FINGERPRINT_SCORE).sort((a, b) => b.score - a.score).map(({ tag }) => tag);
1453
+ const skillTags = rankedTags.length > 0 ? rankedTags : profile.skillTags;
1454
+ return {
1455
+ skillTags,
1456
+ seniorityBand: profile.seniority,
1457
+ prefs: {
1458
+ roleTypes: profile.roleTypes,
1459
+ remoteOnly: profile.remoteOnly,
1460
+ compFloorUsd: profile.compFloorUsd
1461
+ }
1462
+ };
1463
+ }
1464
+ var TERMINALHIRE_DIR2, PROFILE_FILE, KEY_FILE2, ALGO2, KEY_BYTES2, IV_BYTES2, DECAY_HALF_LIFE_MS, LANGUAGE_TAGS, MIN_FINGERPRINT_SCORE;
1465
+ var init_profile = __esm({
1466
+ "src/profile.ts"() {
1467
+ "use strict";
1468
+ init_src();
1469
+ TERMINALHIRE_DIR2 = join3(homedir2(), ".terminalhire");
1470
+ PROFILE_FILE = join3(TERMINALHIRE_DIR2, "profile.enc");
1471
+ KEY_FILE2 = join3(TERMINALHIRE_DIR2, "key");
1472
+ ALGO2 = "aes-256-gcm";
1473
+ KEY_BYTES2 = 32;
1474
+ IV_BYTES2 = 12;
1475
+ DECAY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1e3;
1476
+ LANGUAGE_TAGS = /* @__PURE__ */ new Set([
1477
+ "typescript",
1478
+ "javascript",
1479
+ "python",
1480
+ "go",
1481
+ "rust",
1482
+ "java",
1483
+ "ruby",
1484
+ "elixir",
1485
+ "scala",
1486
+ "kotlin",
1487
+ "swift",
1488
+ "cpp",
1489
+ "csharp",
1490
+ "php",
1491
+ "haskell",
1492
+ "clojure",
1493
+ "r"
1494
+ ]);
1495
+ MIN_FINGERPRINT_SCORE = 0.05;
1496
+ }
1497
+ });
1498
+
1499
+ // bin/jpi-login.js
1500
+ var jpi_login_exports = {};
1501
+ __export(jpi_login_exports, {
1502
+ run: () => run
1503
+ });
1504
+ async function run() {
1505
+ const subcommand = process.argv[2];
1506
+ if (subcommand === "logout") {
1507
+ await runLogout();
1508
+ } else {
1509
+ await runLogin();
1510
+ }
1511
+ }
1512
+ async function runLogin() {
1513
+ const { runDeviceFlow: runDeviceFlow2, readGitHubToken: readGitHubToken2 } = await Promise.resolve().then(() => (init_github_auth(), github_auth_exports));
1514
+ const { fetchGitHubProfile: fetchGitHubProfile2, githubToFingerprint: githubToFingerprint2 } = await Promise.resolve().then(() => (init_src(), src_exports));
1515
+ const { readProfile: readProfile2, writeProfile: writeProfile2, accumulateGitHubTags: accumulateGitHubTags2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
1516
+ console.log("");
1517
+ console.log(" terminalhire \u2014 Sign in with GitHub");
1518
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1519
+ console.log(" Scope: read:user (public profile + public repos only)");
1520
+ console.log(" Your token is encrypted and stored at ~/.terminalhire/github-token.enc");
1521
+ console.log(" GitHub data enriches your LOCAL profile \u2014 no data leaves your machine.");
1522
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1523
+ try {
1524
+ const login = await runDeviceFlow2();
1525
+ const token = await readGitHubToken2();
1526
+ if (!token) throw new Error("Token was not stored after device flow \u2014 unexpected.");
1527
+ console.log(`
1528
+ Fetching public profile for @${login}...`);
1529
+ let ghProfile;
1530
+ if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") {
1531
+ const { createRequire: createRequire2 } = await import("module");
1532
+ const { fileURLToPath: fileURLToPath4 } = await import("url");
1533
+ const { join: join7, dirname } = await import("path");
1534
+ const __dirname3 = fileURLToPath4(new URL(".", import.meta.url));
1535
+ const fixturePath = join7(__dirname3, "../../fixtures/github-sample.json");
1536
+ const { readFileSync: readFileSync7 } = await import("fs");
1537
+ ghProfile = JSON.parse(readFileSync7(fixturePath, "utf8"));
1538
+ } else {
1539
+ ghProfile = await fetchGitHubProfile2(login, token);
1540
+ }
1541
+ const fragment = githubToFingerprint2(ghProfile);
1542
+ const profile = await readProfile2();
1543
+ accumulateGitHubTags2(profile, fragment.skillTags);
1544
+ if (fragment.seniorityBand && !profile.seniority) {
1545
+ profile.seniority = fragment.seniorityBand;
1546
+ }
1547
+ if (!profile.displayName && ghProfile.name) {
1548
+ profile.displayName = ghProfile.name;
1549
+ }
1550
+ if (!profile.contactEmail && ghProfile.publicEmail) {
1551
+ profile.contactEmail = ghProfile.publicEmail;
1552
+ }
1553
+ profile.github = {
1554
+ login: ghProfile.login,
1555
+ profileUrl: `https://github.com/${ghProfile.login}`,
1556
+ topLanguages: ghProfile.topLanguages.slice(0, 5),
1557
+ publicRepos: ghProfile.publicRepos
1558
+ };
1559
+ await writeProfile2(profile);
1560
+ console.log("");
1561
+ console.log(" GitHub profile merged into local encrypted profile:");
1562
+ console.log(` Login: @${ghProfile.login}`);
1563
+ if (ghProfile.name) {
1564
+ console.log(` Name: ${ghProfile.name}`);
1565
+ }
1566
+ console.log(` Public repos: ${ghProfile.publicRepos}`);
1567
+ console.log(` Top languages: ${ghProfile.topLanguages.slice(0, 5).join(", ")}`);
1568
+ if (fragment.seniorityBand) {
1569
+ console.log(` Seniority est: ${fragment.seniorityBand}`);
1570
+ }
1571
+ console.log(` Skill tags: ${fragment.skillTags.join(", ")}`);
1572
+ if (fragment.skillTags.length === 0) {
1573
+ console.log(" (No matching vocabulary tags found in public repos/topics)");
1574
+ }
1575
+ console.log("");
1576
+ console.log(" Profile updated at ~/.terminalhire/profile.enc (encrypted at rest)");
1577
+ console.log(" GitHub data stays on your machine unless you consent to share it in a lead.");
1578
+ console.log("");
1579
+ console.log(" Run `terminalhire jobs` to see matching roles using your enriched profile.");
1580
+ console.log("");
1581
+ } catch (err) {
1582
+ console.error("\n Login error:", err instanceof Error ? err.message : String(err));
1583
+ process.exit(1);
1584
+ }
1585
+ }
1586
+ async function runLogout() {
1587
+ const { deleteGitHubToken: deleteGitHubToken2, hasGitHubToken: hasGitHubToken2 } = await Promise.resolve().then(() => (init_github_auth(), github_auth_exports));
1588
+ const { readProfile: readProfile2, writeProfile: writeProfile2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
1589
+ const hasToken = await hasGitHubToken2();
1590
+ if (!hasToken) {
1591
+ console.log("\n No GitHub token stored \u2014 nothing to remove.\n");
1592
+ process.exit(0);
1593
+ }
1594
+ await deleteGitHubToken2();
1595
+ const profile = await readProfile2();
1596
+ if (profile.github) {
1597
+ delete profile.github;
1598
+ await writeProfile2(profile);
1599
+ console.log("\n GitHub identity cleared from local profile.");
1600
+ }
1601
+ console.log(" GitHub token deleted from ~/.terminalhire/github-token.enc");
1602
+ console.log(" Skill tags accumulated from GitHub remain in your profile.");
1603
+ console.log(" To also delete those: terminalhire profile --delete\n");
1604
+ }
1605
+ var init_jpi_login = __esm({
1606
+ "bin/jpi-login.js"() {
1607
+ "use strict";
1608
+ }
1609
+ });
1610
+
1611
+ // bin/jpi-jobs.js
1612
+ var jpi_jobs_exports = {};
1613
+ __export(jpi_jobs_exports, {
1614
+ run: () => run2
1615
+ });
1616
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "fs";
1617
+ import { join as join4 } from "path";
1618
+ import { homedir as homedir3 } from "os";
1619
+ import { createInterface } from "readline";
1620
+ import { fileURLToPath as fileURLToPath2 } from "url";
1621
+ function readIndexCache() {
1622
+ try {
1623
+ const raw = readFileSync4(INDEX_CACHE_FILE, "utf8");
1624
+ const entry = JSON.parse(raw);
1625
+ if (Date.now() - entry.ts < INDEX_TTL_MS) return entry.index;
1626
+ return null;
1627
+ } catch {
1628
+ return null;
1629
+ }
1630
+ }
1631
+ function writeIndexCache(index) {
1632
+ mkdirSync3(TERMINALHIRE_DIR3, { recursive: true });
1633
+ writeFileSync3(INDEX_CACHE_FILE, JSON.stringify({ ts: Date.now(), index }), "utf8");
1634
+ }
1635
+ async function fetchIndex() {
1636
+ const cached = readIndexCache();
1637
+ if (cached) return cached;
1638
+ const res = await fetch(`${API_URL}/api/index`, {
1639
+ signal: AbortSignal.timeout(1e4)
1640
+ });
1641
+ if (!res.ok) throw new Error(`/api/index returned ${res.status}`);
1642
+ const index = await res.json();
1643
+ writeIndexCache(index);
1644
+ return index;
1645
+ }
1646
+ function prompt(question) {
1647
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1648
+ return new Promise((resolve) => {
1649
+ rl.question(question, (answer) => {
1650
+ rl.close();
1651
+ resolve(answer.trim().toLowerCase());
1652
+ });
1653
+ });
1654
+ }
1655
+ function buildConsentText(job, profile) {
1656
+ const fields = ["skillTags: " + JSON.stringify(profile.skillTags)];
1657
+ if (profile.seniority) fields.push('seniorityBand: "' + profile.seniority + '"');
1658
+ if (profile.displayName) fields.push('displayName: "' + profile.displayName + '"');
1659
+ if (profile.contactEmail) fields.push('contactEmail: "' + profile.contactEmail + '"');
1660
+ if (profile.github) {
1661
+ fields.push(
1662
+ 'github.login: "' + profile.github.login + '"',
1663
+ 'github.profileUrl: "' + profile.github.profileUrl + '"',
1664
+ "github.topLanguages: " + JSON.stringify(profile.github.topLanguages),
1665
+ "github.publicRepos: " + profile.github.publicRepos
1666
+ );
1667
+ }
1668
+ const githubNote = profile.github ? "\nGitHub fields above are public data only (scope: read:user). No private repos.\n" : "";
1669
+ return `You are about to share the following information with Coastal Recruiting LLC
1670
+ for opportunity: ${job.title} at ${job.company} (${job.id})
1671
+
1672
+ Fields that will be sent:
1673
+ ` + fields.map((f) => ` \u2022 ${f}`).join("\n") + "\n" + githubNote + `
1674
+ Nothing else leaves your machine. This action is specific to this role.
1675
+ Coastal Recruiting LLC will use this to evaluate you for the role.
1676
+ You can delete your profile at any time with: terminalhire profile --delete`;
1677
+ }
1678
+ function buildLeadPayload(job, profile, consentText, note) {
1679
+ const approvedFields = {
1680
+ skillTags: profile.skillTags
1681
+ };
1682
+ if (profile.seniority) approvedFields.seniorityBand = profile.seniority;
1683
+ if (profile.displayName) approvedFields.displayName = profile.displayName;
1684
+ if (profile.contactEmail) approvedFields.contactEmail = profile.contactEmail;
1685
+ if (note) approvedFields.note = note;
1686
+ if (profile.github) {
1687
+ approvedFields.github = {
1688
+ login: profile.github.login,
1689
+ profileUrl: profile.github.profileUrl,
1690
+ topLanguages: profile.github.topLanguages,
1691
+ publicRepos: profile.github.publicRepos
1692
+ };
1693
+ }
1694
+ return {
1695
+ opportunityId: job.id,
1696
+ buyerId: "coastal",
1697
+ buyerLegalName: "Coastal Recruiting LLC",
1698
+ approvedFields,
1699
+ consentText,
1700
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1701
+ };
1702
+ }
1703
+ function formatScore(score) {
1704
+ return Math.round(score * 100) + "%";
1705
+ }
1706
+ function formatComp(job) {
1707
+ if (job.compMin && job.compMax) {
1708
+ return `$${Math.round(job.compMin / 1e3)}k\u2013$${Math.round(job.compMax / 1e3)}k`;
1709
+ }
1710
+ if (job.compMin) return `$${Math.round(job.compMin / 1e3)}k+`;
1711
+ return "";
1712
+ }
1713
+ function printResult(i, result) {
1714
+ const { job, score, matchedTags, reason } = result;
1715
+ const comp = formatComp(job);
1716
+ const remote = job.remote ? "remote" : job.location ?? "onsite";
1717
+ const compStr = comp ? ` \xB7 ${comp}` : "";
1718
+ const mode = job.applyMode === "buyer-lead" ? " [COASTAL LEAD]" : "";
1719
+ console.log(`
1720
+ ${i + 1}. ${job.title} \u2014 ${job.company}${mode}`);
1721
+ console.log(` ${remote}${compStr} \xB7 ${job.roleType} \xB7 score: ${formatScore(score)}`);
1722
+ console.log(` ${reason}`);
1723
+ console.log(` Tags matched: ${matchedTags.slice(0, 5).join(", ")}`);
1724
+ if (job.applyMode === "direct") {
1725
+ console.log(` Apply: ${job.url}`);
1726
+ } else {
1727
+ console.log(` Apply: via Coastal Recruiting LLC (consent required)`);
1728
+ }
1729
+ }
1730
+ async function handleBuyerLead(job, profile) {
1731
+ const consentText = buildConsentText(job, profile);
1732
+ console.log("\n" + "\u2500".repeat(70));
1733
+ console.log(consentText);
1734
+ console.log("\u2500".repeat(70));
1735
+ const answer = await prompt(
1736
+ "\nShare your profile with Coastal Recruiting LLC for this role? [y/N] "
1737
+ );
1738
+ if (answer !== "y" && answer !== "yes") {
1739
+ console.log("Aborted \u2014 nothing was sent.");
1740
+ return;
1741
+ }
1742
+ let note;
1743
+ const noteAnswer = await prompt("Optional note to Coastal (press Enter to skip): ");
1744
+ if (noteAnswer.trim()) note = noteAnswer.trim();
1745
+ const payload = buildLeadPayload(job, profile, consentText, note);
1746
+ console.log("\nSending lead payload...");
1747
+ const res = await fetch(`${API_URL}/api/lead`, {
1748
+ method: "POST",
1749
+ headers: { "Content-Type": "application/json" },
1750
+ body: JSON.stringify(payload),
1751
+ signal: AbortSignal.timeout(1e4)
1752
+ });
1753
+ if (!res.ok) {
1754
+ console.error(`Error: /api/lead returned ${res.status}`);
1755
+ return;
1756
+ }
1757
+ console.log("Lead sent. Coastal Recruiting LLC will be in touch if there is a match.");
1758
+ }
1759
+ async function run2() {
1760
+ try {
1761
+ const { readProfile: readProfile2, profileToFingerprint: profileToFingerprint2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
1762
+ const { match: match2 } = await Promise.resolve().then(() => (init_src(), src_exports));
1763
+ const profile = await readProfile2();
1764
+ if (profile.skillTags.length === 0) {
1765
+ console.log("\u2726 terminalhire jobs: no skill tags in local profile yet.");
1766
+ console.log(" Start a Claude Code session in a personal project to accumulate tags.");
1767
+ console.log(" Or edit your profile: terminalhire profile --edit");
1768
+ return;
1769
+ }
1770
+ console.log(`Fetching job index from ${API_URL}/api/index...`);
1771
+ const index = await fetchIndex();
1772
+ const jobs = index.jobs ?? [];
1773
+ if (jobs.length === 0) {
1774
+ console.log("No jobs in index. Try again later.");
1775
+ return;
1776
+ }
1777
+ const fp = profileToFingerprint2(profile);
1778
+ if (REMOTE_ONLY) fp.prefs = { ...fp.prefs, remoteOnly: true };
1779
+ const results = match2(fp, jobs, SHOW_ALL ? jobs.length : LIMIT);
1780
+ try {
1781
+ const cacheRaw = readFileSync4(INDEX_CACHE_FILE, "utf8");
1782
+ const cacheEntry = JSON.parse(cacheRaw);
1783
+ cacheEntry.matchCount = results.length;
1784
+ writeFileSync3(INDEX_CACHE_FILE, JSON.stringify(cacheEntry), "utf8");
1785
+ } catch {
1786
+ }
1787
+ if (results.length === 0) {
1788
+ console.log("No matching roles found for your current profile.");
1789
+ console.log(" Your tags: " + profile.skillTags.join(", "));
1790
+ return;
1791
+ }
1792
+ console.log(`
1793
+ \u2726 ${results.length} role${results.length === 1 ? "" : "s"} matching your profile (local match \u2014 no data sent)
1794
+ `);
1795
+ for (let i = 0; i < results.length; i++) {
1796
+ printResult(i, results[i]);
1797
+ }
1798
+ if (!process.stdin.isTTY) {
1799
+ return;
1800
+ }
1801
+ console.log("\n" + "\u2500".repeat(70));
1802
+ const pick = await prompt(
1803
+ `
1804
+ Enter a number to act on a role, or press Enter to exit: `
1805
+ );
1806
+ const idx = parseInt(pick, 10) - 1;
1807
+ if (isNaN(idx) || idx < 0 || idx >= results.length) {
1808
+ return;
1809
+ }
1810
+ const chosen = results[idx];
1811
+ if (chosen.job.applyMode === "direct") {
1812
+ console.log(`
1813
+ Open this URL to apply directly (no data shared):
1814
+ ${chosen.job.url}`);
1815
+ } else if (chosen.job.applyMode === "buyer-lead") {
1816
+ await handleBuyerLead(chosen.job, profile);
1817
+ }
1818
+ } catch (err) {
1819
+ console.error("terminalhire jobs error:", err.message ?? err);
1820
+ process.exit(1);
1821
+ }
1822
+ }
1823
+ var __dirname, TERMINALHIRE_DIR3, INDEX_CACHE_FILE, INDEX_TTL_MS, API_URL, DEFAULT_LIMIT, args, limitArg, LIMIT, REMOTE_ONLY, SHOW_ALL;
1824
+ var init_jpi_jobs = __esm({
1825
+ "bin/jpi-jobs.js"() {
1826
+ "use strict";
1827
+ __dirname = fileURLToPath2(new URL(".", import.meta.url));
1828
+ TERMINALHIRE_DIR3 = join4(homedir3(), ".terminalhire");
1829
+ INDEX_CACHE_FILE = join4(TERMINALHIRE_DIR3, "index-cache.json");
1830
+ INDEX_TTL_MS = 15 * 60 * 1e3;
1831
+ API_URL = process.env["TERMINALHIRE_API_URL"] ?? process.env["TERMINALHIRE_API_URL"] ?? process.env["JPI_API_URL"] ?? "https://terminalhire.com";
1832
+ DEFAULT_LIMIT = 10;
1833
+ args = process.argv.slice(2);
1834
+ limitArg = args.indexOf("--limit");
1835
+ LIMIT = limitArg !== -1 ? parseInt(args[limitArg + 1] ?? "10", 10) : DEFAULT_LIMIT;
1836
+ REMOTE_ONLY = args.includes("--remote-only");
1837
+ SHOW_ALL = args.includes("--all");
1838
+ }
1839
+ });
1840
+
1841
+ // bin/jpi-profile.js
1842
+ var jpi_profile_exports = {};
1843
+ __export(jpi_profile_exports, {
1844
+ run: () => run3
1845
+ });
1846
+ import { createInterface as createInterface2 } from "readline";
1847
+ function prompt2(question) {
1848
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
1849
+ return new Promise((resolve) => {
1850
+ rl.question(question, (answer) => {
1851
+ rl.close();
1852
+ resolve(answer.trim());
1853
+ });
1854
+ });
1855
+ }
1856
+ async function run3() {
1857
+ const { readProfile: readProfile2, writeProfile: writeProfile2, deleteProfile: deleteProfile2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
1858
+ const args2 = process.argv.slice(2);
1859
+ if (args2.includes("--show")) {
1860
+ const profile = await readProfile2();
1861
+ console.log("\n\u2726 terminalhire local profile (encrypted at rest \u2014 shown here for your review only)\n");
1862
+ console.log(" Skill tags: " + (profile.skillTags.length > 0 ? profile.skillTags.join(", ") : "(none yet)"));
1863
+ console.log(" Seniority: " + (profile.seniority ?? "(not set)"));
1864
+ if (profile.displayName) console.log(" Display name: " + profile.displayName);
1865
+ if (profile.contactEmail) console.log(" Contact email: " + profile.contactEmail);
1866
+ if (profile.remoteOnly !== void 0) console.log(" Remote only: " + profile.remoteOnly);
1867
+ if (profile.compFloorUsd !== void 0) console.log(" Comp floor USD: $" + profile.compFloorUsd);
1868
+ console.log(" Employer sessions contributed: " + profile.hasEmployerSessions);
1869
+ if (profile.github) {
1870
+ console.log("");
1871
+ console.log(" GitHub (public data only, scope: read:user):");
1872
+ console.log(" Login: @" + profile.github.login);
1873
+ console.log(" Profile URL: " + profile.github.profileUrl);
1874
+ console.log(" Top languages: " + profile.github.topLanguages.join(", "));
1875
+ console.log(" Public repos: " + profile.github.publicRepos);
1876
+ console.log("");
1877
+ console.log(' GitHub fields are included in a lead ONLY when you consent "yes".');
1878
+ console.log(" To disconnect GitHub: terminalhire logout");
1879
+ } else {
1880
+ console.log("");
1881
+ console.log(" GitHub: not connected (run: terminalhire login for instant enrichment)");
1882
+ }
1883
+ console.log("");
1884
+ console.log(" Raw profile JSON:");
1885
+ console.log(JSON.stringify(profile, null, 2));
1886
+ console.log("\nThis profile NEVER leaves your machine except in a consented lead payload.");
1887
+ return;
1888
+ }
1889
+ if (args2.includes("--delete")) {
1890
+ console.log("\nThis will permanently delete your local terminalhire profile and encryption key.");
1891
+ const answer = await prompt2('Type "yes" to confirm: ');
1892
+ if (answer !== "yes") {
1893
+ console.log("Aborted.");
1894
+ process.exit(0);
1895
+ }
1896
+ await deleteProfile2();
1897
+ console.log("Profile and key deleted from ~/.terminalhire/");
1898
+ return;
1899
+ }
1900
+ if (args2.includes("--edit")) {
1901
+ const profile = await readProfile2();
1902
+ console.log("\n\u2726 terminalhire profile editor (press Enter to keep current value)\n");
1903
+ const name = await prompt2(`Display name [${profile.displayName ?? "not set"}]: `);
1904
+ if (name) profile.displayName = name;
1905
+ const email = await prompt2(`Contact email [${profile.contactEmail ?? "not set"}]: `);
1906
+ if (email) profile.contactEmail = email;
1907
+ const remote = await prompt2(`Remote only? (y/n) [${profile.remoteOnly ? "y" : "n"}]: `);
1908
+ if (remote === "y") profile.remoteOnly = true;
1909
+ if (remote === "n") profile.remoteOnly = false;
1910
+ const floor = await prompt2(`Comp floor USD [${profile.compFloorUsd ?? "not set"}]: `);
1911
+ if (floor && !isNaN(parseInt(floor, 10))) profile.compFloorUsd = parseInt(floor, 10);
1912
+ await writeProfile2(profile);
1913
+ console.log("\nProfile updated (encrypted at ~/.terminalhire/profile.enc)");
1914
+ return;
1915
+ }
1916
+ console.log("Usage: terminalhire profile --show | --edit | --delete");
1917
+ }
1918
+ var init_jpi_profile = __esm({
1919
+ "bin/jpi-profile.js"() {
1920
+ "use strict";
1921
+ }
1922
+ });
1923
+
1924
+ // src/signal.ts
1925
+ var signal_exports = {};
1926
+ __export(signal_exports, {
1927
+ extractFingerprint: () => extractFingerprint
1928
+ });
1929
+ import { readFileSync as readFileSync5, readdirSync } from "fs";
1930
+ import { execSync } from "child_process";
1931
+ import { join as join5 } from "path";
1932
+ function safeExec(cmd) {
1933
+ try {
1934
+ return execSync(cmd, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
1935
+ } catch {
1936
+ return "";
1937
+ }
1938
+ }
1939
+ function isEmployerContext(cwd) {
1940
+ const remote = safeExec('git -C "' + cwd + '" remote get-url origin 2>/dev/null');
1941
+ if (remote) {
1942
+ try {
1943
+ const sshMatch = remote.match(/^git@([^:]+):/);
1944
+ const httpsMatch = remote.match(/^https?:\/\/([^/]+)/);
1945
+ const host = (sshMatch?.[1] ?? httpsMatch?.[1] ?? "").toLowerCase();
1946
+ if (host && !PERSONAL_GIT_HOSTS.has(host)) {
1947
+ return true;
1948
+ }
1949
+ } catch {
1950
+ }
1951
+ }
1952
+ const email = safeExec('git -C "' + cwd + '" config user.email 2>/dev/null');
1953
+ if (email) {
1954
+ const domain = email.split("@")[1]?.toLowerCase() ?? "";
1955
+ if (domain && !PERSONAL_EMAIL_DOMAINS.has(domain)) {
1956
+ return true;
1957
+ }
1958
+ }
1959
+ return false;
1960
+ }
1961
+ function readJsonSafe(path) {
1962
+ try {
1963
+ return JSON.parse(readFileSync5(path, "utf8"));
1964
+ } catch {
1965
+ return null;
1966
+ }
1967
+ }
1968
+ function readFileSafe(path) {
1969
+ try {
1970
+ return readFileSync5(path, "utf8");
1971
+ } catch {
1972
+ return "";
1973
+ }
1974
+ }
1975
+ function tokensFromPackageJson(cwd) {
1976
+ const pkg = readJsonSafe(join5(cwd, "package.json"));
1977
+ if (!pkg || typeof pkg !== "object") return [];
1978
+ const p = pkg;
1979
+ const deps = {
1980
+ ...typeof p["dependencies"] === "object" ? p["dependencies"] : {},
1981
+ ...typeof p["devDependencies"] === "object" ? p["devDependencies"] : {}
1982
+ };
1983
+ return Object.keys(deps);
1984
+ }
1985
+ function tokensFromRequirementsTxt(cwd) {
1986
+ const content = readFileSafe(join5(cwd, "requirements.txt"));
1987
+ if (!content) return [];
1988
+ return content.split("\n").map((l) => l.trim().split(/[>=<!\[;]/)[0].trim().toLowerCase()).filter(Boolean);
1989
+ }
1990
+ function tokensFromGoMod(cwd) {
1991
+ const content = readFileSafe(join5(cwd, "go.mod"));
1992
+ if (!content) return ["go"];
1993
+ const requires = Array.from(content.matchAll(/^\s+([^\s]+)\s+v/gm)).map((m) => m[1].split("/").pop() ?? "").filter(Boolean);
1994
+ return ["go", ...requires];
1995
+ }
1996
+ function tokensFromCargoToml(cwd) {
1997
+ const content = readFileSafe(join5(cwd, "Cargo.toml"));
1998
+ if (!content) return [];
1999
+ const deps = Array.from(content.matchAll(/^([a-zA-Z0-9_-]+)\s*=/gm)).map((m) => m[1].toLowerCase());
2000
+ return ["rust", ...deps];
2001
+ }
2002
+ function tokensFromFileExtensions(cwd) {
2003
+ const tokens = [];
2004
+ const scanDirs = [cwd];
2005
+ try {
2006
+ const srcDir = join5(cwd, "src");
2007
+ readdirSync(srcDir);
2008
+ scanDirs.push(srcDir);
2009
+ } catch {
2010
+ }
2011
+ for (const dir of scanDirs) {
2012
+ try {
2013
+ const entries = readdirSync(dir, { withFileTypes: true });
2014
+ for (const e of entries) {
2015
+ if (!e.isFile()) continue;
2016
+ const dotIdx = e.name.lastIndexOf(".");
2017
+ if (dotIdx === -1) continue;
2018
+ const ext = e.name.slice(dotIdx).toLowerCase();
2019
+ const tag = EXT_MAP[ext];
2020
+ if (tag) tokens.push(tag);
2021
+ }
2022
+ } catch {
2023
+ }
2024
+ }
2025
+ return tokens;
2026
+ }
2027
+ function inferSeniority3(rawTokens) {
2028
+ const seniorSignals = /* @__PURE__ */ new Set([
2029
+ "kubernetes",
2030
+ "terraform",
2031
+ "pulumi",
2032
+ "kafka",
2033
+ "spark",
2034
+ "airflow",
2035
+ "dbt",
2036
+ "opentelemetry",
2037
+ "prometheus",
2038
+ "grafana",
2039
+ "microservices",
2040
+ "api-design",
2041
+ "security",
2042
+ "oauth",
2043
+ "payments"
2044
+ ]);
2045
+ const midSignals = /* @__PURE__ */ new Set([
2046
+ "docker",
2047
+ "ci-cd",
2048
+ "github-actions",
2049
+ "testing",
2050
+ "postgresql",
2051
+ "redis",
2052
+ "graphql",
2053
+ "trpc"
2054
+ ]);
2055
+ const normalized = new Set(normalize(rawTokens));
2056
+ const seniorHits = [...normalized].filter((t) => seniorSignals.has(t)).length;
2057
+ const midHits = [...normalized].filter((t) => midSignals.has(t)).length;
2058
+ if (seniorHits >= 2) return "senior";
2059
+ if (seniorHits >= 1 || midHits >= 2) return "mid";
2060
+ return void 0;
2061
+ }
2062
+ function extractFingerprint(cwd) {
2063
+ const employer = isEmployerContext(cwd);
2064
+ const rawTokens = [
2065
+ ...tokensFromPackageJson(cwd),
2066
+ ...tokensFromRequirementsTxt(cwd),
2067
+ ...tokensFromGoMod(cwd),
2068
+ ...tokensFromCargoToml(cwd),
2069
+ ...tokensFromFileExtensions(cwd)
2070
+ ];
2071
+ let skillTags = normalize(rawTokens);
2072
+ if (employer) {
2073
+ skillTags = skillTags.filter((t) => LANGUAGE_TAGS2.has(t));
2074
+ }
2075
+ const seniorityBand = employer ? void 0 : inferSeniority3(rawTokens);
2076
+ return {
2077
+ skillTags,
2078
+ seniorityBand,
2079
+ employerContext: employer
2080
+ };
2081
+ }
2082
+ var LANGUAGE_TAGS2, EXT_MAP, PERSONAL_GIT_HOSTS, PERSONAL_EMAIL_DOMAINS;
2083
+ var init_signal = __esm({
2084
+ "src/signal.ts"() {
2085
+ "use strict";
2086
+ init_src();
2087
+ LANGUAGE_TAGS2 = /* @__PURE__ */ new Set([
2088
+ "typescript",
2089
+ "javascript",
2090
+ "python",
2091
+ "go",
2092
+ "rust",
2093
+ "java",
2094
+ "ruby",
2095
+ "elixir",
2096
+ "scala",
2097
+ "kotlin",
2098
+ "swift",
2099
+ "cpp",
2100
+ "csharp",
2101
+ "php",
2102
+ "haskell",
2103
+ "clojure",
2104
+ "r"
2105
+ ]);
2106
+ EXT_MAP = {
2107
+ ".ts": "typescript",
2108
+ ".tsx": "typescript",
2109
+ ".js": "javascript",
2110
+ ".mjs": "javascript",
2111
+ ".cjs": "javascript",
2112
+ ".jsx": "javascript",
2113
+ ".py": "python",
2114
+ ".go": "go",
2115
+ ".rs": "rust",
2116
+ ".java": "java",
2117
+ ".rb": "ruby",
2118
+ ".ex": "elixir",
2119
+ ".exs": "elixir",
2120
+ ".scala": "scala",
2121
+ ".kt": "kotlin",
2122
+ ".swift": "swift",
2123
+ ".cpp": "cpp",
2124
+ ".cc": "cpp",
2125
+ ".cxx": "cpp",
2126
+ ".hpp": "cpp",
2127
+ ".cs": "csharp",
2128
+ ".php": "php",
2129
+ ".hs": "haskell",
2130
+ ".clj": "clojure",
2131
+ ".cljs": "clojure",
2132
+ ".r": "r",
2133
+ ".vue": "vue",
2134
+ ".svelte": "svelte"
2135
+ };
2136
+ PERSONAL_GIT_HOSTS = /* @__PURE__ */ new Set([
2137
+ "github.com",
2138
+ "gitlab.com",
2139
+ "bitbucket.org",
2140
+ "codeberg.org",
2141
+ "sr.ht"
2142
+ ]);
2143
+ PERSONAL_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
2144
+ "gmail.com",
2145
+ "googlemail.com",
2146
+ "yahoo.com",
2147
+ "outlook.com",
2148
+ "hotmail.com",
2149
+ "icloud.com",
2150
+ "me.com",
2151
+ "mac.com",
2152
+ "proton.me",
2153
+ "protonmail.com",
2154
+ "fastmail.com",
2155
+ "hey.com",
2156
+ "duck.com"
2157
+ ]);
2158
+ }
2159
+ });
2160
+
2161
+ // bin/jpi-learn.js
2162
+ var jpi_learn_exports = {};
2163
+ __export(jpi_learn_exports, {
2164
+ run: () => run4
2165
+ });
2166
+ async function run4() {
2167
+ try {
2168
+ const args2 = process.argv.slice(2);
2169
+ const cwdIdx = args2.indexOf("--cwd");
2170
+ const cwd = cwdIdx !== -1 && args2[cwdIdx + 1] ? args2[cwdIdx + 1] : process.cwd();
2171
+ const { extractFingerprint: extractFingerprint2 } = await Promise.resolve().then(() => (init_signal(), signal_exports));
2172
+ const { readProfile: readProfile2, writeProfile: writeProfile2, accumulateSession: accumulateSession2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
2173
+ const fingerprint = extractFingerprint2(cwd);
2174
+ const profile = await readProfile2();
2175
+ accumulateSession2(
2176
+ profile,
2177
+ fingerprint.skillTags,
2178
+ fingerprint.employerContext,
2179
+ fingerprint.seniorityBand
2180
+ );
2181
+ await writeProfile2(profile);
2182
+ process.exit(0);
2183
+ } catch {
2184
+ process.exit(0);
2185
+ }
2186
+ }
2187
+ var isMain;
2188
+ var init_jpi_learn = __esm({
2189
+ "bin/jpi-learn.js"() {
2190
+ "use strict";
2191
+ isMain = process.argv[1]?.endsWith("jpi-learn.js") || process.argv[1]?.endsWith("jpi-learn");
2192
+ if (isMain) {
2193
+ run4();
2194
+ }
2195
+ }
2196
+ });
2197
+
2198
+ // bin/jpi-dispatch.js
2199
+ import { fileURLToPath as fileURLToPath3 } from "url";
2200
+ import { join as join6 } from "path";
2201
+ import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
2202
+ import { createRequire } from "module";
2203
+ var __dirname2 = fileURLToPath3(new URL(".", import.meta.url));
2204
+ var firstArg = process.argv[2];
2205
+ if (!firstArg && !process.stdin.isTTY) {
2206
+ const { default: childProcess } = await import("child_process");
2207
+ const nudgeScript = join6(__dirname2, "jpi.js");
2208
+ const child = childProcess.spawnSync(process.execPath, [nudgeScript], {
2209
+ stdio: ["inherit", "inherit", "inherit"]
2210
+ });
2211
+ process.exit(child.status ?? 0);
2212
+ }
2213
+ if (!firstArg || firstArg === "help" || firstArg === "--help" || firstArg === "-h") {
2214
+ console.log("");
2215
+ console.log("terminalhire v3.1 \u2014 local-first job matching for developers");
2216
+ console.log("");
2217
+ console.log("Commands:");
2218
+ console.log(" terminalhire login Sign in with GitHub (recommended \u2014 enriches profile instantly)");
2219
+ console.log(" terminalhire logout Clear stored GitHub token");
2220
+ console.log(" terminalhire jobs Fetch job index, match locally, browse roles");
2221
+ console.log(" terminalhire jobs --limit N Show top N results (default: 10)");
2222
+ console.log(" terminalhire jobs --remote-only Filter to remote roles only");
2223
+ console.log(" terminalhire profile --show Display your encrypted local profile");
2224
+ console.log(" terminalhire profile --edit Set displayName, contactEmail, prefs");
2225
+ console.log(" terminalhire profile --delete Wipe profile and encryption key from disk");
2226
+ console.log("");
2227
+ console.log("Install / uninstall the Claude Code statusLine nudge:");
2228
+ console.log(" node install.js");
2229
+ console.log(" node install.js --uninstall");
2230
+ console.log("");
2231
+ console.log("Privacy: your profile never leaves your device.");
2232
+ console.log(" GET /api/index \u2014 anonymous index download (no dev data)");
2233
+ console.log(" POST /api/lead \u2014 only after explicit per-role named-entity consent");
2234
+ console.log(" GitHub token \u2014 encrypted at ~/.terminalhire/github-token.enc, scope: read:user");
2235
+ console.log("");
2236
+ process.exit(0);
2237
+ }
2238
+ if (firstArg === "--version" || firstArg === "-v") {
2239
+ console.log("terminalhire v3.1.0");
2240
+ process.exit(0);
2241
+ }
2242
+ if (firstArg === "login" || firstArg === "logout") {
2243
+ const mod = await Promise.resolve().then(() => (init_jpi_login(), jpi_login_exports));
2244
+ await mod.run();
2245
+ process.exit(0);
2246
+ }
2247
+ if (firstArg === "jobs") {
2248
+ process.argv.splice(2, 1);
2249
+ const mod = await Promise.resolve().then(() => (init_jpi_jobs(), jpi_jobs_exports));
2250
+ await mod.run();
2251
+ process.exit(0);
2252
+ }
2253
+ if (firstArg === "profile") {
2254
+ const mod = await Promise.resolve().then(() => (init_jpi_profile(), jpi_profile_exports));
2255
+ await mod.run();
2256
+ process.exit(0);
2257
+ }
2258
+ if (firstArg === "learn") {
2259
+ const mod = await Promise.resolve().then(() => (init_jpi_learn(), jpi_learn_exports));
2260
+ await mod.run();
2261
+ process.exit(0);
2262
+ }
2263
+ console.error(`terminalhire: unknown command '${firstArg}'. Run 'terminalhire help' for usage.`);
2264
+ process.exit(1);