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.
- package/README.md +39 -12
- package/dist/bin/jpi-dispatch.js +1038 -35
- package/dist/bin/jpi-jobs.js +34 -1
- package/dist/bin/jpi-learn.js +23 -0
- package/dist/bin/jpi-login.js +23 -0
- package/dist/bin/jpi-profile.js +23 -0
- package/dist/bin/jpi-refresh.js +1897 -0
- package/dist/bin/jpi-save.js +674 -0
- package/dist/bin/jpi-spinner.js +352 -0
- package/dist/bin/jpi-sync.js +837 -0
- package/dist/bin/spinner.js +242 -0
- package/dist/src/profile.js +23 -0
- package/install.js +96 -4
- package/package.json +13 -3
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/spinner.js
|
|
4
|
+
import {
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
renameSync
|
|
10
|
+
} from "fs";
|
|
11
|
+
import { join, dirname } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
var TH_DIR = process.env["TERMINALHIRE_DIR"] || join(homedir(), ".terminalhire");
|
|
14
|
+
var CLAUDE_SETTINGS = process.env["TERMINALHIRE_CLAUDE_SETTINGS"] || join(homedir(), ".claude", "settings.json");
|
|
15
|
+
var CONFIG_FILE = join(TH_DIR, "config.json");
|
|
16
|
+
var SPINNER_STATE_FILE = join(TH_DIR, "spinner-state.json");
|
|
17
|
+
var SPINNER_DEFAULTS = { enabled: false, mode: "append", max: 6, frequency: "sometimes" };
|
|
18
|
+
function readJson(path, fallback) {
|
|
19
|
+
try {
|
|
20
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback;
|
|
21
|
+
} catch {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function atomicWriteJson(path, obj) {
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
|
29
|
+
renameSync(tmp, path);
|
|
30
|
+
}
|
|
31
|
+
function titleCase(s) {
|
|
32
|
+
return String(s || "").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
33
|
+
}
|
|
34
|
+
function readSpinnerConfig() {
|
|
35
|
+
const cfg = readJson(CONFIG_FILE, {});
|
|
36
|
+
const spinner = cfg && typeof cfg.spinner === "object" ? cfg.spinner : {};
|
|
37
|
+
const merged = { ...SPINNER_DEFAULTS, ...spinner };
|
|
38
|
+
if (merged.mode !== "append" && merged.mode !== "replace") merged.mode = SPINNER_DEFAULTS.mode;
|
|
39
|
+
merged.max = Math.max(1, Math.min(12, Number(merged.max) || SPINNER_DEFAULTS.max));
|
|
40
|
+
merged.enabled = merged.enabled === true;
|
|
41
|
+
if (!["always", "sometimes", "rare"].includes(merged.frequency)) {
|
|
42
|
+
merged.frequency = SPINNER_DEFAULTS.frequency;
|
|
43
|
+
}
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
46
|
+
var VERB_INTROS = ["Matched:", "You\u2019d fit:", "Worth a look:", "On your radar:", "Fits your stack:"];
|
|
47
|
+
function ctaVerb() {
|
|
48
|
+
return "\u2605 jobs that fit you \xB7 run: terminalhire jobs";
|
|
49
|
+
}
|
|
50
|
+
function formatVerbs(topMatches, max = 6) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const seen = /* @__PURE__ */ new Set();
|
|
53
|
+
for (const m of Array.isArray(topMatches) ? topMatches : []) {
|
|
54
|
+
if (!m || !m.title || !m.company) continue;
|
|
55
|
+
let title = String(m.title).trim().replace(/\s+/g, " ");
|
|
56
|
+
if (title.length > 32) title = title.slice(0, 31).trimEnd() + "\u2026";
|
|
57
|
+
const company = titleCase(String(m.company).trim().replace(/\s+/g, " "));
|
|
58
|
+
const pct = Math.max(1, Math.min(99, Math.round((Number(m.score) || 0) * 100)));
|
|
59
|
+
const key = `${title.toLowerCase()}@${company.toLowerCase()}`;
|
|
60
|
+
if (seen.has(key)) continue;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
const intro = VERB_INTROS[out.length % VERB_INTROS.length];
|
|
63
|
+
out.push(`${intro} ${title} @ ${company} \xB7 ${pct}% match`);
|
|
64
|
+
if (out.length >= max) break;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function rankBySessionTags(topMatches, sessionTags) {
|
|
69
|
+
const tags = Array.isArray(sessionTags) ? sessionTags.filter(Boolean) : [];
|
|
70
|
+
if (tags.length === 0 || !Array.isArray(topMatches)) return topMatches;
|
|
71
|
+
const normalized = tags.map((t) => String(t).toLowerCase().trim());
|
|
72
|
+
return topMatches.map((m, originalIndex) => {
|
|
73
|
+
const haystack = `${String(m.title || "").toLowerCase()} ${String(m.company || "").toLowerCase()}`;
|
|
74
|
+
const hits = normalized.reduce((n, tag) => n + (haystack.includes(tag) ? 1 : 0), 0);
|
|
75
|
+
return { m, hits, originalIndex };
|
|
76
|
+
}).sort((a, b) => b.hits - a.hits || a.originalIndex - b.originalIndex).map(({ m }) => m);
|
|
77
|
+
}
|
|
78
|
+
function verbCountForFrequency(frequency, max) {
|
|
79
|
+
switch (frequency) {
|
|
80
|
+
case "always":
|
|
81
|
+
return max;
|
|
82
|
+
case "rare":
|
|
83
|
+
return 1;
|
|
84
|
+
case "sometimes":
|
|
85
|
+
default:
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function buildContextVerbs(topMatches, sessionTags) {
|
|
90
|
+
const sess = (Array.isArray(sessionTags) ? sessionTags : []).map((t) => String(t).toLowerCase().trim()).filter(Boolean);
|
|
91
|
+
const roleTags = /* @__PURE__ */ new Set();
|
|
92
|
+
for (const m of Array.isArray(topMatches) ? topMatches : []) {
|
|
93
|
+
const mt = m && Array.isArray(m.matchedTags) ? m.matchedTags : [];
|
|
94
|
+
for (const t of mt) roleTags.add(String(t).toLowerCase().trim());
|
|
95
|
+
}
|
|
96
|
+
const overlap = [];
|
|
97
|
+
for (const t of sess) {
|
|
98
|
+
if (roleTags.has(t) && !overlap.includes(t)) overlap.push(t);
|
|
99
|
+
}
|
|
100
|
+
if (overlap.length >= 2) {
|
|
101
|
+
const a = titleCase(overlap[0]);
|
|
102
|
+
const b = titleCase(overlap[1]);
|
|
103
|
+
return [`\u2726 Fits your ${a} + ${b} work`, `\u2726 A role matching what you're building`];
|
|
104
|
+
}
|
|
105
|
+
if (overlap.length === 1) {
|
|
106
|
+
const a = titleCase(overlap[0]);
|
|
107
|
+
return [`\u2726 A role matching your ${a} work`, `\u2726 Your ${a} work \u2014 link in the tip below`];
|
|
108
|
+
}
|
|
109
|
+
return [`\u2726 A role that fits your work`, `\u2726 Job match for you \u2014 link in the tip below`];
|
|
110
|
+
}
|
|
111
|
+
function buildSpinnerPool(topMatches, max = 6, opts = {}) {
|
|
112
|
+
const { sessionTags, frequency = "always" } = opts;
|
|
113
|
+
const ranked = rankBySessionTags(topMatches, sessionTags);
|
|
114
|
+
if (!Array.isArray(ranked) || ranked.length === 0) return [];
|
|
115
|
+
const headers = buildContextVerbs(ranked, sessionTags);
|
|
116
|
+
const cap = Math.max(1, verbCountForFrequency(frequency, headers.length));
|
|
117
|
+
return [...headers.slice(0, cap), ctaVerb()];
|
|
118
|
+
}
|
|
119
|
+
function readState() {
|
|
120
|
+
return readJson(SPINNER_STATE_FILE, { verbs: [], mode: "replace" });
|
|
121
|
+
}
|
|
122
|
+
function applySpinnerVerbs(ourVerbs, mode = "replace") {
|
|
123
|
+
const verbs = (Array.isArray(ourVerbs) ? ourVerbs : []).filter(Boolean);
|
|
124
|
+
if (verbs.length === 0) return clearSpinnerVerbs();
|
|
125
|
+
const settings = readJson(CLAUDE_SETTINGS, {}) || {};
|
|
126
|
+
const existing = settings.spinnerVerbs && typeof settings.spinnerVerbs === "object" ? settings.spinnerVerbs : null;
|
|
127
|
+
const prevOurs = new Set(readState().verbs || []);
|
|
128
|
+
const userVerbs = existing && Array.isArray(existing.verbs) ? existing.verbs.filter((v) => !prevOurs.has(v)) : [];
|
|
129
|
+
const newVerbs = [...verbs, ...userVerbs];
|
|
130
|
+
settings.spinnerVerbs = { mode: mode === "append" ? "append" : "replace", verbs: newVerbs };
|
|
131
|
+
atomicWriteJson(CLAUDE_SETTINGS, settings);
|
|
132
|
+
const st = readState();
|
|
133
|
+
atomicWriteJson(SPINNER_STATE_FILE, { ...st, verbs, mode, ts: Date.now() });
|
|
134
|
+
return { applied: verbs.length, total: newVerbs.length };
|
|
135
|
+
}
|
|
136
|
+
function clearSpinnerVerbs() {
|
|
137
|
+
const settings = readJson(CLAUDE_SETTINGS, null);
|
|
138
|
+
const prevOurs = new Set(readState().verbs || []);
|
|
139
|
+
let keptUserVerbs = 0;
|
|
140
|
+
if (settings && settings.spinnerVerbs && Array.isArray(settings.spinnerVerbs.verbs)) {
|
|
141
|
+
const userVerbs = settings.spinnerVerbs.verbs.filter((v) => !prevOurs.has(v));
|
|
142
|
+
keptUserVerbs = userVerbs.length;
|
|
143
|
+
if (userVerbs.length > 0) {
|
|
144
|
+
settings.spinnerVerbs = {
|
|
145
|
+
mode: settings.spinnerVerbs.mode === "append" ? "append" : "replace",
|
|
146
|
+
verbs: userVerbs
|
|
147
|
+
};
|
|
148
|
+
} else {
|
|
149
|
+
delete settings.spinnerVerbs;
|
|
150
|
+
}
|
|
151
|
+
atomicWriteJson(CLAUDE_SETTINGS, settings);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const st = readState();
|
|
155
|
+
atomicWriteJson(SPINNER_STATE_FILE, { ...st, verbs: [], mode: st.mode || "replace", ts: Date.now() });
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
return { cleared: true, keptUserVerbs };
|
|
159
|
+
}
|
|
160
|
+
function buildTips(topMatches, baseUrl, max = 8) {
|
|
161
|
+
const base = String(baseUrl || "https://terminalhire.com").replace(/\/+$/, "");
|
|
162
|
+
const out = [];
|
|
163
|
+
const seenRole = /* @__PURE__ */ new Set();
|
|
164
|
+
const perCompany = /* @__PURE__ */ new Map();
|
|
165
|
+
const COMPANY_CAP = 2;
|
|
166
|
+
for (const m of Array.isArray(topMatches) ? topMatches : []) {
|
|
167
|
+
if (!m || !m.title || !m.company || !m.id) continue;
|
|
168
|
+
const idx = String(m.id).indexOf(":");
|
|
169
|
+
if (idx <= 0) continue;
|
|
170
|
+
const source = String(m.id).slice(0, idx);
|
|
171
|
+
const ext = String(m.id).slice(idx + 1);
|
|
172
|
+
if (!source || !ext) continue;
|
|
173
|
+
const companyRaw = String(m.company).trim().replace(/\s+/g, " ");
|
|
174
|
+
const titleRaw = String(m.title).trim().replace(/\s+/g, " ");
|
|
175
|
+
const roleKey = `${titleRaw.toLowerCase()}@${companyRaw.toLowerCase()}`;
|
|
176
|
+
const coKey = companyRaw.toLowerCase();
|
|
177
|
+
if (seenRole.has(roleKey)) continue;
|
|
178
|
+
if ((perCompany.get(coKey) || 0) >= COMPANY_CAP) continue;
|
|
179
|
+
seenRole.add(roleKey);
|
|
180
|
+
perCompany.set(coKey, (perCompany.get(coKey) || 0) + 1);
|
|
181
|
+
let title = titleRaw;
|
|
182
|
+
if (title.length > 34) title = title.slice(0, 33).trimEnd() + "\u2026";
|
|
183
|
+
const company = titleCase(companyRaw);
|
|
184
|
+
const pct = Math.max(1, Math.min(99, Math.round((Number(m.score) || 0) * 100)));
|
|
185
|
+
const token = Buffer.from(String(m.id)).toString("base64url");
|
|
186
|
+
const url = `${base}/j/${token}`;
|
|
187
|
+
out.push(`\u2197 ${title} @ ${company} \xB7 ${pct}% \u2014 ${url}`);
|
|
188
|
+
if (out.length >= max) break;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
function applySpinnerTips(ourTips) {
|
|
193
|
+
const tips = (Array.isArray(ourTips) ? ourTips : []).filter(Boolean);
|
|
194
|
+
if (tips.length === 0) return clearSpinnerTips();
|
|
195
|
+
const settings = readJson(CLAUDE_SETTINGS, {}) || {};
|
|
196
|
+
const existing = settings.spinnerTipsOverride && Array.isArray(settings.spinnerTipsOverride.tips) ? settings.spinnerTipsOverride.tips : [];
|
|
197
|
+
const prevOurs = new Set(readState().tips || []);
|
|
198
|
+
const userTips = existing.filter((t) => !prevOurs.has(t));
|
|
199
|
+
settings.spinnerTipsEnabled = true;
|
|
200
|
+
settings.spinnerTipsOverride = { excludeDefault: true, tips: [...tips, ...userTips] };
|
|
201
|
+
atomicWriteJson(CLAUDE_SETTINGS, settings);
|
|
202
|
+
const st = readState();
|
|
203
|
+
atomicWriteJson(SPINNER_STATE_FILE, { ...st, tips, ts: Date.now() });
|
|
204
|
+
return { applied: tips.length };
|
|
205
|
+
}
|
|
206
|
+
function clearSpinnerTips() {
|
|
207
|
+
const settings = readJson(CLAUDE_SETTINGS, null);
|
|
208
|
+
const prevOurs = new Set(readState().tips || []);
|
|
209
|
+
if (settings && settings.spinnerTipsOverride && Array.isArray(settings.spinnerTipsOverride.tips)) {
|
|
210
|
+
const userTips = settings.spinnerTipsOverride.tips.filter((t) => !prevOurs.has(t));
|
|
211
|
+
if (userTips.length > 0) {
|
|
212
|
+
settings.spinnerTipsOverride = {
|
|
213
|
+
excludeDefault: settings.spinnerTipsOverride.excludeDefault === true,
|
|
214
|
+
tips: userTips
|
|
215
|
+
};
|
|
216
|
+
} else {
|
|
217
|
+
delete settings.spinnerTipsOverride;
|
|
218
|
+
delete settings.spinnerTipsEnabled;
|
|
219
|
+
}
|
|
220
|
+
atomicWriteJson(CLAUDE_SETTINGS, settings);
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const st = readState();
|
|
224
|
+
atomicWriteJson(SPINNER_STATE_FILE, { ...st, tips: [], ts: Date.now() });
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
return { cleared: true };
|
|
228
|
+
}
|
|
229
|
+
export {
|
|
230
|
+
SPINNER_DEFAULTS,
|
|
231
|
+
applySpinnerTips,
|
|
232
|
+
applySpinnerVerbs,
|
|
233
|
+
buildContextVerbs,
|
|
234
|
+
buildSpinnerPool,
|
|
235
|
+
buildTips,
|
|
236
|
+
clearSpinnerTips,
|
|
237
|
+
clearSpinnerVerbs,
|
|
238
|
+
ctaVerb,
|
|
239
|
+
formatVerbs,
|
|
240
|
+
rankBySessionTags,
|
|
241
|
+
readSpinnerConfig
|
|
242
|
+
};
|
package/dist/src/profile.js
CHANGED
|
@@ -387,6 +387,26 @@ function accumulateGitHubTags(profile, tags) {
|
|
|
387
387
|
false
|
|
388
388
|
);
|
|
389
389
|
}
|
|
390
|
+
async function listSavedJobs() {
|
|
391
|
+
const profile = await readProfile();
|
|
392
|
+
return profile.savedJobs ?? [];
|
|
393
|
+
}
|
|
394
|
+
async function addSavedJob(job) {
|
|
395
|
+
const profile = await readProfile();
|
|
396
|
+
const existing = profile.savedJobs ?? [];
|
|
397
|
+
const filtered = existing.filter((j) => j.id !== job.id);
|
|
398
|
+
profile.savedJobs = [...filtered, { ...job, savedAt: (/* @__PURE__ */ new Date()).toISOString() }];
|
|
399
|
+
await writeProfile(profile);
|
|
400
|
+
}
|
|
401
|
+
async function removeSavedJob(id) {
|
|
402
|
+
const profile = await readProfile();
|
|
403
|
+
const existing = profile.savedJobs ?? [];
|
|
404
|
+
const filtered = existing.filter((j) => j.id !== id);
|
|
405
|
+
if (filtered.length === existing.length) return false;
|
|
406
|
+
profile.savedJobs = filtered;
|
|
407
|
+
await writeProfile(profile);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
390
410
|
async function deleteProfile() {
|
|
391
411
|
const { rmSync } = await import("fs");
|
|
392
412
|
try {
|
|
@@ -416,8 +436,11 @@ export {
|
|
|
416
436
|
accumulateGitHubTags,
|
|
417
437
|
accumulateSession,
|
|
418
438
|
accumulateTags,
|
|
439
|
+
addSavedJob,
|
|
419
440
|
deleteProfile,
|
|
441
|
+
listSavedJobs,
|
|
420
442
|
profileToFingerprint,
|
|
421
443
|
readProfile,
|
|
444
|
+
removeSavedJob,
|
|
422
445
|
writeProfile
|
|
423
446
|
};
|
package/install.js
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from 'node:fs';
|
|
34
34
|
import { homedir } from 'node:os';
|
|
35
35
|
import { join, resolve, dirname } from 'node:path';
|
|
36
|
-
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
37
|
import { createInterface } from 'node:readline';
|
|
38
38
|
import { spawnSync } from 'node:child_process';
|
|
39
39
|
|
|
@@ -49,6 +49,33 @@ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
|
49
49
|
const SETTINGS_DIR = dirname(SETTINGS_PATH);
|
|
50
50
|
const TERMINALHIRE_DIR = join(homedir(), '.terminalhire');
|
|
51
51
|
const WRAPPER_PATH = join(TERMINALHIRE_DIR, 'statusline-wrapper.sh');
|
|
52
|
+
const CONFIG_FILE = join(TERMINALHIRE_DIR, 'config.json');
|
|
53
|
+
|
|
54
|
+
// Resolve the spinner module (dist preferred; bin fallback for the dev workspace).
|
|
55
|
+
async function loadSpinnerModule() {
|
|
56
|
+
const candidates = [
|
|
57
|
+
resolve(join(__dirname, 'dist', 'bin', 'spinner.js')),
|
|
58
|
+
resolve(join(__dirname, 'bin', 'spinner.js')),
|
|
59
|
+
];
|
|
60
|
+
for (const c of candidates) {
|
|
61
|
+
if (existsSync(c)) {
|
|
62
|
+
try { return await import(pathToFileURL(c).href); } catch { /* try next */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Shallow-merge a patch into ~/.terminalhire/config.json.
|
|
69
|
+
function patchConfig(patch) {
|
|
70
|
+
let cfg = {};
|
|
71
|
+
try {
|
|
72
|
+
if (existsSync(CONFIG_FILE)) cfg = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
73
|
+
} catch {
|
|
74
|
+
cfg = {};
|
|
75
|
+
}
|
|
76
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
77
|
+
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cfg, ...patch }, null, 2) + '\n', 'utf8');
|
|
78
|
+
}
|
|
52
79
|
|
|
53
80
|
// The existing statusLine command on the user's machine that we must preserve
|
|
54
81
|
const KNOWN_EXISTING_STATUSLINE = 'bash /Users/ericgang/.claude/statusline-command.sh';
|
|
@@ -120,10 +147,26 @@ function isOurEntry(statusLine) {
|
|
|
120
147
|
return false;
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Normalize a statusLine value that may be a string or an object
|
|
152
|
+
* (e.g. {type:"command", command:"..."} from Claude Code's settings).
|
|
153
|
+
* Returns the shell command string, or null if the value is unusable.
|
|
154
|
+
*/
|
|
155
|
+
function extractCommand(statusLine) {
|
|
156
|
+
if (!statusLine) return null;
|
|
157
|
+
if (typeof statusLine === 'string') return statusLine;
|
|
158
|
+
// Object form: {type: "command", command: "..."}
|
|
159
|
+
if (typeof statusLine === 'object' && typeof statusLine.command === 'string') {
|
|
160
|
+
return statusLine.command;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
123
165
|
/**
|
|
124
166
|
* Build the chaining wrapper shell script.
|
|
125
167
|
* Both the existing command and the terminalhire nudge receive the SAME stdin JSON.
|
|
126
168
|
* Output: existing command's output first, then terminalhire's output.
|
|
169
|
+
* existingCmd MUST be a plain string (call extractCommand first).
|
|
127
170
|
*/
|
|
128
171
|
function buildWrapper(existingCmd) {
|
|
129
172
|
return `#!/usr/bin/env bash
|
|
@@ -190,6 +233,15 @@ async function install() {
|
|
|
190
233
|
console.log(' 4. Nudge frequency: configurable via `terminalhire config --nudge`.');
|
|
191
234
|
console.log(' Default: once per session. Options: always | every:N.');
|
|
192
235
|
console.log('');
|
|
236
|
+
console.log(' 5. SPINNER JOB SURFACE (enabled by this install):');
|
|
237
|
+
console.log(' While Claude is working, the spinner line shows your top LOCAL job');
|
|
238
|
+
console.log(' matches, e.g. Senior Backend Engineer @ Stripe · 82% …');
|
|
239
|
+
console.log(' The "tip" line below it shows a ⌘-clickable link to open the listing');
|
|
240
|
+
console.log(' (a terminalhire.com/j/… redirect — clicks are logged anonymously, no');
|
|
241
|
+
console.log(' profile data). Uses official `spinnerVerbs`/`spinnerTipsOverride` settings');
|
|
242
|
+
console.log(' (no patching, Rule 7). Computed locally, zero egress — only public job text.');
|
|
243
|
+
console.log(' Turn it off any time: terminalhire spinner --off');
|
|
244
|
+
console.log('');
|
|
193
245
|
console.log('YOUR LOCAL PROFILE (~/.terminalhire/profile.enc):');
|
|
194
246
|
console.log(' • Encrypted at rest with AES-256-GCM (Node built-in crypto).');
|
|
195
247
|
console.log(' • Key stored at ~/.terminalhire/key (0600) or OS keychain if keytar is installed.');
|
|
@@ -219,7 +271,8 @@ async function install() {
|
|
|
219
271
|
console.log('');
|
|
220
272
|
|
|
221
273
|
const settings = readSettings();
|
|
222
|
-
|
|
274
|
+
// Normalize: settings.statusLine may be a string or an object {type,command}
|
|
275
|
+
const currentStatusLine = extractCommand(settings.statusLine);
|
|
223
276
|
|
|
224
277
|
// Already installed?
|
|
225
278
|
if (isOurEntry(currentStatusLine)) {
|
|
@@ -257,7 +310,9 @@ async function install() {
|
|
|
257
310
|
const backupPath = backupSettings();
|
|
258
311
|
|
|
259
312
|
if (installStrategy === 'direct') {
|
|
260
|
-
|
|
313
|
+
// Claude Code requires statusLine as an object { type, command } — NOT a bare
|
|
314
|
+
// string (a string fails schema validation and makes Claude skip ALL settings).
|
|
315
|
+
settings.statusLine = { type: 'command', command: buildDirectEntry(BIN_PATH) };
|
|
261
316
|
writeSettings(settings);
|
|
262
317
|
console.log(' Set statusLine in ~/.claude/settings.json');
|
|
263
318
|
console.log(` → node ${BIN_PATH}`);
|
|
@@ -269,7 +324,8 @@ async function install() {
|
|
|
269
324
|
chmodSync(WRAPPER_PATH, 0o755);
|
|
270
325
|
console.log(` Created wrapper: ${WRAPPER_PATH}`);
|
|
271
326
|
|
|
272
|
-
|
|
327
|
+
// Object form { type, command } — a bare string breaks Claude Code's settings schema.
|
|
328
|
+
settings.statusLine = { type: 'command', command: `bash ${WRAPPER_PATH}` };
|
|
273
329
|
writeSettings(settings);
|
|
274
330
|
console.log(' Updated statusLine in ~/.claude/settings.json');
|
|
275
331
|
console.log(` → bash ${WRAPPER_PATH}`);
|
|
@@ -280,6 +336,29 @@ async function install() {
|
|
|
280
336
|
console.log(` Backup: ${backupPath}`);
|
|
281
337
|
}
|
|
282
338
|
|
|
339
|
+
// Enable the spinner job surface as part of this single consented install
|
|
340
|
+
// (Rule 10, amended: install IS the opt-in). Best-effort — never blocks install.
|
|
341
|
+
try {
|
|
342
|
+
patchConfig({ spinner: { enabled: true, mode: 'replace', max: 6 } });
|
|
343
|
+
const spinnerMod = await loadSpinnerModule();
|
|
344
|
+
if (spinnerMod) {
|
|
345
|
+
let topMatches = [];
|
|
346
|
+
try {
|
|
347
|
+
const cache = JSON.parse(readFileSync(join(TERMINALHIRE_DIR, 'index-cache.json'), 'utf8'));
|
|
348
|
+
if (Array.isArray(cache.topMatches)) topMatches = cache.topMatches;
|
|
349
|
+
} catch { /* no cache yet — monitor will populate it */ }
|
|
350
|
+
const verbs = spinnerMod.buildSpinnerPool(topMatches, 6);
|
|
351
|
+
if (verbs.length > 0) spinnerMod.applySpinnerVerbs(verbs, 'replace');
|
|
352
|
+
console.log(
|
|
353
|
+
' Spinner job surface: ENABLED' +
|
|
354
|
+
(verbs.length
|
|
355
|
+
? ` (${verbs.length} match${verbs.length === 1 ? '' : 'es'} live now)`
|
|
356
|
+
: ' (matches appear after the first background refresh)')
|
|
357
|
+
);
|
|
358
|
+
console.log(' Turn off any time: terminalhire spinner --off');
|
|
359
|
+
}
|
|
360
|
+
} catch { /* spinner is best-effort; never block the install */ }
|
|
361
|
+
|
|
283
362
|
console.log('');
|
|
284
363
|
console.log('Done. Restart Claude Code to activate the status bar nudge.');
|
|
285
364
|
console.log('');
|
|
@@ -359,6 +438,19 @@ async function uninstall() {
|
|
|
359
438
|
} catch { /* ignore */ }
|
|
360
439
|
}
|
|
361
440
|
|
|
441
|
+
// Remove the spinner job verbs we injected (preserving any the user set themselves).
|
|
442
|
+
try {
|
|
443
|
+
patchConfig({ spinner: { enabled: false } });
|
|
444
|
+
const spinnerMod = await loadSpinnerModule();
|
|
445
|
+
if (spinnerMod) {
|
|
446
|
+
const res = spinnerMod.clearSpinnerVerbs();
|
|
447
|
+
console.log(
|
|
448
|
+
' Removed spinner job verbs' +
|
|
449
|
+
(res && res.keptUserVerbs ? ` (kept ${res.keptUserVerbs} of your own)` : '') + '.'
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
} catch { /* best-effort */ }
|
|
453
|
+
|
|
362
454
|
console.log('');
|
|
363
455
|
console.log(' Your profile is untouched. To also delete it:');
|
|
364
456
|
console.log(' terminalhire profile --delete');
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminalhire",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local-first job matching for developers — Claude Code statusLine
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Local-first job matching for developers — Claude Code statusLine nudge and ambient spinner job surface",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/staqsIO/terminalhire.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://terminalhire.com",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/staqsIO/terminalhire/issues"
|
|
12
|
+
},
|
|
5
13
|
"type": "module",
|
|
6
14
|
"engines": {
|
|
7
15
|
"node": ">=18"
|
|
@@ -19,6 +27,7 @@
|
|
|
19
27
|
],
|
|
20
28
|
"scripts": {
|
|
21
29
|
"build": "tsup",
|
|
30
|
+
"bundle:plugin": "npm run build && rm -rf ../../plugins/terminalhire/dist && cp -R dist ../../plugins/terminalhire/dist && cp package.json ../../plugins/terminalhire/dist/package.json",
|
|
22
31
|
"prepublishOnly": "npm run build",
|
|
23
32
|
"install-hook": "node install.js",
|
|
24
33
|
"postinstall": "node ./postinstall.js"
|
|
@@ -33,7 +42,8 @@
|
|
|
33
42
|
"claude",
|
|
34
43
|
"claude-code",
|
|
35
44
|
"job-matching",
|
|
36
|
-
"local-first"
|
|
45
|
+
"local-first",
|
|
46
|
+
"spinner"
|
|
37
47
|
],
|
|
38
48
|
"author": "staqs",
|
|
39
49
|
"license": "MIT",
|