terminalhire 0.1.1 → 0.2.2

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,352 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/jpi-spinner.js
4
+ import {
5
+ readFileSync as readFileSync2,
6
+ writeFileSync as writeFileSync2,
7
+ copyFileSync,
8
+ existsSync as existsSync2,
9
+ mkdirSync as mkdirSync2
10
+ } from "fs";
11
+ import { join as join2 } from "path";
12
+ import { homedir as homedir2 } from "os";
13
+ import { createInterface } from "readline";
14
+
15
+ // bin/spinner.js
16
+ import {
17
+ readFileSync,
18
+ writeFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ renameSync
22
+ } from "fs";
23
+ import { join, dirname } from "path";
24
+ import { homedir } from "os";
25
+ var TH_DIR = process.env["TERMINALHIRE_DIR"] || join(homedir(), ".terminalhire");
26
+ var CLAUDE_SETTINGS = process.env["TERMINALHIRE_CLAUDE_SETTINGS"] || join(homedir(), ".claude", "settings.json");
27
+ var CONFIG_FILE = join(TH_DIR, "config.json");
28
+ var SPINNER_STATE_FILE = join(TH_DIR, "spinner-state.json");
29
+ var SPINNER_DEFAULTS = { enabled: false, mode: "append", max: 6, frequency: "sometimes" };
30
+ function readJson(path, fallback) {
31
+ try {
32
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback;
33
+ } catch {
34
+ return fallback;
35
+ }
36
+ }
37
+ function atomicWriteJson(path, obj) {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ const tmp = `${path}.tmp-${process.pid}`;
40
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
41
+ renameSync(tmp, path);
42
+ }
43
+ function titleCase(s) {
44
+ return String(s || "").replace(/\b\w/g, (c) => c.toUpperCase());
45
+ }
46
+ function readSpinnerConfig() {
47
+ const cfg = readJson(CONFIG_FILE, {});
48
+ const spinner = cfg && typeof cfg.spinner === "object" ? cfg.spinner : {};
49
+ const merged = { ...SPINNER_DEFAULTS, ...spinner };
50
+ if (merged.mode !== "append" && merged.mode !== "replace") merged.mode = SPINNER_DEFAULTS.mode;
51
+ merged.max = Math.max(1, Math.min(12, Number(merged.max) || SPINNER_DEFAULTS.max));
52
+ merged.enabled = merged.enabled === true;
53
+ if (!["always", "sometimes", "rare"].includes(merged.frequency)) {
54
+ merged.frequency = SPINNER_DEFAULTS.frequency;
55
+ }
56
+ return merged;
57
+ }
58
+ function ctaVerb() {
59
+ return "\u2605 jobs that fit you \xB7 run: terminalhire jobs";
60
+ }
61
+ function rankBySessionTags(topMatches, sessionTags) {
62
+ const tags = Array.isArray(sessionTags) ? sessionTags.filter(Boolean) : [];
63
+ if (tags.length === 0 || !Array.isArray(topMatches)) return topMatches;
64
+ const normalized = tags.map((t) => String(t).toLowerCase().trim());
65
+ return topMatches.map((m, originalIndex) => {
66
+ const haystack = `${String(m.title || "").toLowerCase()} ${String(m.company || "").toLowerCase()}`;
67
+ const hits = normalized.reduce((n, tag) => n + (haystack.includes(tag) ? 1 : 0), 0);
68
+ return { m, hits, originalIndex };
69
+ }).sort((a, b) => b.hits - a.hits || a.originalIndex - b.originalIndex).map(({ m }) => m);
70
+ }
71
+ function verbCountForFrequency(frequency, max) {
72
+ switch (frequency) {
73
+ case "always":
74
+ return max;
75
+ case "rare":
76
+ return 1;
77
+ case "sometimes":
78
+ default:
79
+ return 2;
80
+ }
81
+ }
82
+ function buildContextVerbs(topMatches, sessionTags) {
83
+ const sess = (Array.isArray(sessionTags) ? sessionTags : []).map((t) => String(t).toLowerCase().trim()).filter(Boolean);
84
+ const roleTags = /* @__PURE__ */ new Set();
85
+ for (const m of Array.isArray(topMatches) ? topMatches : []) {
86
+ const mt = m && Array.isArray(m.matchedTags) ? m.matchedTags : [];
87
+ for (const t of mt) roleTags.add(String(t).toLowerCase().trim());
88
+ }
89
+ const overlap = [];
90
+ for (const t of sess) {
91
+ if (roleTags.has(t) && !overlap.includes(t)) overlap.push(t);
92
+ }
93
+ if (overlap.length >= 2) {
94
+ const a = titleCase(overlap[0]);
95
+ const b = titleCase(overlap[1]);
96
+ return [`\u2726 Fits your ${a} + ${b} work`, `\u2726 A role matching what you're building`];
97
+ }
98
+ if (overlap.length === 1) {
99
+ const a = titleCase(overlap[0]);
100
+ return [`\u2726 A role matching your ${a} work`, `\u2726 Your ${a} work \u2014 link in the tip below`];
101
+ }
102
+ return [`\u2726 A role that fits your work`, `\u2726 Job match for you \u2014 link in the tip below`];
103
+ }
104
+ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
105
+ const { sessionTags, frequency = "always" } = opts;
106
+ const ranked = rankBySessionTags(topMatches, sessionTags);
107
+ if (!Array.isArray(ranked) || ranked.length === 0) return [];
108
+ const headers = buildContextVerbs(ranked, sessionTags);
109
+ const cap = Math.max(1, verbCountForFrequency(frequency, headers.length));
110
+ return [...headers.slice(0, cap), ctaVerb()];
111
+ }
112
+ function readState() {
113
+ return readJson(SPINNER_STATE_FILE, { verbs: [], mode: "replace" });
114
+ }
115
+ function applySpinnerVerbs(ourVerbs, mode = "replace") {
116
+ const verbs = (Array.isArray(ourVerbs) ? ourVerbs : []).filter(Boolean);
117
+ if (verbs.length === 0) return clearSpinnerVerbs();
118
+ const settings = readJson(CLAUDE_SETTINGS, {}) || {};
119
+ const existing = settings.spinnerVerbs && typeof settings.spinnerVerbs === "object" ? settings.spinnerVerbs : null;
120
+ const prevOurs = new Set(readState().verbs || []);
121
+ const userVerbs = existing && Array.isArray(existing.verbs) ? existing.verbs.filter((v) => !prevOurs.has(v)) : [];
122
+ const newVerbs = [...verbs, ...userVerbs];
123
+ settings.spinnerVerbs = { mode: mode === "append" ? "append" : "replace", verbs: newVerbs };
124
+ atomicWriteJson(CLAUDE_SETTINGS, settings);
125
+ const st = readState();
126
+ atomicWriteJson(SPINNER_STATE_FILE, { ...st, verbs, mode, ts: Date.now() });
127
+ return { applied: verbs.length, total: newVerbs.length };
128
+ }
129
+ function clearSpinnerVerbs() {
130
+ const settings = readJson(CLAUDE_SETTINGS, null);
131
+ const prevOurs = new Set(readState().verbs || []);
132
+ let keptUserVerbs = 0;
133
+ if (settings && settings.spinnerVerbs && Array.isArray(settings.spinnerVerbs.verbs)) {
134
+ const userVerbs = settings.spinnerVerbs.verbs.filter((v) => !prevOurs.has(v));
135
+ keptUserVerbs = userVerbs.length;
136
+ if (userVerbs.length > 0) {
137
+ settings.spinnerVerbs = {
138
+ mode: settings.spinnerVerbs.mode === "append" ? "append" : "replace",
139
+ verbs: userVerbs
140
+ };
141
+ } else {
142
+ delete settings.spinnerVerbs;
143
+ }
144
+ atomicWriteJson(CLAUDE_SETTINGS, settings);
145
+ }
146
+ try {
147
+ const st = readState();
148
+ atomicWriteJson(SPINNER_STATE_FILE, { ...st, verbs: [], mode: st.mode || "replace", ts: Date.now() });
149
+ } catch {
150
+ }
151
+ return { cleared: true, keptUserVerbs };
152
+ }
153
+ function clearSpinnerTips() {
154
+ const settings = readJson(CLAUDE_SETTINGS, null);
155
+ const prevOurs = new Set(readState().tips || []);
156
+ if (settings && settings.spinnerTipsOverride && Array.isArray(settings.spinnerTipsOverride.tips)) {
157
+ const userTips = settings.spinnerTipsOverride.tips.filter((t) => !prevOurs.has(t));
158
+ if (userTips.length > 0) {
159
+ settings.spinnerTipsOverride = {
160
+ excludeDefault: settings.spinnerTipsOverride.excludeDefault === true,
161
+ tips: userTips
162
+ };
163
+ } else {
164
+ delete settings.spinnerTipsOverride;
165
+ delete settings.spinnerTipsEnabled;
166
+ }
167
+ atomicWriteJson(CLAUDE_SETTINGS, settings);
168
+ }
169
+ try {
170
+ const st = readState();
171
+ atomicWriteJson(SPINNER_STATE_FILE, { ...st, tips: [], ts: Date.now() });
172
+ } catch {
173
+ }
174
+ return { cleared: true };
175
+ }
176
+
177
+ // bin/jpi-spinner.js
178
+ var TH_DIR2 = process.env["TERMINALHIRE_DIR"] || join2(homedir2(), ".terminalhire");
179
+ var CONFIG_FILE2 = join2(TH_DIR2, "config.json");
180
+ var SETTINGS_PATH = process.env["TERMINALHIRE_CLAUDE_SETTINGS"] || join2(homedir2(), ".claude", "settings.json");
181
+ var CACHE_FILE = join2(TH_DIR2, "index-cache.json");
182
+ function readConfig() {
183
+ try {
184
+ return existsSync2(CONFIG_FILE2) ? JSON.parse(readFileSync2(CONFIG_FILE2, "utf8")) : {};
185
+ } catch {
186
+ return {};
187
+ }
188
+ }
189
+ function writeConfig(patch) {
190
+ mkdirSync2(TH_DIR2, { recursive: true });
191
+ const merged = { ...readConfig(), ...patch };
192
+ writeFileSync2(CONFIG_FILE2, JSON.stringify(merged, null, 2) + "\n", "utf8");
193
+ }
194
+ function backupSettings() {
195
+ if (!existsSync2(SETTINGS_PATH)) return null;
196
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
197
+ const backupPath = `${SETTINGS_PATH}.terminalhire-backup-${ts}`;
198
+ copyFileSync(SETTINGS_PATH, backupPath);
199
+ return backupPath;
200
+ }
201
+ function ask(question) {
202
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
203
+ return new Promise((res) => {
204
+ rl.question(question, (answer) => {
205
+ rl.close();
206
+ res(answer.trim().toLowerCase());
207
+ });
208
+ });
209
+ }
210
+ function readTopMatches() {
211
+ try {
212
+ const c = JSON.parse(readFileSync2(CACHE_FILE, "utf8"));
213
+ return Array.isArray(c.topMatches) ? c.topMatches : [];
214
+ } catch {
215
+ return [];
216
+ }
217
+ }
218
+ async function run() {
219
+ const args = process.argv.slice(2).filter((a) => a !== "spinner");
220
+ const has = (f) => args.includes(f);
221
+ const val = (f) => {
222
+ const i = args.indexOf(f);
223
+ return i >= 0 ? args[i + 1] : void 0;
224
+ };
225
+ if (has("--show") || args.length === 0) {
226
+ const sc = readSpinnerConfig();
227
+ console.log("");
228
+ console.log("terminalhire spinner \u2014 job matches in the Claude Code spinner line");
229
+ console.log("");
230
+ console.log(` enabled: ${sc.enabled}`);
231
+ console.log(` mode: ${sc.mode} (replace = only job matches; append = mixed with Claude defaults)`);
232
+ console.log(` max: ${sc.max} (max job verbs that rotate)`);
233
+ console.log(` frequency: ${sc.frequency} (always = up to max; sometimes = up to 2; rare = 1 per cycle)`);
234
+ console.log("");
235
+ console.log(" terminalhire spinner --on enable (asks consent, backs up settings.json)");
236
+ console.log(" terminalhire spinner --off disable + restore your original spinner");
237
+ console.log(" terminalhire spinner --mode append mix job verbs with Claude's defaults");
238
+ console.log(" terminalhire spinner --mode replace show only job matches");
239
+ console.log(" terminalhire spinner --max N cap how many job verbs rotate (1\u201312)");
240
+ console.log(" terminalhire spinner --frequency always surface up to max role verbs every cycle");
241
+ console.log(" terminalhire spinner --frequency sometimes surface up to 2 role verbs (default)");
242
+ console.log(" terminalhire spinner --frequency rare surface 1 role verb per cycle (quietest)");
243
+ console.log("");
244
+ return;
245
+ }
246
+ if (has("--off")) {
247
+ const res = clearSpinnerVerbs();
248
+ clearSpinnerTips();
249
+ writeConfig({ spinner: { ...readSpinnerConfig(), enabled: false } });
250
+ console.log("");
251
+ console.log(" Spinner job verbs removed.");
252
+ if (res.keptUserVerbs > 0) {
253
+ console.log(` Preserved ${res.keptUserVerbs} spinner verb(s) you set yourself.`);
254
+ } else {
255
+ console.log(" Your original spinner is restored.");
256
+ }
257
+ console.log(" Re-enable any time: terminalhire spinner --on");
258
+ console.log("");
259
+ return;
260
+ }
261
+ if ((has("--mode") || has("--max") || has("--frequency")) && !has("--on")) {
262
+ const cur = readSpinnerConfig();
263
+ const next = { ...cur };
264
+ const m = val("--mode");
265
+ if (m) {
266
+ if (m !== "append" && m !== "replace") {
267
+ console.error('Error: --mode must be "append" or "replace".');
268
+ process.exit(1);
269
+ }
270
+ next.mode = m;
271
+ }
272
+ const mx = val("--max");
273
+ if (mx) {
274
+ const n = parseInt(mx, 10);
275
+ if (!(n >= 1 && n <= 12)) {
276
+ console.error("Error: --max must be a number 1\u201312.");
277
+ process.exit(1);
278
+ }
279
+ next.max = n;
280
+ }
281
+ const freq = val("--frequency");
282
+ if (freq) {
283
+ if (!["always", "sometimes", "rare"].includes(freq)) {
284
+ console.error('Error: --frequency must be "always", "sometimes", or "rare".');
285
+ process.exit(1);
286
+ }
287
+ next.frequency = freq;
288
+ }
289
+ writeConfig({ spinner: next });
290
+ console.log(` spinner config updated: mode=${next.mode} max=${next.max} frequency=${next.frequency} enabled=${next.enabled}`);
291
+ if (next.enabled) {
292
+ const verbs = buildSpinnerPool(readTopMatches(), next.max, { frequency: next.frequency });
293
+ if (verbs.length) applySpinnerVerbs(verbs, next.mode);
294
+ else clearSpinnerVerbs();
295
+ }
296
+ return;
297
+ }
298
+ if (has("--on")) {
299
+ const mode = val("--mode") === "replace" ? "replace" : "append";
300
+ const maxRaw = parseInt(val("--max"), 10);
301
+ const max = maxRaw >= 1 && maxRaw <= 12 ? maxRaw : 6;
302
+ const freqRaw = val("--frequency");
303
+ const frequency = ["always", "sometimes", "rare"].includes(freqRaw) ? freqRaw : "sometimes";
304
+ console.log("");
305
+ 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\u2510");
306
+ console.log("\u2502 terminalhire \u2014 enable the spinner job surface \u2502");
307
+ 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\u2518");
308
+ console.log("");
309
+ console.log("WHAT THIS CHANGES:");
310
+ console.log(' \u2022 Adds a "spinnerVerbs" key to ~/.claude/settings.json \u2014 the official,');
311
+ console.log(" documented Claude Code setting. No patching, no binary changes (Rule 7).");
312
+ console.log(" \u2022 While Claude works, the spinner line shows your TOP LOCAL JOB MATCHES,");
313
+ console.log(" e.g. Senior Backend Engineer @ Stripe \xB7 82% \u2026");
314
+ console.log(" \u2022 The tip line below shows a \u2318-clickable terminalhire.com/j/\u2026 link to open");
315
+ console.log(" the listing (clicks logged anonymously, no profile data).");
316
+ console.log(` \u2022 mode=${mode} (replace = only job matches; append = mixed with defaults)`);
317
+ console.log(` \u2022 frequency=${frequency} (always = every cycle; sometimes = up to 2 verbs; rare = 1 verb)`);
318
+ console.log(" \u2022 Matches are computed LOCALLY and refreshed in the background.");
319
+ console.log(" ZERO egress \u2014 your profile never leaves the machine; only public job");
320
+ console.log(" text appears on YOUR screen.");
321
+ console.log(" \u2022 Any spinner verbs you already set are preserved, never clobbered.");
322
+ console.log("");
323
+ console.log("FULLY REVERSIBLE:");
324
+ console.log(" terminalhire spinner --off removes job verbs, restores your spinner");
325
+ console.log(" (a timestamped backup of settings.json is taken now)");
326
+ console.log("");
327
+ const answer = await ask('Enable the spinner job surface? Type "yes" to continue: ');
328
+ if (answer !== "yes") {
329
+ console.log("\nAborted \u2014 nothing changed.");
330
+ process.exit(0);
331
+ }
332
+ const backup = backupSettings();
333
+ writeConfig({ spinner: { enabled: true, mode, max, frequency } });
334
+ const verbs = buildSpinnerPool(readTopMatches(), max, { frequency });
335
+ if (verbs.length) applySpinnerVerbs(verbs, mode);
336
+ console.log("");
337
+ if (backup) console.log(` Backed up settings to: ${backup}`);
338
+ console.log(` Enabled. ${verbs.length} job verb(s) live now; refreshes in the background.`);
339
+ if (verbs.length === 0) {
340
+ console.log(" (No matches cached yet \u2014 run `terminalhire refresh` or wait for the monitor.)");
341
+ }
342
+ console.log(" Claude Code picks up settings.json changes automatically.");
343
+ console.log(" Turn off any time: terminalhire spinner --off");
344
+ console.log("");
345
+ return;
346
+ }
347
+ console.error("Usage: terminalhire spinner --on | --off | --show | --mode <append|replace> | --max N | --frequency <always|sometimes|rare>");
348
+ process.exit(1);
349
+ }
350
+ export {
351
+ run
352
+ };