claude-status-box 1.0.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/README.md +65 -0
- package/bin/claude-status.js +143 -0
- package/lib/constants.js +73 -0
- package/lib/debounce.js +46 -0
- package/lib/doctor.js +71 -0
- package/lib/helper.js +116 -0
- package/lib/hooks.js +155 -0
- package/lib/keybindings.js +91 -0
- package/package.json +33 -0
- package/scripts/postinstall.js +69 -0
- package/scripts/preuninstall.js +27 -0
- package/src/claude-status-helper.swift +129 -0
- package/src/helper-Info.plist +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# claude-status-box
|
|
2
|
+
|
|
3
|
+
Drives the **claude-status** ESP32-C3 BLE indicator box (3 LEDs + buzzer +
|
|
4
|
+
button) from Claude Code hooks on macOS. Installs a `claude-status` CLI.
|
|
5
|
+
|
|
6
|
+
| State | Box | Meaning |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `working` | green blinking | Claude is doing things |
|
|
9
|
+
| `waiting` | red solid + beeps (20 s) | needs your attention |
|
|
10
|
+
| `idle` | blue solid | done / ready |
|
|
11
|
+
| `off` | everything off | — |
|
|
12
|
+
|
|
13
|
+
The box also has a hardware button that acts as a real **spacebar** (it's a
|
|
14
|
+
BLE keyboard) — with the keybinding this package installs, one press accepts
|
|
15
|
+
the highlighted option in Claude Code's select prompts.
|
|
16
|
+
|
|
17
|
+
Voice tip: with Claude Code voice in **hold** mode (`"voice": {"enabled":
|
|
18
|
+
true, "mode": "hold"}` in `~/.claude/settings.json` — the default), holding
|
|
19
|
+
the box's button is push-to-talk: dictation while held, stops on release.
|
|
20
|
+
This package doesn't change voice settings; set the mode to taste.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install -g claude-status-box
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Postinstall does everything:
|
|
29
|
+
|
|
30
|
+
1. Compiles a tiny Swift CoreBluetooth helper to `~/.claude-status/bin/`
|
|
31
|
+
(needs Xcode Command Line Tools: `xcode-select --install`).
|
|
32
|
+
2. Installs 9 Claude Code hooks into `~/.claude/settings.json` (backup kept,
|
|
33
|
+
nothing else in the file is touched, re-install is idempotent).
|
|
34
|
+
3. Adds `space → select:accept` (Select context) to `~/.claude/keybindings.json`
|
|
35
|
+
so the box's button accepts select prompts (never clobbers an existing
|
|
36
|
+
space binding).
|
|
37
|
+
4. Pings the device.
|
|
38
|
+
|
|
39
|
+
One-time hardware setup: power the box and pair **claude-status** in
|
|
40
|
+
System Settings > Bluetooth (it appears as a keyboard — that's what keeps
|
|
41
|
+
macOS holding a persistent connection).
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
Hooks run `claude-status <state>` (async, debounced — repeated same-state
|
|
46
|
+
events within 20 s never touch the radio). The Swift helper uses the
|
|
47
|
+
connection macOS already holds to the device as a BLE HID keyboard
|
|
48
|
+
(`retrieveConnectedPeripherals`, no scanning) and writes the state string to
|
|
49
|
+
the custom GATT characteristic — sub-second, no daemon.
|
|
50
|
+
|
|
51
|
+
macOS attributes the Bluetooth permission to your **terminal app** — allow it
|
|
52
|
+
once in System Settings > Privacy & Security > Bluetooth if prompted.
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
claude-status test # cycle working → waiting (buzzer!) → idle
|
|
58
|
+
claude-status doctor # full diagnostic: helper, device, hooks
|
|
59
|
+
claude-status install-hooks # re-run setup (also after moving npm prefix)
|
|
60
|
+
claude-status uninstall-hooks # remove our hooks, leave everything else
|
|
61
|
+
claude-status write <payload> # raw characteristic write (debug: g1, rb, z1, x…)
|
|
62
|
+
claude-status working|waiting|idle|off # what the hooks call
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`npm uninstall -g claude-status-box` removes the hooks and `~/.claude-status/`.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { STATES, DEVICE_NAME } = require('../lib/constants');
|
|
5
|
+
const helper = require('../lib/helper');
|
|
6
|
+
const debounce = require('../lib/debounce');
|
|
7
|
+
const hooks = require('../lib/hooks');
|
|
8
|
+
const keybindings = require('../lib/keybindings');
|
|
9
|
+
|
|
10
|
+
const USAGE = `usage:
|
|
11
|
+
claude-status <working|waiting|idle|off> set indicator state (used by Claude Code hooks)
|
|
12
|
+
claude-status test cycle states on the device (LEDs + buzzer)
|
|
13
|
+
claude-status doctor diagnose device, helper, and hook setup
|
|
14
|
+
claude-status install-hooks compile helper + (re)install Claude Code hooks
|
|
15
|
+
claude-status uninstall-hooks remove our hooks from ~/.claude/settings.json
|
|
16
|
+
claude-status write <payload> raw characteristic write (firmware debugging)`;
|
|
17
|
+
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Hook path: must be fast and must never fail the hook. Repeated same-state
|
|
23
|
+
// calls short-circuit before any BLE work.
|
|
24
|
+
function setState(state) {
|
|
25
|
+
if (debounce.shouldSkip(state)) return;
|
|
26
|
+
debounce.record(state);
|
|
27
|
+
const res = helper.runHelper([state]);
|
|
28
|
+
if (res.code !== 0) {
|
|
29
|
+
debounce.clear();
|
|
30
|
+
helper.logError(`set ${state} failed (code ${res.code}): ${res.stderr.trim()}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function installHooks() {
|
|
35
|
+
const build = helper.ensureBuilt();
|
|
36
|
+
if (build.ok) {
|
|
37
|
+
console.log(build.skipped ? 'helper: up to date' : 'helper: compiled');
|
|
38
|
+
} else {
|
|
39
|
+
console.warn(`helper: NOT built — ${build.error}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const report = hooks.installHooks();
|
|
43
|
+
console.log(`hooks: installed ${report.added} (replaced ${report.removed}) → ${report.binPath} <state>`);
|
|
44
|
+
if (report.backupPath) console.log(`backup: ${report.backupPath}`);
|
|
45
|
+
|
|
46
|
+
const kb = keybindings.installBinding();
|
|
47
|
+
if (kb.status === 'conflict') {
|
|
48
|
+
console.warn(`keybinding: NOT set — space is already bound to "${kb.existing}" in the Select context`);
|
|
49
|
+
} else {
|
|
50
|
+
console.log(`keybinding: space → select:accept (${kb.status}) — the box's button acts as Space`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (build.ok) {
|
|
54
|
+
const ping = helper.runHelper(['ping'], { timeoutMs: 10_000 });
|
|
55
|
+
if (ping.code === 0) {
|
|
56
|
+
console.log(`device: ${DEVICE_NAME} connected ✓`);
|
|
57
|
+
} else {
|
|
58
|
+
console.warn(`device: not reachable yet — ${ping.stderr.trim() || 'run `claude-status doctor`'}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function test() {
|
|
64
|
+
const build = helper.ensureBuilt();
|
|
65
|
+
if (!build.ok) {
|
|
66
|
+
console.error(build.error);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.log('cycling states — watch the box:');
|
|
70
|
+
const steps = [
|
|
71
|
+
['working', 'green blinking'],
|
|
72
|
+
['waiting', 'red solid + beeps 0.5s on/0.5s off (auto-stops after 20s)'],
|
|
73
|
+
['idle', 'blue solid'],
|
|
74
|
+
];
|
|
75
|
+
for (const [state, expect] of steps) {
|
|
76
|
+
const res = helper.runHelper([state], { timeoutMs: 10_000 });
|
|
77
|
+
console.log(` ${state.padEnd(7)} → ${res.code === 0 ? `ok (${expect})` : `FAILED: ${res.stderr.trim()}`}`);
|
|
78
|
+
if (res.code !== 0) process.exit(1);
|
|
79
|
+
await sleep(3000);
|
|
80
|
+
}
|
|
81
|
+
debounce.clear();
|
|
82
|
+
console.log('done — box should be blue (idle).');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function doctor() {
|
|
86
|
+
const { runDoctor } = require('../lib/doctor');
|
|
87
|
+
const checks = runDoctor();
|
|
88
|
+
let failed = 0;
|
|
89
|
+
for (const c of checks) {
|
|
90
|
+
if (!c.ok) failed++;
|
|
91
|
+
console.log(`${c.ok ? '✓' : '✗'} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
92
|
+
}
|
|
93
|
+
process.exit(failed ? 1 : 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
98
|
+
|
|
99
|
+
if (STATES.includes(cmd)) {
|
|
100
|
+
setState(cmd);
|
|
101
|
+
return; // always exit 0 — hooks must never see a failure
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
switch (cmd) {
|
|
105
|
+
case 'test':
|
|
106
|
+
await test();
|
|
107
|
+
break;
|
|
108
|
+
case 'doctor':
|
|
109
|
+
doctor();
|
|
110
|
+
break;
|
|
111
|
+
case 'install-hooks':
|
|
112
|
+
installHooks();
|
|
113
|
+
break;
|
|
114
|
+
case 'uninstall-hooks': {
|
|
115
|
+
const report = hooks.uninstallHooks();
|
|
116
|
+
console.log(`hooks: removed ${report.removed}`);
|
|
117
|
+
if (report.backupPath) console.log(`backup: ${report.backupPath}`);
|
|
118
|
+
console.log(`keybinding: ${keybindings.uninstallBinding().status}`);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'write': {
|
|
122
|
+
if (rest.length !== 1) {
|
|
123
|
+
console.error(USAGE);
|
|
124
|
+
process.exit(64);
|
|
125
|
+
}
|
|
126
|
+
const res = helper.runHelper(['write', rest[0]], { timeoutMs: 10_000 });
|
|
127
|
+
if (res.code !== 0) {
|
|
128
|
+
console.error(res.stderr.trim() || `helper exited ${res.code}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
console.log('ok');
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
default:
|
|
135
|
+
console.error(USAGE);
|
|
136
|
+
process.exit(64);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main().catch((err) => {
|
|
141
|
+
console.error(err.message);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const DEVICE_NAME = 'claude-status';
|
|
7
|
+
const STATES = ['working', 'waiting', 'idle', 'off'];
|
|
8
|
+
|
|
9
|
+
// Claude Code hook event → indicator state
|
|
10
|
+
const HOOK_EVENTS = {
|
|
11
|
+
UserPromptSubmit: 'working',
|
|
12
|
+
PostToolUse: 'working',
|
|
13
|
+
ElicitationResult: 'working',
|
|
14
|
+
PermissionRequest: 'waiting',
|
|
15
|
+
Elicitation: 'waiting',
|
|
16
|
+
Notification: 'waiting',
|
|
17
|
+
Stop: 'idle',
|
|
18
|
+
SessionStart: 'idle',
|
|
19
|
+
SessionEnd: 'idle',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Identifies hooks written by this package (bare or absolute-path command),
|
|
23
|
+
// regardless of which npm prefix installed them.
|
|
24
|
+
const OUR_HOOK_RE = /(^|\/)claude-status (working|waiting|idle|off)\b/;
|
|
25
|
+
|
|
26
|
+
const HOME_DIR = path.join(os.homedir(), '.claude-status');
|
|
27
|
+
const HELPER_BIN = path.join(HOME_DIR, 'bin', 'claude-status-helper');
|
|
28
|
+
const HELPER_HASH_FILE = path.join(HOME_DIR, 'helper.sha256');
|
|
29
|
+
const ERROR_LOG = path.join(HOME_DIR, 'last-error.log');
|
|
30
|
+
|
|
31
|
+
const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
32
|
+
const BACKUP_PREFIX = 'settings.json.claude-status.bak.';
|
|
33
|
+
const BACKUPS_TO_KEEP = 3;
|
|
34
|
+
|
|
35
|
+
// The box's hardware button sends a real Space keypress; this binding makes
|
|
36
|
+
// Space accept the highlighted option in Claude Code's select prompts.
|
|
37
|
+
const KEYBINDINGS_PATH = path.join(os.homedir(), '.claude', 'keybindings.json');
|
|
38
|
+
const KEYBINDING_CONTEXT = 'Select';
|
|
39
|
+
const KEYBINDING_KEY = 'space';
|
|
40
|
+
const KEYBINDING_ACTION = 'select:accept';
|
|
41
|
+
|
|
42
|
+
const STATE_FILE = path.join(os.tmpdir(), 'claude-status.state');
|
|
43
|
+
const STATE_TTL_MS = 20_000;
|
|
44
|
+
|
|
45
|
+
const SWIFT_SRC = path.join(__dirname, '..', 'src', 'claude-status-helper.swift');
|
|
46
|
+
const INFO_PLIST = path.join(__dirname, '..', 'src', 'helper-Info.plist');
|
|
47
|
+
|
|
48
|
+
const HELPER_TIMEOUT_MS = 3000; // helper's own wall clock
|
|
49
|
+
const NODE_KILL_MS = 4000; // backstop: kill the helper if it somehow hangs
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
DEVICE_NAME,
|
|
53
|
+
STATES,
|
|
54
|
+
HOOK_EVENTS,
|
|
55
|
+
OUR_HOOK_RE,
|
|
56
|
+
HOME_DIR,
|
|
57
|
+
HELPER_BIN,
|
|
58
|
+
HELPER_HASH_FILE,
|
|
59
|
+
ERROR_LOG,
|
|
60
|
+
SETTINGS_PATH,
|
|
61
|
+
BACKUP_PREFIX,
|
|
62
|
+
BACKUPS_TO_KEEP,
|
|
63
|
+
KEYBINDINGS_PATH,
|
|
64
|
+
KEYBINDING_CONTEXT,
|
|
65
|
+
KEYBINDING_KEY,
|
|
66
|
+
KEYBINDING_ACTION,
|
|
67
|
+
STATE_FILE,
|
|
68
|
+
STATE_TTL_MS,
|
|
69
|
+
SWIFT_SRC,
|
|
70
|
+
INFO_PLIST,
|
|
71
|
+
HELPER_TIMEOUT_MS,
|
|
72
|
+
NODE_KILL_MS,
|
|
73
|
+
};
|
package/lib/debounce.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
const { STATE_FILE, STATE_TTL_MS } = require('./constants');
|
|
6
|
+
|
|
7
|
+
// True when `state` was already sent within the TTL — the caller can skip
|
|
8
|
+
// BLE entirely. Hook storms (PostToolUse firing "working" constantly) hit
|
|
9
|
+
// this path; the TTL keeps a missed write self-healing under activity.
|
|
10
|
+
function shouldSkip(state) {
|
|
11
|
+
try {
|
|
12
|
+
const [last, at] = fs.readFileSync(STATE_FILE, 'utf8').trim().split(' ');
|
|
13
|
+
return last === state && Date.now() - Number(at) < STATE_TTL_MS;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Record optimistically before writing over BLE; atomic rename prevents
|
|
20
|
+
// torn reads from concurrent async hook invocations.
|
|
21
|
+
function record(state) {
|
|
22
|
+
const tmp = `${STATE_FILE}.tmp.${process.pid}`;
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(tmp, `${state} ${Date.now()}\n`);
|
|
25
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
26
|
+
} catch {
|
|
27
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Clear after a failed write so the next hook event retries instead of
|
|
32
|
+
// being suppressed for the TTL.
|
|
33
|
+
function clear() {
|
|
34
|
+
try { fs.unlinkSync(STATE_FILE); } catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function current() {
|
|
38
|
+
try {
|
|
39
|
+
const [state, at] = fs.readFileSync(STATE_FILE, 'utf8').trim().split(' ');
|
|
40
|
+
return { state, ageMs: Date.now() - Number(at) };
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { shouldSkip, record, clear, current };
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
DEVICE_NAME,
|
|
7
|
+
HOOK_EVENTS,
|
|
8
|
+
HELPER_BIN,
|
|
9
|
+
SETTINGS_PATH,
|
|
10
|
+
ERROR_LOG,
|
|
11
|
+
} = require('./constants');
|
|
12
|
+
const { findSwiftc, isBuilt, runHelper } = require('./helper');
|
|
13
|
+
const { readSettings, installedHookCount, globalBinPath } = require('./hooks');
|
|
14
|
+
const debounce = require('./debounce');
|
|
15
|
+
|
|
16
|
+
const PING_EXPLANATIONS = {
|
|
17
|
+
0: null,
|
|
18
|
+
2: 'Bluetooth off or permission denied — enable Bluetooth for your terminal app in System Settings > Privacy & Security > Bluetooth',
|
|
19
|
+
3: `"${DEVICE_NAME}" is not connected — power it on and pair it in System Settings > Bluetooth`,
|
|
20
|
+
4: 'ping timed out — Bluetooth may be initializing, or the TCC permission prompt is waiting for you',
|
|
21
|
+
missing: 'helper binary not compiled — run `claude-status install-hooks`',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function runDoctor() {
|
|
25
|
+
const checks = [];
|
|
26
|
+
const add = (ok, label, detail) => checks.push({ ok, label, detail });
|
|
27
|
+
|
|
28
|
+
add(process.platform === 'darwin', 'macOS', process.platform);
|
|
29
|
+
|
|
30
|
+
const swiftc = findSwiftc();
|
|
31
|
+
add(!!swiftc, 'swiftc (Xcode CLT)', swiftc || 'missing — run `xcode-select --install`');
|
|
32
|
+
|
|
33
|
+
const built = isBuilt();
|
|
34
|
+
add(built, 'helper compiled & current', built ? HELPER_BIN : 'missing/stale — run `claude-status install-hooks`');
|
|
35
|
+
|
|
36
|
+
if (built) {
|
|
37
|
+
const ping = runHelper(['ping'], { timeoutMs: 10_000 });
|
|
38
|
+
const explanation = PING_EXPLANATIONS[ping.code] ?? ping.stderr.trim();
|
|
39
|
+
add(ping.code === 0, `device "${DEVICE_NAME}" reachable`, ping.code === 0 ? ping.stdout.trim() : explanation);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let settingsOk = true;
|
|
43
|
+
try {
|
|
44
|
+
readSettings();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
settingsOk = false;
|
|
47
|
+
add(false, 'settings.json valid', err.message);
|
|
48
|
+
}
|
|
49
|
+
if (settingsOk) {
|
|
50
|
+
add(true, 'settings.json valid', SETTINGS_PATH);
|
|
51
|
+
const expected = Object.keys(HOOK_EVENTS).length;
|
|
52
|
+
const count = installedHookCount();
|
|
53
|
+
add(count === expected, `hooks installed (${count}/${expected})`,
|
|
54
|
+
count === expected ? 'all events wired' : 'run `claude-status install-hooks`');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { bindingInstalled } = require('./keybindings');
|
|
58
|
+
add(bindingInstalled(), 'keybinding space → select:accept',
|
|
59
|
+
bindingInstalled() ? 'box button accepts select prompts' : 'run `claude-status install-hooks`');
|
|
60
|
+
|
|
61
|
+
const binPath = globalBinPath();
|
|
62
|
+
add(fs.existsSync(binPath), 'CLI on expected path', binPath);
|
|
63
|
+
|
|
64
|
+
const state = debounce.current();
|
|
65
|
+
add(true, 'debounce state', state ? `${state.state} (${Math.round(state.ageMs / 1000)}s ago)` : 'none');
|
|
66
|
+
if (fs.existsSync(ERROR_LOG)) add(true, 'error log', ERROR_LOG);
|
|
67
|
+
|
|
68
|
+
return checks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { runDoctor };
|
package/lib/helper.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
HELPER_BIN,
|
|
10
|
+
HELPER_HASH_FILE,
|
|
11
|
+
ERROR_LOG,
|
|
12
|
+
SWIFT_SRC,
|
|
13
|
+
INFO_PLIST,
|
|
14
|
+
HELPER_TIMEOUT_MS,
|
|
15
|
+
NODE_KILL_MS,
|
|
16
|
+
} = require('./constants');
|
|
17
|
+
|
|
18
|
+
function findSwiftc() {
|
|
19
|
+
for (const [cmd, args] of [['xcrun', ['--find', 'swiftc']], ['which', ['swiftc']]]) {
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
22
|
+
if (out) return out;
|
|
23
|
+
} catch {
|
|
24
|
+
// try next
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sdkPath() {
|
|
31
|
+
try {
|
|
32
|
+
return execFileSync('xcrun', ['--sdk', 'macosx', '--show-sdk-path'], {
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
35
|
+
}).trim() || null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sourceHash() {
|
|
42
|
+
return crypto.createHash('sha256').update(fs.readFileSync(SWIFT_SRC)).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isBuilt() {
|
|
46
|
+
try {
|
|
47
|
+
fs.accessSync(HELPER_BIN, fs.constants.X_OK);
|
|
48
|
+
return fs.readFileSync(HELPER_HASH_FILE, 'utf8').trim() === sourceHash();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Compile the Swift helper if missing or stale. Never throws.
|
|
55
|
+
function ensureBuilt() {
|
|
56
|
+
if (isBuilt()) return { ok: true, skipped: true };
|
|
57
|
+
|
|
58
|
+
const swiftc = findSwiftc();
|
|
59
|
+
if (!swiftc) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: 'swiftc not found — install Xcode Command Line Tools (`xcode-select --install`), then run `claude-status install-hooks`',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fs.mkdirSync(path.dirname(HELPER_BIN), { recursive: true });
|
|
67
|
+
const args = [
|
|
68
|
+
'-O', SWIFT_SRC,
|
|
69
|
+
'-o', HELPER_BIN,
|
|
70
|
+
'-Xlinker', '-sectcreate', '-Xlinker', '__TEXT', '-Xlinker', '__info_plist', '-Xlinker', INFO_PLIST,
|
|
71
|
+
];
|
|
72
|
+
// The raw toolchain swiftc can't resolve the SDK on its own (fails with
|
|
73
|
+
// "unable to load standard library") — pin it explicitly.
|
|
74
|
+
const sdk = sdkPath();
|
|
75
|
+
if (sdk) args.unshift('-sdk', sdk);
|
|
76
|
+
try {
|
|
77
|
+
execFileSync(swiftc, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const detail = (err.stderr || err.message || 'unknown swiftc failure').toString();
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(path.dirname(ERROR_LOG), { recursive: true });
|
|
82
|
+
fs.writeFileSync(ERROR_LOG, `[${new Date().toISOString()}] swiftc failed:\n${detail}\n`);
|
|
83
|
+
} catch {}
|
|
84
|
+
return { ok: false, error: `helper compilation failed (details in ${ERROR_LOG})` };
|
|
85
|
+
}
|
|
86
|
+
fs.writeFileSync(HELPER_HASH_FILE, sourceHash() + '\n');
|
|
87
|
+
return { ok: true, skipped: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Run the helper. Returns { code, stdout, stderr } — code is null-ish
|
|
91
|
+
// mapped to 'missing' when the binary isn't built, 4 on kill-by-timeout.
|
|
92
|
+
function runHelper(args, { timeoutMs = HELPER_TIMEOUT_MS } = {}) {
|
|
93
|
+
if (!fs.existsSync(HELPER_BIN)) {
|
|
94
|
+
return { code: 'missing', stdout: '', stderr: 'helper not compiled — run `claude-status install-hooks`' };
|
|
95
|
+
}
|
|
96
|
+
const res = spawnSync(HELPER_BIN, [...args, '--timeout', String(timeoutMs)], {
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
timeout: timeoutMs + (NODE_KILL_MS - HELPER_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
if (res.error || res.signal) {
|
|
101
|
+
return { code: 4, stdout: res.stdout || '', stderr: (res.stderr || '') + (res.error ? res.error.message : `killed by ${res.signal}`) };
|
|
102
|
+
}
|
|
103
|
+
return { code: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function logError(line) {
|
|
107
|
+
try {
|
|
108
|
+
fs.mkdirSync(path.dirname(ERROR_LOG), { recursive: true });
|
|
109
|
+
let existing = '';
|
|
110
|
+
try { existing = fs.readFileSync(ERROR_LOG, 'utf8'); } catch {}
|
|
111
|
+
if (existing.length > 50_000) existing = existing.slice(-25_000);
|
|
112
|
+
fs.writeFileSync(ERROR_LOG, existing + `[${new Date().toISOString()}] ${line}\n`);
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { findSwiftc, ensureBuilt, isBuilt, runHelper, logError };
|
package/lib/hooks.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { execSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
HOOK_EVENTS,
|
|
9
|
+
OUR_HOOK_RE,
|
|
10
|
+
SETTINGS_PATH,
|
|
11
|
+
BACKUP_PREFIX,
|
|
12
|
+
BACKUPS_TO_KEEP,
|
|
13
|
+
} = require('./constants');
|
|
14
|
+
|
|
15
|
+
// Absolute path the hooks should invoke — hook shells are non-interactive
|
|
16
|
+
// and may not have the npm global bin on PATH.
|
|
17
|
+
function globalBinPath() {
|
|
18
|
+
const invoked = process.argv[1];
|
|
19
|
+
if (invoked && path.basename(invoked) === 'claude-status') return invoked;
|
|
20
|
+
try {
|
|
21
|
+
const prefix = execSync('npm prefix -g', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
22
|
+
if (prefix) return path.join(prefix, 'bin', 'claude-status');
|
|
23
|
+
} catch {}
|
|
24
|
+
return path.join(path.dirname(process.execPath), 'claude-status');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns { settings, raw } — raw is null when the file doesn't exist yet.
|
|
28
|
+
// Throws with a friendly message on unparseable JSON (never overwrite that).
|
|
29
|
+
function readSettings() {
|
|
30
|
+
let raw = null;
|
|
31
|
+
try {
|
|
32
|
+
raw = fs.readFileSync(SETTINGS_PATH, 'utf8');
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.code !== 'ENOENT') throw err;
|
|
35
|
+
return { settings: {}, raw: null };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return { settings: JSON.parse(raw), raw };
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(`${SETTINGS_PATH} is not valid JSON — left untouched; fix it and run \`claude-status install-hooks\``);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function backupSettings(raw) {
|
|
45
|
+
if (raw == null) return null;
|
|
46
|
+
const dir = path.dirname(SETTINGS_PATH);
|
|
47
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
|
48
|
+
const backupPath = path.join(dir, `${BACKUP_PREFIX}${stamp}`);
|
|
49
|
+
fs.writeFileSync(backupPath, raw);
|
|
50
|
+
const backups = fs.readdirSync(dir)
|
|
51
|
+
.filter((f) => f.startsWith(BACKUP_PREFIX))
|
|
52
|
+
.sort()
|
|
53
|
+
.reverse();
|
|
54
|
+
for (const stale of backups.slice(BACKUPS_TO_KEEP)) {
|
|
55
|
+
try { fs.unlinkSync(path.join(dir, stale)); } catch {}
|
|
56
|
+
}
|
|
57
|
+
return backupPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeSettings(settings) {
|
|
61
|
+
fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
62
|
+
const tmp = `${SETTINGS_PATH}.tmp.${process.pid}`;
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
64
|
+
fs.renameSync(tmp, SETTINGS_PATH);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Remove every hook entry whose command matches ours, pruning emptied
|
|
68
|
+
// matcher groups and event keys. Mutates `settings`; returns removal count.
|
|
69
|
+
function stripOurHooks(settings) {
|
|
70
|
+
let removed = 0;
|
|
71
|
+
const hooks = settings.hooks;
|
|
72
|
+
if (!hooks || typeof hooks !== 'object') return removed;
|
|
73
|
+
for (const event of Object.keys(hooks)) {
|
|
74
|
+
if (!Array.isArray(hooks[event])) continue;
|
|
75
|
+
for (const group of hooks[event]) {
|
|
76
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
77
|
+
const before = group.hooks.length;
|
|
78
|
+
group.hooks = group.hooks.filter(
|
|
79
|
+
(h) => !(h && typeof h.command === 'string' && OUR_HOOK_RE.test(h.command))
|
|
80
|
+
);
|
|
81
|
+
removed += before - group.hooks.length;
|
|
82
|
+
}
|
|
83
|
+
hooks[event] = hooks[event].filter((g) => !g || !Array.isArray(g.hooks) || g.hooks.length > 0);
|
|
84
|
+
if (hooks[event].length === 0) delete hooks[event];
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(hooks).length === 0) delete settings.hooks;
|
|
87
|
+
return removed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function addOurHooks(settings, binPath) {
|
|
91
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
92
|
+
for (const [event, state] of Object.entries(HOOK_EVENTS)) {
|
|
93
|
+
if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
|
|
94
|
+
settings.hooks[event].push({
|
|
95
|
+
hooks: [
|
|
96
|
+
{
|
|
97
|
+
type: 'command',
|
|
98
|
+
command: `${binPath} ${state}`,
|
|
99
|
+
timeout: 5,
|
|
100
|
+
async: true,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return Object.keys(HOOK_EVENTS).length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Idempotent install: strip ours everywhere, then append the 9 hooks.
|
|
109
|
+
function installHooks() {
|
|
110
|
+
const { settings, raw } = readSettings();
|
|
111
|
+
const backupPath = backupSettings(raw);
|
|
112
|
+
const removed = stripOurHooks(settings);
|
|
113
|
+
const binPath = globalBinPath();
|
|
114
|
+
const added = addOurHooks(settings, binPath);
|
|
115
|
+
writeSettings(settings);
|
|
116
|
+
return { added, removed, backupPath, binPath };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function uninstallHooks() {
|
|
120
|
+
const { settings, raw } = readSettings();
|
|
121
|
+
if (raw == null) return { removed: 0, backupPath: null };
|
|
122
|
+
const backupPath = backupSettings(raw);
|
|
123
|
+
const removed = stripOurHooks(settings);
|
|
124
|
+
writeSettings(settings);
|
|
125
|
+
return { removed, backupPath };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// How many of our hooks are installed per expected event (for doctor).
|
|
129
|
+
function installedHookCount() {
|
|
130
|
+
let settings;
|
|
131
|
+
try {
|
|
132
|
+
({ settings } = readSettings());
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
let count = 0;
|
|
137
|
+
for (const event of Object.keys(HOOK_EVENTS)) {
|
|
138
|
+
const groups = settings.hooks?.[event];
|
|
139
|
+
if (!Array.isArray(groups)) continue;
|
|
140
|
+
for (const group of groups) {
|
|
141
|
+
for (const h of group?.hooks ?? []) {
|
|
142
|
+
if (h && typeof h.command === 'string' && OUR_HOOK_RE.test(h.command)) count++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return count;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
globalBinPath,
|
|
151
|
+
readSettings,
|
|
152
|
+
installHooks,
|
|
153
|
+
uninstallHooks,
|
|
154
|
+
installedHookCount,
|
|
155
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
KEYBINDINGS_PATH,
|
|
8
|
+
KEYBINDING_CONTEXT,
|
|
9
|
+
KEYBINDING_KEY,
|
|
10
|
+
KEYBINDING_ACTION,
|
|
11
|
+
} = require('./constants');
|
|
12
|
+
|
|
13
|
+
function read() {
|
|
14
|
+
let raw = null;
|
|
15
|
+
try {
|
|
16
|
+
raw = fs.readFileSync(KEYBINDINGS_PATH, 'utf8');
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code !== 'ENOENT') throw err;
|
|
19
|
+
return {
|
|
20
|
+
obj: { $schema: 'https://www.schemastore.org/claude-code-keybindings.json', bindings: [] },
|
|
21
|
+
raw: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return { obj: JSON.parse(raw), raw };
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`${KEYBINDINGS_PATH} is not valid JSON — left untouched`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function write(obj) {
|
|
32
|
+
fs.mkdirSync(path.dirname(KEYBINDINGS_PATH), { recursive: true });
|
|
33
|
+
const tmp = `${KEYBINDINGS_PATH}.tmp.${process.pid}`;
|
|
34
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
|
|
35
|
+
fs.renameSync(tmp, KEYBINDINGS_PATH);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findContext(obj) {
|
|
39
|
+
if (!Array.isArray(obj.bindings)) obj.bindings = [];
|
|
40
|
+
return obj.bindings.find(
|
|
41
|
+
(b) => b && b.context === KEYBINDING_CONTEXT && b.bindings && typeof b.bindings === 'object'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Idempotent: adds space→select:accept in the Select context. Never clobbers
|
|
46
|
+
// a user's own space binding — reports a conflict instead.
|
|
47
|
+
function installBinding() {
|
|
48
|
+
const { obj } = read();
|
|
49
|
+
const entry = findContext(obj);
|
|
50
|
+
if (entry) {
|
|
51
|
+
if (entry.bindings[KEYBINDING_KEY] === KEYBINDING_ACTION) return { status: 'present' };
|
|
52
|
+
if (KEYBINDING_KEY in entry.bindings) {
|
|
53
|
+
return { status: 'conflict', existing: entry.bindings[KEYBINDING_KEY] };
|
|
54
|
+
}
|
|
55
|
+
entry.bindings[KEYBINDING_KEY] = KEYBINDING_ACTION;
|
|
56
|
+
} else {
|
|
57
|
+
obj.bindings.push({ context: KEYBINDING_CONTEXT, bindings: { [KEYBINDING_KEY]: KEYBINDING_ACTION } });
|
|
58
|
+
}
|
|
59
|
+
write(obj);
|
|
60
|
+
return { status: 'added' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Removes only our exact binding; drops the context entry if it becomes empty.
|
|
64
|
+
function uninstallBinding() {
|
|
65
|
+
let state;
|
|
66
|
+
try {
|
|
67
|
+
state = read();
|
|
68
|
+
} catch {
|
|
69
|
+
return { status: 'skipped' };
|
|
70
|
+
}
|
|
71
|
+
if (state.raw == null) return { status: 'absent' };
|
|
72
|
+
const entry = findContext(state.obj);
|
|
73
|
+
if (!entry || entry.bindings[KEYBINDING_KEY] !== KEYBINDING_ACTION) return { status: 'absent' };
|
|
74
|
+
delete entry.bindings[KEYBINDING_KEY];
|
|
75
|
+
if (Object.keys(entry.bindings).length === 0) {
|
|
76
|
+
state.obj.bindings = state.obj.bindings.filter((b) => b !== entry);
|
|
77
|
+
}
|
|
78
|
+
write(state.obj);
|
|
79
|
+
return { status: 'removed' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function bindingInstalled() {
|
|
83
|
+
try {
|
|
84
|
+
const entry = findContext(read().obj);
|
|
85
|
+
return !!entry && entry.bindings[KEYBINDING_KEY] === KEYBINDING_ACTION;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { installBinding, uninstallBinding, bindingInstalled };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-status-box",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Drive the claude-status ESP32-C3 BLE indicator box (LEDs + buzzer + button) from Claude Code hooks",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude",
|
|
8
|
+
"esp32",
|
|
9
|
+
"ble",
|
|
10
|
+
"status-light",
|
|
11
|
+
"hooks"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"bin": {
|
|
15
|
+
"claude-status": "bin/claude-status.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"lib",
|
|
20
|
+
"src",
|
|
21
|
+
"scripts"
|
|
22
|
+
],
|
|
23
|
+
"os": [
|
|
24
|
+
"darwin"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"postinstall": "node scripts/postinstall.js",
|
|
31
|
+
"preuninstall": "node scripts/preuninstall.js"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Postinstall must NEVER fail the npm install — every problem becomes a
|
|
4
|
+
// warning plus an actionable next step, and we always exit 0.
|
|
5
|
+
|
|
6
|
+
function main() {
|
|
7
|
+
if (process.platform !== 'darwin') {
|
|
8
|
+
console.log('claude-status: macOS only — skipping setup.');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (process.env.CI) {
|
|
12
|
+
console.log('claude-status: CI detected — skipping setup.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if ((process.getuid && process.getuid() === 0) || process.env.SUDO_USER) {
|
|
16
|
+
console.log('claude-status: running as root — skipping setup so nothing lands in root\'s home.');
|
|
17
|
+
console.log(' Run `claude-status install-hooks` as your normal user to finish.');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const helper = require('../lib/helper');
|
|
22
|
+
const hooks = require('../lib/hooks');
|
|
23
|
+
const { DEVICE_NAME } = require('../lib/constants');
|
|
24
|
+
|
|
25
|
+
const summary = [];
|
|
26
|
+
|
|
27
|
+
const build = helper.ensureBuilt();
|
|
28
|
+
summary.push(build.ok ? '✓ BLE helper compiled' : `✗ BLE helper: ${build.error}`);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const report = hooks.installHooks();
|
|
32
|
+
summary.push(`✓ Claude Code hooks installed (${report.added} events → ${report.binPath} <state>)`);
|
|
33
|
+
if (report.backupPath) summary.push(` settings backup: ${report.backupPath}`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
summary.push(`✗ hooks: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const keybindings = require('../lib/keybindings');
|
|
40
|
+
const kb = keybindings.installBinding();
|
|
41
|
+
summary.push(kb.status === 'conflict'
|
|
42
|
+
? `△ keybinding: space already bound to "${kb.existing}" in Select context — left as is`
|
|
43
|
+
: `✓ keybinding: space → select:accept (box button acts as Space)`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
summary.push(`✗ keybinding: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (build.ok) {
|
|
49
|
+
const ping = helper.runHelper(['ping'], { timeoutMs: 10_000 });
|
|
50
|
+
if (ping.code === 0) {
|
|
51
|
+
summary.push(`✓ ${DEVICE_NAME} is connected`);
|
|
52
|
+
} else {
|
|
53
|
+
summary.push(`△ ${DEVICE_NAME} not reachable: ${ping.stderr.trim() || 'unknown'}`);
|
|
54
|
+
summary.push(' (power the box on and pair it in System Settings > Bluetooth, then `claude-status doctor`)');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('\nclaude-status setup:');
|
|
59
|
+
for (const line of summary) console.log(` ${line}`);
|
|
60
|
+
console.log(' run `claude-status doctor` any time to re-check.\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
main();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.warn(`claude-status postinstall hit an unexpected error: ${err.message}`);
|
|
67
|
+
console.warn(' run `claude-status install-hooks` to retry.');
|
|
68
|
+
}
|
|
69
|
+
process.exitCode = 0;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Cleanup on uninstall — must never block npm from removing the package.
|
|
4
|
+
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform === 'darwin' && !(process.getuid && process.getuid() === 0)) {
|
|
9
|
+
const hooks = require('../lib/hooks');
|
|
10
|
+
const { HOME_DIR, STATE_FILE } = require('../lib/constants');
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const report = hooks.uninstallHooks();
|
|
14
|
+
console.log(`claude-status: removed ${report.removed} hooks from settings.json`);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.warn(`claude-status: could not remove hooks — ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
require('../lib/keybindings').uninstallBinding();
|
|
20
|
+
} catch {}
|
|
21
|
+
try { fs.rmSync(HOME_DIR, { recursive: true, force: true }); } catch {}
|
|
22
|
+
try { fs.unlinkSync(STATE_FILE); } catch {}
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.warn(`claude-status preuninstall: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
process.exitCode = 0;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// claude-status-helper — write a short status string to the claude-status
|
|
2
|
+
// ESP32 over BLE, using the connection macOS already holds to it as a HID
|
|
3
|
+
// keyboard (no scanning: retrieveConnectedPeripherals).
|
|
4
|
+
//
|
|
5
|
+
// usage: claude-status-helper <state> | write <payload> | ping
|
|
6
|
+
// [--name <peripheral-name>] [--timeout <ms>]
|
|
7
|
+
//
|
|
8
|
+
// exit codes: 0 ok (ping: device present) · 2 bluetooth off/denied/unsupported
|
|
9
|
+
// 3 device not connected · 4 timeout · 5 service/char missing
|
|
10
|
+
// 6 write failed · 64 usage
|
|
11
|
+
|
|
12
|
+
import CoreBluetooth
|
|
13
|
+
import Foundation
|
|
14
|
+
|
|
15
|
+
// Note: macOS hides the HID service (1812) from third-party CoreBluetooth
|
|
16
|
+
// apps, so we look the connected device up by the battery / device-info
|
|
17
|
+
// services the firmware also exposes.
|
|
18
|
+
let RETRIEVAL_SERVICES = [CBUUID(string: "180F"), CBUUID(string: "180A")]
|
|
19
|
+
let STATUS_SERVICE = CBUUID(string: "4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
|
20
|
+
let STATUS_CHARACTERISTIC = CBUUID(string: "BEB5483E-36E1-4688-B7F5-EA07361B26A8")
|
|
21
|
+
|
|
22
|
+
func die(_ code: Int32, _ message: String) -> Never {
|
|
23
|
+
FileHandle.standardError.write((message + "\n").data(using: .utf8)!)
|
|
24
|
+
exit(code)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var deviceName = "claude-status"
|
|
28
|
+
var timeoutMs = 3000
|
|
29
|
+
var positional: [String] = []
|
|
30
|
+
var iter = CommandLine.arguments.dropFirst().makeIterator()
|
|
31
|
+
while let arg = iter.next() {
|
|
32
|
+
switch arg {
|
|
33
|
+
case "--name":
|
|
34
|
+
guard let v = iter.next() else { die(64, "--name requires a value") }
|
|
35
|
+
deviceName = v
|
|
36
|
+
case "--timeout":
|
|
37
|
+
guard let v = iter.next(), let n = Int(v), n > 0 else { die(64, "--timeout requires a positive integer (ms)") }
|
|
38
|
+
timeoutMs = n
|
|
39
|
+
default:
|
|
40
|
+
positional.append(arg)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var payload: Data? = nil
|
|
45
|
+
var pingOnly = false
|
|
46
|
+
switch positional.first {
|
|
47
|
+
case nil:
|
|
48
|
+
die(64, "usage: claude-status-helper <state>|write <payload>|ping [--name <n>] [--timeout <ms>]")
|
|
49
|
+
case "ping":
|
|
50
|
+
pingOnly = true
|
|
51
|
+
case "write":
|
|
52
|
+
guard positional.count == 2 else { die(64, "write requires exactly one payload argument") }
|
|
53
|
+
payload = positional[1].data(using: .utf8)
|
|
54
|
+
case let cmd?:
|
|
55
|
+
guard positional.count == 1, cmd.utf8.count <= 32 else { die(64, "state must be a single token of at most 32 bytes") }
|
|
56
|
+
payload = cmd.data(using: .utf8)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
final class Writer: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
|
60
|
+
var central: CBCentralManager!
|
|
61
|
+
var peripheral: CBPeripheral?
|
|
62
|
+
|
|
63
|
+
func start() {
|
|
64
|
+
central = CBCentralManager(delegate: self, queue: .main)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func centralManagerDidUpdateState(_ c: CBCentralManager) {
|
|
68
|
+
switch c.state {
|
|
69
|
+
case .poweredOn:
|
|
70
|
+
let match = c.retrieveConnectedPeripherals(withServices: RETRIEVAL_SERVICES)
|
|
71
|
+
.first { $0.name == deviceName }
|
|
72
|
+
guard let p = match else {
|
|
73
|
+
die(3, "no connected peripheral named \"\(deviceName)\" — pair it in System Settings > Bluetooth")
|
|
74
|
+
}
|
|
75
|
+
if pingOnly {
|
|
76
|
+
print("found \(deviceName)")
|
|
77
|
+
exit(0)
|
|
78
|
+
}
|
|
79
|
+
peripheral = p
|
|
80
|
+
p.delegate = self
|
|
81
|
+
c.connect(p)
|
|
82
|
+
case .poweredOff:
|
|
83
|
+
die(2, "Bluetooth is powered off")
|
|
84
|
+
case .unauthorized:
|
|
85
|
+
die(2, "Bluetooth permission denied — allow your terminal app in System Settings > Privacy & Security > Bluetooth")
|
|
86
|
+
case .unsupported:
|
|
87
|
+
die(2, "Bluetooth LE is not supported on this machine")
|
|
88
|
+
default:
|
|
89
|
+
break // .resetting/.unknown — wait; the timeout is the backstop
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func centralManager(_ c: CBCentralManager, didConnect p: CBPeripheral) {
|
|
94
|
+
p.discoverServices([STATUS_SERVICE])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func centralManager(_ c: CBCentralManager, didFailToConnect p: CBPeripheral, error: Error?) {
|
|
98
|
+
die(4, "connect failed: \(error?.localizedDescription ?? "unknown error")")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
|
|
102
|
+
guard error == nil, let service = p.services?.first(where: { $0.uuid == STATUS_SERVICE }) else {
|
|
103
|
+
die(5, "status service not found on \(deviceName) — wrong firmware?")
|
|
104
|
+
}
|
|
105
|
+
p.discoverCharacteristics([STATUS_CHARACTERISTIC], for: service)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
109
|
+
guard error == nil, let ch = service.characteristics?.first(where: { $0.uuid == STATUS_CHARACTERISTIC }) else {
|
|
110
|
+
die(5, "status characteristic not found on \(deviceName) — wrong firmware?")
|
|
111
|
+
}
|
|
112
|
+
p.writeValue(payload!, for: ch, type: .withResponse)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func peripheral(_ p: CBPeripheral, didWriteValueFor ch: CBCharacteristic, error: Error?) {
|
|
116
|
+
if let e = error {
|
|
117
|
+
die(6, "write failed: \(e.localizedDescription)")
|
|
118
|
+
}
|
|
119
|
+
central.cancelPeripheralConnection(p)
|
|
120
|
+
exit(0)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let writer = Writer()
|
|
125
|
+
writer.start()
|
|
126
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) {
|
|
127
|
+
die(4, "timed out after \(timeoutMs) ms")
|
|
128
|
+
}
|
|
129
|
+
dispatchMain()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>com.claude-status.helper</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>claude-status-helper</string>
|
|
9
|
+
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
10
|
+
<string>Sends Claude Code status to the claude-status LED device.</string>
|
|
11
|
+
</dict>
|
|
12
|
+
</plist>
|