terminalhire 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/jpi-dispatch.js +30 -3
- package/dist/bin/jpi-init.js +30 -3
- package/dist/bin/jpi-statusline-launch.js +71 -0
- package/dist/bin/jpi-statusline.js +65 -0
- package/package.json +3 -2
- package/statusline-install.js +271 -0
package/dist/bin/jpi-dispatch.js
CHANGED
|
@@ -13351,6 +13351,13 @@ function resolveInstallJs() {
|
|
|
13351
13351
|
if (existsSync18(fromBin)) return fromBin;
|
|
13352
13352
|
return fromBin;
|
|
13353
13353
|
}
|
|
13354
|
+
function resolveStatuslineInstallJs() {
|
|
13355
|
+
const fromDist = resolve(join24(__dirname3, "..", "..", "statusline-install.js"));
|
|
13356
|
+
const fromBin = resolve(join24(__dirname3, "..", "statusline-install.js"));
|
|
13357
|
+
if (existsSync18(fromDist)) return fromDist;
|
|
13358
|
+
if (existsSync18(fromBin)) return fromBin;
|
|
13359
|
+
return fromBin;
|
|
13360
|
+
}
|
|
13354
13361
|
async function run17() {
|
|
13355
13362
|
console.log("");
|
|
13356
13363
|
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");
|
|
@@ -13363,10 +13370,12 @@ async function run17() {
|
|
|
13363
13370
|
console.log(" 2. Seed your local job cache (anonymous index download)");
|
|
13364
13371
|
console.log(" 3. Enable the ambient spinner job surface in ~/.claude/settings.json");
|
|
13365
13372
|
console.log(" (with backup + your explicit consent before any file is touched)");
|
|
13373
|
+
console.log(" 4. Optionally show connection notifications in your statusLine");
|
|
13374
|
+
console.log(" (\u{1F4AC} unread + intro requests only \u2014 never job ads; separate consent)");
|
|
13366
13375
|
console.log("");
|
|
13367
13376
|
console.log('You can stop at any step. Nothing is changed until you say "yes".');
|
|
13368
13377
|
console.log("");
|
|
13369
|
-
console.log("Step 1/
|
|
13378
|
+
console.log("Step 1/4 \u2014 GitHub sign-in (optional but recommended)");
|
|
13370
13379
|
console.log("");
|
|
13371
13380
|
console.log(" Scope: read:user \u2014 public profile + public repos only.");
|
|
13372
13381
|
console.log(" Your token is encrypted at ~/.terminalhire/github-token.enc.");
|
|
@@ -13394,7 +13403,7 @@ async function run17() {
|
|
|
13394
13403
|
console.log(" Sign in any time with: terminalhire login");
|
|
13395
13404
|
}
|
|
13396
13405
|
console.log("");
|
|
13397
|
-
console.log("Step 2/
|
|
13406
|
+
console.log("Step 2/4 \u2014 Seeding local job cache");
|
|
13398
13407
|
console.log("");
|
|
13399
13408
|
console.log(" Fetching anonymous job index (no dev data sent)...");
|
|
13400
13409
|
const jobsScript = resolveScript("jpi-jobs");
|
|
@@ -13414,7 +13423,7 @@ async function run17() {
|
|
|
13414
13423
|
console.log(" Run `terminalhire jobs` after a few Claude Code sessions to populate it.");
|
|
13415
13424
|
}
|
|
13416
13425
|
console.log("");
|
|
13417
|
-
console.log("Step 3/
|
|
13426
|
+
console.log("Step 3/4 \u2014 Enable the ambient spinner job surface in ~/.claude/settings.json");
|
|
13418
13427
|
console.log("");
|
|
13419
13428
|
console.log(" This is the only step that modifies a system file.");
|
|
13420
13429
|
console.log(" A timestamped backup is created before any change.");
|
|
@@ -13430,6 +13439,24 @@ async function run17() {
|
|
|
13430
13439
|
console.log(" Hook installation did not complete. Run manually: node install.js");
|
|
13431
13440
|
}
|
|
13432
13441
|
console.log("");
|
|
13442
|
+
console.log("Step 4/4 \u2014 Connection notifications in your statusLine (optional)");
|
|
13443
|
+
console.log("");
|
|
13444
|
+
console.log(" A statusLine that shows ONLY personal connection signals \u2014 \u{1F4AC} unread");
|
|
13445
|
+
console.log(" messages and inbound intro requests. Never job ads (those stay in the");
|
|
13446
|
+
console.log(" spinner). Local cache read, zero network. Separate consent + backup;");
|
|
13447
|
+
console.log(" it stays current across plugin updates and preserves any existing");
|
|
13448
|
+
console.log(" statusLine you have. Remove any time: node statusline-install.js --uninstall");
|
|
13449
|
+
console.log("");
|
|
13450
|
+
const statuslineInstallJs = resolveStatuslineInstallJs();
|
|
13451
|
+
const statuslineChild = spawnSync(process.execPath, [statuslineInstallJs], {
|
|
13452
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
13453
|
+
env: process.env
|
|
13454
|
+
});
|
|
13455
|
+
if (statuslineChild.status !== 0) {
|
|
13456
|
+
console.log("");
|
|
13457
|
+
console.log(" statusLine setup did not complete. Run manually: node statusline-install.js");
|
|
13458
|
+
}
|
|
13459
|
+
console.log("");
|
|
13433
13460
|
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");
|
|
13434
13461
|
console.log("\u2502 terminalhire init complete! \u2502");
|
|
13435
13462
|
console.log("\u2502 \u2502");
|
package/dist/bin/jpi-init.js
CHANGED
|
@@ -29,6 +29,13 @@ function resolveInstallJs() {
|
|
|
29
29
|
if (existsSync(fromBin)) return fromBin;
|
|
30
30
|
return fromBin;
|
|
31
31
|
}
|
|
32
|
+
function resolveStatuslineInstallJs() {
|
|
33
|
+
const fromDist = resolve(join(__dirname, "..", "..", "statusline-install.js"));
|
|
34
|
+
const fromBin = resolve(join(__dirname, "..", "statusline-install.js"));
|
|
35
|
+
if (existsSync(fromDist)) return fromDist;
|
|
36
|
+
if (existsSync(fromBin)) return fromBin;
|
|
37
|
+
return fromBin;
|
|
38
|
+
}
|
|
32
39
|
async function run() {
|
|
33
40
|
console.log("");
|
|
34
41
|
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");
|
|
@@ -41,10 +48,12 @@ async function run() {
|
|
|
41
48
|
console.log(" 2. Seed your local job cache (anonymous index download)");
|
|
42
49
|
console.log(" 3. Enable the ambient spinner job surface in ~/.claude/settings.json");
|
|
43
50
|
console.log(" (with backup + your explicit consent before any file is touched)");
|
|
51
|
+
console.log(" 4. Optionally show connection notifications in your statusLine");
|
|
52
|
+
console.log(" (\u{1F4AC} unread + intro requests only \u2014 never job ads; separate consent)");
|
|
44
53
|
console.log("");
|
|
45
54
|
console.log('You can stop at any step. Nothing is changed until you say "yes".');
|
|
46
55
|
console.log("");
|
|
47
|
-
console.log("Step 1/
|
|
56
|
+
console.log("Step 1/4 \u2014 GitHub sign-in (optional but recommended)");
|
|
48
57
|
console.log("");
|
|
49
58
|
console.log(" Scope: read:user \u2014 public profile + public repos only.");
|
|
50
59
|
console.log(" Your token is encrypted at ~/.terminalhire/github-token.enc.");
|
|
@@ -72,7 +81,7 @@ async function run() {
|
|
|
72
81
|
console.log(" Sign in any time with: terminalhire login");
|
|
73
82
|
}
|
|
74
83
|
console.log("");
|
|
75
|
-
console.log("Step 2/
|
|
84
|
+
console.log("Step 2/4 \u2014 Seeding local job cache");
|
|
76
85
|
console.log("");
|
|
77
86
|
console.log(" Fetching anonymous job index (no dev data sent)...");
|
|
78
87
|
const jobsScript = resolveScript("jpi-jobs");
|
|
@@ -92,7 +101,7 @@ async function run() {
|
|
|
92
101
|
console.log(" Run `terminalhire jobs` after a few Claude Code sessions to populate it.");
|
|
93
102
|
}
|
|
94
103
|
console.log("");
|
|
95
|
-
console.log("Step 3/
|
|
104
|
+
console.log("Step 3/4 \u2014 Enable the ambient spinner job surface in ~/.claude/settings.json");
|
|
96
105
|
console.log("");
|
|
97
106
|
console.log(" This is the only step that modifies a system file.");
|
|
98
107
|
console.log(" A timestamped backup is created before any change.");
|
|
@@ -108,6 +117,24 @@ async function run() {
|
|
|
108
117
|
console.log(" Hook installation did not complete. Run manually: node install.js");
|
|
109
118
|
}
|
|
110
119
|
console.log("");
|
|
120
|
+
console.log("Step 4/4 \u2014 Connection notifications in your statusLine (optional)");
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log(" A statusLine that shows ONLY personal connection signals \u2014 \u{1F4AC} unread");
|
|
123
|
+
console.log(" messages and inbound intro requests. Never job ads (those stay in the");
|
|
124
|
+
console.log(" spinner). Local cache read, zero network. Separate consent + backup;");
|
|
125
|
+
console.log(" it stays current across plugin updates and preserves any existing");
|
|
126
|
+
console.log(" statusLine you have. Remove any time: node statusline-install.js --uninstall");
|
|
127
|
+
console.log("");
|
|
128
|
+
const statuslineInstallJs = resolveStatuslineInstallJs();
|
|
129
|
+
const statuslineChild = spawnSync(process.execPath, [statuslineInstallJs], {
|
|
130
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
131
|
+
env: process.env
|
|
132
|
+
});
|
|
133
|
+
if (statuslineChild.status !== 0) {
|
|
134
|
+
console.log("");
|
|
135
|
+
console.log(" statusLine setup did not complete. Run manually: node statusline-install.js");
|
|
136
|
+
}
|
|
137
|
+
console.log("");
|
|
111
138
|
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
139
|
console.log("\u2502 terminalhire init complete! \u2502");
|
|
113
140
|
console.log("\u2502 \u2502");
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/jpi-statusline-launch.js
|
|
4
|
+
import { readFileSync, existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { spawnSync } from "child_process";
|
|
8
|
+
import { pathToFileURL } from "url";
|
|
9
|
+
var TH_DIR = join(homedir(), ".terminalhire");
|
|
10
|
+
var POINTER = join(TH_DIR, "engine-path");
|
|
11
|
+
var FOREIGN = join(TH_DIR, "statusline-foreign.json");
|
|
12
|
+
function firstLine(buf) {
|
|
13
|
+
if (!buf || !buf.length) return "";
|
|
14
|
+
const s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
|
15
|
+
const nl = s.indexOf("\n");
|
|
16
|
+
return (nl === -1 ? s : s.slice(0, nl)).replace(/\s+$/u, "");
|
|
17
|
+
}
|
|
18
|
+
async function renderOurs(input) {
|
|
19
|
+
let engine = "";
|
|
20
|
+
try {
|
|
21
|
+
if (!existsSync(POINTER)) return "";
|
|
22
|
+
engine = readFileSync(POINTER, "utf8").trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
if (!engine || !existsSync(engine)) return "";
|
|
27
|
+
try {
|
|
28
|
+
const line = await Promise.race([
|
|
29
|
+
import(pathToFileURL(engine).href).then(
|
|
30
|
+
(mod) => mod && typeof mod.render === "function" ? firstLine(mod.render()) : null
|
|
31
|
+
),
|
|
32
|
+
new Promise((resolve) => setTimeout(() => resolve(void 0), 30))
|
|
33
|
+
]);
|
|
34
|
+
if (typeof line === "string") return line;
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const r = spawnSync(process.execPath, [engine], { input, timeout: 800 });
|
|
39
|
+
return firstLine(r.stdout);
|
|
40
|
+
} catch {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function renderForeign(input) {
|
|
45
|
+
try {
|
|
46
|
+
if (!existsSync(FOREIGN)) return "";
|
|
47
|
+
const saved = JSON.parse(readFileSync(FOREIGN, "utf8"));
|
|
48
|
+
const cmd = saved && saved.statusLine && saved.statusLine.command;
|
|
49
|
+
if (!cmd) return "";
|
|
50
|
+
const r = spawnSync(cmd, { shell: true, input, timeout: 4e3 });
|
|
51
|
+
return firstLine(r.stdout);
|
|
52
|
+
} catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
let input = Buffer.alloc(0);
|
|
58
|
+
try {
|
|
59
|
+
if (!process.stdin.isTTY) input = readFileSync(0);
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
const segments = [];
|
|
63
|
+
const foreign = renderForeign(input);
|
|
64
|
+
if (foreign) segments.push(foreign);
|
|
65
|
+
const ours = await renderOurs(input);
|
|
66
|
+
if (ours) segments.push(ours);
|
|
67
|
+
if (segments.length > 0) process.stdout.write(segments.join(" \xB7 ") + "\n");
|
|
68
|
+
process.exit(0);
|
|
69
|
+
} catch {
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/jpi-statusline.js
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { pathToFileURL } from "url";
|
|
8
|
+
var INDEX_CACHE_FILE = join(homedir(), ".terminalhire", "index-cache.json");
|
|
9
|
+
var INDEX_CACHE_TTL_MS = 15 * 60 * 1e3;
|
|
10
|
+
function readFreshCache() {
|
|
11
|
+
try {
|
|
12
|
+
const entry = JSON.parse(readFileSync(INDEX_CACHE_FILE, "utf8"));
|
|
13
|
+
if (typeof entry.ts !== "number" || Date.now() - entry.ts > INDEX_CACHE_TTL_MS) return null;
|
|
14
|
+
return entry;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function unreadChatCount(entry) {
|
|
20
|
+
const n = entry && entry.unreadChat && entry.unreadChat.count;
|
|
21
|
+
return typeof n === "number" && n > 0 ? n : 0;
|
|
22
|
+
}
|
|
23
|
+
function incomingCount(entry) {
|
|
24
|
+
const n = entry && entry.incomingPending && entry.incomingPending.count;
|
|
25
|
+
return typeof n === "number" && n > 0 ? n : 0;
|
|
26
|
+
}
|
|
27
|
+
function sessionStale(entry) {
|
|
28
|
+
return !!entry && entry.sessionStale === true;
|
|
29
|
+
}
|
|
30
|
+
function render() {
|
|
31
|
+
try {
|
|
32
|
+
const entry = readFreshCache();
|
|
33
|
+
if (!entry) return "";
|
|
34
|
+
const unread = unreadChatCount(entry);
|
|
35
|
+
const incoming = incomingCount(entry);
|
|
36
|
+
const stale = sessionStale(entry) && unread === 0 && incoming === 0;
|
|
37
|
+
const parts = [];
|
|
38
|
+
if (unread > 0) parts.push(`\u{1F4AC} ${unread} unread`);
|
|
39
|
+
if (incoming > 0) parts.push(`\u2709 ${incoming} waiting to connect`);
|
|
40
|
+
if (parts.length > 0) {
|
|
41
|
+
let line = parts.join(" \xB7 ");
|
|
42
|
+
line += unread > 0 ? " \u2014 run: terminalhire chat" : " \u2014 run: terminalhire intro --list";
|
|
43
|
+
return line;
|
|
44
|
+
}
|
|
45
|
+
if (stale) {
|
|
46
|
+
return "\u26A0 terminalhire session expired \u2014 run: terminalhire link";
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
} catch {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
55
|
+
if (isMain) {
|
|
56
|
+
const line = render();
|
|
57
|
+
if (line) process.stdout.write(line + "\n");
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
export {
|
|
64
|
+
render
|
|
65
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminalhire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Local-first job matching for developers — ambient job matches in the Claude Code spinner. Matching runs on your machine; your profile never leaves it.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
"dist",
|
|
23
23
|
"fixtures",
|
|
24
24
|
"install.js",
|
|
25
|
+
"statusline-install.js",
|
|
25
26
|
"postinstall.js",
|
|
26
27
|
"README.md"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
30
|
"test": "for f in test/*.test.js; do echo \"# $f\"; node \"$f\" || exit 1; done",
|
|
30
31
|
"build": "tsup",
|
|
31
|
-
"bundle:plugin": "npm run build && rm -rf ../../plugins/terminalhire/dist && cp -R dist ../../plugins/terminalhire/dist && cp package.json ../../plugins/terminalhire/dist/package.json && cp install.js ../../plugins/terminalhire/dist/install.js && cp postinstall.js ../../plugins/terminalhire/dist/postinstall.js",
|
|
32
|
+
"bundle:plugin": "npm run build && rm -rf ../../plugins/terminalhire/dist && cp -R dist ../../plugins/terminalhire/dist && cp package.json ../../plugins/terminalhire/dist/package.json && cp install.js ../../plugins/terminalhire/dist/install.js && cp statusline-install.js ../../plugins/terminalhire/dist/statusline-install.js && cp postinstall.js ../../plugins/terminalhire/dist/postinstall.js",
|
|
32
33
|
"prepublishOnly": "npm run build",
|
|
33
34
|
"install-hook": "node install.js",
|
|
34
35
|
"postinstall": "node ./postinstall.js"
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* statusline-install.js — consent-gated installer for the CONNECTION-ONLY statusLine
|
|
4
|
+
* (feature 002 / ADR-002). Companion to install.js (which is spinner-only and, by its
|
|
5
|
+
* binding rule, NEVER writes settings.statusLine). This file is the ONLY writer of
|
|
6
|
+
* settings.statusLine, and it writes ONLY the self-updating launcher command — the
|
|
7
|
+
* statusLine it enables carries personal connection notifications, never job ads
|
|
8
|
+
* (the entire justification for re-opening the demoted statusLine surface).
|
|
9
|
+
*
|
|
10
|
+
* Guardrails (CONSTITUTION Rule 12 / ~/.claude/rules/safety.md):
|
|
11
|
+
* - Explicit typed "yes" BEFORE touching any system file (no-consent → settings.json
|
|
12
|
+
* byte-for-byte unchanged, no backup churn — V-4).
|
|
13
|
+
* - Timestamped backup of ~/.claude/settings.json before any write.
|
|
14
|
+
* - settings.statusLine.command is ZERO-VOLATILE: `node ~/.terminalhire/statusline-launch.js`
|
|
15
|
+
* — a stable, version-agnostic path (V-1). All version logic lives behind the
|
|
16
|
+
* hook-written engine-path pointer, so this artifact is written once and never
|
|
17
|
+
* re-stamped → it cannot recreate the stale-wrapper bug.
|
|
18
|
+
* - Chain-never-clobber: a pre-existing FOREIGN statusLine is saved verbatim to a
|
|
19
|
+
* sidecar and run FIRST by the launcher; uninstall restores it byte-for-byte (V-5),
|
|
20
|
+
* and it still renders when our part is empty (V-8).
|
|
21
|
+
* - Legacy re-point (Q4): an old terminalhire wrapper (npm-pinned .sh) is detected and,
|
|
22
|
+
* with a targeted yes, replaced by the self-updating launcher — never silently.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* node statusline-install.js
|
|
26
|
+
* node statusline-install.js --uninstall
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, rmSync,
|
|
31
|
+
} from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { join, resolve, dirname } from 'node:path';
|
|
34
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
35
|
+
import { createInterface } from 'node:readline';
|
|
36
|
+
|
|
37
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
38
|
+
|
|
39
|
+
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
40
|
+
const SETTINGS_DIR = dirname(SETTINGS_PATH);
|
|
41
|
+
const TERMINALHIRE_DIR = join(homedir(), '.terminalhire');
|
|
42
|
+
const STABLE_LAUNCHER = join(TERMINALHIRE_DIR, 'statusline-launch.js');
|
|
43
|
+
const FOREIGN_SIDECAR = join(TERMINALHIRE_DIR, 'statusline-foreign.json');
|
|
44
|
+
const LEGACY_WRAPPER = join(TERMINALHIRE_DIR, 'statusline-wrapper.sh');
|
|
45
|
+
|
|
46
|
+
// The exact command written to settings.statusLine — stable, version-agnostic (V-1).
|
|
47
|
+
const LAUNCHER_COMMAND = `node ${STABLE_LAUNCHER}`;
|
|
48
|
+
|
|
49
|
+
const UNINSTALL = process.argv.includes('--uninstall');
|
|
50
|
+
|
|
51
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function ask(question) {
|
|
54
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
55
|
+
return new Promise(res => {
|
|
56
|
+
rl.question(question, answer => {
|
|
57
|
+
rl.close();
|
|
58
|
+
res(answer.trim().toLowerCase());
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function backupSettings() {
|
|
64
|
+
if (!existsSync(SETTINGS_PATH)) return null;
|
|
65
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
66
|
+
const backupPath = `${SETTINGS_PATH}.terminalhire-backup-${ts}`;
|
|
67
|
+
copyFileSync(SETTINGS_PATH, backupPath);
|
|
68
|
+
console.log(` Backed up settings to: ${backupPath}`);
|
|
69
|
+
return backupPath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readSettings() {
|
|
73
|
+
try {
|
|
74
|
+
if (existsSync(SETTINGS_PATH)) return JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
|
75
|
+
} catch { /* fall through to empty */ }
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeSettings(settings) {
|
|
80
|
+
mkdirSync(SETTINGS_DIR, { recursive: true });
|
|
81
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Resolve the launcher SOURCE (bundled dist preferred; bin fallback for the dev workspace).
|
|
85
|
+
function resolveLauncherSource() {
|
|
86
|
+
const candidates = [
|
|
87
|
+
resolve(join(__dirname, 'dist', 'bin', 'jpi-statusline-launch.js')),
|
|
88
|
+
resolve(join(__dirname, 'bin', 'jpi-statusline-launch.js')),
|
|
89
|
+
];
|
|
90
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Classify an existing statusLine command:
|
|
95
|
+
// 'ours' — already the self-updating launcher (idempotent)
|
|
96
|
+
// 'legacy' — an OLD terminalhire wrapper (npm-pinned .sh / dispatch) → offer re-point
|
|
97
|
+
// 'foreign' — someone else's statusLine → chain-wrap + preserve
|
|
98
|
+
// 'none' — no command
|
|
99
|
+
function classifyExisting(statusLine) {
|
|
100
|
+
const cmd = statusLine && typeof statusLine.command === 'string' ? statusLine.command : '';
|
|
101
|
+
if (!cmd) return 'none';
|
|
102
|
+
if (cmd.includes('statusline-launch.js')) return 'ours';
|
|
103
|
+
if (
|
|
104
|
+
cmd.includes('statusline-wrapper.sh') ||
|
|
105
|
+
existsSync(LEGACY_WRAPPER) && cmd.includes('.terminalhire') ||
|
|
106
|
+
cmd.includes('jpi-dispatch') ||
|
|
107
|
+
/terminalhire|\bjpi\b|jpi-/.test(cmd)
|
|
108
|
+
) {
|
|
109
|
+
return 'legacy';
|
|
110
|
+
}
|
|
111
|
+
return 'foreign';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Install ───────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async function install() {
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log('┌─────────────────────────────────────────────────────────────────┐');
|
|
119
|
+
console.log('│ terminalhire — connection notifications in your statusLine │');
|
|
120
|
+
console.log('│ Personal connection signals only. Never job ads. │');
|
|
121
|
+
console.log('└─────────────────────────────────────────────────────────────────┘');
|
|
122
|
+
console.log('');
|
|
123
|
+
console.log('DISCLOSURE — read before enabling');
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log('WHAT THIS ENABLES:');
|
|
126
|
+
console.log(' A Claude Code statusLine that shows ONLY personal connection signals:');
|
|
127
|
+
console.log(' • 💬 N unread — messages from developers you accepted an intro with');
|
|
128
|
+
console.log(' • ✉ N waiting to connect — inbound intro requests awaiting your reply');
|
|
129
|
+
console.log(' • ⚠ session expired — a re-link hint when your linked session lapses');
|
|
130
|
+
console.log(' It NEVER shows job matches, role %-scores, or any advert — those stay in');
|
|
131
|
+
console.log(' the ambient spinner. The line is a LOCAL cache read: zero network calls.');
|
|
132
|
+
console.log('');
|
|
133
|
+
console.log('HOW IT STAYS CURRENT (no manual re-install ever):');
|
|
134
|
+
console.log(' settings.json gets a stable command: ' + LAUNCHER_COMMAND);
|
|
135
|
+
console.log(' On every session the plugin refreshes a pointer to its current engine, so');
|
|
136
|
+
console.log(' the statusLine follows plugin auto-updates automatically.');
|
|
137
|
+
console.log('');
|
|
138
|
+
console.log('WHAT THIS CHANGES:');
|
|
139
|
+
console.log(' • ~/.claude/settings.json — sets statusLine to the launcher above.');
|
|
140
|
+
console.log(' • A timestamped backup is created before any change.');
|
|
141
|
+
console.log(' • Any statusLine you already have is PRESERVED (run first) — never replaced.');
|
|
142
|
+
console.log('');
|
|
143
|
+
console.log('HOW TO REMOVE:');
|
|
144
|
+
console.log(' • node statusline-install.js --uninstall (restores your prior statusLine)');
|
|
145
|
+
console.log('');
|
|
146
|
+
|
|
147
|
+
const answer = await ask('Enable the connection-notification statusLine? Type "yes" to continue: ');
|
|
148
|
+
if (answer !== 'yes') {
|
|
149
|
+
console.log('\nAborted — nothing was changed.');
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const src = resolveLauncherSource();
|
|
154
|
+
if (!src) {
|
|
155
|
+
console.log('\n Could not locate the statusLine launcher — nothing was changed.');
|
|
156
|
+
console.log(' Rebuild the package (npm run build) and try again.');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const settings = readSettings();
|
|
161
|
+
const existing = settings.statusLine;
|
|
162
|
+
const kind = classifyExisting(existing);
|
|
163
|
+
|
|
164
|
+
// Legacy re-point (Q4) — consent-gated, never silent.
|
|
165
|
+
if (kind === 'legacy') {
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log(' Found an older terminalhire statusLine (npm-pinned — can go stale).');
|
|
168
|
+
const rp = await ask(' Replace it with the self-updating launcher? Type "yes": ');
|
|
169
|
+
if (rp !== 'yes') {
|
|
170
|
+
console.log('\n Left your existing statusLine as-is — nothing was changed.');
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
// It is our own legacy artifact → do NOT preserve it as "foreign".
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Only NOW (post-consent) do we touch the filesystem — V-4.
|
|
177
|
+
console.log('');
|
|
178
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
179
|
+
|
|
180
|
+
// Copy the launcher to its stable, version-agnostic home.
|
|
181
|
+
copyFileSync(src, STABLE_LAUNCHER);
|
|
182
|
+
|
|
183
|
+
// Chain-wrap a genuinely FOREIGN statusLine: save it verbatim so the launcher runs it
|
|
184
|
+
// first and uninstall can restore it byte-for-byte (V-5/V-8).
|
|
185
|
+
if (kind === 'foreign') {
|
|
186
|
+
writeFileSync(FOREIGN_SIDECAR, JSON.stringify({ statusLine: existing }, null, 2) + '\n', 'utf8');
|
|
187
|
+
console.log(' Preserved your existing statusLine — it will render alongside ours.');
|
|
188
|
+
} else if (kind === 'ours' && existsSync(FOREIGN_SIDECAR)) {
|
|
189
|
+
// Idempotent re-run: keep any previously-saved foreign statusLine sidecar.
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const backupPath = backupSettings();
|
|
193
|
+
|
|
194
|
+
settings.statusLine = { type: 'command', command: LAUNCHER_COMMAND };
|
|
195
|
+
writeSettings(settings);
|
|
196
|
+
|
|
197
|
+
console.log(' statusLine: ENABLED');
|
|
198
|
+
console.log(` Command: ${LAUNCHER_COMMAND}`);
|
|
199
|
+
if (backupPath) console.log(` Backup: ${backupPath}`);
|
|
200
|
+
console.log('');
|
|
201
|
+
console.log('Done. Restart Claude Code to see connection notifications in your statusLine.');
|
|
202
|
+
console.log(' Remove any time: node statusline-install.js --uninstall');
|
|
203
|
+
console.log('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Uninstall ─────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
async function uninstall() {
|
|
209
|
+
console.log('');
|
|
210
|
+
console.log('terminalhire — remove the connection-notification statusLine');
|
|
211
|
+
console.log('');
|
|
212
|
+
console.log(' This restores whatever statusLine you had before (byte-for-byte), or');
|
|
213
|
+
console.log(' removes the statusLine entirely if terminalhire added it fresh.');
|
|
214
|
+
console.log('');
|
|
215
|
+
|
|
216
|
+
const answer = await ask('Remove the terminalhire statusLine? Type "yes" to continue: ');
|
|
217
|
+
if (answer !== 'yes') {
|
|
218
|
+
console.log('\nAborted — nothing was changed.');
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log('');
|
|
223
|
+
const settings = readSettings();
|
|
224
|
+
const isOurs = settings.statusLine
|
|
225
|
+
&& typeof settings.statusLine.command === 'string'
|
|
226
|
+
&& settings.statusLine.command.includes('statusline-launch.js');
|
|
227
|
+
|
|
228
|
+
// Only mutate settings if the current statusLine is ours (never clobber a foreign one
|
|
229
|
+
// the user re-added after install).
|
|
230
|
+
if (isOurs) {
|
|
231
|
+
const backupPath = backupSettings();
|
|
232
|
+
if (existsSync(FOREIGN_SIDECAR)) {
|
|
233
|
+
try {
|
|
234
|
+
const saved = JSON.parse(readFileSync(FOREIGN_SIDECAR, 'utf8'));
|
|
235
|
+
if (saved && saved.statusLine) settings.statusLine = saved.statusLine; // byte-for-byte (V-5)
|
|
236
|
+
else delete settings.statusLine;
|
|
237
|
+
} catch {
|
|
238
|
+
delete settings.statusLine;
|
|
239
|
+
}
|
|
240
|
+
console.log(' Restored your previous statusLine.');
|
|
241
|
+
} else {
|
|
242
|
+
delete settings.statusLine;
|
|
243
|
+
console.log(' Removed the terminalhire statusLine.');
|
|
244
|
+
}
|
|
245
|
+
writeSettings(settings);
|
|
246
|
+
if (backupPath) console.log(` Backup: ${backupPath}`);
|
|
247
|
+
} else {
|
|
248
|
+
console.log(' No terminalhire statusLine is currently active — settings.json left unchanged.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Clean up our stable artifacts (the pointer is re-written harmlessly by the hook).
|
|
252
|
+
try { if (existsSync(FOREIGN_SIDECAR)) rmSync(FOREIGN_SIDECAR); } catch { /* best-effort */ }
|
|
253
|
+
try { if (existsSync(STABLE_LAUNCHER)) rmSync(STABLE_LAUNCHER); } catch { /* best-effort */ }
|
|
254
|
+
|
|
255
|
+
console.log('');
|
|
256
|
+
console.log('Done. Restart Claude Code to apply.');
|
|
257
|
+
console.log('');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
263
|
+
if (isMain) {
|
|
264
|
+
const run = UNINSTALL ? uninstall : install;
|
|
265
|
+
run().catch(err => {
|
|
266
|
+
console.error(`${UNINSTALL ? 'Uninstall' : 'Install'} error:`, err.message);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { install as installStatusline, uninstall as uninstallStatusline, LAUNCHER_COMMAND };
|