@colmbus72/yeehaw 0.1.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/app.d.ts +1 -0
  4. package/dist/app.js +414 -0
  5. package/dist/components/BarnHeader.d.ts +6 -0
  6. package/dist/components/BarnHeader.js +21 -0
  7. package/dist/components/BottomBar.d.ts +16 -0
  8. package/dist/components/BottomBar.js +7 -0
  9. package/dist/components/Header.d.ts +8 -0
  10. package/dist/components/Header.js +83 -0
  11. package/dist/components/HelpOverlay.d.ts +7 -0
  12. package/dist/components/HelpOverlay.js +17 -0
  13. package/dist/components/List.d.ts +17 -0
  14. package/dist/components/List.js +53 -0
  15. package/dist/components/Markdown.d.ts +8 -0
  16. package/dist/components/Markdown.js +23 -0
  17. package/dist/components/Panel.d.ts +10 -0
  18. package/dist/components/Panel.js +5 -0
  19. package/dist/components/PathInput.d.ts +9 -0
  20. package/dist/components/PathInput.js +141 -0
  21. package/dist/components/ScrollableMarkdown.d.ts +11 -0
  22. package/dist/components/ScrollableMarkdown.js +56 -0
  23. package/dist/components/StatusBar.d.ts +5 -0
  24. package/dist/components/StatusBar.js +20 -0
  25. package/dist/components/TextArea.d.ts +17 -0
  26. package/dist/components/TextArea.js +140 -0
  27. package/dist/components/index.d.ts +5 -0
  28. package/dist/components/index.js +5 -0
  29. package/dist/hooks/index.d.ts +3 -0
  30. package/dist/hooks/index.js +3 -0
  31. package/dist/hooks/useConfig.d.ts +11 -0
  32. package/dist/hooks/useConfig.js +36 -0
  33. package/dist/hooks/useRemoteYeehaw.d.ts +13 -0
  34. package/dist/hooks/useRemoteYeehaw.js +49 -0
  35. package/dist/hooks/useSessions.d.ts +11 -0
  36. package/dist/hooks/useSessions.js +46 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.js +34 -0
  39. package/dist/lib/config.d.ts +27 -0
  40. package/dist/lib/config.js +150 -0
  41. package/dist/lib/detection.d.ts +16 -0
  42. package/dist/lib/detection.js +41 -0
  43. package/dist/lib/editor.d.ts +5 -0
  44. package/dist/lib/editor.js +35 -0
  45. package/dist/lib/errors.d.ts +28 -0
  46. package/dist/lib/errors.js +48 -0
  47. package/dist/lib/git.d.ts +11 -0
  48. package/dist/lib/git.js +73 -0
  49. package/dist/lib/github.d.ts +43 -0
  50. package/dist/lib/github.js +111 -0
  51. package/dist/lib/hotkeys.d.ts +27 -0
  52. package/dist/lib/hotkeys.js +92 -0
  53. package/dist/lib/index.d.ts +10 -0
  54. package/dist/lib/index.js +10 -0
  55. package/dist/lib/livestock.d.ts +51 -0
  56. package/dist/lib/livestock.js +233 -0
  57. package/dist/lib/mcp-validation.d.ts +33 -0
  58. package/dist/lib/mcp-validation.js +62 -0
  59. package/dist/lib/paths.d.ts +8 -0
  60. package/dist/lib/paths.js +28 -0
  61. package/dist/lib/shell.d.ts +34 -0
  62. package/dist/lib/shell.js +61 -0
  63. package/dist/lib/ssh.d.ts +15 -0
  64. package/dist/lib/ssh.js +77 -0
  65. package/dist/lib/tmux-config.d.ts +3 -0
  66. package/dist/lib/tmux-config.js +42 -0
  67. package/dist/lib/tmux.d.ts +32 -0
  68. package/dist/lib/tmux.js +397 -0
  69. package/dist/mcp-server.d.ts +23 -0
  70. package/dist/mcp-server.js +825 -0
  71. package/dist/types.d.ts +89 -0
  72. package/dist/types.js +2 -0
  73. package/dist/views/BarnContext.d.ts +22 -0
  74. package/dist/views/BarnContext.js +252 -0
  75. package/dist/views/GlobalDashboard.d.ts +16 -0
  76. package/dist/views/GlobalDashboard.js +253 -0
  77. package/dist/views/Home.d.ts +11 -0
  78. package/dist/views/Home.js +27 -0
  79. package/dist/views/IssuesView.d.ts +7 -0
  80. package/dist/views/IssuesView.js +157 -0
  81. package/dist/views/LivestockDetailView.d.ts +11 -0
  82. package/dist/views/LivestockDetailView.js +140 -0
  83. package/dist/views/LogsView.d.ts +8 -0
  84. package/dist/views/LogsView.js +84 -0
  85. package/dist/views/NightSkyView.d.ts +5 -0
  86. package/dist/views/NightSkyView.js +441 -0
  87. package/dist/views/ProjectContext.d.ts +18 -0
  88. package/dist/views/ProjectContext.js +333 -0
  89. package/dist/views/Projects.d.ts +8 -0
  90. package/dist/views/Projects.js +20 -0
  91. package/dist/views/WikiView.d.ts +8 -0
  92. package/dist/views/WikiView.js +138 -0
  93. package/dist/views/index.d.ts +2 -0
  94. package/dist/views/index.js +2 -0
  95. package/package.json +65 -0
@@ -0,0 +1,397 @@
1
+ import { execaSync } from 'execa';
2
+ import { spawnSync } from 'child_process';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { writeTmuxConfig, TMUX_CONFIG_PATH } from './tmux-config.js';
6
+ import { shellEscape } from './shell.js';
7
+ // Get the path to the MCP server (it's in dist/, not dist/lib/)
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const MCP_SERVER_PATH = join(__dirname, '..', 'mcp-server.js');
11
+ export const YEEHAW_SESSION = 'yeehaw';
12
+ // Remote mode state tracking
13
+ let remoteWindowIndex = null;
14
+ // Keys to unbind when entering remote mode (so they pass through to inner tmux)
15
+ const REMOTE_MODE_UNBIND_KEYS = ['C-h', 'C-l', 'C-y'];
16
+ export function hasTmux() {
17
+ try {
18
+ execaSync('which', ['tmux']);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export function isInsideYeehawSession() {
26
+ const tmuxEnv = process.env.TMUX;
27
+ if (!tmuxEnv)
28
+ return false;
29
+ // Check if we're in the yeehaw session
30
+ try {
31
+ const result = execaSync('tmux', ['display-message', '-p', '#{session_name}']);
32
+ return result.stdout.trim() === YEEHAW_SESSION;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ export function yeehawSessionExists() {
39
+ try {
40
+ execaSync('tmux', ['has-session', '-t', YEEHAW_SESSION]);
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ export function createYeehawSession() {
48
+ // Write the tmux config
49
+ writeTmuxConfig();
50
+ // Create the session with window 0 named "yeehaw"
51
+ execaSync('tmux', [
52
+ 'new-session',
53
+ '-d',
54
+ '-s', YEEHAW_SESSION,
55
+ '-n', 'yeehaw',
56
+ ]);
57
+ // Source the config
58
+ execaSync('tmux', ['source-file', TMUX_CONFIG_PATH]);
59
+ // Set up hook to hide status bar in window 0
60
+ setupStatusBarHooks();
61
+ }
62
+ export function setupStatusBarHooks() {
63
+ // Hide status bar when in window 0, show in other windows
64
+ try {
65
+ // Start with status off (we begin in window 0)
66
+ execaSync('tmux', ['set', '-t', YEEHAW_SESSION, 'status', 'off']);
67
+ // Hook for when window changes
68
+ execaSync('tmux', [
69
+ 'set-hook', '-t', YEEHAW_SESSION,
70
+ 'after-select-window',
71
+ 'if-shell -F "#{==:#{window_index},0}" "set status off" "set status on"'
72
+ ]);
73
+ }
74
+ catch {
75
+ // Hooks might fail on older tmux versions, not critical
76
+ }
77
+ }
78
+ export function attachToYeehaw() {
79
+ // This replaces the current process
80
+ spawnSync('tmux', ['attach-session', '-t', YEEHAW_SESSION], {
81
+ stdio: 'inherit',
82
+ });
83
+ process.exit(0);
84
+ }
85
+ // All yeehaw MCP tools that should be auto-approved
86
+ const YEEHAW_MCP_TOOLS = [
87
+ 'mcp__yeehaw__list_projects',
88
+ 'mcp__yeehaw__get_project',
89
+ 'mcp__yeehaw__create_project',
90
+ 'mcp__yeehaw__update_project',
91
+ 'mcp__yeehaw__delete_project',
92
+ 'mcp__yeehaw__add_livestock',
93
+ 'mcp__yeehaw__remove_livestock',
94
+ 'mcp__yeehaw__list_barns',
95
+ 'mcp__yeehaw__get_barn',
96
+ 'mcp__yeehaw__create_barn',
97
+ 'mcp__yeehaw__update_barn',
98
+ 'mcp__yeehaw__delete_barn',
99
+ 'mcp__yeehaw__get_wiki',
100
+ 'mcp__yeehaw__add_wiki_section',
101
+ 'mcp__yeehaw__update_wiki_section',
102
+ 'mcp__yeehaw__delete_wiki_section',
103
+ ];
104
+ export function createClaudeWindow(workingDir, windowName) {
105
+ // Build MCP config for yeehaw server
106
+ const mcpConfig = JSON.stringify({
107
+ mcpServers: {
108
+ yeehaw: {
109
+ command: 'node',
110
+ args: [MCP_SERVER_PATH],
111
+ },
112
+ },
113
+ });
114
+ // Build allowed tools list for auto-approval
115
+ const allowedTools = YEEHAW_MCP_TOOLS.join(',');
116
+ // Create new window running claude with yeehaw MCP server (-a appends after current window)
117
+ // Use shell escaping to safely handle special characters in JSON
118
+ const claudeCmd = `claude --mcp-config ${shellEscape(mcpConfig)} --allowedTools ${shellEscape(allowedTools)}`;
119
+ execaSync('tmux', [
120
+ 'new-window',
121
+ '-a',
122
+ '-t', YEEHAW_SESSION,
123
+ '-n', windowName,
124
+ '-c', workingDir,
125
+ claudeCmd,
126
+ ]);
127
+ // Get the window index we just created (new window is now current)
128
+ const result = execaSync('tmux', [
129
+ 'display-message', '-p', '#{window_index}'
130
+ ]);
131
+ return parseInt(result.stdout.trim(), 10);
132
+ }
133
+ export function createShellWindow(workingDir, windowName, shell = '/bin/zsh') {
134
+ // Create new window running shell (-a appends after current window)
135
+ execaSync('tmux', [
136
+ 'new-window',
137
+ '-a',
138
+ '-t', YEEHAW_SESSION,
139
+ '-n', windowName,
140
+ '-c', workingDir,
141
+ shell,
142
+ ]);
143
+ // Get the window index we just created (new window is now current)
144
+ const result = execaSync('tmux', [
145
+ 'display-message', '-p', '#{window_index}'
146
+ ]);
147
+ return parseInt(result.stdout.trim(), 10);
148
+ }
149
+ export function createSshWindow(windowName, host, user, port, identityFile, remotePath) {
150
+ // Two levels of escaping needed:
151
+ // 1. Remote shell sees: cd /path && exec $SHELL -l
152
+ // 2. Local shell (tmux) sees: ssh ... -t 'cd /path && ...'
153
+ // Build the remote command - escape remotePath for the remote shell
154
+ const remoteCmd = `cd ${shellEscape(remotePath)} && exec $SHELL -l`;
155
+ // Build SSH command parts - escape for local shell (tmux passes to sh)
156
+ const sshParts = [
157
+ 'ssh',
158
+ '-p', String(port),
159
+ '-i', shellEscape(identityFile),
160
+ shellEscape(`${user}@${host}`),
161
+ '-t',
162
+ shellEscape(remoteCmd) // Double-escaped: once for remote, once for local
163
+ ];
164
+ const sshCmd = sshParts.join(' ');
165
+ execaSync('tmux', [
166
+ 'new-window',
167
+ '-a',
168
+ '-t', YEEHAW_SESSION,
169
+ '-n', windowName,
170
+ sshCmd,
171
+ ]);
172
+ const result = execaSync('tmux', [
173
+ 'display-message', '-p', '#{window_index}'
174
+ ]);
175
+ return parseInt(result.stdout.trim(), 10);
176
+ }
177
+ export function detachFromSession() {
178
+ execaSync('tmux', ['detach-client']);
179
+ }
180
+ export function killYeehawSession() {
181
+ try {
182
+ execaSync('tmux', ['kill-session', '-t', YEEHAW_SESSION]);
183
+ }
184
+ catch {
185
+ // Session might already be dead
186
+ }
187
+ }
188
+ export function switchToWindow(windowIndex) {
189
+ execaSync('tmux', ['select-window', '-t', `${YEEHAW_SESSION}:${windowIndex}`]);
190
+ }
191
+ export function listYeehawWindows() {
192
+ try {
193
+ // Use tab as delimiter since pane_title can contain colons
194
+ const result = execaSync('tmux', [
195
+ 'list-windows',
196
+ '-t', YEEHAW_SESSION,
197
+ '-F', '#{window_index}\t#{window_name}\t#{window_active}\t#{pane_title}\t#{pane_current_command}\t#{window_activity}',
198
+ ]);
199
+ return result.stdout
200
+ .split('\n')
201
+ .filter(Boolean)
202
+ .map((line) => {
203
+ const [index, name, active, paneTitle, paneCurrentCommand, windowActivity] = line.split('\t');
204
+ return {
205
+ index: parseInt(index, 10),
206
+ name,
207
+ active: active === '1',
208
+ paneTitle: paneTitle || '',
209
+ paneCurrentCommand: paneCurrentCommand || '',
210
+ windowActivity: parseInt(windowActivity, 10) || 0,
211
+ };
212
+ });
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ }
218
+ export function killWindow(windowIndex) {
219
+ try {
220
+ execaSync('tmux', ['kill-window', '-t', `${YEEHAW_SESSION}:${windowIndex}`]);
221
+ }
222
+ catch {
223
+ // Window might already be dead
224
+ }
225
+ }
226
+ /**
227
+ * Format relative time from a Unix timestamp
228
+ */
229
+ function formatRelativeTime(timestamp) {
230
+ const now = Math.floor(Date.now() / 1000);
231
+ const diff = now - timestamp;
232
+ if (diff < 60)
233
+ return 'now';
234
+ if (diff < 3600)
235
+ return `${Math.floor(diff / 60)}m`;
236
+ if (diff < 86400)
237
+ return `${Math.floor(diff / 3600)}h`;
238
+ return `${Math.floor(diff / 86400)}d`;
239
+ }
240
+ /**
241
+ * Check if a pane title indicates Claude is actively working (has spinner)
242
+ */
243
+ function isClaudeWorking(paneTitle) {
244
+ // Braille spinner characters used by Claude Code
245
+ const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⠂', '⠐', '⠈'];
246
+ return spinnerChars.some(char => paneTitle.startsWith(char));
247
+ }
248
+ /**
249
+ * Get formatted status text for a tmux window
250
+ */
251
+ export function getWindowStatus(window) {
252
+ const isClaudeSession = window.name.includes('claude') && window.paneCurrentCommand === 'node';
253
+ const relativeTime = formatRelativeTime(window.windowActivity);
254
+ if (isClaudeSession) {
255
+ // For Claude sessions, use pane title (contains task context)
256
+ if (window.paneTitle) {
257
+ const working = isClaudeWorking(window.paneTitle);
258
+ if (working) {
259
+ return window.paneTitle; // Shows spinner + task description
260
+ }
261
+ // Not working - show title with idle time if stale
262
+ if (relativeTime !== 'now' && relativeTime !== '1m') {
263
+ return `${window.paneTitle} (${relativeTime})`;
264
+ }
265
+ return window.paneTitle;
266
+ }
267
+ return relativeTime === 'now' ? 'active' : `idle ${relativeTime}`;
268
+ }
269
+ // For shell sessions, show current command
270
+ const cmd = window.paneCurrentCommand;
271
+ if (cmd && cmd !== 'zsh' && cmd !== 'bash' && cmd !== 'sh' && cmd !== 'fish') {
272
+ // Running a specific command
273
+ return cmd;
274
+ }
275
+ // At shell prompt - show idle time
276
+ return relativeTime === 'now' ? 'ready' : `idle ${relativeTime}`;
277
+ }
278
+ export function updateStatusBar(projectName) {
279
+ const left = projectName
280
+ ? `#[bold] YEEHAW | ${projectName} `
281
+ : '#[bold] YEEHAW ';
282
+ try {
283
+ execaSync('tmux', ['set', '-t', YEEHAW_SESSION, 'status-left', left]);
284
+ }
285
+ catch {
286
+ // Not critical if this fails
287
+ }
288
+ }
289
+ export function enterRemoteMode(barnName, host, user, port, identityFile) {
290
+ // 1. Create SSH window that attaches to remote yeehaw tmux
291
+ const windowName = `remote:${barnName}`;
292
+ const remoteCmd = 'tmux attach -t yeehaw';
293
+ const sshParts = [
294
+ 'ssh',
295
+ '-p', String(port),
296
+ '-i', shellEscape(identityFile),
297
+ '-t', // Force TTY allocation
298
+ shellEscape(`${user}@${host}`),
299
+ shellEscape(remoteCmd)
300
+ ];
301
+ const sshCmd = sshParts.join(' ');
302
+ execaSync('tmux', [
303
+ 'new-window',
304
+ '-a',
305
+ '-t', YEEHAW_SESSION,
306
+ '-n', windowName,
307
+ sshCmd,
308
+ ]);
309
+ const result = execaSync('tmux', [
310
+ 'display-message', '-p', '#{window_index}'
311
+ ]);
312
+ const windowIndex = parseInt(result.stdout.trim(), 10);
313
+ remoteWindowIndex = windowIndex;
314
+ // 2. Unbind navigation keys so they pass through to inner tmux
315
+ for (const key of REMOTE_MODE_UNBIND_KEYS) {
316
+ try {
317
+ execaSync('tmux', ['unbind-key', '-n', key]);
318
+ }
319
+ catch {
320
+ // Key might not be bound, ignore
321
+ }
322
+ }
323
+ // 3. Bind Ctrl-\ as escape hatch
324
+ try {
325
+ execaSync('tmux', [
326
+ 'bind-key', '-n', 'C-\\',
327
+ `run-shell "tmux kill-window -t ${YEEHAW_SESSION}:${windowIndex}; exit 0"`
328
+ ]);
329
+ }
330
+ catch {
331
+ // Ignore errors
332
+ }
333
+ // 4. Show minimal status bar with connection info
334
+ try {
335
+ execaSync('tmux', ['set', '-t', YEEHAW_SESSION, 'status', 'on']);
336
+ execaSync('tmux', ['set', '-t', YEEHAW_SESSION, 'status-left', `#[bold] Connected to: ${barnName} `]);
337
+ execaSync('tmux', ['set', '-t', YEEHAW_SESSION, 'status-right', ' Ctrl-\\ back ']);
338
+ }
339
+ catch {
340
+ // Not critical
341
+ }
342
+ // 5. Set up hook to restore when remote window closes
343
+ try {
344
+ execaSync('tmux', [
345
+ 'set-hook', '-t', YEEHAW_SESSION,
346
+ 'window-unlinked',
347
+ `if-shell "[ ! -z \\"#{@remote_mode}\\" ]" "run-shell \\"tmux set -u @remote_mode; tmux source-file ${TMUX_CONFIG_PATH}; tmux select-window -t ${YEEHAW_SESSION}:0; tmux set status off\\""`
348
+ ]);
349
+ // Mark that we're in remote mode
350
+ execaSync('tmux', ['set', '-t', YEEHAW_SESSION, '@remote_mode', '1']);
351
+ }
352
+ catch {
353
+ // Hooks might fail on older tmux
354
+ }
355
+ return windowIndex;
356
+ }
357
+ export function exitRemoteMode() {
358
+ // Kill the remote window if it exists
359
+ if (remoteWindowIndex !== null) {
360
+ try {
361
+ execaSync('tmux', ['kill-window', '-t', `${YEEHAW_SESSION}:${remoteWindowIndex}`]);
362
+ }
363
+ catch {
364
+ // Window might already be dead
365
+ }
366
+ remoteWindowIndex = null;
367
+ }
368
+ // Restore keybindings by re-sourcing config
369
+ try {
370
+ execaSync('tmux', ['source-file', TMUX_CONFIG_PATH]);
371
+ }
372
+ catch {
373
+ // Not critical
374
+ }
375
+ // Unbind the escape hatch
376
+ try {
377
+ execaSync('tmux', ['unbind-key', '-n', 'C-\\']);
378
+ }
379
+ catch {
380
+ // Ignore
381
+ }
382
+ // Hide status bar and switch to window 0
383
+ try {
384
+ execaSync('tmux', ['set', '-t', YEEHAW_SESSION, 'status', 'off']);
385
+ execaSync('tmux', ['select-window', '-t', `${YEEHAW_SESSION}:0`]);
386
+ execaSync('tmux', ['set', '-u', '-t', YEEHAW_SESSION, '@remote_mode']);
387
+ }
388
+ catch {
389
+ // Not critical
390
+ }
391
+ }
392
+ export function isInRemoteMode() {
393
+ return remoteWindowIndex !== null;
394
+ }
395
+ export function getRemoteWindowIndex() {
396
+ return remoteWindowIndex;
397
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Yeehaw MCP Server
4
+ *
5
+ * Exposes Yeehaw project/barn/livestock management to Claude Code sessions.
6
+ *
7
+ * Terminology:
8
+ * - Project: A codebase you're working on
9
+ * - Barn: A server you manage
10
+ * - Livestock: Deployed instances of your apps (local or on barns)
11
+ * - Critter: System services that support livestock (nginx, mysql, etc.)
12
+ *
13
+ * Usage: Add to Claude Code's MCP config:
14
+ * {
15
+ * "mcpServers": {
16
+ * "yeehaw": {
17
+ * "command": "node",
18
+ * "args": ["/path/to/yeehaw/dist/mcp-server.js"]
19
+ * }
20
+ * }
21
+ * }
22
+ */
23
+ export {};