@automagik/genie 0.260201.2240
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +26 -0
- package/.worktrees/.metadata.json +3 -0
- package/README.md +532 -0
- package/bun.lock +101 -0
- package/dist/claudio.js +76 -0
- package/dist/genie.js +201 -0
- package/dist/term.js +136 -0
- package/install.sh +351 -0
- package/package.json +37 -0
- package/scripts/version.ts +48 -0
- package/src/claudio.ts +128 -0
- package/src/commands/launch.ts +245 -0
- package/src/commands/models.ts +43 -0
- package/src/commands/profiles.ts +95 -0
- package/src/commands/setup.ts +5 -0
- package/src/genie-commands/hooks.ts +317 -0
- package/src/genie-commands/install.ts +351 -0
- package/src/genie-commands/setup.ts +282 -0
- package/src/genie-commands/shortcuts.ts +62 -0
- package/src/genie-commands/update.ts +228 -0
- package/src/genie.ts +106 -0
- package/src/lib/api-client.ts +109 -0
- package/src/lib/claude-settings.ts +252 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/genie-config.ts +164 -0
- package/src/lib/hook-manager.ts +130 -0
- package/src/lib/hook-script.ts +256 -0
- package/src/lib/hooks/compose.ts +72 -0
- package/src/lib/hooks/index.ts +163 -0
- package/src/lib/hooks/presets/audited.ts +191 -0
- package/src/lib/hooks/presets/collaborative.ts +143 -0
- package/src/lib/hooks/presets/sandboxed.ts +153 -0
- package/src/lib/hooks/presets/supervised.ts +66 -0
- package/src/lib/hooks/utils/escape.ts +46 -0
- package/src/lib/log-reader.ts +213 -0
- package/src/lib/picker.ts +62 -0
- package/src/lib/session-metadata.ts +58 -0
- package/src/lib/system-detect.ts +185 -0
- package/src/lib/tmux.ts +410 -0
- package/src/lib/version.ts +15 -0
- package/src/lib/wizard.ts +104 -0
- package/src/lib/worktree.ts +362 -0
- package/src/term-commands/attach.ts +23 -0
- package/src/term-commands/exec.ts +34 -0
- package/src/term-commands/hook.ts +42 -0
- package/src/term-commands/ls.ts +33 -0
- package/src/term-commands/new.ts +73 -0
- package/src/term-commands/pane.ts +81 -0
- package/src/term-commands/read.ts +70 -0
- package/src/term-commands/rm.ts +47 -0
- package/src/term-commands/send.ts +34 -0
- package/src/term-commands/shortcuts.ts +355 -0
- package/src/term-commands/split.ts +87 -0
- package/src/term-commands/status.ts +116 -0
- package/src/term-commands/window.ts +72 -0
- package/src/term.ts +192 -0
- package/src/types/config.ts +17 -0
- package/src/types/genie-config.ts +104 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as tmux from './tmux.js';
|
|
2
|
+
|
|
3
|
+
export interface ReadOptions {
|
|
4
|
+
lines?: number; // Number of lines (default 100)
|
|
5
|
+
from?: number; // Start line
|
|
6
|
+
to?: number; // End line
|
|
7
|
+
search?: string; // Search pattern
|
|
8
|
+
grep?: string; // Regex pattern
|
|
9
|
+
follow?: boolean; // Live tail mode
|
|
10
|
+
all?: boolean; // Entire scrollback
|
|
11
|
+
reverse?: boolean; // Newest first
|
|
12
|
+
range?: string; // Range syntax like "100:200"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strip internal TMUX_MCP markers from log output
|
|
17
|
+
*/
|
|
18
|
+
function stripTmuxMarkers(content: string): string {
|
|
19
|
+
const lines = content.split('\n');
|
|
20
|
+
|
|
21
|
+
// First pass: mark lines to remove
|
|
22
|
+
const filtered = lines.filter(line => {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
|
|
25
|
+
// Remove complete marker lines
|
|
26
|
+
if (trimmed.match(/^TMUX_MCP_START$/)) return false;
|
|
27
|
+
if (trimmed.match(/^TMUX_MCP_DONE_\d+$/)) return false;
|
|
28
|
+
|
|
29
|
+
// Remove partial marker fragments (from split echo commands)
|
|
30
|
+
if (trimmed.includes('TMUX_MCP_START')) return false;
|
|
31
|
+
if (trimmed.includes('TMUX_MCP_DONE_')) return false;
|
|
32
|
+
|
|
33
|
+
// Remove lines containing echo commands for markers
|
|
34
|
+
if (line.includes('echo "TMUX_MCP_START"')) return false;
|
|
35
|
+
if (line.includes('echo "TMUX_MCP_DONE_')) return false;
|
|
36
|
+
|
|
37
|
+
// Remove bash locale warnings and fragments
|
|
38
|
+
if (line.includes('-bash:')) return false;
|
|
39
|
+
if (line.includes('warning: setlocale:')) return false;
|
|
40
|
+
if (line.includes('cannot change locale')) return false;
|
|
41
|
+
if (trimmed === 'or directory') return false; // Orphan fragment from wrapped warning
|
|
42
|
+
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Remove leading/trailing empty lines
|
|
47
|
+
while (filtered.length > 0 && filtered[0].trim() === '') {
|
|
48
|
+
filtered.shift();
|
|
49
|
+
}
|
|
50
|
+
while (filtered.length > 0 && filtered[filtered.length - 1].trim() === '') {
|
|
51
|
+
filtered.pop();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return filtered.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read logs from a tmux session with comprehensive filtering options
|
|
59
|
+
*/
|
|
60
|
+
export async function readSessionLogs(
|
|
61
|
+
sessionName: string,
|
|
62
|
+
options: ReadOptions = {}
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
// Find session
|
|
65
|
+
const session = await tmux.findSessionByName(sessionName);
|
|
66
|
+
if (!session) {
|
|
67
|
+
throw new Error(`Session "${sessionName}" not found`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Get first window and pane
|
|
71
|
+
const windows = await tmux.listWindows(session.id);
|
|
72
|
+
if (!windows || windows.length === 0) {
|
|
73
|
+
throw new Error(`No windows found in session "${sessionName}"`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const panes = await tmux.listPanes(windows[0].id);
|
|
77
|
+
if (!panes || panes.length === 0) {
|
|
78
|
+
throw new Error(`No panes found in session "${sessionName}"`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const paneId = panes[0].id;
|
|
82
|
+
|
|
83
|
+
// Parse range if provided
|
|
84
|
+
if (options.range) {
|
|
85
|
+
const parts = options.range.split(':');
|
|
86
|
+
if (parts.length === 2) {
|
|
87
|
+
options.from = parseInt(parts[0], 10);
|
|
88
|
+
options.to = parseInt(parts[1], 10);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Handle different read modes
|
|
93
|
+
if (options.all) {
|
|
94
|
+
// Get entire scrollback buffer (tmux history limit, usually 2000-10000 lines)
|
|
95
|
+
const content = await tmux.capturePaneContent(paneId, 10000);
|
|
96
|
+
return stripTmuxMarkers(content);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.from !== undefined && options.to !== undefined) {
|
|
100
|
+
// Read specific range
|
|
101
|
+
const fullContent = await tmux.capturePaneContent(paneId, 10000);
|
|
102
|
+
const cleanContent = stripTmuxMarkers(fullContent);
|
|
103
|
+
const lines = cleanContent.split('\n');
|
|
104
|
+
const rangeContent = lines.slice(options.from, options.to + 1).join('\n');
|
|
105
|
+
|
|
106
|
+
if (options.reverse) {
|
|
107
|
+
return rangeContent.split('\n').reverse().join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return rangeContent;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (options.search || options.grep) {
|
|
114
|
+
// Search logs
|
|
115
|
+
const pattern = options.search || options.grep;
|
|
116
|
+
const fullContent = await tmux.capturePaneContent(paneId, 10000);
|
|
117
|
+
const cleanContent = stripTmuxMarkers(fullContent);
|
|
118
|
+
const lines = cleanContent.split('\n');
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const regex = new RegExp(pattern, 'i');
|
|
122
|
+
const matchedLines = lines.filter(line => regex.test(line));
|
|
123
|
+
|
|
124
|
+
if (options.reverse) {
|
|
125
|
+
return matchedLines.reverse().join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return matchedLines.join('\n');
|
|
129
|
+
} catch (error: any) {
|
|
130
|
+
throw new Error(`Invalid regex pattern: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Default: last N lines
|
|
135
|
+
const lineCount = options.lines || 100;
|
|
136
|
+
let content = await tmux.capturePaneContent(paneId, lineCount);
|
|
137
|
+
content = stripTmuxMarkers(content);
|
|
138
|
+
|
|
139
|
+
if (options.reverse) {
|
|
140
|
+
// Newest first
|
|
141
|
+
content = content.split('\n').reverse().join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return content;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Follow a session's logs in real-time (like tail -f)
|
|
149
|
+
* Returns a function to stop following
|
|
150
|
+
*/
|
|
151
|
+
export async function followSessionLogs(
|
|
152
|
+
sessionName: string,
|
|
153
|
+
callback: (line: string) => void
|
|
154
|
+
): Promise<() => void> {
|
|
155
|
+
const session = await tmux.findSessionByName(sessionName);
|
|
156
|
+
if (!session) {
|
|
157
|
+
throw new Error(`Session "${sessionName}" not found`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const windows = await tmux.listWindows(session.id);
|
|
161
|
+
if (!windows || windows.length === 0) {
|
|
162
|
+
throw new Error(`No windows found in session "${sessionName}"`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const panes = await tmux.listPanes(windows[0].id);
|
|
166
|
+
if (!panes || panes.length === 0) {
|
|
167
|
+
throw new Error(`No panes found in session "${sessionName}"`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const paneId = panes[0].id;
|
|
171
|
+
let lastContent = '';
|
|
172
|
+
let following = true;
|
|
173
|
+
|
|
174
|
+
// Poll for new content every 500ms
|
|
175
|
+
const pollInterval = setInterval(async () => {
|
|
176
|
+
if (!following) {
|
|
177
|
+
clearInterval(pollInterval);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const rawContent = await tmux.capturePaneContent(paneId, 100);
|
|
183
|
+
const content = stripTmuxMarkers(rawContent);
|
|
184
|
+
|
|
185
|
+
if (content !== lastContent) {
|
|
186
|
+
const newLines = content.split('\n');
|
|
187
|
+
const oldLines = lastContent.split('\n');
|
|
188
|
+
|
|
189
|
+
// Find new lines by comparing arrays
|
|
190
|
+
const startIndex = oldLines.length > 0 ? oldLines.length - 1 : 0;
|
|
191
|
+
const addedLines = newLines.slice(startIndex);
|
|
192
|
+
|
|
193
|
+
addedLines.forEach(line => {
|
|
194
|
+
if (line && line !== oldLines[oldLines.length - 1]) {
|
|
195
|
+
callback(line);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
lastContent = content;
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// Session might have been closed
|
|
203
|
+
clearInterval(pollInterval);
|
|
204
|
+
following = false;
|
|
205
|
+
}
|
|
206
|
+
}, 500);
|
|
207
|
+
|
|
208
|
+
// Return stop function
|
|
209
|
+
return () => {
|
|
210
|
+
following = false;
|
|
211
|
+
clearInterval(pollInterval);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { search, input, confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Model } from './api-client.js';
|
|
3
|
+
|
|
4
|
+
export interface PickerOptions {
|
|
5
|
+
message: string;
|
|
6
|
+
models: Model[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function pickModel(options: PickerOptions): Promise<string> {
|
|
10
|
+
const { message, models } = options;
|
|
11
|
+
|
|
12
|
+
const modelIds = models.map((m) => m.id);
|
|
13
|
+
|
|
14
|
+
const selected = await search({
|
|
15
|
+
message,
|
|
16
|
+
source: async (term) => {
|
|
17
|
+
const searchTerm = (term || '').toLowerCase();
|
|
18
|
+
const filtered = modelIds.filter((id) =>
|
|
19
|
+
id.toLowerCase().includes(searchTerm)
|
|
20
|
+
);
|
|
21
|
+
return filtered.map((id) => ({
|
|
22
|
+
name: id,
|
|
23
|
+
value: id,
|
|
24
|
+
}));
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return selected;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pickProfileModels(models: Model[]): Promise<{ opus: string; sonnet: string; haiku: string }> {
|
|
32
|
+
const opus = await pickModel({
|
|
33
|
+
message: 'Select OPUS model:',
|
|
34
|
+
models,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const sonnet = await pickModel({
|
|
38
|
+
message: 'Select SONNET model:',
|
|
39
|
+
models,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const haiku = await pickModel({
|
|
43
|
+
message: 'Select HAIKU model:',
|
|
44
|
+
models,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return { opus, sonnet, haiku };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function promptText(message: string, defaultValue?: string): Promise<string> {
|
|
51
|
+
return input({
|
|
52
|
+
message,
|
|
53
|
+
default: defaultValue,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function promptConfirm(message: string, defaultValue = false): Promise<boolean> {
|
|
58
|
+
return confirm({
|
|
59
|
+
message,
|
|
60
|
+
default: defaultValue,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, access } from 'fs/promises';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
export interface SessionMetadata {
|
|
6
|
+
worktreePath?: string;
|
|
7
|
+
workspace?: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SessionsData {
|
|
12
|
+
sessions: Record<string, SessionMetadata>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CONFIG_DIR = join(homedir(), '.config', 'term');
|
|
16
|
+
const SESSIONS_FILE = join(CONFIG_DIR, 'sessions.json');
|
|
17
|
+
|
|
18
|
+
async function ensureConfigDir(): Promise<void> {
|
|
19
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function loadSessions(): Promise<SessionsData> {
|
|
23
|
+
try {
|
|
24
|
+
await access(SESSIONS_FILE);
|
|
25
|
+
const content = await readFile(SESSIONS_FILE, 'utf-8');
|
|
26
|
+
return JSON.parse(content);
|
|
27
|
+
} catch {
|
|
28
|
+
return { sessions: {} };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function saveSessions(data: SessionsData): Promise<void> {
|
|
33
|
+
await ensureConfigDir();
|
|
34
|
+
await writeFile(SESSIONS_FILE, JSON.stringify(data, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function saveSessionMetadata(
|
|
38
|
+
name: string,
|
|
39
|
+
data: Omit<SessionMetadata, 'createdAt'>
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const sessions = await loadSessions();
|
|
42
|
+
sessions.sessions[name] = {
|
|
43
|
+
...data,
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
await saveSessions(sessions);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function loadSessionMetadata(name: string): Promise<SessionMetadata | null> {
|
|
50
|
+
const sessions = await loadSessions();
|
|
51
|
+
return sessions.sessions[name] || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function deleteSessionMetadata(name: string): Promise<void> {
|
|
55
|
+
const sessions = await loadSessions();
|
|
56
|
+
delete sessions.sessions[name];
|
|
57
|
+
await saveSessions(sessions);
|
|
58
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { platform, arch } from 'os';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { $ } from 'bun';
|
|
4
|
+
|
|
5
|
+
export type OSType = 'macos' | 'linux' | 'unknown';
|
|
6
|
+
export type LinuxDistro = 'debian' | 'ubuntu' | 'fedora' | 'rhel' | 'arch' | 'unknown';
|
|
7
|
+
export type PackageManager = 'brew' | 'apt' | 'dnf' | 'yum' | 'pacman' | 'none';
|
|
8
|
+
|
|
9
|
+
export interface SystemInfo {
|
|
10
|
+
os: OSType;
|
|
11
|
+
arch: string;
|
|
12
|
+
linuxDistro?: LinuxDistro;
|
|
13
|
+
preferredPM: PackageManager;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CommandCheck {
|
|
17
|
+
exists: boolean;
|
|
18
|
+
version?: string;
|
|
19
|
+
path?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PrerequisiteStatus {
|
|
23
|
+
name: string;
|
|
24
|
+
installed: boolean;
|
|
25
|
+
version?: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
required: boolean;
|
|
28
|
+
description: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getOSType(): OSType {
|
|
32
|
+
const p = platform();
|
|
33
|
+
if (p === 'darwin') return 'macos';
|
|
34
|
+
if (p === 'linux') return 'linux';
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function parseOsRelease(): Promise<LinuxDistro> {
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile('/etc/os-release', 'utf-8');
|
|
41
|
+
const lines = content.split('\n');
|
|
42
|
+
const info: Record<string, string> = {};
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const [key, ...valueParts] = line.split('=');
|
|
46
|
+
if (key && valueParts.length > 0) {
|
|
47
|
+
info[key] = valueParts.join('=').replace(/^"|"$/g, '');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const id = (info.ID || '').toLowerCase();
|
|
52
|
+
const idLike = (info.ID_LIKE || '').toLowerCase();
|
|
53
|
+
|
|
54
|
+
if (id === 'ubuntu' || idLike.includes('ubuntu')) return 'ubuntu';
|
|
55
|
+
if (id === 'debian' || idLike.includes('debian')) return 'debian';
|
|
56
|
+
if (id === 'fedora' || idLike.includes('fedora')) return 'fedora';
|
|
57
|
+
if (id === 'rhel' || id === 'centos' || id === 'rocky' || id === 'almalinux' || idLike.includes('rhel')) return 'rhel';
|
|
58
|
+
if (id === 'arch' || idLike.includes('arch')) return 'arch';
|
|
59
|
+
|
|
60
|
+
return 'unknown';
|
|
61
|
+
} catch {
|
|
62
|
+
return 'unknown';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function commandExists(cmd: string): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
const result = await $`which ${cmd}`.quiet();
|
|
69
|
+
return result.exitCode === 0;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function detectPackageManager(): Promise<PackageManager> {
|
|
76
|
+
// Prefer brew if available (works on both macOS and Linux)
|
|
77
|
+
if (await commandExists('brew')) return 'brew';
|
|
78
|
+
if (await commandExists('apt')) return 'apt';
|
|
79
|
+
if (await commandExists('dnf')) return 'dnf';
|
|
80
|
+
if (await commandExists('yum')) return 'yum';
|
|
81
|
+
if (await commandExists('pacman')) return 'pacman';
|
|
82
|
+
return 'none';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function detectSystem(): Promise<SystemInfo> {
|
|
86
|
+
const os = getOSType();
|
|
87
|
+
const systemArch = arch();
|
|
88
|
+
|
|
89
|
+
const result: SystemInfo = {
|
|
90
|
+
os,
|
|
91
|
+
arch: systemArch,
|
|
92
|
+
preferredPM: await detectPackageManager(),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (os === 'linux') {
|
|
96
|
+
result.linuxDistro = await parseOsRelease();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function checkCommand(cmd: string): Promise<CommandCheck> {
|
|
103
|
+
try {
|
|
104
|
+
const whichResult = await $`which ${cmd}`.quiet().text();
|
|
105
|
+
const cmdPath = whichResult.trim();
|
|
106
|
+
|
|
107
|
+
if (!cmdPath) {
|
|
108
|
+
return { exists: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Try to get version
|
|
112
|
+
let version: string | undefined;
|
|
113
|
+
try {
|
|
114
|
+
// Try common version flags
|
|
115
|
+
const versionResult = await $`${cmd} --version`.quiet().text();
|
|
116
|
+
// Extract first line and clean it up
|
|
117
|
+
const firstLine = versionResult.split('\n')[0].trim();
|
|
118
|
+
// Try to extract version number
|
|
119
|
+
const versionMatch = firstLine.match(/(\d+\.[\d.]+[a-z0-9-]*)/i);
|
|
120
|
+
version = versionMatch ? versionMatch[1] : firstLine.slice(0, 50);
|
|
121
|
+
} catch {
|
|
122
|
+
// Some commands don't support --version, try -v
|
|
123
|
+
try {
|
|
124
|
+
const vResult = await $`${cmd} -v`.quiet().text();
|
|
125
|
+
const firstLine = vResult.split('\n')[0].trim();
|
|
126
|
+
const versionMatch = firstLine.match(/(\d+\.[\d.]+[a-z0-9-]*)/i);
|
|
127
|
+
version = versionMatch ? versionMatch[1] : firstLine.slice(0, 50);
|
|
128
|
+
} catch {
|
|
129
|
+
// Version unknown but command exists
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { exists: true, version, path: cmdPath };
|
|
134
|
+
} catch {
|
|
135
|
+
return { exists: false };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const PREREQUISITES = [
|
|
140
|
+
{
|
|
141
|
+
name: 'tmux',
|
|
142
|
+
required: true,
|
|
143
|
+
description: 'Terminal multiplexer for shared sessions',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'bun',
|
|
147
|
+
required: true,
|
|
148
|
+
description: 'Fast JavaScript runtime and package manager',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'claude',
|
|
152
|
+
required: false,
|
|
153
|
+
description: 'Claude Code CLI (optional, recommended)',
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
export async function checkAllPrerequisites(): Promise<PrerequisiteStatus[]> {
|
|
158
|
+
const results: PrerequisiteStatus[] = [];
|
|
159
|
+
|
|
160
|
+
for (const prereq of PREREQUISITES) {
|
|
161
|
+
const check = await checkCommand(prereq.name);
|
|
162
|
+
results.push({
|
|
163
|
+
name: prereq.name,
|
|
164
|
+
installed: check.exists,
|
|
165
|
+
version: check.version,
|
|
166
|
+
path: check.path,
|
|
167
|
+
required: prereq.required,
|
|
168
|
+
description: prereq.description,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getDistroDisplayName(distro: LinuxDistro): string {
|
|
176
|
+
const names: Record<LinuxDistro, string> = {
|
|
177
|
+
ubuntu: 'Ubuntu',
|
|
178
|
+
debian: 'Debian',
|
|
179
|
+
fedora: 'Fedora',
|
|
180
|
+
rhel: 'RHEL/CentOS',
|
|
181
|
+
arch: 'Arch Linux',
|
|
182
|
+
unknown: 'Linux',
|
|
183
|
+
};
|
|
184
|
+
return names[distro] || 'Linux';
|
|
185
|
+
}
|