@cobanalitalha/claude-spinner 2.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 +77 -0
- package/bin/claude-spinner.js +73 -0
- package/package.json +29 -0
- package/src/animations.js +135 -0
- package/src/daemon.js +175 -0
- package/src/install.js +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# claude-spinner
|
|
2
|
+
|
|
3
|
+
Animated spinners for your Claude Code session.
|
|
4
|
+
|
|
5
|
+
`claude-spinner` hooks into [Claude Code](https://claude.ai/code) and runs a small animation in the top-right corner of your terminal while Claude is working. A random animation is chosen each session.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Animations
|
|
10
|
+
|
|
11
|
+
| Name | Description | Speed |
|
|
12
|
+
|------|-------------|-------|
|
|
13
|
+
| `coffee` | Steam drifting above a coffee cup | 350 ms |
|
|
14
|
+
| `rocket` | Exhaust flame pulsing beneath a rocket | 180 ms |
|
|
15
|
+
| `clock` | Arrow hand spinning around a dial | 130 ms |
|
|
16
|
+
| `ball` | Ball bouncing inside a box | 110 ms |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
- A background daemon draws the animation directly to your terminal using `/dev/tty`, so it never interferes with Claude's output.
|
|
23
|
+
- The spinner appears the moment Claude starts using a tool and disappears cleanly when Claude finishes responding.
|
|
24
|
+
- If the hook's stdin is piped (non-TTY), the daemon resolves the real terminal device by walking up the process tree.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g claude-spinner
|
|
32
|
+
claude-spinner install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then restart Claude Code. That's it.
|
|
36
|
+
|
|
37
|
+
`claude-spinner install` adds two hooks to `~/.claude/settings.json`:
|
|
38
|
+
|
|
39
|
+
| Hook | Command | Effect |
|
|
40
|
+
|------|---------|--------|
|
|
41
|
+
| `PreToolUse` | `claude-spinner start` | Starts the daemon when Claude begins working |
|
|
42
|
+
| `Stop` | `claude-spinner stop` | Stops the daemon when Claude finishes |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Requirements
|
|
47
|
+
|
|
48
|
+
- Node.js 18 or later
|
|
49
|
+
- macOS or Linux (requires `/dev/tty` access)
|
|
50
|
+
- [Claude Code](https://claude.ai/code) CLI
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
claude-spinner install # Add hooks to ~/.claude/settings.json
|
|
58
|
+
claude-spinner uninstall # Remove hooks from ~/.claude/settings.json
|
|
59
|
+
claude-spinner start # Start the spinner daemon manually
|
|
60
|
+
claude-spinner stop # Stop the spinner daemon
|
|
61
|
+
claude-spinner status # Show whether the daemon is running
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Uninstall
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
claude-spinner uninstall
|
|
70
|
+
npm uninstall -g claude-spinner
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const [,, command] = process.argv;
|
|
5
|
+
|
|
6
|
+
switch (command) {
|
|
7
|
+
case 'start': {
|
|
8
|
+
require('../src/daemon').start();
|
|
9
|
+
break;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
case 'stop': {
|
|
13
|
+
require('../src/daemon').stop();
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
case 'install': {
|
|
18
|
+
require('../src/install').install();
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
case 'uninstall': {
|
|
23
|
+
require('../src/install').uninstall();
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case 'status': {
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const pidFile = path.join(os.tmpdir(), 'claude-spinner.pid');
|
|
32
|
+
try {
|
|
33
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
console.log(`Running (pid ${pid})`);
|
|
36
|
+
} catch {
|
|
37
|
+
console.log('Not running');
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
default: {
|
|
43
|
+
console.log(`
|
|
44
|
+
┌──────────────────────────────────────────────────┐
|
|
45
|
+
│ claude-spinner v2.0.0 │
|
|
46
|
+
│ Animated spinners for your Claude Code session │
|
|
47
|
+
└──────────────────────────────────────────────────┘
|
|
48
|
+
|
|
49
|
+
COMMANDS
|
|
50
|
+
install Add hooks to ~/.claude/settings.json
|
|
51
|
+
uninstall Remove hooks from ~/.claude/settings.json
|
|
52
|
+
start Start the spinner animation daemon
|
|
53
|
+
stop Stop the spinner animation daemon
|
|
54
|
+
status Show whether the daemon is running
|
|
55
|
+
|
|
56
|
+
QUICK START
|
|
57
|
+
npm install -g claude-spinner
|
|
58
|
+
claude-spinner install
|
|
59
|
+
# Then open Claude Code – a spinner appears automatically!
|
|
60
|
+
|
|
61
|
+
HOW IT WORKS
|
|
62
|
+
• Draws an animated spinner in the top-right corner of
|
|
63
|
+
your terminal using /dev/tty (never interferes with output).
|
|
64
|
+
• A random animation is chosen each session:
|
|
65
|
+
coffee – steam rising from a cup
|
|
66
|
+
rocket – exhaust flame pulsing beneath a rocket
|
|
67
|
+
clock – arrow hand spinning around a dial
|
|
68
|
+
ball – ball bouncing inside a box
|
|
69
|
+
• Starts automatically when Claude uses a tool and stops
|
|
70
|
+
when Claude finishes responding.
|
|
71
|
+
`);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cobanalitalha/claude-spinner",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Animated spinners for your Claude Code session",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-spinner": "bin/claude-spinner.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"terminal",
|
|
19
|
+
"animation",
|
|
20
|
+
"spinner",
|
|
21
|
+
"hooks"
|
|
22
|
+
],
|
|
23
|
+
"author": "Ali Talha ÇOBAN",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/carpodok/claude-spinner.git"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ANSI colour codes
|
|
4
|
+
const Y = '\x1b[33m'; // yellow
|
|
5
|
+
const C = '\x1b[36m'; // cyan
|
|
6
|
+
const G = '\x1b[32m'; // green
|
|
7
|
+
const R = '\x1b[31m'; // red
|
|
8
|
+
const W = '\x1b[37m'; // white
|
|
9
|
+
const DIM = '\x1b[2m';
|
|
10
|
+
const RS = '\x1b[0m'; // reset
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// 1. COFFEE CUP – steam drifts above the cup
|
|
14
|
+
// Width: 13 Height: 7 Interval: 350 ms
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
16
|
+
const STEAM = [
|
|
17
|
+
' ~ ~ ~ ', // frame 0 (all 13 visible chars)
|
|
18
|
+
' ~ ~ ~ ', // frame 1
|
|
19
|
+
'~ ~ ~ ', // frame 2
|
|
20
|
+
' ~ ~ ~ ', // frame 3
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const CUP_BODY = [
|
|
24
|
+
`${Y} .-------. ${RS}`,
|
|
25
|
+
`${Y} | | ${RS}`,
|
|
26
|
+
`${Y} | |)${RS}`,
|
|
27
|
+
`${Y} '-------' ${RS}`,
|
|
28
|
+
` `,
|
|
29
|
+
` `,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const coffee = {
|
|
33
|
+
name: 'coffee',
|
|
34
|
+
width: 13,
|
|
35
|
+
intervalMs: 350,
|
|
36
|
+
frames: STEAM.map(s => [`${DIM}${s}${RS}`, ...CUP_BODY]),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// 2. ROCKET – exhaust flame pulses beneath the rocket
|
|
41
|
+
// Width: 10 Height: 7 Interval: 180 ms
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
43
|
+
const EXHAUST = [
|
|
44
|
+
` * * `, // frame 0 (10 visible chars)
|
|
45
|
+
` ** ** `, // frame 1
|
|
46
|
+
` **** *** `, // frame 2
|
|
47
|
+
` ** ** `, // frame 3
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const ROCKET_BODY = [
|
|
51
|
+
`${W} /\\ ${RS}`,
|
|
52
|
+
`${W} / \\ ${RS}`,
|
|
53
|
+
`${W} | ${C}**${W} | ${RS}`,
|
|
54
|
+
`${W} | | ${RS}`,
|
|
55
|
+
`${W} '----' ${RS}`,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const rocket = {
|
|
59
|
+
name: 'rocket',
|
|
60
|
+
width: 10,
|
|
61
|
+
intervalMs: 180,
|
|
62
|
+
frames: EXHAUST.map(e => [
|
|
63
|
+
...ROCKET_BODY,
|
|
64
|
+
`${R}${e}${RS}`,
|
|
65
|
+
` `,
|
|
66
|
+
]),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// 3. CLOCK – arrow hand rotates around the dial
|
|
71
|
+
// Width: 11 Height: 7 Interval: 130 ms
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
73
|
+
const CLOCK_ARROWS = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖'];
|
|
74
|
+
|
|
75
|
+
const clock = {
|
|
76
|
+
name: 'clock',
|
|
77
|
+
width: 11,
|
|
78
|
+
intervalMs: 130,
|
|
79
|
+
frames: CLOCK_ARROWS.map(arrow => [
|
|
80
|
+
`${C} .---. ${RS}`,
|
|
81
|
+
`${C} / \\ ${RS}`,
|
|
82
|
+
`${C} | ${Y}${arrow}${C} | ${RS}`,
|
|
83
|
+
`${C} | | ${RS}`,
|
|
84
|
+
`${C} \\ / ${RS}`,
|
|
85
|
+
`${C} '---' ${RS}`,
|
|
86
|
+
` `,
|
|
87
|
+
]),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// 4. BOUNCING BALL – ball ricochets inside a box
|
|
92
|
+
// Width: 14 Height: 7 Interval: 110 ms
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
94
|
+
function makeBallFrames() {
|
|
95
|
+
const X_MAX = 11; // x: 0 .. 11 (12 interior columns)
|
|
96
|
+
const Y_MAX = 2; // y: 0 .. 2 (3 interior rows)
|
|
97
|
+
let x = 0, y = 0, vx = 1, vy = 1;
|
|
98
|
+
const frames = [];
|
|
99
|
+
|
|
100
|
+
const top = `${DIM}.------------.${RS}`;
|
|
101
|
+
const bot = `${DIM}'------------'${RS}`;
|
|
102
|
+
const pad = ` `; // 14 spaces
|
|
103
|
+
|
|
104
|
+
for (let f = 0; f < 28; f++) {
|
|
105
|
+
const rows = [];
|
|
106
|
+
for (let row = 0; row <= Y_MAX; row++) {
|
|
107
|
+
const interior = row === y
|
|
108
|
+
? ' '.repeat(x) + `${G}●${RS}` + ' '.repeat(X_MAX - x)
|
|
109
|
+
: ' '.repeat(X_MAX + 1);
|
|
110
|
+
rows.push(`${DIM}|${RS}${interior}${DIM}|${RS}`);
|
|
111
|
+
}
|
|
112
|
+
frames.push([top, ...rows, bot, pad, pad]);
|
|
113
|
+
|
|
114
|
+
// Move and bounce
|
|
115
|
+
x += vx; y += vy;
|
|
116
|
+
if (x <= 0) { x = 0; vx = 1; }
|
|
117
|
+
if (x >= X_MAX) { x = X_MAX; vx = -1; }
|
|
118
|
+
if (y <= 0) { y = 0; vy = 1; }
|
|
119
|
+
if (y >= Y_MAX) { y = Y_MAX; vy = -1; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return frames;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const ball = {
|
|
126
|
+
name: 'ball',
|
|
127
|
+
width: 14,
|
|
128
|
+
intervalMs: 110,
|
|
129
|
+
frames: makeBallFrames(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
133
|
+
// Export – add new animations to this object; daemon picks one at random.
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
135
|
+
module.exports = { coffee, rocket, clock, ball };
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* daemon.js
|
|
5
|
+
*
|
|
6
|
+
* Two responsibilities:
|
|
7
|
+
* 1. Exported API: start() / stop() – called from the CLI to manage the daemon process.
|
|
8
|
+
* 2. Daemon main loop – runs when this file is the entry point with the --daemon flag.
|
|
9
|
+
*
|
|
10
|
+
* KEY DESIGN NOTE
|
|
11
|
+
* ───────────────
|
|
12
|
+
* The daemon is spawned with `detached: true`, which means it loses its controlling
|
|
13
|
+
* terminal and cannot open /dev/tty. We work around this by resolving the real TTY
|
|
14
|
+
* device path (e.g. /dev/ttys003 on macOS, /dev/pts/2 on Linux) *before* detaching
|
|
15
|
+
* and passing it via the SPINNER_TTY environment variable. A detached process can still
|
|
16
|
+
* open a specific device node by path as long as it has permission.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const { spawn, execSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const PID_FILE = path.join(os.tmpdir(), 'claude-spinner.pid');
|
|
25
|
+
const DAEMON_ARG = '--daemon';
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────
|
|
28
|
+
// Public API
|
|
29
|
+
// ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function isAlive(pid) {
|
|
32
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPid() {
|
|
36
|
+
try { return parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Find the real TTY device path for the current process. Returns null if not a TTY. */
|
|
40
|
+
function resolveTTY() {
|
|
41
|
+
// 1. Direct check – works when hook stdin is still connected to the terminal
|
|
42
|
+
try {
|
|
43
|
+
const out = execSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] }).trim();
|
|
44
|
+
if (out && out !== 'not a tty' && fs.existsSync(out)) return out;
|
|
45
|
+
} catch {}
|
|
46
|
+
|
|
47
|
+
// 2. Walk up the process tree looking for a parent that owns a TTY.
|
|
48
|
+
// Claude Code itself always runs in a real terminal, so one of the
|
|
49
|
+
// ancestor processes will have a TTY even if the hook's stdio is piped.
|
|
50
|
+
try {
|
|
51
|
+
let pid = process.ppid;
|
|
52
|
+
for (let depth = 0; depth < 5 && pid > 1; depth++) {
|
|
53
|
+
const ttyName = execSync(`ps -o tty= -p ${pid} 2>/dev/null`, { encoding: 'utf8' }).trim();
|
|
54
|
+
if (ttyName && ttyName !== '??' && ttyName !== '?') {
|
|
55
|
+
// macOS: "s003" → "/dev/ttys003" | Linux: "pts/2" → "/dev/pts/2"
|
|
56
|
+
const ttyPath = ttyName.startsWith('/') ? ttyName
|
|
57
|
+
: ttyName.startsWith('pts/') ? `/dev/${ttyName}`
|
|
58
|
+
: `/dev/tty${ttyName}`;
|
|
59
|
+
if (fs.existsSync(ttyPath)) return ttyPath;
|
|
60
|
+
}
|
|
61
|
+
// Move up one level
|
|
62
|
+
const ppid = execSync(`ps -o ppid= -p ${pid} 2>/dev/null`, { encoding: 'utf8' }).trim();
|
|
63
|
+
pid = parseInt(ppid, 10);
|
|
64
|
+
if (!pid || isNaN(pid)) break;
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
68
|
+
return null; // not a supported environment – fail silently
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function start() {
|
|
72
|
+
const pid = readPid();
|
|
73
|
+
if (pid && isAlive(pid)) return; // already running
|
|
74
|
+
|
|
75
|
+
const ttyPath = resolveTTY();
|
|
76
|
+
if (!ttyPath) return; // not a TTY environment – silently do nothing
|
|
77
|
+
|
|
78
|
+
const cols = (process.stdout.columns || 80).toString();
|
|
79
|
+
|
|
80
|
+
const child = spawn(process.execPath, [__filename, DAEMON_ARG], {
|
|
81
|
+
detached: true,
|
|
82
|
+
stdio: 'ignore',
|
|
83
|
+
env: { ...process.env, SPINNER_TTY: ttyPath, SPINNER_COLS: cols },
|
|
84
|
+
});
|
|
85
|
+
child.unref();
|
|
86
|
+
|
|
87
|
+
try { fs.writeFileSync(PID_FILE, String(child.pid)); } catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stop() {
|
|
91
|
+
const pid = readPid();
|
|
92
|
+
if (!pid) return;
|
|
93
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
94
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { start, stop };
|
|
98
|
+
|
|
99
|
+
// ─────────────────────────────────────────────────────────────
|
|
100
|
+
// Daemon entry point
|
|
101
|
+
// ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
if (require.main === module && process.argv.includes(DAEMON_ARG)) {
|
|
104
|
+
runDaemon().catch(() => process.exit(1));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─────────────────────────────────────────────────────────────
|
|
108
|
+
// Daemon implementation
|
|
109
|
+
// ─────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
async function runDaemon() {
|
|
112
|
+
const animations = require('./animations');
|
|
113
|
+
const keys = Object.keys(animations);
|
|
114
|
+
const anim = animations[keys[Math.floor(Math.random() * keys.length)]];
|
|
115
|
+
|
|
116
|
+
const ttyPath = process.env.SPINNER_TTY;
|
|
117
|
+
if (!ttyPath) process.exit(1);
|
|
118
|
+
|
|
119
|
+
let ttyFd;
|
|
120
|
+
try {
|
|
121
|
+
ttyFd = fs.openSync(ttyPath, 'r+');
|
|
122
|
+
} catch {
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const cols = parseInt(process.env.SPINNER_COLS || '80', 10);
|
|
127
|
+
const col = Math.max(1, cols - anim.width + 1);
|
|
128
|
+
const HEIGHT = anim.frames[0].length;
|
|
129
|
+
|
|
130
|
+
function writeTTY(str) {
|
|
131
|
+
try { fs.writeSync(ttyFd, str); } catch {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Draw a frame at the fixed top-right position without disturbing Claude's cursor. */
|
|
135
|
+
function draw(frame) {
|
|
136
|
+
let buf = '\x1b7'; // DECSC: save cursor
|
|
137
|
+
for (let i = 0; i < frame.length; i++) {
|
|
138
|
+
buf += `\x1b[${i + 1};${col}H${frame[i]}`;
|
|
139
|
+
}
|
|
140
|
+
buf += '\x1b8'; // DECRC: restore cursor
|
|
141
|
+
writeTTY(buf);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Erase the spinner area on exit. */
|
|
145
|
+
function clear() {
|
|
146
|
+
const blank = ' '.repeat(anim.width + 2);
|
|
147
|
+
let buf = '\x1b7';
|
|
148
|
+
for (let i = 0; i < HEIGHT; i++) {
|
|
149
|
+
buf += `\x1b[${i + 1};${col}H${blank}`;
|
|
150
|
+
}
|
|
151
|
+
buf += '\x1b8';
|
|
152
|
+
writeTTY(buf);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sleep(ms) {
|
|
156
|
+
return new Promise(r => setTimeout(r, ms));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Graceful shutdown: clear the spinner then exit.
|
|
160
|
+
process.on('SIGTERM', () => {
|
|
161
|
+
clear();
|
|
162
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
163
|
+
process.exit(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Animation loop ──────────────────────────────────────────
|
|
167
|
+
let frameIdx = 0;
|
|
168
|
+
draw(anim.frames[0]);
|
|
169
|
+
|
|
170
|
+
while (true) {
|
|
171
|
+
await sleep(anim.intervalMs);
|
|
172
|
+
frameIdx = (frameIdx + 1) % anim.frames.length;
|
|
173
|
+
draw(anim.frames[frameIdx]);
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
8
|
+
|
|
9
|
+
// Hook entries we manage – identified by their command string
|
|
10
|
+
const HOOK_START = 'claude-spinner start';
|
|
11
|
+
const HOOK_STOP = 'claude-spinner stop';
|
|
12
|
+
|
|
13
|
+
// Legacy hook commands from the old "whip-claude" name – removed on install
|
|
14
|
+
const LEGACY_START = 'whip-claude start';
|
|
15
|
+
const LEGACY_STOP = 'whip-claude stop';
|
|
16
|
+
|
|
17
|
+
function readSettings() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(SETTINGS_PATH, 'utf8');
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeSettings(settings) {
|
|
27
|
+
const dir = path.dirname(SETTINGS_PATH);
|
|
28
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Return true if a hook command is already present in the list
|
|
33
|
+
function hasHook(hookList, command) {
|
|
34
|
+
if (!Array.isArray(hookList)) return false;
|
|
35
|
+
return hookList.some((entry) =>
|
|
36
|
+
Array.isArray(entry.hooks) &&
|
|
37
|
+
entry.hooks.some((h) => h.command === command)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Append a hook entry for the given command if not already present
|
|
42
|
+
function addHook(hookList, command) {
|
|
43
|
+
if (hasHook(hookList, command)) return hookList;
|
|
44
|
+
return [
|
|
45
|
+
...hookList,
|
|
46
|
+
{
|
|
47
|
+
matcher: '.*',
|
|
48
|
+
hooks: [{ type: 'command', command }],
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Remove all hook entries whose command matches
|
|
54
|
+
function removeHook(hookList, command) {
|
|
55
|
+
if (!Array.isArray(hookList)) return [];
|
|
56
|
+
return hookList.filter(
|
|
57
|
+
(entry) =>
|
|
58
|
+
!Array.isArray(entry.hooks) ||
|
|
59
|
+
!entry.hooks.some((h) => h.command === command)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function install() {
|
|
64
|
+
const settings = readSettings();
|
|
65
|
+
if (!settings.hooks) settings.hooks = {};
|
|
66
|
+
|
|
67
|
+
const h = settings.hooks;
|
|
68
|
+
|
|
69
|
+
// Remove legacy whip-claude hooks if present
|
|
70
|
+
h.PreToolUse = removeHook(h.PreToolUse || [], LEGACY_START);
|
|
71
|
+
h.Stop = removeHook(h.Stop || [], LEGACY_STOP);
|
|
72
|
+
|
|
73
|
+
h.PreToolUse = addHook(h.PreToolUse, HOOK_START);
|
|
74
|
+
h.Stop = addHook(h.Stop, HOOK_STOP);
|
|
75
|
+
|
|
76
|
+
writeSettings(settings);
|
|
77
|
+
|
|
78
|
+
console.log('✓ claude-spinner hooks installed in ~/.claude/settings.json');
|
|
79
|
+
console.log(' • PreToolUse → claude-spinner start');
|
|
80
|
+
console.log(' • Stop → claude-spinner stop');
|
|
81
|
+
console.log('\nRestart Claude Code for the hooks to take effect.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function uninstall() {
|
|
85
|
+
const settings = readSettings();
|
|
86
|
+
if (!settings.hooks) {
|
|
87
|
+
console.log('No hooks found – nothing to remove.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const h = settings.hooks;
|
|
92
|
+
|
|
93
|
+
h.PreToolUse = removeHook(h.PreToolUse, HOOK_START);
|
|
94
|
+
h.Stop = removeHook(h.Stop, HOOK_STOP);
|
|
95
|
+
|
|
96
|
+
// Also clean up legacy hooks if still present
|
|
97
|
+
h.PreToolUse = removeHook(h.PreToolUse, LEGACY_START);
|
|
98
|
+
h.Stop = removeHook(h.Stop, LEGACY_STOP);
|
|
99
|
+
|
|
100
|
+
// Clean up empty arrays
|
|
101
|
+
if (h.PreToolUse && h.PreToolUse.length === 0) delete h.PreToolUse;
|
|
102
|
+
if (h.Stop && h.Stop.length === 0) delete h.Stop;
|
|
103
|
+
if (Object.keys(h).length === 0) delete settings.hooks;
|
|
104
|
+
|
|
105
|
+
writeSettings(settings);
|
|
106
|
+
|
|
107
|
+
console.log('✓ claude-spinner hooks removed from ~/.claude/settings.json');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { install, uninstall };
|