@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,43 @@
1
+ export interface GitHubIssue {
2
+ number: number;
3
+ title: string;
4
+ state: 'open' | 'closed';
5
+ author: string;
6
+ labels: string[];
7
+ createdAt: string;
8
+ updatedAt: string;
9
+ commentsCount: number;
10
+ body: string | null;
11
+ url: string;
12
+ }
13
+ export interface GitHubRepo {
14
+ owner: string;
15
+ repo: string;
16
+ }
17
+ /**
18
+ * Parse a GitHub URL to extract owner and repo.
19
+ * Handles formats like:
20
+ * - https://github.com/owner/repo
21
+ * - https://github.com/owner/repo.git
22
+ * - git@github.com:owner/repo.git
23
+ */
24
+ export declare function parseGitHubUrl(url: string): GitHubRepo | null;
25
+ /**
26
+ * Check if gh CLI is installed and authenticated.
27
+ */
28
+ export declare function isGhCliAvailable(): boolean;
29
+ /**
30
+ * Fetch issues for a GitHub repository using gh CLI.
31
+ */
32
+ export declare function fetchGitHubIssues(owner: string, repo: string, options?: {
33
+ state?: 'open' | 'closed' | 'all';
34
+ limit?: number;
35
+ }): GitHubIssue[];
36
+ /**
37
+ * Fetch a single issue with full details.
38
+ */
39
+ export declare function fetchGitHubIssue(owner: string, repo: string, issueNumber: number): GitHubIssue | null;
40
+ /**
41
+ * Open an issue in the default browser.
42
+ */
43
+ export declare function openIssueInBrowser(url: string): void;
@@ -0,0 +1,111 @@
1
+ import { execaSync } from 'execa';
2
+ /**
3
+ * Parse a GitHub URL to extract owner and repo.
4
+ * Handles formats like:
5
+ * - https://github.com/owner/repo
6
+ * - https://github.com/owner/repo.git
7
+ * - git@github.com:owner/repo.git
8
+ */
9
+ export function parseGitHubUrl(url) {
10
+ // HTTPS format
11
+ const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
12
+ if (httpsMatch) {
13
+ return { owner: httpsMatch[1], repo: httpsMatch[2].replace(/\.git$/, '') };
14
+ }
15
+ // SSH format
16
+ const sshMatch = url.match(/github\.com:([^/]+)\/([^/.\s]+)/);
17
+ if (sshMatch) {
18
+ return { owner: sshMatch[1], repo: sshMatch[2].replace(/\.git$/, '') };
19
+ }
20
+ return null;
21
+ }
22
+ /**
23
+ * Check if gh CLI is installed and authenticated.
24
+ */
25
+ export function isGhCliAvailable() {
26
+ try {
27
+ execaSync('gh', ['auth', 'status']);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Fetch issues for a GitHub repository using gh CLI.
36
+ */
37
+ export function fetchGitHubIssues(owner, repo, options = {}) {
38
+ const { state = 'open', limit = 50 } = options;
39
+ try {
40
+ const result = execaSync('gh', [
41
+ 'issue', 'list',
42
+ '--repo', `${owner}/${repo}`,
43
+ '--state', state,
44
+ '--limit', String(limit),
45
+ '--json', 'number,title,state,author,labels,createdAt,updatedAt,comments,body,url',
46
+ ]);
47
+ const issues = JSON.parse(result.stdout);
48
+ return issues.map((issue) => ({
49
+ number: issue.number,
50
+ title: issue.title,
51
+ state: issue.state.toLowerCase(),
52
+ author: issue.author.login,
53
+ labels: issue.labels.map((l) => l.name),
54
+ createdAt: issue.createdAt,
55
+ updatedAt: issue.updatedAt,
56
+ commentsCount: issue.comments.length,
57
+ body: issue.body || null,
58
+ url: issue.url,
59
+ }));
60
+ }
61
+ catch (err) {
62
+ console.error('[github] Failed to fetch issues:', err);
63
+ return [];
64
+ }
65
+ }
66
+ /**
67
+ * Fetch a single issue with full details.
68
+ */
69
+ export function fetchGitHubIssue(owner, repo, issueNumber) {
70
+ try {
71
+ const result = execaSync('gh', [
72
+ 'issue', 'view', String(issueNumber),
73
+ '--repo', `${owner}/${repo}`,
74
+ '--json', 'number,title,state,author,labels,createdAt,updatedAt,comments,body,url',
75
+ ]);
76
+ const issue = JSON.parse(result.stdout);
77
+ return {
78
+ number: issue.number,
79
+ title: issue.title,
80
+ state: issue.state.toLowerCase(),
81
+ author: issue.author.login,
82
+ labels: issue.labels.map((l) => l.name),
83
+ createdAt: issue.createdAt,
84
+ updatedAt: issue.updatedAt,
85
+ commentsCount: issue.comments.length,
86
+ body: issue.body || null,
87
+ url: issue.url,
88
+ };
89
+ }
90
+ catch (err) {
91
+ console.error('[github] Failed to fetch issue:', err);
92
+ return null;
93
+ }
94
+ }
95
+ /**
96
+ * Open an issue in the default browser.
97
+ */
98
+ export function openIssueInBrowser(url) {
99
+ try {
100
+ execaSync('open', [url]);
101
+ }
102
+ catch {
103
+ // Fallback for Linux
104
+ try {
105
+ execaSync('xdg-open', [url]);
106
+ }
107
+ catch {
108
+ // Ignore if can't open
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,27 @@
1
+ export type HotkeyScope = 'global' | 'global-dashboard' | 'project-context' | 'barn-context' | 'wiki-view' | 'issues-view' | 'livestock-detail' | 'logs-view' | 'night-sky' | 'list' | 'content';
2
+ export type HotkeyCategory = 'navigation' | 'action' | 'system';
3
+ export interface Hotkey {
4
+ key: string;
5
+ description: string;
6
+ category: HotkeyCategory;
7
+ scopes: HotkeyScope[];
8
+ panel?: string;
9
+ }
10
+ export declare const HOTKEYS: Hotkey[];
11
+ /**
12
+ * Get hotkeys for a specific view and optional panel focus
13
+ */
14
+ export declare function getHotkeysForContext(scope: HotkeyScope, focusedPanel?: string, includeGlobal?: boolean): Hotkey[];
15
+ /**
16
+ * Get hotkeys formatted for the help overlay (grouped by category)
17
+ */
18
+ export declare function getHotkeysGrouped(scope: HotkeyScope, focusedPanel?: string): {
19
+ navigation: Hotkey[];
20
+ action: Hotkey[];
21
+ system: Hotkey[];
22
+ };
23
+ /**
24
+ * Format hotkeys as a single-line hint string
25
+ * Example: "[n] new [e] edit [d] delete [q] back"
26
+ */
27
+ export declare function formatHotkeyHints(scope: HotkeyScope, focusedPanel?: string, maxKeys?: number): string;
@@ -0,0 +1,92 @@
1
+ // src/lib/hotkeys.ts
2
+ export const HOTKEYS = [
3
+ // === GLOBAL (everywhere) ===
4
+ { key: '?', description: 'Toggle help', category: 'system', scopes: ['global'] },
5
+ { key: 'q', description: 'Back / Detach', category: 'system', scopes: ['global'] },
6
+ { key: 'Esc', description: 'Back / Cancel', category: 'system', scopes: ['global'] },
7
+ // === LIST NAVIGATION (any focused list) ===
8
+ { key: 'j/k', description: 'Navigate up/down', category: 'navigation', scopes: ['list'] },
9
+ { key: 'g/G', description: 'Go to first/last', category: 'navigation', scopes: ['list'] },
10
+ { key: 'Enter', description: 'Select item', category: 'navigation', scopes: ['list'] },
11
+ // === CONTENT NAVIGATION (markdown panels) ===
12
+ { key: 'j/k', description: 'Scroll up/down', category: 'navigation', scopes: ['content'] },
13
+ { key: 'g/G', description: 'Jump to top/bottom', category: 'navigation', scopes: ['content'] },
14
+ { key: 'PgUp/PgDn', description: 'Scroll page', category: 'navigation', scopes: ['content'] },
15
+ // === GLOBAL DASHBOARD ===
16
+ { key: 'Tab', description: 'Switch panel', category: 'navigation', scopes: ['global-dashboard', 'project-context', 'barn-context', 'wiki-view', 'issues-view'] },
17
+ { key: 'Q', description: 'Quit Yeehaw', category: 'system', scopes: ['global-dashboard'] },
18
+ { key: 'c', description: 'New Claude session', category: 'action', scopes: ['global-dashboard', 'project-context'] },
19
+ { key: '1-9', description: 'Quick switch window', category: 'action', scopes: ['global-dashboard', 'project-context'] },
20
+ { key: 's', description: 'Open shell session', category: 'action', scopes: ['global-dashboard', 'project-context', 'barn-context', 'livestock-detail'], panel: 'barns' },
21
+ // === CONTEXT-AWARE ACTIONS ===
22
+ { key: 'n', description: 'New (in focused panel)', category: 'action', scopes: ['global-dashboard', 'project-context', 'barn-context', 'wiki-view'] },
23
+ { key: 'e', description: 'Edit selected', category: 'action', scopes: ['project-context', 'barn-context', 'wiki-view'] },
24
+ { key: 'd', description: 'Delete (with confirm)', category: 'action', scopes: ['project-context', 'barn-context', 'wiki-view'] },
25
+ { key: 'D', description: 'Delete container (type name)', category: 'action', scopes: ['project-context', 'barn-context'] },
26
+ // === PROJECT CONTEXT ===
27
+ { key: 'w', description: 'Open wiki', category: 'action', scopes: ['project-context'] },
28
+ { key: 'i', description: 'Open issues', category: 'action', scopes: ['project-context'] },
29
+ // === ISSUES VIEW ===
30
+ { key: 'r', description: 'Refresh issues', category: 'action', scopes: ['issues-view'] },
31
+ { key: 'o', description: 'Open in browser', category: 'action', scopes: ['issues-view'] },
32
+ // === LIVESTOCK DETAIL ===
33
+ { key: 'l', description: 'View logs', category: 'action', scopes: ['livestock-detail'] },
34
+ // === LOGS VIEW ===
35
+ { key: 'r', description: 'Refresh logs', category: 'action', scopes: ['logs-view'] },
36
+ // === NIGHT SKY (screensaver) ===
37
+ { key: 'v', description: 'Visualizer', category: 'navigation', scopes: ['global-dashboard'] },
38
+ { key: 'c', description: 'Spawn cloud', category: 'action', scopes: ['night-sky'] },
39
+ { key: 'r', description: 'Randomize', category: 'action', scopes: ['night-sky'] },
40
+ ];
41
+ /**
42
+ * Get hotkeys for a specific view and optional panel focus
43
+ */
44
+ export function getHotkeysForContext(scope, focusedPanel, includeGlobal = true) {
45
+ return HOTKEYS.filter((h) => {
46
+ // Include if scope matches
47
+ const scopeMatch = h.scopes.includes(scope) ||
48
+ (includeGlobal && h.scopes.includes('global'));
49
+ // If hotkey has panel requirement, check it
50
+ if (h.panel && focusedPanel && h.panel !== focusedPanel) {
51
+ return false;
52
+ }
53
+ return scopeMatch;
54
+ });
55
+ }
56
+ /**
57
+ * Get hotkeys formatted for the help overlay (grouped by category)
58
+ */
59
+ export function getHotkeysGrouped(scope, focusedPanel) {
60
+ const hotkeys = getHotkeysForContext(scope, focusedPanel);
61
+ return {
62
+ navigation: hotkeys.filter((h) => h.category === 'navigation'),
63
+ action: hotkeys.filter((h) => h.category === 'action'),
64
+ system: hotkeys.filter((h) => h.category === 'system'),
65
+ };
66
+ }
67
+ /**
68
+ * Format hotkeys as a single-line hint string
69
+ * Example: "[n] new [e] edit [d] delete [q] back"
70
+ */
71
+ export function formatHotkeyHints(scope, focusedPanel, maxKeys = 6) {
72
+ const hotkeys = getHotkeysForContext(scope, focusedPanel, true);
73
+ // Prioritize: actions first, then navigation, then system (except always include q)
74
+ const prioritized = [
75
+ ...hotkeys.filter((h) => h.category === 'action'),
76
+ ...hotkeys.filter((h) => h.category === 'navigation'),
77
+ ...hotkeys.filter((h) => h.category === 'system' && h.key !== 'q' && h.key !== '?'),
78
+ ...hotkeys.filter((h) => h.key === 'q' || h.key === '?'),
79
+ ];
80
+ // Dedupe by key
81
+ const seen = new Set();
82
+ const unique = prioritized.filter((h) => {
83
+ if (seen.has(h.key))
84
+ return false;
85
+ seen.add(h.key);
86
+ return true;
87
+ });
88
+ return unique
89
+ .slice(0, maxKeys)
90
+ .map((h) => `[${h.key}] ${h.description.toLowerCase()}`)
91
+ .join(' ');
92
+ }
@@ -0,0 +1,10 @@
1
+ export * from './paths.js';
2
+ export * from './config.js';
3
+ export * from './tmux.js';
4
+ export * from './git.js';
5
+ export * from './livestock.js';
6
+ export * from './hotkeys.js';
7
+ export * from './shell.js';
8
+ export * from './errors.js';
9
+ export * from './mcp-validation.js';
10
+ export * from './detection.js';
@@ -0,0 +1,10 @@
1
+ export * from './paths.js';
2
+ export * from './config.js';
3
+ export * from './tmux.js';
4
+ export * from './git.js';
5
+ export * from './livestock.js';
6
+ export * from './hotkeys.js';
7
+ export * from './shell.js';
8
+ export * from './errors.js';
9
+ export * from './mcp-validation.js';
10
+ export * from './detection.js';
@@ -0,0 +1,51 @@
1
+ import type { Livestock, Barn } from '../types.js';
2
+ /**
3
+ * Resolve a relative path within a livestock to an absolute path.
4
+ * For local livestock, returns the local absolute path.
5
+ * For remote livestock, returns the remote absolute path (for use with SSH).
6
+ */
7
+ export declare function resolveLivestockPath(livestock: Livestock, relativePath: string): string;
8
+ /**
9
+ * Build SSH command prefix for a barn
10
+ */
11
+ export declare function buildSshCommand(barn: Barn): string[];
12
+ /**
13
+ * Read a file from livestock (local or remote via SSH)
14
+ */
15
+ export declare function readLivestockFile(livestock: Livestock, relativePath: string): Promise<{
16
+ content: string;
17
+ error?: string;
18
+ }>;
19
+ /**
20
+ * Read log files from livestock with filtering options
21
+ */
22
+ export declare function readLivestockLogs(livestock: Livestock, options?: {
23
+ lines?: number;
24
+ pattern?: string;
25
+ }): Promise<{
26
+ content: string;
27
+ error?: string;
28
+ }>;
29
+ /**
30
+ * Parse .env file content into key-value pairs
31
+ */
32
+ export declare function parseEnvFile(content: string): Record<string, string>;
33
+ /**
34
+ * Read env file from livestock, optionally hiding values
35
+ */
36
+ export declare function readLivestockEnv(livestock: Livestock, showValues?: boolean): Promise<{
37
+ content: string;
38
+ error?: string;
39
+ }>;
40
+ /**
41
+ * Detected framework configuration suggestions
42
+ */
43
+ export interface DetectedConfig {
44
+ framework?: 'laravel' | 'django' | 'rails' | 'node' | 'unknown';
45
+ log_path?: string;
46
+ env_path?: string;
47
+ }
48
+ /**
49
+ * Detect framework and suggest log_path/env_path based on project structure
50
+ */
51
+ export declare function detectLivestockConfig(path: string, barn?: Barn): Promise<DetectedConfig>;
@@ -0,0 +1,233 @@
1
+ import { join, isAbsolute } from 'path';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { execa } from 'execa';
4
+ import { loadBarn } from './config.js';
5
+ import { shellEscape } from './shell.js';
6
+ import { getErrorMessage } from './errors.js';
7
+ /**
8
+ * Resolve a relative path within a livestock to an absolute path.
9
+ * For local livestock, returns the local absolute path.
10
+ * For remote livestock, returns the remote absolute path (for use with SSH).
11
+ */
12
+ export function resolveLivestockPath(livestock, relativePath) {
13
+ if (isAbsolute(relativePath)) {
14
+ return relativePath;
15
+ }
16
+ return join(livestock.path, relativePath);
17
+ }
18
+ /**
19
+ * Build SSH command prefix for a barn
20
+ */
21
+ export function buildSshCommand(barn) {
22
+ const args = ['ssh'];
23
+ if (barn.port && barn.port !== 22) {
24
+ args.push('-p', String(barn.port));
25
+ }
26
+ if (barn.identity_file) {
27
+ args.push('-i', barn.identity_file);
28
+ }
29
+ args.push(`${barn.user}@${barn.host}`);
30
+ return args;
31
+ }
32
+ /**
33
+ * Read a file from livestock (local or remote via SSH)
34
+ */
35
+ export async function readLivestockFile(livestock, relativePath) {
36
+ const fullPath = resolveLivestockPath(livestock, relativePath);
37
+ // Local livestock
38
+ if (!livestock.barn) {
39
+ try {
40
+ if (!existsSync(fullPath)) {
41
+ return { content: '', error: `File not found: ${fullPath}` };
42
+ }
43
+ const content = readFileSync(fullPath, 'utf-8');
44
+ return { content };
45
+ }
46
+ catch (err) {
47
+ return { content: '', error: `Failed to read file: ${err}` };
48
+ }
49
+ }
50
+ // Remote livestock - SSH to barn
51
+ const barn = loadBarn(livestock.barn);
52
+ if (!barn) {
53
+ return { content: '', error: `Barn not found: ${livestock.barn}` };
54
+ }
55
+ if (!barn.host || !barn.user) {
56
+ return { content: '', error: `Barn '${barn.name}' is not configured for SSH` };
57
+ }
58
+ try {
59
+ const sshArgs = buildSshCommand(barn);
60
+ // Use shellEscape for the path to prevent injection
61
+ const result = await execa(sshArgs[0], [...sshArgs.slice(1), `cat ${shellEscape(fullPath)}`]);
62
+ return { content: result.stdout };
63
+ }
64
+ catch (err) {
65
+ return { content: '', error: `SSH error: ${getErrorMessage(err)}` };
66
+ }
67
+ }
68
+ /**
69
+ * Read log files from livestock with filtering options
70
+ */
71
+ export async function readLivestockLogs(livestock, options = {}) {
72
+ if (!livestock.log_path) {
73
+ return { content: '', error: 'log_path not configured for this livestock' };
74
+ }
75
+ const { lines = 100, pattern } = options;
76
+ const logPath = resolveLivestockPath(livestock, livestock.log_path);
77
+ // Build the command with proper escaping
78
+ let cmd;
79
+ const escapedLogPath = shellEscape(logPath);
80
+ const escapedLines = String(lines); // lines is a number, safe
81
+ if (pattern) {
82
+ // Escape the grep pattern for shell
83
+ const escapedPattern = shellEscape(pattern);
84
+ // grep with tail
85
+ cmd = `find ${escapedLogPath} -name '*.log' -type f 2>/dev/null | xargs tail -n ${escapedLines} 2>/dev/null | grep -i ${escapedPattern} || true`;
86
+ }
87
+ else {
88
+ // Just tail the logs
89
+ cmd = `find ${escapedLogPath} -name '*.log' -type f 2>/dev/null | xargs tail -n ${escapedLines} 2>/dev/null || true`;
90
+ }
91
+ // Local livestock
92
+ if (!livestock.barn) {
93
+ try {
94
+ const result = await execa('sh', ['-c', cmd]);
95
+ if (!result.stdout.trim()) {
96
+ return { content: '', error: `No log files found in ${logPath}` };
97
+ }
98
+ return { content: result.stdout };
99
+ }
100
+ catch (err) {
101
+ return { content: '', error: `Failed to read logs: ${getErrorMessage(err)}` };
102
+ }
103
+ }
104
+ // Remote livestock - SSH
105
+ const barn = loadBarn(livestock.barn);
106
+ if (!barn) {
107
+ return { content: '', error: `Barn not found: ${livestock.barn}` };
108
+ }
109
+ if (!barn.host || !barn.user) {
110
+ return { content: '', error: `Barn '${barn.name}' is not configured for SSH` };
111
+ }
112
+ try {
113
+ const sshArgs = buildSshCommand(barn);
114
+ const result = await execa(sshArgs[0], [...sshArgs.slice(1), cmd]);
115
+ if (!result.stdout.trim()) {
116
+ return { content: '', error: `No log files found in ${logPath}` };
117
+ }
118
+ return { content: result.stdout };
119
+ }
120
+ catch (err) {
121
+ return { content: '', error: `SSH error: ${getErrorMessage(err)}` };
122
+ }
123
+ }
124
+ /**
125
+ * Parse .env file content into key-value pairs
126
+ */
127
+ export function parseEnvFile(content) {
128
+ const result = {};
129
+ const lines = content.split('\n');
130
+ for (const line of lines) {
131
+ const trimmed = line.trim();
132
+ // Skip comments and empty lines
133
+ if (!trimmed || trimmed.startsWith('#'))
134
+ continue;
135
+ const eqIndex = trimmed.indexOf('=');
136
+ if (eqIndex === -1)
137
+ continue;
138
+ const key = trimmed.slice(0, eqIndex).trim();
139
+ let value = trimmed.slice(eqIndex + 1).trim();
140
+ // Remove surrounding quotes
141
+ if ((value.startsWith('"') && value.endsWith('"')) ||
142
+ (value.startsWith("'") && value.endsWith("'"))) {
143
+ value = value.slice(1, -1);
144
+ }
145
+ result[key] = value;
146
+ }
147
+ return result;
148
+ }
149
+ /**
150
+ * Read env file from livestock, optionally hiding values
151
+ */
152
+ export async function readLivestockEnv(livestock, showValues = false) {
153
+ if (!livestock.env_path) {
154
+ return { content: '', error: 'env_path not configured for this livestock' };
155
+ }
156
+ const result = await readLivestockFile(livestock, livestock.env_path);
157
+ if (result.error) {
158
+ return result;
159
+ }
160
+ if (showValues) {
161
+ return result;
162
+ }
163
+ // Parse and return keys only
164
+ const parsed = parseEnvFile(result.content);
165
+ const keysOnly = Object.keys(parsed)
166
+ .map(key => `${key}=<hidden>`)
167
+ .join('\n');
168
+ return { content: keysOnly };
169
+ }
170
+ /**
171
+ * Check if a file exists (local or remote via SSH)
172
+ */
173
+ async function fileExists(path, fullPath, barn) {
174
+ if (!barn) {
175
+ // Local check
176
+ return existsSync(join(path, fullPath));
177
+ }
178
+ // Remote check via SSH
179
+ if (!barn.host || !barn.user) {
180
+ return false;
181
+ }
182
+ try {
183
+ const sshArgs = buildSshCommand(barn);
184
+ const testPath = join(path, fullPath);
185
+ // Use shellEscape for the path
186
+ await execa(sshArgs[0], [...sshArgs.slice(1), `test -e ${shellEscape(testPath)}`]);
187
+ return true;
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
193
+ /**
194
+ * Detect framework and suggest log_path/env_path based on project structure
195
+ */
196
+ export async function detectLivestockConfig(path, barn) {
197
+ const result = {};
198
+ // Laravel: artisan file
199
+ if (await fileExists(path, 'artisan', barn)) {
200
+ result.framework = 'laravel';
201
+ result.log_path = 'storage/logs/';
202
+ result.env_path = '.env';
203
+ return result;
204
+ }
205
+ // Django: manage.py
206
+ if (await fileExists(path, 'manage.py', barn)) {
207
+ result.framework = 'django';
208
+ result.log_path = 'logs/';
209
+ result.env_path = '.env';
210
+ return result;
211
+ }
212
+ // Rails: Gemfile + bin/rails
213
+ if (await fileExists(path, 'Gemfile', barn) && await fileExists(path, 'bin/rails', barn)) {
214
+ result.framework = 'rails';
215
+ result.log_path = 'log/';
216
+ result.env_path = '.env';
217
+ return result;
218
+ }
219
+ // Node: package.json
220
+ if (await fileExists(path, 'package.json', barn)) {
221
+ result.framework = 'node';
222
+ result.log_path = 'logs/';
223
+ result.env_path = '.env';
224
+ return result;
225
+ }
226
+ // Generic fallback: check if .env exists
227
+ if (await fileExists(path, '.env', barn)) {
228
+ result.framework = 'unknown';
229
+ result.env_path = '.env';
230
+ return result;
231
+ }
232
+ return result;
233
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * MCP tool arguments come as Record<string, unknown> | undefined.
3
+ * These helpers safely extract and validate values.
4
+ */
5
+ export interface McpArgs {
6
+ [key: string]: unknown;
7
+ }
8
+ /**
9
+ * Get a required string argument, throwing if missing or wrong type
10
+ */
11
+ export declare function requireString(args: McpArgs | undefined, key: string): string;
12
+ /**
13
+ * Get an optional string argument
14
+ */
15
+ export declare function optionalString(args: McpArgs | undefined, key: string): string | undefined;
16
+ /**
17
+ * Get an optional number argument
18
+ */
19
+ export declare function optionalNumber(args: McpArgs | undefined, key: string): number | undefined;
20
+ /**
21
+ * Get an optional boolean argument with default
22
+ */
23
+ export declare function optionalBoolean(args: McpArgs | undefined, key: string, defaultValue: boolean): boolean;
24
+ /**
25
+ * Wrapper to handle validation errors and return MCP error response
26
+ */
27
+ export declare function validateArgs<T>(args: McpArgs | undefined, validator: (args: McpArgs | undefined) => T): {
28
+ data: T;
29
+ error?: never;
30
+ } | {
31
+ data?: never;
32
+ error: string;
33
+ };
@@ -0,0 +1,62 @@
1
+ // src/lib/mcp-validation.ts
2
+ import { isString, isNumber, isBoolean } from './errors.js';
3
+ /**
4
+ * Get a required string argument, throwing if missing or wrong type
5
+ */
6
+ export function requireString(args, key) {
7
+ const value = args?.[key];
8
+ if (!isString(value)) {
9
+ throw new Error(`Missing or invalid required parameter: ${key}`);
10
+ }
11
+ return value;
12
+ }
13
+ /**
14
+ * Get an optional string argument
15
+ */
16
+ export function optionalString(args, key) {
17
+ const value = args?.[key];
18
+ if (value === undefined || value === null) {
19
+ return undefined;
20
+ }
21
+ if (!isString(value)) {
22
+ throw new Error(`Invalid parameter type for ${key}: expected string`);
23
+ }
24
+ return value;
25
+ }
26
+ /**
27
+ * Get an optional number argument
28
+ */
29
+ export function optionalNumber(args, key) {
30
+ const value = args?.[key];
31
+ if (value === undefined || value === null) {
32
+ return undefined;
33
+ }
34
+ if (!isNumber(value)) {
35
+ throw new Error(`Invalid parameter type for ${key}: expected number`);
36
+ }
37
+ return value;
38
+ }
39
+ /**
40
+ * Get an optional boolean argument with default
41
+ */
42
+ export function optionalBoolean(args, key, defaultValue) {
43
+ const value = args?.[key];
44
+ if (value === undefined || value === null) {
45
+ return defaultValue;
46
+ }
47
+ if (!isBoolean(value)) {
48
+ throw new Error(`Invalid parameter type for ${key}: expected boolean`);
49
+ }
50
+ return value;
51
+ }
52
+ /**
53
+ * Wrapper to handle validation errors and return MCP error response
54
+ */
55
+ export function validateArgs(args, validator) {
56
+ try {
57
+ return { data: validator(args) };
58
+ }
59
+ catch (err) {
60
+ return { error: err instanceof Error ? err.message : String(err) };
61
+ }
62
+ }