@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 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 };