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,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
+ };
@@ -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
- const currentStatusLine = settings.statusLine;
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
- settings.statusLine = buildDirectEntry(BIN_PATH);
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
- settings.statusLine = `bash ${WRAPPER_PATH}`;
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.1.1",
4
- "description": "Local-first job matching for developers — Claude Code statusLine integration",
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",