terminalhire 0.1.0 → 0.1.1

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,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/jpi-config.js
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ var TERMINALHIRE_DIR = join(homedir(), ".terminalhire");
8
+ var CONFIG_FILE = join(TERMINALHIRE_DIR, "config.json");
9
+ var DEFAULT_CONFIG = { nudge: "session" };
10
+ function readConfig() {
11
+ try {
12
+ if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
13
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_FILE, "utf8")) };
14
+ } catch {
15
+ return { ...DEFAULT_CONFIG };
16
+ }
17
+ }
18
+ function writeConfig(patch) {
19
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
20
+ const merged = { ...readConfig(), ...patch };
21
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n", "utf8");
22
+ }
23
+ function parseNudgeMode(raw) {
24
+ if (raw === "session" || raw === "always") return raw;
25
+ const m = /^every:(\d+)$/.exec(raw);
26
+ if (m && parseInt(m[1], 10) >= 1) return raw;
27
+ return null;
28
+ }
29
+ async function run() {
30
+ const args = process.argv.slice(2);
31
+ const filtered = args[0] === "config" ? args.slice(1) : args;
32
+ if (filtered.includes("--show") || filtered.length === 0) {
33
+ const cfg = readConfig();
34
+ const envOverride = process.env["TERMINALHIRE_NUDGE"];
35
+ console.log("");
36
+ console.log("terminalhire config");
37
+ console.log("");
38
+ console.log(` nudge: ${cfg.nudge}`);
39
+ if (envOverride) {
40
+ console.log(` (overridden by TERMINALHIRE_NUDGE=${envOverride} at runtime)`);
41
+ }
42
+ console.log(` config file: ${CONFIG_FILE}`);
43
+ console.log("");
44
+ console.log(" Valid nudge values:");
45
+ console.log(" session \u2014 print at most once per Claude Code session (default)");
46
+ console.log(" always \u2014 print every statusLine render when matches exist");
47
+ console.log(" every:N \u2014 print every Nth render (e.g. every:3)");
48
+ console.log("");
49
+ return;
50
+ }
51
+ const nudgeIdx = filtered.indexOf("--nudge");
52
+ if (nudgeIdx !== -1) {
53
+ const value = filtered[nudgeIdx + 1];
54
+ if (!value) {
55
+ console.error("Error: --nudge requires a value: session | always | every:N");
56
+ process.exit(1);
57
+ }
58
+ const parsed = parseNudgeMode(value);
59
+ if (!parsed) {
60
+ console.error(`Error: invalid nudge value "${value}". Valid: session | always | every:N`);
61
+ process.exit(1);
62
+ }
63
+ writeConfig({ nudge: parsed });
64
+ console.log(` nudge set to: ${parsed}`);
65
+ console.log(` (saved to ${CONFIG_FILE})`);
66
+ return;
67
+ }
68
+ console.error("Usage: terminalhire config --nudge <session|always|every:N>");
69
+ console.error(" terminalhire config --show");
70
+ process.exit(1);
71
+ }
72
+ export {
73
+ run
74
+ };
@@ -202,7 +202,7 @@ async function resolveStoredLogin() {
202
202
  }
203
203
  }
204
204
  function sleep(ms) {
205
- return new Promise((resolve) => setTimeout(resolve, ms));
205
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
206
206
  }
207
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
208
  var init_github_auth = __esm({
@@ -1529,12 +1529,12 @@ async function runLogin() {
1529
1529
  let ghProfile;
1530
1530
  if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") {
1531
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"));
1532
+ const { fileURLToPath: fileURLToPath5 } = await import("url");
1533
+ const { join: join9, dirname: dirname2 } = await import("path");
1534
+ const __dirname4 = fileURLToPath5(new URL(".", import.meta.url));
1535
+ const fixturePath = join9(__dirname4, "../../fixtures/github-sample.json");
1536
+ const { readFileSync: readFileSync8 } = await import("fs");
1537
+ ghProfile = JSON.parse(readFileSync8(fixturePath, "utf8"));
1538
1538
  } else {
1539
1539
  ghProfile = await fetchGitHubProfile2(login, token);
1540
1540
  }
@@ -1645,10 +1645,10 @@ async function fetchIndex() {
1645
1645
  }
1646
1646
  function prompt(question) {
1647
1647
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1648
- return new Promise((resolve) => {
1648
+ return new Promise((resolve2) => {
1649
1649
  rl.question(question, (answer) => {
1650
1650
  rl.close();
1651
- resolve(answer.trim().toLowerCase());
1651
+ resolve2(answer.trim().toLowerCase());
1652
1652
  });
1653
1653
  });
1654
1654
  }
@@ -1846,10 +1846,10 @@ __export(jpi_profile_exports, {
1846
1846
  import { createInterface as createInterface2 } from "readline";
1847
1847
  function prompt2(question) {
1848
1848
  const rl = createInterface2({ input: process.stdin, output: process.stdout });
1849
- return new Promise((resolve) => {
1849
+ return new Promise((resolve2) => {
1850
1850
  rl.question(question, (answer) => {
1851
1851
  rl.close();
1852
- resolve(answer.trim());
1852
+ resolve2(answer.trim());
1853
1853
  });
1854
1854
  });
1855
1855
  }
@@ -2195,16 +2195,244 @@ var init_jpi_learn = __esm({
2195
2195
  }
2196
2196
  });
2197
2197
 
2198
- // bin/jpi-dispatch.js
2199
- import { fileURLToPath as fileURLToPath3 } from "url";
2198
+ // bin/jpi-config.js
2199
+ var jpi_config_exports = {};
2200
+ __export(jpi_config_exports, {
2201
+ run: () => run5
2202
+ });
2203
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs";
2200
2204
  import { join as join6 } from "path";
2201
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
2205
+ import { homedir as homedir4 } from "os";
2206
+ function readConfig() {
2207
+ try {
2208
+ if (!existsSync4(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
2209
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync6(CONFIG_FILE, "utf8")) };
2210
+ } catch {
2211
+ return { ...DEFAULT_CONFIG };
2212
+ }
2213
+ }
2214
+ function writeConfig(patch) {
2215
+ mkdirSync4(TERMINALHIRE_DIR4, { recursive: true });
2216
+ const merged = { ...readConfig(), ...patch };
2217
+ writeFileSync4(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n", "utf8");
2218
+ }
2219
+ function parseNudgeMode(raw) {
2220
+ if (raw === "session" || raw === "always") return raw;
2221
+ const m = /^every:(\d+)$/.exec(raw);
2222
+ if (m && parseInt(m[1], 10) >= 1) return raw;
2223
+ return null;
2224
+ }
2225
+ async function run5() {
2226
+ const args2 = process.argv.slice(2);
2227
+ const filtered = args2[0] === "config" ? args2.slice(1) : args2;
2228
+ if (filtered.includes("--show") || filtered.length === 0) {
2229
+ const cfg = readConfig();
2230
+ const envOverride = process.env["TERMINALHIRE_NUDGE"];
2231
+ console.log("");
2232
+ console.log("terminalhire config");
2233
+ console.log("");
2234
+ console.log(` nudge: ${cfg.nudge}`);
2235
+ if (envOverride) {
2236
+ console.log(` (overridden by TERMINALHIRE_NUDGE=${envOverride} at runtime)`);
2237
+ }
2238
+ console.log(` config file: ${CONFIG_FILE}`);
2239
+ console.log("");
2240
+ console.log(" Valid nudge values:");
2241
+ console.log(" session \u2014 print at most once per Claude Code session (default)");
2242
+ console.log(" always \u2014 print every statusLine render when matches exist");
2243
+ console.log(" every:N \u2014 print every Nth render (e.g. every:3)");
2244
+ console.log("");
2245
+ return;
2246
+ }
2247
+ const nudgeIdx = filtered.indexOf("--nudge");
2248
+ if (nudgeIdx !== -1) {
2249
+ const value = filtered[nudgeIdx + 1];
2250
+ if (!value) {
2251
+ console.error("Error: --nudge requires a value: session | always | every:N");
2252
+ process.exit(1);
2253
+ }
2254
+ const parsed = parseNudgeMode(value);
2255
+ if (!parsed) {
2256
+ console.error(`Error: invalid nudge value "${value}". Valid: session | always | every:N`);
2257
+ process.exit(1);
2258
+ }
2259
+ writeConfig({ nudge: parsed });
2260
+ console.log(` nudge set to: ${parsed}`);
2261
+ console.log(` (saved to ${CONFIG_FILE})`);
2262
+ return;
2263
+ }
2264
+ console.error("Usage: terminalhire config --nudge <session|always|every:N>");
2265
+ console.error(" terminalhire config --show");
2266
+ process.exit(1);
2267
+ }
2268
+ var TERMINALHIRE_DIR4, CONFIG_FILE, DEFAULT_CONFIG;
2269
+ var init_jpi_config = __esm({
2270
+ "bin/jpi-config.js"() {
2271
+ "use strict";
2272
+ TERMINALHIRE_DIR4 = join6(homedir4(), ".terminalhire");
2273
+ CONFIG_FILE = join6(TERMINALHIRE_DIR4, "config.json");
2274
+ DEFAULT_CONFIG = { nudge: "session" };
2275
+ }
2276
+ });
2277
+
2278
+ // bin/jpi-init.js
2279
+ var jpi_init_exports = {};
2280
+ __export(jpi_init_exports, {
2281
+ run: () => run6
2282
+ });
2283
+ import { existsSync as existsSync5 } from "fs";
2284
+ import { join as join7, resolve } from "path";
2285
+ import { fileURLToPath as fileURLToPath3 } from "url";
2286
+ import { createInterface as createInterface3 } from "readline";
2287
+ import { spawnSync, spawn } from "child_process";
2288
+ import { homedir as homedir5 } from "os";
2289
+ function ask(question) {
2290
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
2291
+ return new Promise((resolve2) => {
2292
+ rl.question(question, (answer) => {
2293
+ rl.close();
2294
+ resolve2(answer.trim().toLowerCase());
2295
+ });
2296
+ });
2297
+ }
2298
+ function resolveScript(name) {
2299
+ const distPath = resolve(join7(__dirname2, "..", "..", "dist", "bin", `${name}.js`));
2300
+ const legacyPath = resolve(join7(__dirname2, `${name}.js`));
2301
+ return existsSync5(distPath) ? distPath : legacyPath;
2302
+ }
2303
+ function resolveInstallJs() {
2304
+ const fromDist = resolve(join7(__dirname2, "..", "..", "install.js"));
2305
+ const fromBin = resolve(join7(__dirname2, "..", "install.js"));
2306
+ if (existsSync5(fromDist)) return fromDist;
2307
+ if (existsSync5(fromBin)) return fromBin;
2308
+ return fromBin;
2309
+ }
2310
+ async function run6() {
2311
+ console.log("");
2312
+ console.log("\u250C\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\u2510");
2313
+ console.log("\u2502 terminalhire init \u2014 one-command onboarding \u2502");
2314
+ console.log("\u2502 Local-first job matching for developers in Claude Code \u2502");
2315
+ console.log("\u2514\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\u2518");
2316
+ console.log("");
2317
+ console.log("This will:");
2318
+ console.log(" 1. Optionally sign you in with GitHub (public profile only, read:user)");
2319
+ console.log(" 2. Seed your local job cache (anonymous index download)");
2320
+ console.log(" 3. Install the statusLine hook in ~/.claude/settings.json");
2321
+ console.log(" (with backup + your explicit consent before any file is touched)");
2322
+ console.log("");
2323
+ console.log('You can stop at any step. Nothing is changed until you say "yes".');
2324
+ console.log("");
2325
+ console.log("Step 1/3 \u2014 GitHub sign-in (optional but recommended)");
2326
+ console.log("");
2327
+ console.log(" Scope: read:user \u2014 public profile + public repos only.");
2328
+ console.log(" Your token is encrypted at ~/.terminalhire/github-token.enc.");
2329
+ console.log(" GitHub data enriches your local profile. Nothing leaves your machine");
2330
+ console.log(" until you explicitly consent to a specific lead.");
2331
+ console.log("");
2332
+ const githubAnswer = await ask("Sign in with GitHub now? [Y/n] (Enter = yes, n = stay local): ");
2333
+ const doGitHub = githubAnswer === "" || githubAnswer === "y" || githubAnswer === "yes";
2334
+ if (doGitHub) {
2335
+ console.log("");
2336
+ console.log(" Starting GitHub device flow...");
2337
+ const loginScript = resolveScript("jpi-login");
2338
+ const child = spawnSync(process.execPath, [loginScript, "login"], {
2339
+ stdio: ["inherit", "inherit", "inherit"],
2340
+ env: process.env
2341
+ });
2342
+ if (child.status !== 0) {
2343
+ console.log("");
2344
+ console.log(" GitHub sign-in did not complete. Continuing without GitHub.");
2345
+ console.log(" You can sign in any time with: terminalhire login");
2346
+ }
2347
+ } else {
2348
+ console.log("");
2349
+ console.log(" Staying local-only. Tags accumulate from your personal project sessions.");
2350
+ console.log(" Sign in any time with: terminalhire login");
2351
+ }
2352
+ console.log("");
2353
+ console.log("Step 2/3 \u2014 Seeding local job cache");
2354
+ console.log("");
2355
+ console.log(" Fetching anonymous job index (no dev data sent)...");
2356
+ const jobsScript = resolveScript("jpi-jobs");
2357
+ const seedChild = spawnSync(
2358
+ process.execPath,
2359
+ [jobsScript, "--limit", "0"],
2360
+ {
2361
+ stdio: ["ignore", "pipe", "pipe"],
2362
+ env: { ...process.env, TERMINALHIRE_SEED_ONLY: "1" },
2363
+ timeout: 15e3
2364
+ }
2365
+ );
2366
+ if (seedChild.status === 0) {
2367
+ console.log(" Job cache seeded successfully.");
2368
+ } else {
2369
+ console.log(" Could not seed job cache right now (no profile tags yet, or offline).");
2370
+ console.log(" Run `terminalhire jobs` after a few Claude Code sessions to populate it.");
2371
+ }
2372
+ console.log("");
2373
+ console.log("Step 3/3 \u2014 Install statusLine hook in ~/.claude/settings.json");
2374
+ console.log("");
2375
+ console.log(" This is the only step that modifies a system file.");
2376
+ console.log(" A timestamped backup is created before any change.");
2377
+ console.log(" Uninstall at any time: node install.js --uninstall");
2378
+ console.log("");
2379
+ const installJs = resolveInstallJs();
2380
+ const installChild = spawnSync(process.execPath, [installJs], {
2381
+ stdio: ["inherit", "inherit", "inherit"],
2382
+ env: process.env
2383
+ });
2384
+ if (installChild.status !== 0) {
2385
+ console.log("");
2386
+ console.log(" Hook installation did not complete. Run manually: node install.js");
2387
+ }
2388
+ console.log("");
2389
+ console.log("\u250C\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\u2510");
2390
+ console.log("\u2502 terminalhire init complete! \u2502");
2391
+ console.log("\u2502 \u2502");
2392
+ console.log("\u2502 Restart Claude Code to see the statusLine nudge. \u2502");
2393
+ console.log("\u2502 \u2502");
2394
+ console.log("\u2502 Quick reference: \u2502");
2395
+ console.log("\u2502 terminalhire jobs \u2014 browse matching roles \u2502");
2396
+ console.log("\u2502 terminalhire config --show \u2014 view nudge settings \u2502");
2397
+ console.log("\u2502 terminalhire login \u2014 sign in with GitHub \u2502");
2398
+ console.log("\u2502 terminalhire profile --show \u2014 inspect your local profile \u2502");
2399
+ console.log("\u2514\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\u2518");
2400
+ console.log("");
2401
+ }
2402
+ var __dirname2;
2403
+ var init_jpi_init = __esm({
2404
+ "bin/jpi-init.js"() {
2405
+ "use strict";
2406
+ __dirname2 = fileURLToPath3(new URL(".", import.meta.url));
2407
+ }
2408
+ });
2409
+
2410
+ // bin/jpi-dispatch.js
2411
+ import { fileURLToPath as fileURLToPath4 } from "url";
2412
+ import { join as join8, dirname } from "path";
2413
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2202
2414
  import { createRequire } from "module";
2203
- var __dirname2 = fileURLToPath3(new URL(".", import.meta.url));
2415
+ var __dirname3 = fileURLToPath4(new URL(".", import.meta.url));
2416
+ function readPackageVersion() {
2417
+ try {
2418
+ const candidates = [
2419
+ join8(__dirname3, "..", "..", "package.json"),
2420
+ join8(__dirname3, "..", "package.json")
2421
+ ];
2422
+ for (const p of candidates) {
2423
+ if (existsSync6(p)) {
2424
+ const pkg = JSON.parse(readFileSync7(p, "utf8"));
2425
+ if (pkg.version) return pkg.version;
2426
+ }
2427
+ }
2428
+ } catch {
2429
+ }
2430
+ return "0.1.1";
2431
+ }
2204
2432
  var firstArg = process.argv[2];
2205
2433
  if (!firstArg && !process.stdin.isTTY) {
2206
2434
  const { default: childProcess } = await import("child_process");
2207
- const nudgeScript = join6(__dirname2, "jpi.js");
2435
+ const nudgeScript = join8(__dirname3, "jpi.js");
2208
2436
  const child = childProcess.spawnSync(process.execPath, [nudgeScript], {
2209
2437
  stdio: ["inherit", "inherit", "inherit"]
2210
2438
  });
@@ -2212,21 +2440,27 @@ if (!firstArg && !process.stdin.isTTY) {
2212
2440
  }
2213
2441
  if (!firstArg || firstArg === "help" || firstArg === "--help" || firstArg === "-h") {
2214
2442
  console.log("");
2215
- console.log("terminalhire v3.1 \u2014 local-first job matching for developers");
2443
+ console.log(`terminalhire v${readPackageVersion()} \u2014 local-first job matching for developers`);
2216
2444
  console.log("");
2217
2445
  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");
2446
+ console.log(" terminalhire init One-command onboarding (start here)");
2447
+ console.log(" terminalhire login Sign in with GitHub (enriches profile instantly)");
2448
+ console.log(" terminalhire logout Clear stored GitHub token");
2449
+ console.log(" terminalhire jobs Fetch job index, match locally, browse roles");
2450
+ console.log(" terminalhire jobs --limit N Show top N results (default: 10)");
2451
+ console.log(" terminalhire jobs --remote-only Filter to remote roles only");
2452
+ console.log(" terminalhire profile --show Display your encrypted local profile");
2453
+ console.log(" terminalhire profile --edit Set displayName, contactEmail, prefs");
2454
+ console.log(" terminalhire profile --delete Wipe profile and encryption key from disk");
2455
+ console.log(" terminalhire config --nudge session Nudge at most once per session (default)");
2456
+ console.log(" terminalhire config --nudge always Nudge every statusLine render");
2457
+ console.log(" terminalhire config --nudge every:N Nudge every Nth render");
2458
+ console.log(" terminalhire config --show Print current config");
2226
2459
  console.log("");
2227
2460
  console.log("Install / uninstall the Claude Code statusLine nudge:");
2228
- console.log(" node install.js");
2229
- console.log(" node install.js --uninstall");
2461
+ console.log(" terminalhire init (preferred \u2014 full guided onboarding)");
2462
+ console.log(" node install.js (manual install)");
2463
+ console.log(" node install.js --uninstall (manual uninstall)");
2230
2464
  console.log("");
2231
2465
  console.log("Privacy: your profile never leaves your device.");
2232
2466
  console.log(" GET /api/index \u2014 anonymous index download (no dev data)");
@@ -2236,7 +2470,7 @@ if (!firstArg || firstArg === "help" || firstArg === "--help" || firstArg === "-
2236
2470
  process.exit(0);
2237
2471
  }
2238
2472
  if (firstArg === "--version" || firstArg === "-v") {
2239
- console.log("terminalhire v3.1.0");
2473
+ console.log(`terminalhire v${readPackageVersion()}`);
2240
2474
  process.exit(0);
2241
2475
  }
2242
2476
  if (firstArg === "login" || firstArg === "logout") {
@@ -2260,5 +2494,15 @@ if (firstArg === "learn") {
2260
2494
  await mod.run();
2261
2495
  process.exit(0);
2262
2496
  }
2497
+ if (firstArg === "config") {
2498
+ const mod = await Promise.resolve().then(() => (init_jpi_config(), jpi_config_exports));
2499
+ await mod.run();
2500
+ process.exit(0);
2501
+ }
2502
+ if (firstArg === "init") {
2503
+ const mod = await Promise.resolve().then(() => (init_jpi_init(), jpi_init_exports));
2504
+ await mod.run();
2505
+ process.exit(0);
2506
+ }
2263
2507
  console.error(`terminalhire: unknown command '${firstArg}'. Run 'terminalhire help' for usage.`);
2264
2508
  process.exit(1);
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/jpi-init.js
4
+ import { existsSync } from "fs";
5
+ import { join, resolve } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { createInterface } from "readline";
8
+ import { spawnSync, spawn } from "child_process";
9
+ import { homedir } from "os";
10
+ var __dirname = fileURLToPath(new URL(".", import.meta.url));
11
+ function ask(question) {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ return new Promise((resolve2) => {
14
+ rl.question(question, (answer) => {
15
+ rl.close();
16
+ resolve2(answer.trim().toLowerCase());
17
+ });
18
+ });
19
+ }
20
+ function resolveScript(name) {
21
+ const distPath = resolve(join(__dirname, "..", "..", "dist", "bin", `${name}.js`));
22
+ const legacyPath = resolve(join(__dirname, `${name}.js`));
23
+ return existsSync(distPath) ? distPath : legacyPath;
24
+ }
25
+ function resolveInstallJs() {
26
+ const fromDist = resolve(join(__dirname, "..", "..", "install.js"));
27
+ const fromBin = resolve(join(__dirname, "..", "install.js"));
28
+ if (existsSync(fromDist)) return fromDist;
29
+ if (existsSync(fromBin)) return fromBin;
30
+ return fromBin;
31
+ }
32
+ async function run() {
33
+ console.log("");
34
+ console.log("\u250C\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\u2510");
35
+ console.log("\u2502 terminalhire init \u2014 one-command onboarding \u2502");
36
+ console.log("\u2502 Local-first job matching for developers in Claude Code \u2502");
37
+ console.log("\u2514\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\u2518");
38
+ console.log("");
39
+ console.log("This will:");
40
+ console.log(" 1. Optionally sign you in with GitHub (public profile only, read:user)");
41
+ console.log(" 2. Seed your local job cache (anonymous index download)");
42
+ console.log(" 3. Install the statusLine hook in ~/.claude/settings.json");
43
+ console.log(" (with backup + your explicit consent before any file is touched)");
44
+ console.log("");
45
+ console.log('You can stop at any step. Nothing is changed until you say "yes".');
46
+ console.log("");
47
+ console.log("Step 1/3 \u2014 GitHub sign-in (optional but recommended)");
48
+ console.log("");
49
+ console.log(" Scope: read:user \u2014 public profile + public repos only.");
50
+ console.log(" Your token is encrypted at ~/.terminalhire/github-token.enc.");
51
+ console.log(" GitHub data enriches your local profile. Nothing leaves your machine");
52
+ console.log(" until you explicitly consent to a specific lead.");
53
+ console.log("");
54
+ const githubAnswer = await ask("Sign in with GitHub now? [Y/n] (Enter = yes, n = stay local): ");
55
+ const doGitHub = githubAnswer === "" || githubAnswer === "y" || githubAnswer === "yes";
56
+ if (doGitHub) {
57
+ console.log("");
58
+ console.log(" Starting GitHub device flow...");
59
+ const loginScript = resolveScript("jpi-login");
60
+ const child = spawnSync(process.execPath, [loginScript, "login"], {
61
+ stdio: ["inherit", "inherit", "inherit"],
62
+ env: process.env
63
+ });
64
+ if (child.status !== 0) {
65
+ console.log("");
66
+ console.log(" GitHub sign-in did not complete. Continuing without GitHub.");
67
+ console.log(" You can sign in any time with: terminalhire login");
68
+ }
69
+ } else {
70
+ console.log("");
71
+ console.log(" Staying local-only. Tags accumulate from your personal project sessions.");
72
+ console.log(" Sign in any time with: terminalhire login");
73
+ }
74
+ console.log("");
75
+ console.log("Step 2/3 \u2014 Seeding local job cache");
76
+ console.log("");
77
+ console.log(" Fetching anonymous job index (no dev data sent)...");
78
+ const jobsScript = resolveScript("jpi-jobs");
79
+ const seedChild = spawnSync(
80
+ process.execPath,
81
+ [jobsScript, "--limit", "0"],
82
+ {
83
+ stdio: ["ignore", "pipe", "pipe"],
84
+ env: { ...process.env, TERMINALHIRE_SEED_ONLY: "1" },
85
+ timeout: 15e3
86
+ }
87
+ );
88
+ if (seedChild.status === 0) {
89
+ console.log(" Job cache seeded successfully.");
90
+ } else {
91
+ console.log(" Could not seed job cache right now (no profile tags yet, or offline).");
92
+ console.log(" Run `terminalhire jobs` after a few Claude Code sessions to populate it.");
93
+ }
94
+ console.log("");
95
+ console.log("Step 3/3 \u2014 Install statusLine hook in ~/.claude/settings.json");
96
+ console.log("");
97
+ console.log(" This is the only step that modifies a system file.");
98
+ console.log(" A timestamped backup is created before any change.");
99
+ console.log(" Uninstall at any time: node install.js --uninstall");
100
+ console.log("");
101
+ const installJs = resolveInstallJs();
102
+ const installChild = spawnSync(process.execPath, [installJs], {
103
+ stdio: ["inherit", "inherit", "inherit"],
104
+ env: process.env
105
+ });
106
+ if (installChild.status !== 0) {
107
+ console.log("");
108
+ console.log(" Hook installation did not complete. Run manually: node install.js");
109
+ }
110
+ console.log("");
111
+ console.log("\u250C\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\u2510");
112
+ console.log("\u2502 terminalhire init complete! \u2502");
113
+ console.log("\u2502 \u2502");
114
+ console.log("\u2502 Restart Claude Code to see the statusLine nudge. \u2502");
115
+ console.log("\u2502 \u2502");
116
+ console.log("\u2502 Quick reference: \u2502");
117
+ console.log("\u2502 terminalhire jobs \u2014 browse matching roles \u2502");
118
+ console.log("\u2502 terminalhire config --show \u2014 view nudge settings \u2502");
119
+ console.log("\u2502 terminalhire login \u2014 sign in with GitHub \u2502");
120
+ console.log("\u2502 terminalhire profile --show \u2014 inspect your local profile \u2502");
121
+ console.log("\u2514\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\u2518");
122
+ console.log("");
123
+ }
124
+ export {
125
+ run
126
+ };
package/dist/bin/jpi.js CHANGED
@@ -9,6 +9,7 @@ import { spawn } from "child_process";
9
9
  var TERMINALHIRE_DIR = join(homedir(), ".terminalhire");
10
10
  var INDEX_CACHE_FILE = join(TERMINALHIRE_DIR, "index-cache.json");
11
11
  var NUDGE_FILE = join(TERMINALHIRE_DIR, "nudged.json");
12
+ var NUDGE_COUNTER_FILE = join(TERMINALHIRE_DIR, "nudge-counter.json");
12
13
  var LEARNED_FILE = join(TERMINALHIRE_DIR, "learned-sessions.json");
13
14
  var INDEX_CACHE_TTL_MS = 15 * 60 * 1e3;
14
15
  var __dirname = fileURLToPath(new URL(".", import.meta.url));
@@ -82,6 +83,66 @@ function getCachedMatchCount() {
82
83
  return null;
83
84
  }
84
85
  }
86
+ function getNudgeMode() {
87
+ const envVal = process.env["TERMINALHIRE_NUDGE"];
88
+ if (envVal) {
89
+ const parsed = parseNudgeMode(envVal);
90
+ if (parsed) return parsed;
91
+ }
92
+ try {
93
+ const configFile = join(TERMINALHIRE_DIR, "config.json");
94
+ if (existsSync(configFile)) {
95
+ const cfg = JSON.parse(readFileSync(configFile, "utf8"));
96
+ if (cfg.nudge) {
97
+ const parsed = parseNudgeMode(cfg.nudge);
98
+ if (parsed) return parsed;
99
+ }
100
+ }
101
+ } catch {
102
+ }
103
+ return "session";
104
+ }
105
+ function parseNudgeMode(raw) {
106
+ if (raw === "session" || raw === "always") return raw;
107
+ const m = /^every:(\d+)$/.exec(raw);
108
+ if (m) {
109
+ const n = parseInt(m[1], 10);
110
+ if (n >= 1) return `every:${n}`;
111
+ }
112
+ return null;
113
+ }
114
+ function bumpRenderCounter() {
115
+ try {
116
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
117
+ let counter = 0;
118
+ if (existsSync(NUDGE_COUNTER_FILE)) {
119
+ const raw = JSON.parse(readFileSync(NUDGE_COUNTER_FILE, "utf8"));
120
+ counter = typeof raw.count === "number" ? raw.count : 0;
121
+ }
122
+ counter++;
123
+ writeFileSync(NUDGE_COUNTER_FILE, JSON.stringify({ count: counter }), "utf8");
124
+ return counter;
125
+ } catch {
126
+ return 1;
127
+ }
128
+ }
129
+ function shouldNudge(nudgeMode, sessionId) {
130
+ if (nudgeMode === "always") {
131
+ return true;
132
+ }
133
+ if (nudgeMode === "session") {
134
+ const nudged2 = readNudged();
135
+ return !nudged2[sessionId];
136
+ }
137
+ const m = /^every:(\d+)$/.exec(nudgeMode);
138
+ if (m) {
139
+ const n = parseInt(m[1], 10);
140
+ const count = bumpRenderCounter();
141
+ return count % n === 0;
142
+ }
143
+ const nudged = readNudged();
144
+ return !nudged[sessionId];
145
+ }
85
146
  try {
86
147
  const input = readStdinSync();
87
148
  const sessionId = input?.session_id;
@@ -92,14 +153,16 @@ try {
92
153
  spawnLearnDetached(workDir);
93
154
  markLearned(sessionId);
94
155
  }
95
- const nudged = readNudged();
96
- if (nudged[sessionId]) process.exit(0);
97
156
  const matchCount = getCachedMatchCount();
98
157
  if (matchCount === null || matchCount === 0) process.exit(0);
158
+ const nudgeMode = getNudgeMode();
159
+ if (!shouldNudge(nudgeMode, sessionId)) process.exit(0);
99
160
  const plural = matchCount === 1 ? "role" : "roles";
100
161
  process.stdout.write(`\u2726 ${matchCount} ${plural} match your current work \u2014 run: terminalhire jobs
101
162
  `);
102
- markNudged(sessionId);
163
+ if (nudgeMode === "session") {
164
+ markNudged(sessionId);
165
+ }
103
166
  process.exit(0);
104
167
  } catch {
105
168
  process.exit(0);
@@ -0,0 +1,49 @@
1
+ // src/config.ts
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ var TERMINALHIRE_DIR = join(homedir(), ".terminalhire");
6
+ var CONFIG_FILE = join(TERMINALHIRE_DIR, "config.json");
7
+ var DEFAULT_CONFIG = {
8
+ nudge: "session"
9
+ };
10
+ function readConfig() {
11
+ try {
12
+ if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
13
+ const raw = readFileSync(CONFIG_FILE, "utf8");
14
+ const parsed = JSON.parse(raw);
15
+ return { ...DEFAULT_CONFIG, ...parsed };
16
+ } catch {
17
+ return { ...DEFAULT_CONFIG };
18
+ }
19
+ }
20
+ function writeConfig(config) {
21
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
22
+ const current = readConfig();
23
+ const merged = { ...current, ...config };
24
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n", "utf8");
25
+ }
26
+ function parseNudgeMode(raw) {
27
+ if (raw === "session" || raw === "always") return raw;
28
+ const m = /^every:(\d+)$/.exec(raw);
29
+ if (m) {
30
+ const n = parseInt(m[1], 10);
31
+ if (n >= 1) return `every:${n}`;
32
+ }
33
+ return null;
34
+ }
35
+ function getNudgeMode() {
36
+ const envVal = process.env["TERMINALHIRE_NUDGE"];
37
+ if (envVal) {
38
+ const parsed = parseNudgeMode(envVal);
39
+ if (parsed) return parsed;
40
+ }
41
+ const config = readConfig();
42
+ return config.nudge ?? "session";
43
+ }
44
+ export {
45
+ getNudgeMode,
46
+ parseNudgeMode,
47
+ readConfig,
48
+ writeConfig
49
+ };
package/install.js CHANGED
@@ -1,14 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * install.js — v3 installer for jpi
3
+ * install.js — v3.1 installer for terminalhire
4
+ *
5
+ * ToS / safety guardrails (binding — see CONSTITUTION.md Rule 7 and Rule 12):
6
+ * - ONLY uses the official Claude Code `statusLine` settings.json hook.
7
+ * - NEVER silently modifies ~/.claude/settings.json.
8
+ * - Always backs up settings.json (timestamped) before any write.
9
+ * - Prints a clear disclosure of exactly what it changed.
10
+ * - Provides one-command uninstall that restores the backup.
11
+ * - CHAIN, never clobber: if a statusLine already exists that is NOT ours,
12
+ * creates a wrapper script that runs BOTH commands, then points settings.json
13
+ * at the wrapper. Idempotent — detects existing wrapper and does not double-wrap.
14
+ * - postinstall (not this file) is print-only and never calls this file.
4
15
  *
5
16
  * What it does:
6
- * 1. Prints a full v3 disclosure (local-first, pull model, named buyer, lead payload)
17
+ * 1. Prints full v3 disclosure
7
18
  * 2. Requires explicit "yes" before touching any system file
8
- * 3. Backs up ~/.claude/settings.json
9
- * 4. Merges a statusLine entry pointing to bin/jpi.js (nudge only, no egress)
10
- * 5. Registers 'jpi' bin in PATH if npm global bin is writable
11
- * 6. Supports --uninstall
19
+ * 3. Backs up ~/.claude/settings.json (timestamped)
20
+ * 4. If no existing statusLine sets terminalhire's nudge directly
21
+ * If existing statusLine (not ours) builds a wrapper that chains both
22
+ * 5. Supports --uninstall (restores the backup / removes wrapper)
12
23
  * 7. Idempotent: safe to run multiple times
13
24
  *
14
25
  * Usage:
@@ -16,7 +27,10 @@
16
27
  * node install.js --uninstall
17
28
  */
18
29
 
19
- import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
30
+ import {
31
+ readFileSync, writeFileSync, copyFileSync, existsSync,
32
+ mkdirSync, chmodSync, readdirSync,
33
+ } from 'node:fs';
20
34
  import { homedir } from 'node:os';
21
35
  import { join, resolve, dirname } from 'node:path';
22
36
  import { fileURLToPath } from 'node:url';
@@ -24,15 +38,26 @@ import { createInterface } from 'node:readline';
24
38
  import { spawnSync } from 'node:child_process';
25
39
 
26
40
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
41
+
27
42
  // Resolve the nudge bin robustly: prefer the bundled dist output (published package),
28
43
  // fall back to the legacy bin/ path for in-workspace / development installs.
29
44
  const _distBin = resolve(join(__dirname, 'dist', 'bin', 'jpi.js'));
30
45
  const _legacyBin = resolve(join(__dirname, 'bin', 'jpi.js'));
31
46
  const BIN_PATH = existsSync(_distBin) ? _distBin : _legacyBin;
47
+
32
48
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
33
49
  const SETTINGS_DIR = dirname(SETTINGS_PATH);
50
+ const TERMINALHIRE_DIR = join(homedir(), '.terminalhire');
51
+ const WRAPPER_PATH = join(TERMINALHIRE_DIR, 'statusline-wrapper.sh');
52
+
53
+ // The existing statusLine command on the user's machine that we must preserve
54
+ const KNOWN_EXISTING_STATUSLINE = 'bash /Users/ericgang/.claude/statusline-command.sh';
55
+
34
56
  const UNINSTALL = process.argv.includes('--uninstall');
35
57
 
58
+ // ── Sentinel comment embedded in the wrapper so we can detect our own wrappers
59
+ const WRAPPER_SENTINEL = '# terminalhire-wrapper-v1';
60
+
36
61
  // ── Helpers ───────────────────────────────────────────────────────────────────
37
62
 
38
63
  function readSettings() {
@@ -47,11 +72,12 @@ function readSettings() {
47
72
  }
48
73
 
49
74
  function backupSettings() {
50
- if (!existsSync(SETTINGS_PATH)) return;
75
+ if (!existsSync(SETTINGS_PATH)) return null;
51
76
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
52
77
  const backupPath = `${SETTINGS_PATH}.terminalhire-backup-${ts}`;
53
78
  copyFileSync(SETTINGS_PATH, backupPath);
54
79
  console.log(` Backed up settings to: ${backupPath}`);
80
+ return backupPath;
55
81
  }
56
82
 
57
83
  function writeSettings(settings) {
@@ -61,24 +87,95 @@ function writeSettings(settings) {
61
87
 
62
88
  function ask(question) {
63
89
  const rl = createInterface({ input: process.stdin, output: process.stdout });
64
- return new Promise(resolve => {
90
+ return new Promise(res => {
65
91
  rl.question(question, answer => {
66
92
  rl.close();
67
- resolve(answer.trim().toLowerCase());
93
+ res(answer.trim().toLowerCase());
68
94
  });
69
95
  });
70
96
  }
71
97
 
72
- function buildStatusLineEntry(binPath) {
98
+ /** Build the direct (no-chain) statusLine entry for terminalhire. */
99
+ function buildDirectEntry(binPath) {
73
100
  return `node ${binPath}`;
74
101
  }
75
102
 
103
+ /**
104
+ * Returns true if the given statusLine value is terminalhire's own entry:
105
+ * either the direct node entry or our wrapper script.
106
+ */
107
+ function isOurEntry(statusLine) {
108
+ if (!statusLine) return false;
109
+ // Direct entry
110
+ if (statusLine === buildDirectEntry(BIN_PATH)) return true;
111
+ // Points at our wrapper
112
+ if (statusLine === `bash ${WRAPPER_PATH}`) return true;
113
+ // Wrapper exists and contains our sentinel
114
+ if (existsSync(WRAPPER_PATH)) {
115
+ try {
116
+ const wrapperContent = readFileSync(WRAPPER_PATH, 'utf8');
117
+ return wrapperContent.includes(WRAPPER_SENTINEL);
118
+ } catch { /* fall through */ }
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Build the chaining wrapper shell script.
125
+ * Both the existing command and the terminalhire nudge receive the SAME stdin JSON.
126
+ * Output: existing command's output first, then terminalhire's output.
127
+ */
128
+ function buildWrapper(existingCmd) {
129
+ return `#!/usr/bin/env bash
130
+ ${WRAPPER_SENTINEL}
131
+ # Chains the original statusLine command with terminalhire's nudge.
132
+ # Both receive the same stdin JSON. Original output prints first, then terminalhire.
133
+ # Generated by: node install.js
134
+ # Existing command: ${existingCmd}
135
+ # terminalhire nudge: node ${BIN_PATH}
136
+
137
+ # Read stdin once into a variable
138
+ INPUT=$(cat)
139
+
140
+ # Run the existing statusLine command
141
+ EXISTING_OUT=$(printf '%s' "$INPUT" | ${existingCmd} 2>/dev/null || true)
142
+
143
+ # Run terminalhire's nudge
144
+ TH_OUT=$(printf '%s' "$INPUT" | node ${BIN_PATH} 2>/dev/null || true)
145
+
146
+ # Print existing output (if any), then terminalhire output (if any)
147
+ if [ -n "$EXISTING_OUT" ]; then
148
+ printf '%s\\n' "$EXISTING_OUT"
149
+ fi
150
+ if [ -n "$TH_OUT" ]; then
151
+ printf '%s\\n' "$TH_OUT"
152
+ fi
153
+ `;
154
+ }
155
+
156
+ /**
157
+ * Find the most recent terminalhire backup of settings.json.
158
+ * Returns the path or null.
159
+ */
160
+ function findLatestBackup() {
161
+ try {
162
+ const files = readdirSync(SETTINGS_DIR)
163
+ .filter(f => f.startsWith('settings.json.terminalhire-backup-'))
164
+ .sort()
165
+ .reverse();
166
+ if (files.length === 0) return null;
167
+ return join(SETTINGS_DIR, files[0]);
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
76
173
  // ── Install ───────────────────────────────────────────────────────────────────
77
174
 
78
175
  async function install() {
79
176
  console.log('');
80
177
  console.log('┌─────────────────────────────────────────────────────────────────┐');
81
- console.log('│ terminalhire v3.1 — local-first job matching │');
178
+ console.log('│ terminalhire v0.1.1install statusLine hook │');
82
179
  console.log('│ Pull your matches. Your profile stays on-device. │');
83
180
  console.log('└─────────────────────────────────────────────────────────────────┘');
84
181
  console.log('');
@@ -88,8 +185,10 @@ async function install() {
88
185
  console.log(' 1. `terminalhire jobs` downloads an anonymous job index from the server');
89
186
  console.log(' (GET /api/index — no dev data in the request).');
90
187
  console.log(' 2. Matching runs LOCALLY against an encrypted profile on your device.');
91
- console.log(' 3. The status bar shows a once-per-session nudge if matches exist.');
92
- console.log(' It reads nothing from disk and makes zero network calls.');
188
+ console.log(' 3. The status bar shows a nudge when matches exist.');
189
+ console.log(' It reads only local files and makes zero network calls.');
190
+ console.log(' 4. Nudge frequency: configurable via `terminalhire config --nudge`.');
191
+ console.log(' Default: once per session. Options: always | every:N.');
93
192
  console.log('');
94
193
  console.log('YOUR LOCAL PROFILE (~/.terminalhire/profile.enc):');
95
194
  console.log(' • Encrypted at rest with AES-256-GCM (Node built-in crypto).');
@@ -112,33 +211,6 @@ async function install() {
112
211
  console.log(' • GitHub data enriches your LOCAL profile — no data leaves the machine');
113
212
  console.log(' unless you consent to include GitHub fields in a specific terminalhire lead.');
114
213
  console.log('');
115
- console.log('LEAD PAYLOAD (what is sent on consent — nothing else):');
116
- console.log(' {');
117
- console.log(' opportunityId, // the specific job id you consented for');
118
- console.log(' buyerId, // "coastal"');
119
- console.log(' buyerLegalName, // "Coastal Recruiting LLC"');
120
- console.log(' approvedFields: {');
121
- console.log(' skillTags, // your closed-vocab skill tags');
122
- console.log(' seniorityBand, // optional');
123
- console.log(' displayName, // optional, only if you set it in your profile');
124
- console.log(' contactEmail, // optional, only if you set it in your profile');
125
- console.log(' note, // optional, typed at consent time');
126
- console.log(' github: { // optional, ONLY if GitHub connected AND you say "yes"');
127
- console.log(' login, // your GitHub username');
128
- console.log(' profileUrl, // https://github.com/<login>');
129
- console.log(' topLanguages, // top public repo languages');
130
- console.log(' publicRepos, // public repo count');
131
- console.log(' }');
132
- console.log(' },');
133
- console.log(' consentText, // verbatim text you approved');
134
- console.log(' createdAt // ISO timestamp');
135
- console.log(' }');
136
- console.log('');
137
- console.log('APPLY-DIRECT vs BUYER-LEAD:');
138
- console.log(' Most roles in the index are "apply-direct" — jpi opens the employer URL.');
139
- console.log(' NO data is sent for apply-direct roles. Only Coastal-repped roles');
140
- console.log(' offer the named-entity consent flow.');
141
- console.log('');
142
214
  console.log('HOW TO DISABLE / DELETE:');
143
215
  console.log(' • Uninstall statusLine: node install.js --uninstall');
144
216
  console.log(' • Delete local profile: terminalhire profile --delete');
@@ -146,75 +218,80 @@ async function install() {
146
218
  console.log(' • Wipe everything: rm -rf ~/.terminalhire');
147
219
  console.log('');
148
220
 
149
- const installAnswer = await ask('Install terminalhire v3.1? Type "yes" to continue: ');
221
+ const settings = readSettings();
222
+ const currentStatusLine = settings.statusLine;
223
+
224
+ // Already installed?
225
+ if (isOurEntry(currentStatusLine)) {
226
+ console.log('Already installed (statusLine is already terminalhire or our wrapper).');
227
+ console.log('');
228
+ console.log('To change nudge frequency: terminalhire config --nudge <session|always|every:N>');
229
+ console.log('To uninstall: node install.js --uninstall');
230
+ console.log('');
231
+ return;
232
+ }
233
+
234
+ // Determine install strategy
235
+ let installStrategy;
236
+ if (!currentStatusLine) {
237
+ installStrategy = 'direct';
238
+ console.log(' No existing statusLine found.');
239
+ console.log(` Will set: statusLine = "node ${BIN_PATH}"`);
240
+ } else {
241
+ installStrategy = 'chain';
242
+ console.log(` Existing statusLine detected: ${currentStatusLine}`);
243
+ console.log(' Will create a wrapper at: ~/.terminalhire/statusline-wrapper.sh');
244
+ console.log(' The wrapper runs BOTH commands (existing first, terminalhire second).');
245
+ console.log(' Both receive the same stdin JSON from Claude Code.');
246
+ console.log(` Will set: statusLine = "bash ${WRAPPER_PATH}"`);
247
+ }
248
+ console.log('');
249
+
250
+ const installAnswer = await ask('Install terminalhire statusLine hook? Type "yes" to continue: ');
150
251
  if (installAnswer !== 'yes') {
151
252
  console.log('\nAborted — nothing was changed.');
152
253
  process.exit(0);
153
254
  }
154
255
 
155
256
  console.log('');
156
- const settings = readSettings();
257
+ const backupPath = backupSettings();
157
258
 
158
- if (settings.statusLine === buildStatusLineEntry(BIN_PATH)) {
159
- console.log('Already installed.');
259
+ if (installStrategy === 'direct') {
260
+ settings.statusLine = buildDirectEntry(BIN_PATH);
261
+ writeSettings(settings);
262
+ console.log(' Set statusLine in ~/.claude/settings.json');
263
+ console.log(` → node ${BIN_PATH}`);
160
264
  } else {
161
- if (settings.statusLine) {
162
- console.log(` Note: replacing existing statusLine: ${settings.statusLine}`);
163
- }
164
- backupSettings();
165
- settings.statusLine = buildStatusLineEntry(BIN_PATH);
265
+ // Write wrapper
266
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
267
+ const wrapperContent = buildWrapper(currentStatusLine);
268
+ writeFileSync(WRAPPER_PATH, wrapperContent, 'utf8');
269
+ chmodSync(WRAPPER_PATH, 0o755);
270
+ console.log(` Created wrapper: ${WRAPPER_PATH}`);
271
+
272
+ settings.statusLine = `bash ${WRAPPER_PATH}`;
166
273
  writeSettings(settings);
167
- console.log(' Written statusLine to ~/.claude/settings.json');
274
+ console.log(' Updated statusLine in ~/.claude/settings.json');
275
+ console.log(` → bash ${WRAPPER_PATH}`);
276
+ console.log(' (chains existing command + terminalhire nudge)');
168
277
  }
169
278
 
170
- // ── GitHub onboarding step ─────────────────────────────────────────────────
171
- console.log('');
172
- console.log('┌─────────────────────────────────────────────────────────────────┐');
173
- console.log('│ RECOMMENDED: Sign in with GitHub for instant, accurate matches │');
174
- console.log('│ │');
175
- console.log('│ terminalhire uses your PUBLIC GitHub profile (scope: read:user) to:│');
176
- console.log('│ • Infer skill tags from public repo languages + topics │');
177
- console.log('│ • Estimate seniority from account age + contribution volume │');
178
- console.log('│ • Pre-fill identity fields (name, public email if set) │');
179
- console.log('│ │');
180
- console.log('│ Your token is encrypted locally. No data leaves your machine │');
181
- console.log('│ until you explicitly consent to share it in a specific lead. │');
182
- console.log('│ Scope: read:user — public repos only. NEVER private repos. │');
183
- console.log('└─────────────────────────────────────────────────────────────────┘');
184
- console.log('');
185
-
186
- const githubAnswer = await ask(
187
- 'Sign in with GitHub now? [Y/n/skip] (Enter = yes, "skip" or "n" = stay local): '
188
- );
189
- const doGitHub = githubAnswer === '' || githubAnswer === 'y' || githubAnswer === 'yes';
190
-
191
- if (doGitHub) {
192
- console.log('');
193
- console.log(' Starting GitHub device flow...');
194
- const _loginDistPath = resolve(join(__dirname, 'dist', 'bin', 'jpi-login.js'));
195
- const _loginLegacyPath = resolve(join(__dirname, 'bin', 'jpi-login.js'));
196
- const loginScript = existsSync(_loginDistPath) ? _loginDistPath : _loginLegacyPath;
197
- const child = spawnSync(process.execPath, [loginScript, 'login'], {
198
- stdio: ['inherit', 'inherit', 'inherit'],
199
- env: process.env,
200
- });
201
- if (child.status !== 0) {
202
- console.log('');
203
- console.log(' GitHub sign-in did not complete. You can run `terminalhire login` any time.');
204
- }
205
- } else {
206
- console.log('');
207
- console.log(' Staying local-only. You can sign in any time with: terminalhire login');
208
- console.log(' Your profile will still accumulate tags from personal project sessions.');
279
+ if (backupPath) {
280
+ console.log(` Backup: ${backupPath}`);
209
281
  }
210
282
 
211
283
  console.log('');
212
284
  console.log('Done. Restart Claude Code to activate the status bar nudge.');
213
285
  console.log('');
214
- console.log(' Status bar format (once per session, only when matches exist):');
286
+ console.log(' Status bar format (default: once per session, only when matches exist):');
215
287
  console.log(' ✦ N roles match your current work — run: terminalhire jobs');
216
288
  console.log('');
217
- console.log(' Commands:');
289
+ console.log(' Nudge frequency:');
290
+ console.log(' terminalhire config --nudge session (default — once per session)');
291
+ console.log(' terminalhire config --nudge always (every statusLine render)');
292
+ console.log(' terminalhire config --nudge every:N (every Nth render)');
293
+ console.log('');
294
+ console.log(' Other commands:');
218
295
  console.log(' terminalhire login — sign in with GitHub (enriches profile instantly)');
219
296
  console.log(' terminalhire logout — clear GitHub token');
220
297
  console.log(' terminalhire jobs — fetch index, match locally, browse roles');
@@ -239,24 +316,55 @@ async function uninstall() {
239
316
  }
240
317
 
241
318
  console.log(` Current statusLine: ${settings.statusLine}`);
242
- const answer = await ask('Remove this entry? Type "yes" to continue: ');
319
+
320
+ // Check if there is a backup to restore
321
+ const latestBackup = findLatestBackup();
322
+ if (latestBackup) {
323
+ console.log(` Backup found: ${latestBackup}`);
324
+ console.log(' Uninstalling will restore this backup (restoring original statusLine).');
325
+ } else {
326
+ console.log(' No backup found — statusLine entry will be deleted (set to none).');
327
+ }
328
+ console.log('');
329
+
330
+ const answer = await ask('Uninstall terminalhire hook? Type "yes" to continue: ');
243
331
  if (answer !== 'yes') {
244
332
  console.log('\nAborted — nothing was changed.');
245
333
  process.exit(0);
246
334
  }
247
335
 
248
336
  console.log('');
249
- backupSettings();
250
- delete settings.statusLine;
251
- writeSettings(settings);
252
337
 
253
- console.log(' Removed statusLine from ~/.claude/settings.json');
338
+ if (latestBackup) {
339
+ // Restore the backup
340
+ copyFileSync(latestBackup, SETTINGS_PATH);
341
+ console.log(` Restored settings from backup: ${latestBackup}`);
342
+ } else {
343
+ // Just remove the statusLine key
344
+ backupSettings();
345
+ delete settings.statusLine;
346
+ writeSettings(settings);
347
+ console.log(' Removed statusLine from ~/.claude/settings.json');
348
+ }
349
+
350
+ // Remove wrapper if it exists and is ours
351
+ if (existsSync(WRAPPER_PATH)) {
352
+ try {
353
+ const wrapperContent = readFileSync(WRAPPER_PATH, 'utf8');
354
+ if (wrapperContent.includes(WRAPPER_SENTINEL)) {
355
+ // We don't delete it automatically — user may have customized it
356
+ console.log(` Wrapper file left in place: ${WRAPPER_PATH}`);
357
+ console.log(' (Remove manually if desired: rm ~/.terminalhire/statusline-wrapper.sh)');
358
+ }
359
+ } catch { /* ignore */ }
360
+ }
361
+
254
362
  console.log('');
255
363
  console.log(' Your profile is untouched. To also delete it:');
256
364
  console.log(' terminalhire profile --delete');
257
365
  console.log(' rm -rf ~/.terminalhire');
258
366
  console.log('');
259
- console.log('Done.');
367
+ console.log('Done. Restart Claude Code to apply.');
260
368
  console.log('');
261
369
  }
262
370
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminalhire",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local-first job matching for developers — Claude Code statusLine integration",
5
5
  "type": "module",
6
6
  "engines": {
@@ -14,12 +14,14 @@
14
14
  "dist",
15
15
  "fixtures",
16
16
  "install.js",
17
+ "postinstall.js",
17
18
  "README.md"
18
19
  ],
19
20
  "scripts": {
20
21
  "build": "tsup",
21
22
  "prepublishOnly": "npm run build",
22
- "install-hook": "node install.js"
23
+ "install-hook": "node install.js",
24
+ "postinstall": "node ./postinstall.js"
23
25
  },
24
26
  "publishConfig": {
25
27
  "access": "public"
package/postinstall.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall.js — print-only notice after `npm install terminalhire`
4
+ *
5
+ * ToS / safety guarantee:
6
+ * - PRINT ONLY. No config edits. No network calls. No file writes.
7
+ * - Always exits 0 (never fails the install).
8
+ * - No-op if non-interactive / CI (TERM=dumb, CI=true, etc.).
9
+ */
10
+
11
+ // Skip in CI / non-interactive environments
12
+ const isCI =
13
+ process.env['CI'] === 'true' ||
14
+ process.env['CI'] === '1' ||
15
+ process.env['CONTINUOUS_INTEGRATION'] === 'true' ||
16
+ process.env['TERM'] === 'dumb' ||
17
+ !process.stdout.isTTY;
18
+
19
+ if (!isCI) {
20
+ process.stdout.write('\n');
21
+ process.stdout.write(' ┌───────────────────────────────────────────────────────────────┐\n');
22
+ process.stdout.write(' │ terminalhire installed — activate the Claude Code nudge: │\n');
23
+ process.stdout.write(' │ │\n');
24
+ process.stdout.write(' │ terminalhire init │\n');
25
+ process.stdout.write(' │ │\n');
26
+ process.stdout.write(' │ This runs one-command onboarding: optional GitHub sign-in, │\n');
27
+ process.stdout.write(' │ seeds your local job cache, then installs the statusLine │\n');
28
+ process.stdout.write(' │ hook — with your explicit consent at every step. │\n');
29
+ process.stdout.write(' │ │\n');
30
+ process.stdout.write(' │ No config is changed until you run that command. │\n');
31
+ process.stdout.write(' └───────────────────────────────────────────────────────────────┘\n');
32
+ process.stdout.write('\n');
33
+ }
34
+
35
+ process.exit(0);