claude-dock 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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/bin/cli.js +168 -0
  4. package/init.lua +603 -0
  5. package/package.json +33 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # Claude Dock
2
+
3
+ A lightweight, expandable terminal dock for macOS built with [Hammerspoon](https://www.hammerspoon.org/). Designed for managing multiple Claude Code terminal sessions.
4
+
5
+ ![Claude Dock](https://img.shields.io/badge/macOS-Hammerspoon-blue)
6
+
7
+ ## Features
8
+
9
+ - **Expandable dock** - Start with 3 slots, add more with "+" button or hotkey
10
+ - **Terminal management** - Each slot tracks a specific terminal window
11
+ - **Auto-launch Claude** - New terminals automatically run `claude` command
12
+ - **Custom naming** - Name your terminals for easy identification
13
+ - **Visual status** - See which terminals are active, minimized, or empty
14
+ - **Quick access** - Click to focus/unminimize terminals
15
+ - **Keyboard shortcuts** - Full hotkey support
16
+
17
+ ## Installation
18
+
19
+ ### Prerequisites
20
+
21
+ 1. Install [Hammerspoon](https://www.hammerspoon.org/):
22
+ ```bash
23
+ brew install --cask hammerspoon
24
+ ```
25
+
26
+ 2. Grant Accessibility permissions:
27
+ - System Settings > Privacy & Security > Accessibility
28
+ - Enable Hammerspoon
29
+
30
+ ### Setup
31
+
32
+ 1. Clone this repo (or download the ZIP from GitHub)
33
+
34
+ 2. Copy to Hammerspoon config:
35
+ ```bash
36
+ cp claude-dock/init.lua ~/.hammerspoon/init.lua
37
+ ```
38
+
39
+ 3. Launch Hammerspoon (or reload if already running)
40
+
41
+ ## Usage
42
+
43
+ ### Keyboard Shortcuts
44
+
45
+ | Shortcut | Action |
46
+ |----------|--------|
47
+ | `Cmd+Option+T` | Toggle dock visibility |
48
+ | `Cmd+Option+N` | Add new slot + launch terminal |
49
+ | `Cmd+Option+R` | Reload configuration |
50
+ | `Option+Click` | Rename a slot |
51
+
52
+ ### Slot States
53
+
54
+ | Color | Status |
55
+ |-------|--------|
56
+ | Gray | Empty - click to open new terminal |
57
+ | Green | Active terminal |
58
+ | Blue | Minimized terminal |
59
+
60
+ ### Click Actions
61
+
62
+ - **Click empty slot** - Prompts for name, opens terminal, runs `claude`
63
+ - **Click active slot** - Focuses that terminal window
64
+ - **Click minimized slot** - Unminimizes and focuses
65
+ - **Click "+" button** - Adds new slot and launches terminal
66
+ - **Option+Click any slot** - Rename it
67
+
68
+ ## Configuration
69
+
70
+ Edit `init.lua` to customize:
71
+
72
+ ```lua
73
+ -- Configuration
74
+ local slotWidth = 140 -- Width of each slot
75
+ local slotHeight = 60 -- Height of each slot
76
+ local gap = 8 -- Gap between slots
77
+ local margin = 10 -- Dock padding
78
+ local bottomOffset = 5 -- Distance from screen bottom
79
+ local slotCount = 3 -- Initial number of slots
80
+ ```
81
+
82
+ ### Using a Different Terminal
83
+
84
+ To use iTerm instead of Terminal.app, modify the `onSlotClick` function:
85
+
86
+ ```lua
87
+ -- Change this:
88
+ hs.applescript([[
89
+ tell application "Terminal"
90
+ do script "claude"
91
+ activate
92
+ end tell
93
+ ]])
94
+
95
+ -- To this:
96
+ hs.applescript([[
97
+ tell application "iTerm"
98
+ create window with default profile command "claude"
99
+ activate
100
+ end tell
101
+ ]])
102
+ ```
103
+
104
+ ## Running Tests
105
+
106
+ ```bash
107
+ hs -c "runTests()"
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT License - see [LICENSE](LICENSE) for details.
113
+
114
+ ## Contributing
115
+
116
+ Contributions welcome! Please open an issue or PR.
package/bin/cli.js ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync, spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const readline = require('readline');
7
+
8
+ const HAMMERSPOON_DIR = path.join(process.env.HOME, '.hammerspoon');
9
+ const INIT_LUA_PATH = path.join(HAMMERSPOON_DIR, 'init.lua');
10
+ const BACKUP_PATH = path.join(HAMMERSPOON_DIR, 'init.lua.backup');
11
+
12
+ const colors = {
13
+ reset: '\x1b[0m',
14
+ green: '\x1b[32m',
15
+ yellow: '\x1b[33m',
16
+ blue: '\x1b[34m',
17
+ red: '\x1b[31m',
18
+ dim: '\x1b[2m',
19
+ };
20
+
21
+ function log(msg) {
22
+ console.log(msg);
23
+ }
24
+
25
+ function success(msg) {
26
+ console.log(`${colors.green}✓${colors.reset} ${msg}`);
27
+ }
28
+
29
+ function warn(msg) {
30
+ console.log(`${colors.yellow}!${colors.reset} ${msg}`);
31
+ }
32
+
33
+ function info(msg) {
34
+ console.log(`${colors.blue}→${colors.reset} ${msg}`);
35
+ }
36
+
37
+ function error(msg) {
38
+ console.log(`${colors.red}✗${colors.reset} ${msg}`);
39
+ }
40
+
41
+ function commandExists(cmd) {
42
+ try {
43
+ execSync(`which ${cmd}`, { stdio: 'ignore' });
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function hammerspoonInstalled() {
51
+ return fs.existsSync('/Applications/Hammerspoon.app');
52
+ }
53
+
54
+ function prompt(question) {
55
+ const rl = readline.createInterface({
56
+ input: process.stdin,
57
+ output: process.stdout,
58
+ });
59
+ return new Promise((resolve) => {
60
+ rl.question(question, (answer) => {
61
+ rl.close();
62
+ resolve(answer.toLowerCase());
63
+ });
64
+ });
65
+ }
66
+
67
+ async function installHammerspoon() {
68
+ if (!commandExists('brew')) {
69
+ error('Homebrew is required to install Hammerspoon.');
70
+ log(' Install it from: https://brew.sh');
71
+ process.exit(1);
72
+ }
73
+
74
+ info('Installing Hammerspoon via Homebrew...');
75
+ try {
76
+ execSync('brew install --cask hammerspoon', { stdio: 'inherit' });
77
+ success('Hammerspoon installed');
78
+ } catch (e) {
79
+ error('Failed to install Hammerspoon');
80
+ process.exit(1);
81
+ }
82
+ }
83
+
84
+ function backupExistingConfig() {
85
+ if (fs.existsSync(INIT_LUA_PATH)) {
86
+ fs.copyFileSync(INIT_LUA_PATH, BACKUP_PATH);
87
+ success(`Backed up existing config to ${colors.dim}${BACKUP_PATH}${colors.reset}`);
88
+ }
89
+ }
90
+
91
+ function installConfig() {
92
+ // Ensure .hammerspoon directory exists
93
+ if (!fs.existsSync(HAMMERSPOON_DIR)) {
94
+ fs.mkdirSync(HAMMERSPOON_DIR, { recursive: true });
95
+ }
96
+
97
+ // Copy init.lua from package
98
+ const sourcePath = path.join(__dirname, '..', 'init.lua');
99
+ fs.copyFileSync(sourcePath, INIT_LUA_PATH);
100
+ success('Installed Claude Dock config');
101
+ }
102
+
103
+ function launchHammerspoon() {
104
+ try {
105
+ // Try to reload if already running
106
+ execSync('hs -c "hs.reload()"', { stdio: 'ignore', timeout: 3000 });
107
+ success('Reloaded Hammerspoon');
108
+ } catch {
109
+ // Not running, launch it
110
+ info('Launching Hammerspoon...');
111
+ spawn('open', ['-a', 'Hammerspoon'], { detached: true, stdio: 'ignore' }).unref();
112
+ success('Hammerspoon launched');
113
+ }
114
+ }
115
+
116
+ async function main() {
117
+ log('');
118
+ log(`${colors.blue}Claude Dock${colors.reset} - Terminal dock for Claude Code sessions`);
119
+ log('');
120
+
121
+ // Check platform
122
+ if (process.platform !== 'darwin') {
123
+ error('Claude Dock only works on macOS');
124
+ process.exit(1);
125
+ }
126
+
127
+ // Check/install Hammerspoon
128
+ if (!hammerspoonInstalled()) {
129
+ warn('Hammerspoon is not installed');
130
+ const answer = await prompt(' Install it now? (Y/n) ');
131
+ if (answer === 'n' || answer === 'no') {
132
+ log('');
133
+ log('Install Hammerspoon manually:');
134
+ log(' brew install --cask hammerspoon');
135
+ log('');
136
+ process.exit(0);
137
+ }
138
+ await installHammerspoon();
139
+ log('');
140
+ } else {
141
+ success('Hammerspoon found');
142
+ }
143
+
144
+ // Backup and install config
145
+ backupExistingConfig();
146
+ installConfig();
147
+
148
+ // Launch/reload Hammerspoon
149
+ launchHammerspoon();
150
+
151
+ // Final instructions
152
+ log('');
153
+ log(`${colors.yellow}Important:${colors.reset} Grant Accessibility permissions to Hammerspoon`);
154
+ log(` System Settings → Privacy & Security → Accessibility → Enable Hammerspoon`);
155
+ log('');
156
+ log(`${colors.green}Done!${colors.reset} Claude Dock is ready.`);
157
+ log('');
158
+ log(`Shortcuts:`);
159
+ log(` ${colors.dim}⌘⌥T${colors.reset} Toggle dock`);
160
+ log(` ${colors.dim}⌘⌥N${colors.reset} Add new terminal`);
161
+ log(` ${colors.dim}⌘⌥R${colors.reset} Reload config`);
162
+ log('');
163
+ }
164
+
165
+ main().catch((e) => {
166
+ error(e.message);
167
+ process.exit(1);
168
+ });
package/init.lua ADDED
@@ -0,0 +1,603 @@
1
+ -- Claude Dock: Terminal dock for managing Claude Code sessions
2
+ -- https://github.com/YOUR_USERNAME/claude-dock
3
+
4
+ require("hs.ipc")
5
+
6
+ -- Configuration
7
+ local config = {
8
+ slotWidth = 140,
9
+ slotHeight = 60,
10
+ gap = 8,
11
+ margin = 10,
12
+ bottomOffset = 5,
13
+ addButtonWidth = 40,
14
+ initialSlots = 3,
15
+ elementsPerSlot = 4, -- bg, border, title, status
16
+ baseElements = 2, -- dock bg + border
17
+ windowCaptureDelay = 0.3,
18
+ windowCaptureRetries = 5,
19
+ colors = {
20
+ dockBg = { red = 0.08, green = 0.08, blue = 0.08, alpha = 0.95 },
21
+ dockBorder = { red = 1, green = 1, blue = 1, alpha = 0.1 },
22
+ slotEmpty = { red = 0.15, green = 0.15, blue = 0.15, alpha = 1 },
23
+ slotActive = { red = 0.1, green = 0.2, blue = 0.1, alpha = 1 },
24
+ slotMinimized = { red = 0.12, green = 0.12, blue = 0.18, alpha = 1 },
25
+ slotBorder = { red = 1, green = 1, blue = 1, alpha = 0.15 },
26
+ textPrimary = { red = 0.9, green = 0.9, blue = 0.9, alpha = 1 },
27
+ textSecondary = { red = 0.5, green = 0.5, blue = 0.5, alpha = 1 },
28
+ addBtnBg = { red = 0.2, green = 0.25, blue = 0.2, alpha = 1 },
29
+ addBtnText = { red = 0.6, green = 0.8, blue = 0.6, alpha = 1 },
30
+ }
31
+ }
32
+
33
+ -- State
34
+ local slotCount = config.initialSlots
35
+ local slots = {}
36
+ local dock = nil
37
+ local tooltip = nil
38
+
39
+ -- Resource handles (for cleanup on reload)
40
+ local windowFilter = nil
41
+ local updateTimer = nil
42
+ local screenWatcher = nil
43
+
44
+ -- Cleanup previous instances on reload
45
+ local function cleanup()
46
+ if windowFilter then
47
+ windowFilter:unsubscribeAll()
48
+ windowFilter = nil
49
+ end
50
+ if updateTimer then
51
+ updateTimer:stop()
52
+ updateTimer = nil
53
+ end
54
+ if screenWatcher then
55
+ screenWatcher:stop()
56
+ screenWatcher = nil
57
+ end
58
+ if dock then
59
+ dock:delete()
60
+ dock = nil
61
+ end
62
+ if tooltip then
63
+ tooltip:delete()
64
+ tooltip = nil
65
+ end
66
+ end
67
+ cleanup() -- Clean up any previous instance
68
+
69
+ local function initSlots()
70
+ for i = 1, slotCount do
71
+ if not slots[i] then
72
+ slots[i] = { windowId = nil, customName = nil, pending = false }
73
+ end
74
+ end
75
+ end
76
+ initSlots()
77
+
78
+ -- Calculate dock dimensions
79
+ local function getDockWidth()
80
+ return (config.slotWidth * slotCount) + (config.gap * slotCount) + (config.margin * 2) + config.addButtonWidth
81
+ end
82
+
83
+ local function getDockHeight()
84
+ return config.slotHeight + (config.margin * 2)
85
+ end
86
+
87
+ local function getDockFrame()
88
+ local screen = hs.screen.mainScreen()
89
+ if not screen then return nil end
90
+ local frame = screen:fullFrame()
91
+ local dockWidth = getDockWidth()
92
+ local dockHeight = getDockHeight()
93
+ return {
94
+ x = (frame.w - dockWidth) / 2,
95
+ y = frame.h - dockHeight - config.bottomOffset,
96
+ w = dockWidth,
97
+ h = dockHeight
98
+ }
99
+ end
100
+
101
+ -- Window helpers
102
+ local function getWindow(windowId)
103
+ if not windowId then return nil end
104
+ return hs.window.get(windowId)
105
+ end
106
+
107
+ local function getWindowTitle(win)
108
+ if not win then return nil end
109
+ local title = win:title() or ""
110
+ if #title > 18 then
111
+ title = title:sub(1, 15) .. "..."
112
+ end
113
+ return title
114
+ end
115
+
116
+ -- Tooltip helpers
117
+ local function showTooltip(text)
118
+ if tooltip then tooltip:delete() end
119
+ local dockFrame = getDockFrame()
120
+ if not dockFrame then return end
121
+
122
+ local tipWidth = 50
123
+ local tipHeight = 24
124
+ local addBtnX = dockFrame.x + config.margin + (slotCount * (config.slotWidth + config.gap))
125
+ local tipX = addBtnX + (config.addButtonWidth - tipWidth) / 2
126
+ local tipY = dockFrame.y - tipHeight - 5
127
+
128
+ tooltip = hs.canvas.new({ x = tipX, y = tipY, w = tipWidth, h = tipHeight })
129
+ tooltip:appendElements({
130
+ type = "rectangle",
131
+ action = "fill",
132
+ roundedRectRadii = { xRadius = 6, yRadius = 6 },
133
+ fillColor = { red = 0.2, green = 0.2, blue = 0.2, alpha = 0.95 },
134
+ frame = { x = 0, y = 0, w = "100%", h = "100%" },
135
+ })
136
+ tooltip:appendElements({
137
+ type = "text",
138
+ frame = { x = 0, y = 4, w = tipWidth, h = tipHeight },
139
+ text = text,
140
+ textAlignment = "center",
141
+ textColor = { red = 1, green = 1, blue = 1, alpha = 1 },
142
+ textSize = 12,
143
+ textFont = ".AppleSystemUIFont",
144
+ })
145
+ tooltip:level(hs.canvas.windowLevels.floating)
146
+ tooltip:show()
147
+ end
148
+
149
+ local function hideTooltip()
150
+ if tooltip then
151
+ tooltip:delete()
152
+ tooltip = nil
153
+ end
154
+ end
155
+
156
+ -- Forward declarations
157
+ local createDock
158
+ local updateAllSlots
159
+
160
+ -- Update slot display
161
+ local function updateSlotDisplay(slotIndex)
162
+ if not dock then return end
163
+ if slotIndex > slotCount then return end
164
+
165
+ local slot = slots[slotIndex]
166
+ local baseIdx = config.baseElements + 1 + ((slotIndex - 1) * config.elementsPerSlot)
167
+
168
+ local title, status, bgColor
169
+ local win = getWindow(slot.windowId)
170
+
171
+ if win then
172
+ title = slot.customName or getWindowTitle(win) or "Terminal"
173
+ if win:isMinimized() then
174
+ status = "(minimized)"
175
+ bgColor = config.colors.slotMinimized
176
+ else
177
+ status = "active"
178
+ bgColor = config.colors.slotActive
179
+ end
180
+ else
181
+ -- Don't clear customName if we're waiting for a window to spawn
182
+ if not slot.pending then
183
+ slot.windowId = nil
184
+ slot.customName = nil
185
+ title = "Empty"
186
+ status = "click to open"
187
+ else
188
+ title = slot.customName or "Opening..."
189
+ status = "launching"
190
+ end
191
+ bgColor = config.colors.slotEmpty
192
+ end
193
+
194
+ if dock[baseIdx] then
195
+ dock[baseIdx].fillColor = bgColor
196
+ end
197
+ if dock[baseIdx + 2] then
198
+ dock[baseIdx + 2].text = title
199
+ end
200
+ if dock[baseIdx + 3] then
201
+ dock[baseIdx + 3].text = status
202
+ end
203
+ end
204
+
205
+ updateAllSlots = function()
206
+ for i = 1, slotCount do
207
+ updateSlotDisplay(i)
208
+ end
209
+ end
210
+
211
+ -- Rename a slot
212
+ local function renameSlot(slotIndex)
213
+ local slot = slots[slotIndex]
214
+ local button, newName = hs.dialog.textPrompt(
215
+ "Rename Slot " .. slotIndex,
216
+ "Enter a name for this slot:",
217
+ slot.customName or "",
218
+ "Save", "Cancel"
219
+ )
220
+ if button == "Save" and newName and newName ~= "" then
221
+ slot.customName = newName
222
+ updateSlotDisplay(slotIndex)
223
+ end
224
+ end
225
+
226
+ -- Capture newly created terminal window with retries
227
+ local function captureNewWindow(slot, retryCount)
228
+ retryCount = retryCount or 0
229
+ if retryCount >= config.windowCaptureRetries then
230
+ slot.pending = false
231
+ updateAllSlots()
232
+ return
233
+ end
234
+
235
+ local termApp = hs.application.get("Terminal")
236
+ if not termApp then
237
+ hs.timer.doAfter(config.windowCaptureDelay, function()
238
+ captureNewWindow(slot, retryCount + 1)
239
+ end)
240
+ return
241
+ end
242
+
243
+ local wins = termApp:allWindows()
244
+ for _, w in ipairs(wins) do
245
+ local winId = w:id()
246
+ local isTracked = false
247
+ for _, s in ipairs(slots) do
248
+ if s.windowId == winId then
249
+ isTracked = true
250
+ break
251
+ end
252
+ end
253
+ if not isTracked then
254
+ slot.windowId = winId
255
+ slot.pending = false
256
+ updateAllSlots()
257
+ return
258
+ end
259
+ end
260
+
261
+ -- Window not found yet, retry
262
+ hs.timer.doAfter(config.windowCaptureDelay, function()
263
+ captureNewWindow(slot, retryCount + 1)
264
+ end)
265
+ end
266
+
267
+ -- Handle slot click
268
+ local function onSlotClick(slotIndex, isOptionClick)
269
+ local slot = slots[slotIndex]
270
+
271
+ if isOptionClick then
272
+ renameSlot(slotIndex)
273
+ return
274
+ end
275
+
276
+ local win = getWindow(slot.windowId)
277
+ if win then
278
+ if win:isMinimized() then
279
+ win:unminimize()
280
+ end
281
+ win:focus()
282
+ else
283
+ local button, newName = hs.dialog.textPrompt(
284
+ "New Claude Terminal",
285
+ "Enter a name for this terminal:",
286
+ "Claude " .. slotIndex,
287
+ "Create", "Cancel"
288
+ )
289
+
290
+ if button ~= "Create" then
291
+ return
292
+ end
293
+
294
+ slot.customName = (newName and newName ~= "") and newName or ("Claude " .. slotIndex)
295
+ slot.pending = true
296
+
297
+ hs.applescript([[
298
+ tell application "Terminal"
299
+ do script "claude"
300
+ activate
301
+ end tell
302
+ ]])
303
+
304
+ captureNewWindow(slot, 0)
305
+ end
306
+
307
+ updateSlotDisplay(slotIndex)
308
+ end
309
+
310
+ -- Add a new slot and launch terminal
311
+ local function addSlot()
312
+ slotCount = slotCount + 1
313
+ slots[slotCount] = { windowId = nil, customName = nil, pending = false }
314
+
315
+ if dock then
316
+ dock:delete()
317
+ end
318
+ createDock()
319
+ onSlotClick(slotCount, false)
320
+ end
321
+
322
+ -- Create the dock UI
323
+ createDock = function()
324
+ local frame = getDockFrame()
325
+ if not frame then
326
+ hs.alert.show("Claude Dock: No screen found")
327
+ return
328
+ end
329
+
330
+ dock = hs.canvas.new(frame)
331
+
332
+ -- Background
333
+ dock:appendElements({
334
+ type = "rectangle",
335
+ action = "fill",
336
+ roundedRectRadii = { xRadius = 14, yRadius = 14 },
337
+ fillColor = config.colors.dockBg,
338
+ frame = { x = 0, y = 0, w = "100%", h = "100%" },
339
+ })
340
+
341
+ -- Border
342
+ dock:appendElements({
343
+ type = "rectangle",
344
+ action = "stroke",
345
+ roundedRectRadii = { xRadius = 14, yRadius = 14 },
346
+ strokeColor = config.colors.dockBorder,
347
+ strokeWidth = 1,
348
+ frame = { x = 0, y = 0, w = "100%", h = "100%" },
349
+ })
350
+
351
+ -- Slots
352
+ for i = 1, slotCount do
353
+ local slotX = config.margin + ((i - 1) * (config.slotWidth + config.gap))
354
+
355
+ dock:appendElements({
356
+ type = "rectangle",
357
+ action = "fill",
358
+ frame = { x = slotX, y = config.margin, w = config.slotWidth, h = config.slotHeight },
359
+ roundedRectRadii = { xRadius = 10, yRadius = 10 },
360
+ fillColor = config.colors.slotEmpty,
361
+ trackMouseUp = true,
362
+ id = "slot" .. i,
363
+ })
364
+
365
+ dock:appendElements({
366
+ type = "rectangle",
367
+ action = "stroke",
368
+ frame = { x = slotX, y = config.margin, w = config.slotWidth, h = config.slotHeight },
369
+ roundedRectRadii = { xRadius = 10, yRadius = 10 },
370
+ strokeColor = config.colors.slotBorder,
371
+ strokeWidth = 1,
372
+ })
373
+
374
+ dock:appendElements({
375
+ type = "text",
376
+ frame = { x = slotX + 6, y = config.margin + 8, w = config.slotWidth - 12, h = 24 },
377
+ text = "Empty",
378
+ textAlignment = "center",
379
+ textColor = config.colors.textPrimary,
380
+ textSize = 13,
381
+ textFont = ".AppleSystemUIFont",
382
+ })
383
+
384
+ dock:appendElements({
385
+ type = "text",
386
+ frame = { x = slotX + 6, y = config.margin + 32, w = config.slotWidth - 12, h = 20 },
387
+ text = "click to open",
388
+ textAlignment = "center",
389
+ textColor = config.colors.textSecondary,
390
+ textSize = 10,
391
+ textFont = ".AppleSystemUIFont",
392
+ })
393
+ end
394
+
395
+ -- Add button
396
+ local addBtnX = config.margin + (slotCount * (config.slotWidth + config.gap))
397
+ dock:appendElements({
398
+ type = "rectangle",
399
+ action = "fill",
400
+ frame = { x = addBtnX, y = config.margin, w = config.addButtonWidth, h = config.slotHeight },
401
+ roundedRectRadii = { xRadius = 10, yRadius = 10 },
402
+ fillColor = config.colors.addBtnBg,
403
+ trackMouseUp = true,
404
+ trackMouseEnterExit = true,
405
+ id = "addBtn",
406
+ })
407
+ dock:appendElements({
408
+ type = "text",
409
+ frame = { x = addBtnX, y = config.margin + 13, w = config.addButtonWidth, h = 30 },
410
+ text = "+",
411
+ textAlignment = "center",
412
+ textColor = config.colors.addBtnText,
413
+ textSize = 28,
414
+ textFont = ".AppleSystemUIFont",
415
+ })
416
+
417
+ dock:mouseCallback(function(_, event, id)
418
+ if event == "mouseUp" then
419
+ if id == "addBtn" then
420
+ addSlot()
421
+ elseif id and id:match("^slot") then
422
+ local idx = tonumber(id:match("%d+"))
423
+ if idx then
424
+ local mods = hs.eventtap.checkKeyboardModifiers()
425
+ onSlotClick(idx, mods.alt)
426
+ end
427
+ end
428
+ elseif event == "mouseEnter" and id == "addBtn" then
429
+ showTooltip("⌘⌥N")
430
+ elseif event == "mouseExit" and id == "addBtn" then
431
+ hideTooltip()
432
+ end
433
+ end)
434
+
435
+ dock:level(hs.canvas.windowLevels.floating)
436
+ dock:behavior(hs.canvas.windowBehaviors.canJoinAllSpaces)
437
+ dock:show()
438
+ updateAllSlots()
439
+ end
440
+
441
+ local function toggleDock()
442
+ if dock then
443
+ if dock:isShowing() then dock:hide() else dock:show() end
444
+ end
445
+ end
446
+
447
+ -- Window event watcher for immediate updates
448
+ windowFilter = hs.window.filter.new("Terminal")
449
+ windowFilter:subscribe({
450
+ hs.window.filter.windowDestroyed,
451
+ hs.window.filter.windowMinimized,
452
+ hs.window.filter.windowUnminimized,
453
+ hs.window.filter.windowFocused,
454
+ }, updateAllSlots)
455
+
456
+ -- Periodic refresh as fallback
457
+ updateTimer = hs.timer.doEvery(2, function()
458
+ if dock and dock:isShowing() then
459
+ updateAllSlots()
460
+ end
461
+ end)
462
+
463
+ -- Screen change handler
464
+ screenWatcher = hs.screen.watcher.new(function()
465
+ if dock then
466
+ local frame = getDockFrame()
467
+ if frame then
468
+ dock:frame(frame)
469
+ end
470
+ end
471
+ end)
472
+ screenWatcher:start()
473
+
474
+ -- Initialize
475
+ createDock()
476
+
477
+ -- Hotkeys
478
+ hs.hotkey.bind({"cmd", "alt"}, "T", toggleDock)
479
+ hs.hotkey.bind({"cmd", "alt"}, "N", addSlot)
480
+ hs.hotkey.bind({"cmd", "alt"}, "R", hs.reload)
481
+
482
+ hs.alert.show("Claude Dock Ready")
483
+
484
+ -- ===================
485
+ -- TESTS (run with: hs -c "runTests()")
486
+ -- ===================
487
+
488
+ function runTests()
489
+ local passed, failed = 0, 0
490
+ local savedSlotCount, savedSlots = slotCount, slots
491
+
492
+ local function test(name, fn)
493
+ local ok, err = pcall(fn)
494
+ if ok then
495
+ print("✓ " .. name)
496
+ passed = passed + 1
497
+ else
498
+ print("✗ " .. name .. ": " .. tostring(err))
499
+ failed = failed + 1
500
+ end
501
+ end
502
+
503
+ local function assertEqual(a, b, msg)
504
+ if a ~= b then
505
+ error((msg or "assertEqual") .. ": expected " .. tostring(b) .. ", got " .. tostring(a))
506
+ end
507
+ end
508
+
509
+ local function restore()
510
+ slotCount, slots = savedSlotCount, savedSlots
511
+ end
512
+
513
+ print("\n=== Claude Dock Tests ===\n")
514
+
515
+ test("getWindow returns nil for nil input", function()
516
+ assertEqual(getWindow(nil), nil)
517
+ end)
518
+
519
+ test("getWindow returns nil for invalid windowId", function()
520
+ assertEqual(getWindow(999999999), nil)
521
+ end)
522
+
523
+ test("getWindowTitle returns nil for nil window", function()
524
+ assertEqual(getWindowTitle(nil), nil)
525
+ end)
526
+
527
+ test("slot clears windowId when window gone", function()
528
+ local testSlot = { windowId = 999999999 }
529
+ if not getWindow(testSlot.windowId) then
530
+ testSlot.windowId = nil
531
+ end
532
+ assertEqual(testSlot.windowId, nil)
533
+ end)
534
+
535
+ test("slot clears customName when window gone", function()
536
+ local testSlot = { windowId = 999999999, customName = "Test" }
537
+ if not getWindow(testSlot.windowId) then
538
+ testSlot.windowId = nil
539
+ testSlot.customName = nil
540
+ end
541
+ assertEqual(testSlot.customName, nil)
542
+ end)
543
+
544
+ test("getDockWidth scales with slotCount", function()
545
+ local w1 = getDockWidth()
546
+ slotCount = slotCount + 1
547
+ local w2 = getDockWidth()
548
+ restore()
549
+ assert(w2 > w1, "width should increase")
550
+ end)
551
+
552
+ test("getDockHeight is constant", function()
553
+ local h1 = getDockHeight()
554
+ slotCount = slotCount + 5
555
+ local h2 = getDockHeight()
556
+ restore()
557
+ assertEqual(h1, h2)
558
+ end)
559
+
560
+ test("initSlots creates correct slots", function()
561
+ slots = {}
562
+ slotCount = 3
563
+ initSlots()
564
+ assert(slots[1] and slots[2] and slots[3], "slots 1-3 should exist")
565
+ assert(not slots[4], "slot 4 should not exist")
566
+ restore()
567
+ end)
568
+
569
+ test("toggleDock changes visibility", function()
570
+ local was = dock:isShowing()
571
+ toggleDock()
572
+ assertEqual(dock:isShowing(), not was)
573
+ toggleDock()
574
+ end)
575
+
576
+ test("updateSlotDisplay handles invalid index", function()
577
+ local ok = pcall(updateSlotDisplay, 9999)
578
+ assert(ok, "should not error")
579
+ end)
580
+
581
+ test("windowFilter exists", function()
582
+ assert(windowFilter, "windowFilter should exist")
583
+ end)
584
+
585
+ test("cleanup function exists", function()
586
+ assert(type(cleanup) == "function", "cleanup should be a function")
587
+ end)
588
+
589
+ test("config has required color fields", function()
590
+ assert(config.colors.slotEmpty, "slotEmpty color")
591
+ assert(config.colors.slotActive, "slotActive color")
592
+ assert(config.colors.slotMinimized, "slotMinimized color")
593
+ end)
594
+
595
+ test("getDockFrame returns table with x,y,w,h", function()
596
+ local frame = getDockFrame()
597
+ assert(frame, "frame should exist")
598
+ assert(frame.x and frame.y and frame.w and frame.h, "frame should have x,y,w,h")
599
+ end)
600
+
601
+ print("\n=== Results: " .. passed .. " passed, " .. failed .. " failed ===\n")
602
+ return failed == 0
603
+ end
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "claude-dock",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight terminal dock for macOS to manage Claude Code sessions",
5
+ "bin": {
6
+ "claude-dock": "./bin/cli.js"
7
+ },
8
+ "keywords": [
9
+ "claude",
10
+ "hammerspoon",
11
+ "macos",
12
+ "terminal",
13
+ "dock",
14
+ "cli"
15
+ ],
16
+ "author": "Matthew Molinar",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/matthewmolinar/claude-dock.git"
21
+ },
22
+ "homepage": "https://github.com/matthewmolinar/claude-dock",
23
+ "engines": {
24
+ "node": ">=14"
25
+ },
26
+ "os": [
27
+ "darwin"
28
+ ],
29
+ "files": [
30
+ "bin/",
31
+ "init.lua"
32
+ ]
33
+ }