@colmbus72/yeehaw 0.5.0 → 0.6.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 (43) hide show
  1. package/claude-plugin/skills/yeehaw-development/SKILL.md +70 -0
  2. package/dist/app.js +166 -15
  3. package/dist/components/CritterHeader.d.ts +7 -0
  4. package/dist/components/CritterHeader.js +81 -0
  5. package/dist/components/List.d.ts +2 -0
  6. package/dist/components/List.js +1 -1
  7. package/dist/lib/auth/index.d.ts +2 -0
  8. package/dist/lib/auth/index.js +3 -0
  9. package/dist/lib/auth/linear.d.ts +20 -0
  10. package/dist/lib/auth/linear.js +79 -0
  11. package/dist/lib/auth/storage.d.ts +12 -0
  12. package/dist/lib/auth/storage.js +53 -0
  13. package/dist/lib/context.d.ts +10 -0
  14. package/dist/lib/context.js +63 -0
  15. package/dist/lib/critters.d.ts +33 -0
  16. package/dist/lib/critters.js +164 -0
  17. package/dist/lib/hotkeys.d.ts +1 -1
  18. package/dist/lib/hotkeys.js +6 -2
  19. package/dist/lib/issues/github.d.ts +11 -0
  20. package/dist/lib/issues/github.js +154 -0
  21. package/dist/lib/issues/index.d.ts +14 -0
  22. package/dist/lib/issues/index.js +27 -0
  23. package/dist/lib/issues/linear.d.ts +24 -0
  24. package/dist/lib/issues/linear.js +345 -0
  25. package/dist/lib/issues/types.d.ts +82 -0
  26. package/dist/lib/issues/types.js +2 -0
  27. package/dist/lib/paths.d.ts +1 -0
  28. package/dist/lib/paths.js +1 -0
  29. package/dist/lib/tmux.d.ts +1 -0
  30. package/dist/lib/tmux.js +45 -0
  31. package/dist/types.d.ts +19 -0
  32. package/dist/views/BarnContext.d.ts +2 -1
  33. package/dist/views/BarnContext.js +136 -14
  34. package/dist/views/CritterDetailView.d.ts +10 -0
  35. package/dist/views/CritterDetailView.js +117 -0
  36. package/dist/views/CritterLogsView.d.ts +8 -0
  37. package/dist/views/CritterLogsView.js +100 -0
  38. package/dist/views/IssuesView.d.ts +2 -1
  39. package/dist/views/IssuesView.js +661 -98
  40. package/dist/views/LivestockDetailView.d.ts +2 -1
  41. package/dist/views/LivestockDetailView.js +8 -1
  42. package/dist/views/ProjectContext.js +35 -1
  43. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: yeehaw-development
3
+ description: Guide development using Yeehaw's wiki as context source and living documentation. Use when working on any project tracked in Yeehaw - consult wiki before architectural decisions and contribute back when patterns evolve.
4
+ ---
5
+
6
+ # Yeehaw Development
7
+
8
+ You're working on a project tracked in Yeehaw. The project wiki contains curated context to guide development - architecture patterns, conventions, gotchas, and common tasks documented by the project owner and evolved through development.
9
+
10
+ ## Wiki-First Development
11
+
12
+ Before making architectural decisions or implementing significant features:
13
+
14
+ 1. **Check available sections** with `mcp__yeehaw__get_wiki`
15
+ 2. **Fetch relevant sections** with `mcp__yeehaw__get_wiki_section`
16
+ 3. **Respect documented patterns** - they exist for a reason
17
+
18
+ The wiki is your primary source of project-specific context. It contains knowledge that isn't obvious from the code alone.
19
+
20
+ ## When to Consult the Wiki
21
+
22
+ | Task | Check These Sections |
23
+ |------|---------------------|
24
+ | Adding features | Architecture, Conventions, Common Tasks |
25
+ | Debugging issues | Gotchas, Domain Context |
26
+ | Unsure about patterns | Conventions, Architecture |
27
+ | New integrations | Architecture, Gotchas |
28
+ | Understanding domain | Domain Context |
29
+
30
+ If a section exists that's relevant to your task, read it before proceeding.
31
+
32
+ ## Proactive Wiki Updates
33
+
34
+ The wiki is a living document. After completing significant work, consider whether the wiki should be updated:
35
+
36
+ **When to update existing sections:**
37
+ - A documented pattern has evolved
38
+ - You discovered nuances worth capturing
39
+ - Steps in Common Tasks have changed
40
+
41
+ **When to add new sections:**
42
+ - Introduced a new architectural pattern
43
+ - Discovered gotchas during debugging
44
+ - Built a feature type that others might repeat
45
+ - Established new conventions
46
+
47
+ **How to update:**
48
+ - `mcp__yeehaw__update_wiki_section` for edits
49
+ - `mcp__yeehaw__add_wiki_section` for new content
50
+
51
+ **Guidelines:**
52
+ - Not every session needs to update the wiki
53
+ - Only update when something genuinely changes project direction or establishes new patterns
54
+ - Keep sections focused and scannable
55
+ - Be specific to THIS codebase, not generic advice
56
+
57
+ ## Project Context
58
+
59
+ Use `mcp__yeehaw__get_project` to understand:
60
+ - Project structure and configured paths
61
+ - Deployment environments (livestock)
62
+ - Available wiki sections
63
+
64
+ When spawned from a specific livestock, you may be focused on deployment-specific work - check if there's relevant context in the wiki.
65
+
66
+ ## Summary
67
+
68
+ 1. **Before major decisions** → check the wiki
69
+ 2. **After significant work** → consider updating the wiki
70
+ 3. **When unsure** → the wiki likely has guidance
package/dist/app.js CHANGED
@@ -13,12 +13,15 @@ import { WikiView } from './views/WikiView.js';
13
13
  import { IssuesView } from './views/IssuesView.js';
14
14
  import { LivestockDetailView } from './views/LivestockDetailView.js';
15
15
  import { LogsView } from './views/LogsView.js';
16
+ import { CritterDetailView } from './views/CritterDetailView.js';
17
+ import { CritterLogsView } from './views/CritterLogsView.js';
16
18
  import { NightSkyView } from './views/NightSkyView.js';
17
19
  import { useConfig } from './hooks/useConfig.js';
18
20
  import { useSessions } from './hooks/useSessions.js';
19
21
  import { useRemoteYeehaw } from './hooks/useRemoteYeehaw.js';
20
- import { hasTmux, switchToWindow, updateStatusBar, createShellWindow, createSshWindow, detachFromSession, killYeehawSession, restartYeehaw, enterRemoteMode, ensureCorrectStatusBar, } from './lib/tmux.js';
22
+ import { hasTmux, switchToWindow, updateStatusBar, createShellWindow, createSshWindow, detachFromSession, killYeehawSession, restartYeehaw, enterRemoteMode, ensureCorrectStatusBar, createClaudeWindowWithPrompt, } from './lib/tmux.js';
21
23
  import { saveProject, deleteProject, saveBarn, deleteBarn, getLivestockForBarn, isLocalBarn, hasValidSshConfig, addCritterToBarn, removeCritterFromBarn } from './lib/config.js';
24
+ import { buildProjectContext, buildLivestockContext } from './lib/context.js';
22
25
  import { getVersionInfo } from './lib/update-check.js';
23
26
  function getHotkeyScope(view) {
24
27
  switch (view.type) {
@@ -29,6 +32,8 @@ function getHotkeyScope(view) {
29
32
  case 'issues': return 'issues-view';
30
33
  case 'livestock': return 'livestock-detail';
31
34
  case 'logs': return 'logs-view';
35
+ case 'critter': return 'critter-detail';
36
+ case 'critter-logs': return 'critter-logs';
32
37
  case 'night-sky': return 'night-sky';
33
38
  default: return 'global-dashboard';
34
39
  }
@@ -40,7 +45,8 @@ function expandPath(path) {
40
45
  return path;
41
46
  }
42
47
  // Global bottom bar items - minimal, consistent across all views
43
- function getBottomBarItems(viewType) {
48
+ // Every hotkey should be visible somewhere in our 3-tier system
49
+ function getBottomBarItems(viewType, options) {
44
50
  // Night sky has its own unique actions
45
51
  if (viewType === 'night-sky') {
46
52
  return [
@@ -49,29 +55,92 @@ function getBottomBarItems(viewType) {
49
55
  { key: 'Esc', label: 'exit' },
50
56
  ];
51
57
  }
52
- // Global dashboard: exit options
58
+ // Global dashboard: exit options + visualizer
53
59
  if (viewType === 'global') {
54
60
  return [
61
+ { key: 'v', label: 'visualizer' },
55
62
  { key: 'q', label: 'detach' },
56
63
  { key: 'Q', label: 'quit' },
57
64
  { key: 'Tab', label: '' },
58
65
  { key: '?', label: 'help' },
59
66
  ];
60
67
  }
68
+ // Project context: page-level actions
69
+ if (viewType === 'project') {
70
+ return [
71
+ { key: 'w', label: 'wiki' },
72
+ { key: 'i', label: 'issues' },
73
+ { key: 'e', label: 'edit' },
74
+ { key: 'Esc', label: 'back' },
75
+ { key: 'Tab', label: '' },
76
+ { key: '?', label: 'help' },
77
+ ];
78
+ }
79
+ // Barn context: page-level actions (edit only for remote barns)
80
+ if (viewType === 'barn') {
81
+ const items = [];
82
+ if (!options?.isLocalBarn) {
83
+ items.push({ key: 'e', label: 'edit' });
84
+ }
85
+ items.push({ key: 'Esc', label: 'back' }, { key: 'Tab', label: '' }, { key: '?', label: 'help' });
86
+ return items;
87
+ }
88
+ // Wiki view: panel hints handle n/e/d, bottom bar just needs navigation
89
+ if (viewType === 'wiki') {
90
+ return [
91
+ { key: 'Esc', label: 'back' },
92
+ { key: 'Tab', label: '' },
93
+ { key: '?', label: 'help' },
94
+ ];
95
+ }
96
+ // Issues view: page-level actions (f/r are page-level, c/o are row-level shown on selected item)
97
+ if (viewType === 'issues') {
98
+ return [
99
+ { key: 'f', label: 'filter' },
100
+ { key: 'r', label: 'refresh' },
101
+ { key: 'Esc', label: 'back' },
102
+ { key: 'Tab', label: '' },
103
+ { key: '?', label: 'help' },
104
+ ];
105
+ }
61
106
  // Livestock detail: page-level actions
107
+ // Local livestock gets [c] claude, remote only gets [s] shell
62
108
  if (viewType === 'livestock') {
109
+ const items = [];
110
+ if (options?.isLocalLivestock) {
111
+ items.push({ key: 'c', label: 'claude' });
112
+ }
113
+ items.push({ key: 's', label: 'shell' }, { key: 'l', label: 'logs' }, { key: 'e', label: 'edit' }, { key: 'Esc', label: 'back' }, { key: '?', label: 'help' });
114
+ return items;
115
+ }
116
+ // Logs view: page-level actions
117
+ if (viewType === 'logs') {
118
+ return [
119
+ { key: 'r', label: 'refresh' },
120
+ { key: 'Esc', label: 'back' },
121
+ { key: '?', label: 'help' },
122
+ ];
123
+ }
124
+ // Critter detail: page-level actions
125
+ if (viewType === 'critter') {
63
126
  return [
64
- { key: 's', label: 'shell' },
65
127
  { key: 'l', label: 'logs' },
66
128
  { key: 'e', label: 'edit' },
67
129
  { key: 'Esc', label: 'back' },
68
130
  { key: '?', label: 'help' },
69
131
  ];
70
132
  }
71
- // All other views: back + help
133
+ // Critter logs view: page-level actions
134
+ if (viewType === 'critter-logs') {
135
+ return [
136
+ { key: 'r', label: 'refresh' },
137
+ { key: 'Esc', label: 'back' },
138
+ { key: '?', label: 'help' },
139
+ ];
140
+ }
141
+ // Fallback (shouldn't reach here)
72
142
  return [
73
143
  { key: 'Esc', label: 'back' },
74
- { key: 'Tab', label: '' },
75
144
  { key: '?', label: 'help' },
76
145
  ];
77
146
  }
@@ -127,6 +196,16 @@ export function App() {
127
196
  return { ...currentView, barn: freshBarn };
128
197
  }
129
198
  }
199
+ // Update critter detail view
200
+ if (currentView.type === 'critter' || currentView.type === 'critter-logs') {
201
+ const freshBarn = barns.find((b) => b.name === currentView.barn.name);
202
+ if (freshBarn && freshBarn !== currentView.barn) {
203
+ const freshCritter = (freshBarn.critters || []).find((c) => c.name === currentView.critter.name);
204
+ if (freshCritter) {
205
+ return { ...currentView, barn: freshBarn, critter: freshCritter };
206
+ }
207
+ }
208
+ }
130
209
  return currentView;
131
210
  });
132
211
  }, [projects, barns]);
@@ -154,7 +233,11 @@ export function App() {
154
233
  const projectName = view.type === 'project' ? view.project.name : 'yeehaw';
155
234
  const workingDir = view.type === 'project' ? expandPath(view.project.path) : process.cwd();
156
235
  const windowName = `${projectName}-claude`;
157
- const windowIndex = createClaude(workingDir, windowName);
236
+ // Inject context when in a project view
237
+ const context = view.type === 'project' ? buildProjectContext(view.project.name) : null;
238
+ const windowIndex = context
239
+ ? createClaudeWindowWithPrompt(workingDir, windowName, context)
240
+ : createClaude(workingDir, windowName);
158
241
  switchToWindow(windowIndex);
159
242
  }
160
243
  catch (err) {
@@ -170,7 +253,11 @@ export function App() {
170
253
  try {
171
254
  const workingDir = expandPath(project.path);
172
255
  const windowName = `${project.name}-claude`;
173
- const windowIndex = createClaude(workingDir, windowName);
256
+ const context = buildProjectContext(project.name);
257
+ // Use context injection if we have project context, otherwise fall back to basic
258
+ const windowIndex = context
259
+ ? createClaudeWindowWithPrompt(workingDir, windowName, context)
260
+ : createClaude(workingDir, windowName);
174
261
  switchToWindow(windowIndex);
175
262
  }
176
263
  catch (err) {
@@ -186,7 +273,11 @@ export function App() {
186
273
  try {
187
274
  const workingDir = expandPath(livestock.path);
188
275
  const windowName = `${projectName}-${livestock.name}-claude`;
189
- const windowIndex = createClaude(workingDir, windowName);
276
+ const context = buildLivestockContext(projectName, livestock.name);
277
+ // Use context injection if we have project context, otherwise fall back to basic
278
+ const windowIndex = context
279
+ ? createClaudeWindowWithPrompt(workingDir, windowName, context)
280
+ : createClaude(workingDir, windowName);
190
281
  switchToWindow(windowIndex);
191
282
  }
192
283
  catch (err) {
@@ -194,6 +285,24 @@ export function App() {
194
285
  setError(`Failed to create Claude session: ${message}`);
195
286
  }
196
287
  }, [tmuxAvailable, createClaude]);
288
+ const handleOpenClaudeWithContext = useCallback((workingDir, issueContext) => {
289
+ if (!tmuxAvailable) {
290
+ setError('tmux is not installed');
291
+ return;
292
+ }
293
+ try {
294
+ // Get the project name for the window name
295
+ const projectName = (view.type === 'issues' ? view.project.name : 'issue');
296
+ const windowName = `${projectName}-claude`;
297
+ // Create claude window with system prompt
298
+ const index = createClaudeWindowWithPrompt(workingDir, windowName, issueContext);
299
+ switchToWindow(index);
300
+ }
301
+ catch (err) {
302
+ const message = err instanceof Error ? err.message : String(err);
303
+ setError(`Failed to create Claude session: ${message}`);
304
+ }
305
+ }, [tmuxAvailable, view]);
197
306
  const handleOpenLivestockSession = useCallback((livestock, barn, projectName) => {
198
307
  if (!tmuxAvailable) {
199
308
  setError('tmux is not installed');
@@ -276,6 +385,26 @@ export function App() {
276
385
  // Update the view with the new livestock data, preserving navigation context
277
386
  setView({ type: 'livestock', project: updatedProject, livestock: updatedLivestock, source, sourceBarn });
278
387
  }, [reload]);
388
+ const handleOpenCritterDetail = useCallback((barn, critter) => {
389
+ setView({ type: 'critter', barn, critter });
390
+ updateStatusBar(`${barn.name} / ${critter.name}`);
391
+ }, []);
392
+ const handleOpenCritterLogs = useCallback((barn, critter) => {
393
+ setView({ type: 'critter-logs', barn, critter });
394
+ updateStatusBar(`${barn.name} / ${critter.name} Logs`);
395
+ }, []);
396
+ const handleBackFromCritter = useCallback((barn) => {
397
+ setView({ type: 'barn', barn });
398
+ updateStatusBar(`Barn: ${barn.name}`);
399
+ }, []);
400
+ const handleUpdateCritter = useCallback((barn, originalCritter, updatedCritter) => {
401
+ // Remove old critter and add updated one
402
+ removeCritterFromBarn(barn.name, originalCritter.name);
403
+ addCritterToBarn(barn.name, updatedCritter);
404
+ reload();
405
+ // Update view with new critter data
406
+ setView({ type: 'critter', barn, critter: updatedCritter });
407
+ }, [reload]);
279
408
  const handleDeleteProject = useCallback((projectName) => {
280
409
  deleteProject(projectName);
281
410
  reload();
@@ -407,6 +536,13 @@ export function App() {
407
536
  else if (view.type === 'livestock') {
408
537
  handleBackFromLivestock(view.source, view.project, view.sourceBarn);
409
538
  }
539
+ else if (view.type === 'critter-logs') {
540
+ setView({ type: 'critter', barn: view.barn, critter: view.critter });
541
+ updateStatusBar(`${view.barn.name} / ${view.critter.name}`);
542
+ }
543
+ else if (view.type === 'critter') {
544
+ handleBackFromCritter(view.barn);
545
+ }
410
546
  else if (view.type === 'project' || view.type === 'barn') {
411
547
  handleBack();
412
548
  }
@@ -475,21 +611,29 @@ export function App() {
475
611
  }, onRemoveCritter: (critterName) => {
476
612
  removeCritterFromBarn(view.barn.name, critterName);
477
613
  reload();
478
- } }));
614
+ }, onSelectCritter: (critter) => handleOpenCritterDetail(view.barn, critter) }));
479
615
  case 'wiki':
480
616
  return (_jsx(WikiView, { project: view.project, onBack: () => handleBackFromSubview(view.project), onUpdateProject: handleUpdateProject }));
481
617
  case 'issues':
482
- return (_jsx(IssuesView, { project: view.project, onBack: () => handleBackFromSubview(view.project) }));
618
+ return (_jsx(IssuesView, { project: view.project, onBack: () => handleBackFromSubview(view.project), onOpenClaude: handleOpenClaudeWithContext }));
483
619
  case 'livestock':
620
+ const livestockBarn = barns.find((b) => b.name === view.livestock.barn) || null;
621
+ const isLocalLivestock = !livestockBarn || isLocalBarn(livestockBarn);
484
622
  return (_jsx(LivestockDetailView, { project: view.project, livestock: view.livestock, source: view.source, sourceBarn: view.sourceBarn, windows: windows, onBack: () => handleBackFromLivestock(view.source, view.project, view.sourceBarn), onOpenLogs: () => handleOpenLogs(view.project, view.livestock, view.source, view.sourceBarn), onOpenSession: () => {
485
- const barn = barns.find((b) => b.name === view.livestock.barn) || null;
486
- handleOpenLivestockSession(view.livestock, barn, view.project.name);
487
- }, onSelectWindow: handleSelectWindow, onUpdateLivestock: (originalLivestock, updatedLivestock) => handleUpdateLivestock(view.project, originalLivestock, updatedLivestock, view.source, view.sourceBarn) }));
623
+ handleOpenLivestockSession(view.livestock, livestockBarn, view.project.name);
624
+ }, onOpenClaude: isLocalLivestock ? () => handleNewClaudeForLivestock(view.livestock, view.project.name) : undefined, onSelectWindow: handleSelectWindow, onUpdateLivestock: (originalLivestock, updatedLivestock) => handleUpdateLivestock(view.project, originalLivestock, updatedLivestock, view.source, view.sourceBarn) }));
488
625
  case 'logs':
489
626
  return (_jsx(LogsView, { project: view.project, livestock: view.livestock, onBack: () => {
490
627
  setView({ type: 'livestock', project: view.project, livestock: view.livestock, source: view.source, sourceBarn: view.sourceBarn });
491
628
  updateStatusBar(`${view.project.name} / ${view.livestock.name}`);
492
629
  } }));
630
+ case 'critter':
631
+ return (_jsx(CritterDetailView, { barn: view.barn, critter: view.critter, onBack: () => handleBackFromCritter(view.barn), onOpenLogs: () => handleOpenCritterLogs(view.barn, view.critter), onUpdateCritter: (original, updated) => handleUpdateCritter(view.barn, original, updated) }));
632
+ case 'critter-logs':
633
+ return (_jsx(CritterLogsView, { barn: view.barn, critter: view.critter, onBack: () => {
634
+ setView({ type: 'critter', barn: view.barn, critter: view.critter });
635
+ updateStatusBar(`${view.barn.name} / ${view.critter.name}`);
636
+ } }));
493
637
  case 'night-sky':
494
638
  return (_jsx(NightSkyView, { onExit: handleExitNightSky }));
495
639
  }
@@ -500,5 +644,12 @@ export function App() {
500
644
  if (showSplash) {
501
645
  return _jsx(SplashScreen, { onComplete: handleSplashComplete });
502
646
  }
503
- return (_jsxs(Box, { flexDirection: "column", height: terminalHeight, children: [error && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: renderView() }), !showHelp && (_jsx(BottomBar, { items: getBottomBarItems(view.type), environments: environments, isDetecting: isDetecting }))] }));
647
+ return (_jsxs(Box, { flexDirection: "column", height: terminalHeight, children: [error && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: renderView() }), !showHelp && (_jsx(BottomBar, { items: getBottomBarItems(view.type, {
648
+ isLocalLivestock: view.type === 'livestock'
649
+ ? !view.livestock.barn || isLocalBarn(barns.find((b) => b.name === view.livestock.barn) || { name: 'local' })
650
+ : undefined,
651
+ isLocalBarn: view.type === 'barn'
652
+ ? isLocalBarn(view.barn)
653
+ : undefined,
654
+ }), environments: environments, isDetecting: isDetecting }))] }));
504
655
  }
@@ -0,0 +1,7 @@
1
+ import type { Critter, Barn } from '../types.js';
2
+ interface CritterHeaderProps {
3
+ barn: Barn;
4
+ critter: Critter;
5
+ }
6
+ export declare function CritterHeader({ barn, critter }: CritterHeaderProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ // Rabbit ASCII art template - @ symbols are pattern spots
4
+ const RABBIT_TEMPLATE = [
5
+ " __",
6
+ " /\\ .-\" /",
7
+ " / ; .' .' ",
8
+ " : :/ .' ",
9
+ " \\ ;-.' ",
10
+ " .--\"\"\"\"--..__/ `. ",
11
+ " .'@@@@@@@@@@@.' o \\ ",
12
+ " /@@@@@@@@@@@@@@@@ ; ",
13
+ " :@@@@@@@@@@@@@@@@\\ : ",
14
+ " .-;@@@@@@@@-.@@@@@@@`.__.--' ",
15
+ ": ;@@@@@@@@@@\\@@@@@, ; ",
16
+ "'._:@@@@@@@@@@@;@@@: ( ",
17
+ " \\/ .__ ; \\ `-. ",
18
+ " ; \"-,/_..--\"`-..__) ",
19
+ " '\"\"--.._: ",
20
+ ];
21
+ // Generate multiple hash values from a string for better distribution
22
+ function multiHash(str) {
23
+ const hashes = [];
24
+ // First hash - djb2
25
+ let hash1 = 5381;
26
+ for (let i = 0; i < str.length; i++) {
27
+ hash1 = ((hash1 << 5) + hash1) ^ str.charCodeAt(i);
28
+ }
29
+ hashes.push(Math.abs(hash1));
30
+ // Second hash - sdbm
31
+ let hash2 = 0;
32
+ for (let i = 0; i < str.length; i++) {
33
+ hash2 = str.charCodeAt(i) + (hash2 << 6) + (hash2 << 16) - hash2;
34
+ }
35
+ hashes.push(Math.abs(hash2));
36
+ // Third hash - fnv-1a inspired
37
+ let hash3 = 2166136261;
38
+ for (let i = 0; i < str.length; i++) {
39
+ hash3 ^= str.charCodeAt(i);
40
+ hash3 = (hash3 * 16777619) >>> 0;
41
+ }
42
+ hashes.push(hash3);
43
+ return hashes;
44
+ }
45
+ // Pattern characters - space and block characters
46
+ const PATTERN_CHARS = [' ', ' ', '░', '░', '▒', '▓', '█'];
47
+ // Generate pattern variation for the rabbit based on critter/barn data
48
+ function generateRabbitArt(critter, barn) {
49
+ // Use barn name + critter name + service for unique seed
50
+ const seed = `${barn.name}-${critter.name}-${critter.service}`;
51
+ const hashes = multiHash(seed);
52
+ // Replace @ symbols with pattern characters
53
+ let charIndex = 0;
54
+ const rabbitArt = RABBIT_TEMPLATE.map((line, lineIndex) => {
55
+ let result = '';
56
+ for (const char of line) {
57
+ if (char === '@') {
58
+ const h1 = hashes[0];
59
+ const h2 = hashes[1];
60
+ const h3 = hashes[2];
61
+ const mix = (h1 >> (charIndex % 17)) ^
62
+ (h2 >> ((charIndex + lineIndex) % 13)) ^
63
+ (h3 >> ((charIndex * 7 + lineIndex * 3) % 19));
64
+ const charChoice = Math.abs(mix) % PATTERN_CHARS.length;
65
+ result += PATTERN_CHARS[charChoice];
66
+ charIndex++;
67
+ }
68
+ else {
69
+ result += char;
70
+ }
71
+ }
72
+ return result;
73
+ });
74
+ return rabbitArt;
75
+ }
76
+ export function CritterHeader({ barn, critter }) {
77
+ const rabbitArt = generateRabbitArt(critter, barn);
78
+ // Use a default color for critters (could be configurable later)
79
+ const color = '#8fbc8f'; // Dark sea green
80
+ return (_jsx(Box, { flexDirection: "column", paddingTop: 1, paddingLeft: 1, children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexDirection: "column", children: rabbitArt.map((line, i) => (_jsx(Text, { color: color, bold: true, children: line }, i))) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, justifyContent: "center", children: [_jsx(Text, { bold: true, color: color, children: critter.name }), _jsxs(Text, { dimColor: true, children: ["barn: ", barn.name] }), _jsxs(Text, { dimColor: true, children: ["service: ", critter.service] })] })] }) }));
81
+ }
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  import type { SessionStatus } from '../lib/signals.js';
2
3
  export interface RowAction {
3
4
  key: string;
@@ -10,6 +11,7 @@ export interface ListItem {
10
11
  meta?: string;
11
12
  sessionStatus?: SessionStatus;
12
13
  actions?: RowAction[];
14
+ prefix?: React.ReactNode;
13
15
  }
14
16
  interface ListProps {
15
17
  items: ListItem[];
@@ -57,6 +57,6 @@ export function List({ items, focused = false, selectedIndex: controlledIndex, o
57
57
  item.sessionStatus === 'error' ? 'red' : undefined;
58
58
  const statusColor = item.status === 'active' ? 'green' :
59
59
  item.status === 'error' ? 'red' : 'gray';
60
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 0, width: 2, children: _jsx(Text, { color: isSelected ? BRAND_COLOR : undefined, children: isSelected ? '›' : ' ' }) }), _jsxs(Box, { gap: 1, flexShrink: 1, flexGrow: 1, overflow: "hidden", children: [_jsx(Text, { color: isSelected ? BRAND_COLOR : undefined, bold: isSelected, wrap: "truncate", children: item.label }), item.status && (_jsx(Text, { color: statusColor, children: "\u25CF" })), item.meta && (_jsx(Text, { color: sessionStatusColor, dimColor: !sessionStatusColor, wrap: "truncate", children: item.meta }))] }), isSelected && item.actions && item.actions.length > 0 && (_jsx(Box, { gap: 2, flexShrink: 0, marginLeft: 1, children: item.actions.map((action) => (_jsxs(Text, { children: [_jsxs(Text, { color: BRAND_COLOR, children: ["[", action.key, "]"] }), _jsxs(Text, { dimColor: true, children: [" ", action.label] })] }, action.key))) }))] }, item.id));
60
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 0, width: 2, children: _jsx(Text, { color: isSelected ? BRAND_COLOR : undefined, children: isSelected ? '›' : ' ' }) }), _jsxs(Box, { gap: 1, flexShrink: 1, flexGrow: 1, overflow: "hidden", children: [item.prefix && (_jsx(Box, { flexShrink: 0, children: item.prefix })), _jsx(Text, { color: isSelected ? BRAND_COLOR : undefined, bold: isSelected, wrap: "truncate", children: item.label }), item.status && (_jsx(Text, { color: statusColor, children: "\u25CF" })), item.meta && (_jsx(Text, { color: sessionStatusColor, dimColor: !sessionStatusColor, wrap: "truncate", children: item.meta }))] }), isSelected && item.actions && item.actions.length > 0 && (_jsx(Box, { gap: 2, flexShrink: 0, marginLeft: 1, children: item.actions.map((action) => (_jsxs(Text, { children: [_jsxs(Text, { color: BRAND_COLOR, children: ["[", action.key, "]"] }), _jsxs(Text, { dimColor: true, children: [" ", action.label] })] }, action.key))) }))] }, item.id));
61
61
  }) }));
62
62
  }
@@ -0,0 +1,2 @@
1
+ export * from './storage.js';
2
+ export * from './linear.js';
@@ -0,0 +1,3 @@
1
+ // src/lib/auth/index.ts
2
+ export * from './storage.js';
3
+ export * from './linear.js';
@@ -0,0 +1,20 @@
1
+ import { clearLinearToken } from './storage.js';
2
+ export declare const LINEAR_API_KEY_URL = "https://linear.app/settings/api";
3
+ export { clearLinearToken };
4
+ /**
5
+ * Check if Linear is currently authenticated.
6
+ */
7
+ export declare function isLinearAuthenticated(): boolean;
8
+ /**
9
+ * Save a Linear API key.
10
+ */
11
+ export declare function saveLinearApiKey(apiKey: string): void;
12
+ /**
13
+ * Validate a Linear API key by making a test request.
14
+ * Returns true if valid, false otherwise.
15
+ */
16
+ export declare function validateLinearApiKey(apiKey: string): Promise<boolean>;
17
+ /**
18
+ * Make an authenticated request to Linear's GraphQL API.
19
+ */
20
+ export declare function linearGraphQL<T>(query: string, variables?: Record<string, unknown>): Promise<T>;
@@ -0,0 +1,79 @@
1
+ // src/lib/auth/linear.ts
2
+ // Linear authentication using Personal API Keys
3
+ // Users create keys at: https://linear.app/settings/api
4
+ import { setLinearToken, getLinearToken, clearLinearToken } from './storage.js';
5
+ export const LINEAR_API_KEY_URL = 'https://linear.app/settings/api';
6
+ export { clearLinearToken };
7
+ /**
8
+ * Check if Linear is currently authenticated.
9
+ */
10
+ export function isLinearAuthenticated() {
11
+ return getLinearToken() !== null;
12
+ }
13
+ /**
14
+ * Save a Linear API key.
15
+ */
16
+ export function saveLinearApiKey(apiKey) {
17
+ setLinearToken(apiKey);
18
+ }
19
+ /**
20
+ * Validate a Linear API key by making a test request.
21
+ * Returns true if valid, false otherwise.
22
+ */
23
+ export async function validateLinearApiKey(apiKey) {
24
+ try {
25
+ const response = await fetch('https://api.linear.app/graphql', {
26
+ method: 'POST',
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ 'Authorization': apiKey,
30
+ },
31
+ body: JSON.stringify({
32
+ query: '{ viewer { id } }',
33
+ }),
34
+ });
35
+ if (!response.ok) {
36
+ return false;
37
+ }
38
+ const text = await response.text();
39
+ // Check if we got HTML instead of JSON (indicates auth/routing issue)
40
+ if (text.startsWith('<!') || text.startsWith('<html')) {
41
+ return false;
42
+ }
43
+ const result = JSON.parse(text);
44
+ return !!(result.data?.viewer?.id);
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /**
51
+ * Make an authenticated request to Linear's GraphQL API.
52
+ */
53
+ export async function linearGraphQL(query, variables) {
54
+ const token = getLinearToken();
55
+ if (!token) {
56
+ throw new Error('Not authenticated with Linear');
57
+ }
58
+ const response = await fetch('https://api.linear.app/graphql', {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'Authorization': token,
63
+ },
64
+ body: JSON.stringify({ query, variables }),
65
+ });
66
+ if (!response.ok) {
67
+ throw new Error(`Linear API error: ${response.statusText}`);
68
+ }
69
+ const text = await response.text();
70
+ // Check if we got HTML instead of JSON
71
+ if (text.startsWith('<!') || text.startsWith('<html')) {
72
+ throw new Error('Linear API returned HTML instead of JSON - check your API key');
73
+ }
74
+ const result = JSON.parse(text);
75
+ if (result.errors?.length) {
76
+ throw new Error(result.errors[0].message);
77
+ }
78
+ return result.data;
79
+ }
@@ -0,0 +1,12 @@
1
+ export interface LinearAuth {
2
+ accessToken: string;
3
+ expiresAt?: string;
4
+ }
5
+ export interface AuthConfig {
6
+ linear?: LinearAuth;
7
+ }
8
+ export declare function loadAuth(): AuthConfig;
9
+ export declare function saveAuth(auth: AuthConfig): void;
10
+ export declare function getLinearToken(): string | null;
11
+ export declare function setLinearToken(accessToken: string, expiresAt?: Date): void;
12
+ export declare function clearLinearToken(): void;
@@ -0,0 +1,53 @@
1
+ // src/lib/auth/storage.ts
2
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
3
+ import YAML from 'js-yaml';
4
+ import { AUTH_FILE, YEEHAW_DIR } from '../paths.js';
5
+ import { mkdirSync } from 'fs';
6
+ export function loadAuth() {
7
+ if (!existsSync(YEEHAW_DIR)) {
8
+ mkdirSync(YEEHAW_DIR, { recursive: true });
9
+ }
10
+ if (!existsSync(AUTH_FILE)) {
11
+ return {};
12
+ }
13
+ try {
14
+ const content = readFileSync(AUTH_FILE, 'utf-8');
15
+ return YAML.load(content) || {};
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ export function saveAuth(auth) {
22
+ if (!existsSync(YEEHAW_DIR)) {
23
+ mkdirSync(YEEHAW_DIR, { recursive: true });
24
+ }
25
+ writeFileSync(AUTH_FILE, YAML.dump(auth), 'utf-8');
26
+ }
27
+ export function getLinearToken() {
28
+ const auth = loadAuth();
29
+ if (!auth.linear?.accessToken) {
30
+ return null;
31
+ }
32
+ // Check expiration if set
33
+ if (auth.linear.expiresAt) {
34
+ const expiresAt = new Date(auth.linear.expiresAt);
35
+ if (expiresAt <= new Date()) {
36
+ return null; // Token expired
37
+ }
38
+ }
39
+ return auth.linear.accessToken;
40
+ }
41
+ export function setLinearToken(accessToken, expiresAt) {
42
+ const auth = loadAuth();
43
+ auth.linear = {
44
+ accessToken,
45
+ expiresAt: expiresAt?.toISOString(),
46
+ };
47
+ saveAuth(auth);
48
+ }
49
+ export function clearLinearToken() {
50
+ const auth = loadAuth();
51
+ delete auth.linear;
52
+ saveAuth(auth);
53
+ }