@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.
Files changed (59) hide show
  1. package/.github/workflows/publish.yml +26 -0
  2. package/.worktrees/.metadata.json +3 -0
  3. package/README.md +532 -0
  4. package/bun.lock +101 -0
  5. package/dist/claudio.js +76 -0
  6. package/dist/genie.js +201 -0
  7. package/dist/term.js +136 -0
  8. package/install.sh +351 -0
  9. package/package.json +37 -0
  10. package/scripts/version.ts +48 -0
  11. package/src/claudio.ts +128 -0
  12. package/src/commands/launch.ts +245 -0
  13. package/src/commands/models.ts +43 -0
  14. package/src/commands/profiles.ts +95 -0
  15. package/src/commands/setup.ts +5 -0
  16. package/src/genie-commands/hooks.ts +317 -0
  17. package/src/genie-commands/install.ts +351 -0
  18. package/src/genie-commands/setup.ts +282 -0
  19. package/src/genie-commands/shortcuts.ts +62 -0
  20. package/src/genie-commands/update.ts +228 -0
  21. package/src/genie.ts +106 -0
  22. package/src/lib/api-client.ts +109 -0
  23. package/src/lib/claude-settings.ts +252 -0
  24. package/src/lib/config.ts +109 -0
  25. package/src/lib/genie-config.ts +164 -0
  26. package/src/lib/hook-manager.ts +130 -0
  27. package/src/lib/hook-script.ts +256 -0
  28. package/src/lib/hooks/compose.ts +72 -0
  29. package/src/lib/hooks/index.ts +163 -0
  30. package/src/lib/hooks/presets/audited.ts +191 -0
  31. package/src/lib/hooks/presets/collaborative.ts +143 -0
  32. package/src/lib/hooks/presets/sandboxed.ts +153 -0
  33. package/src/lib/hooks/presets/supervised.ts +66 -0
  34. package/src/lib/hooks/utils/escape.ts +46 -0
  35. package/src/lib/log-reader.ts +213 -0
  36. package/src/lib/picker.ts +62 -0
  37. package/src/lib/session-metadata.ts +58 -0
  38. package/src/lib/system-detect.ts +185 -0
  39. package/src/lib/tmux.ts +410 -0
  40. package/src/lib/version.ts +15 -0
  41. package/src/lib/wizard.ts +104 -0
  42. package/src/lib/worktree.ts +362 -0
  43. package/src/term-commands/attach.ts +23 -0
  44. package/src/term-commands/exec.ts +34 -0
  45. package/src/term-commands/hook.ts +42 -0
  46. package/src/term-commands/ls.ts +33 -0
  47. package/src/term-commands/new.ts +73 -0
  48. package/src/term-commands/pane.ts +81 -0
  49. package/src/term-commands/read.ts +70 -0
  50. package/src/term-commands/rm.ts +47 -0
  51. package/src/term-commands/send.ts +34 -0
  52. package/src/term-commands/shortcuts.ts +355 -0
  53. package/src/term-commands/split.ts +87 -0
  54. package/src/term-commands/status.ts +116 -0
  55. package/src/term-commands/window.ts +72 -0
  56. package/src/term.ts +192 -0
  57. package/src/types/config.ts +17 -0
  58. package/src/types/genie-config.ts +104 -0
  59. package/tsconfig.json +17 -0
@@ -0,0 +1,410 @@
1
+ import { exec as execCallback } from "child_process";
2
+ import { promisify } from "util";
3
+ import { v4 as uuidv4 } from 'uuid';
4
+
5
+ const exec = promisify(execCallback);
6
+
7
+ // Basic interfaces for tmux objects
8
+ export interface TmuxSession {
9
+ id: string;
10
+ name: string;
11
+ attached: boolean;
12
+ windows: number;
13
+ }
14
+
15
+ export interface TmuxWindow {
16
+ id: string;
17
+ name: string;
18
+ active: boolean;
19
+ sessionId: string;
20
+ }
21
+
22
+ export interface TmuxPane {
23
+ id: string;
24
+ windowId: string;
25
+ active: boolean;
26
+ title: string;
27
+ }
28
+
29
+ interface CommandExecution {
30
+ id: string;
31
+ paneId: string;
32
+ command: string;
33
+ status: 'pending' | 'completed' | 'error';
34
+ startTime: Date;
35
+ result?: string;
36
+ exitCode?: number;
37
+ rawMode?: boolean;
38
+ }
39
+
40
+ export type ShellType = 'bash' | 'zsh' | 'fish';
41
+
42
+ let shellConfig: { type: ShellType } = { type: 'bash' };
43
+
44
+ export function setShellConfig(config: { type: string }): void {
45
+ // Validate shell type
46
+ const validShells: ShellType[] = ['bash', 'zsh', 'fish'];
47
+
48
+ if (validShells.includes(config.type as ShellType)) {
49
+ shellConfig = { type: config.type as ShellType };
50
+ } else {
51
+ shellConfig = { type: 'bash' };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Execute a tmux command and return the result
57
+ */
58
+ export async function executeTmux(tmuxCommand: string): Promise<string> {
59
+ try {
60
+ const { stdout } = await exec(`tmux ${tmuxCommand}`);
61
+ return stdout.trim();
62
+ } catch (error: any) {
63
+ throw new Error(`Failed to execute tmux command: ${error.message}`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Check if tmux server is running
69
+ */
70
+ export async function isTmuxRunning(): Promise<boolean> {
71
+ try {
72
+ await executeTmux("list-sessions -F '#{session_name}'");
73
+ return true;
74
+ } catch (error) {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * List all tmux sessions
81
+ */
82
+ export async function listSessions(): Promise<TmuxSession[]> {
83
+ try {
84
+ const format = "#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
85
+ const output = await executeTmux(`list-sessions -F '${format}'`);
86
+
87
+ if (!output) return [];
88
+
89
+ return output.split('\n').map(line => {
90
+ const [id, name, attached, windows] = line.split(':');
91
+ return {
92
+ id,
93
+ name,
94
+ attached: attached === '1',
95
+ windows: parseInt(windows, 10)
96
+ };
97
+ });
98
+ } catch (error: any) {
99
+ // Handle "no server running" gracefully
100
+ if (error.message.includes('no server running')) {
101
+ return [];
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Find a session by name
109
+ */
110
+ export async function findSessionByName(name: string): Promise<TmuxSession | null> {
111
+ try {
112
+ const sessions = await listSessions();
113
+ return sessions.find(session => session.name === name) || null;
114
+ } catch (error) {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * List windows in a session
121
+ */
122
+ export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
123
+ try {
124
+ const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
125
+ const output = await executeTmux(`list-windows -t '${sessionId}' -F '${format}'`);
126
+
127
+ if (!output) return [];
128
+
129
+ return output.split('\n').map(line => {
130
+ const [id, name, active] = line.split(':');
131
+ return {
132
+ id,
133
+ name,
134
+ active: active === '1',
135
+ sessionId
136
+ };
137
+ });
138
+ } catch (error: any) {
139
+ // Handle session not found or no server running
140
+ if (error.message.includes('no server running') || error.message.includes('session not found')) {
141
+ return [];
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * List panes in a window
149
+ */
150
+ export async function listPanes(windowId: string): Promise<TmuxPane[]> {
151
+ try {
152
+ const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
153
+ const output = await executeTmux(`list-panes -t '${windowId}' -F '${format}'`);
154
+
155
+ if (!output) return [];
156
+
157
+ return output.split('\n').map(line => {
158
+ const [id, title, active] = line.split(':');
159
+ return {
160
+ id,
161
+ windowId,
162
+ title: title,
163
+ active: active === '1'
164
+ };
165
+ });
166
+ } catch (error: any) {
167
+ // Handle window not found or no server running
168
+ if (error.message.includes('no server running') || error.message.includes('window not found')) {
169
+ return [];
170
+ }
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Capture content from a specific pane, by default the latest 200 lines.
177
+ */
178
+ export async function capturePaneContent(paneId: string, lines: number = 200, includeColors: boolean = false): Promise<string> {
179
+ try {
180
+ const colorFlag = includeColors ? '-e' : '';
181
+ return await executeTmux(`capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -`);
182
+ } catch (error: any) {
183
+ // Handle pane not found or no server running
184
+ if (error.message.includes('no server running') || error.message.includes('pane not found')) {
185
+ return '';
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Create a new tmux session
193
+ */
194
+ export async function createSession(name: string): Promise<TmuxSession | null> {
195
+ await executeTmux(`new-session -d -s "${name}" -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`);
196
+ return findSessionByName(name);
197
+ }
198
+
199
+ /**
200
+ * Create a new window in a session
201
+ */
202
+ export async function createWindow(sessionId: string, name: string): Promise<TmuxWindow | null> {
203
+ const output = await executeTmux(`new-window -t '${sessionId}' -n '${name}'`);
204
+ const windows = await listWindows(sessionId);
205
+ return windows.find(window => window.name === name) || null;
206
+ }
207
+
208
+ /**
209
+ * Kill a tmux session by ID
210
+ */
211
+ export async function killSession(sessionId: string): Promise<void> {
212
+ await executeTmux(`kill-session -t '${sessionId}'`);
213
+ }
214
+
215
+ /**
216
+ * Kill a tmux window by ID
217
+ */
218
+ export async function killWindow(windowId: string): Promise<void> {
219
+ await executeTmux(`kill-window -t '${windowId}'`);
220
+ }
221
+
222
+ /**
223
+ * Kill a tmux pane by ID
224
+ */
225
+ export async function killPane(paneId: string): Promise<void> {
226
+ await executeTmux(`kill-pane -t '${paneId}'`);
227
+ }
228
+
229
+ /**
230
+ * Split a tmux pane horizontally or vertically
231
+ */
232
+ export async function splitPane(
233
+ targetPaneId: string,
234
+ direction: 'horizontal' | 'vertical' = 'vertical',
235
+ size?: number
236
+ ): Promise<TmuxPane | null> {
237
+ // Build the split-window command
238
+ let splitCommand = 'split-window';
239
+
240
+ // Add direction flag (-h for horizontal, -v for vertical)
241
+ if (direction === 'horizontal') {
242
+ splitCommand += ' -h';
243
+ } else {
244
+ splitCommand += ' -v';
245
+ }
246
+
247
+ // Add target pane
248
+ splitCommand += ` -t '${targetPaneId}'`;
249
+
250
+ // Add size if specified (as percentage)
251
+ if (size !== undefined && size > 0 && size < 100) {
252
+ splitCommand += ` -p ${size}`;
253
+ }
254
+
255
+ // Execute the split command
256
+ await executeTmux(splitCommand);
257
+
258
+ // Get the window ID from the target pane to list all panes
259
+ const windowInfo = await executeTmux(`display-message -p -t '${targetPaneId}' '#{window_id}'`);
260
+
261
+ // List all panes in the window to find the newly created one
262
+ const panes = await listPanes(windowInfo);
263
+
264
+ // The newest pane is typically the last one in the list
265
+ return panes.length > 0 ? panes[panes.length - 1] : null;
266
+ }
267
+
268
+ // Map to track ongoing command executions
269
+ const activeCommands = new Map<string, CommandExecution>();
270
+
271
+ const startMarkerText = 'TMUX_MCP_START';
272
+ const endMarkerPrefix = "TMUX_MCP_DONE_";
273
+
274
+ // Execute a command in a tmux pane and track its execution
275
+ export async function executeCommand(paneId: string, command: string, rawMode?: boolean, noEnter?: boolean): Promise<string> {
276
+ // Generate unique ID for this command execution
277
+ const commandId = uuidv4();
278
+
279
+ let fullCommand: string;
280
+ if (rawMode || noEnter) {
281
+ fullCommand = command;
282
+ } else {
283
+ const endMarkerText = getEndMarkerText();
284
+ fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
285
+ }
286
+
287
+ // Store command in tracking map
288
+ activeCommands.set(commandId, {
289
+ id: commandId,
290
+ paneId,
291
+ command,
292
+ status: 'pending',
293
+ startTime: new Date(),
294
+ rawMode: rawMode || noEnter
295
+ });
296
+
297
+ // Send the command to the tmux pane
298
+ if (noEnter) {
299
+ // Check if this is a special key (e.g., Up, Down, Left, Right, Escape, Tab, etc.)
300
+ // Special keys in tmux are typically capitalized or have special names
301
+ const specialKeys = ['Up', 'Down', 'Left', 'Right', 'Escape', 'Tab', 'Enter', 'Space',
302
+ 'BSpace', 'Delete', 'Home', 'End', 'PageUp', 'PageDown',
303
+ 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'];
304
+
305
+ if (specialKeys.includes(fullCommand)) {
306
+ // Send special key as-is
307
+ await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
308
+ } else {
309
+ // For regular text, send each character individually to ensure proper processing
310
+ // This handles both single characters (like 'q', 'f') and strings (like 'beam')
311
+ for (const char of fullCommand) {
312
+ await executeTmux(`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`);
313
+ }
314
+ }
315
+ } else {
316
+ await executeTmux(`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`);
317
+ }
318
+
319
+ return commandId;
320
+ }
321
+
322
+ export async function checkCommandStatus(commandId: string): Promise<CommandExecution | null> {
323
+ const command = activeCommands.get(commandId);
324
+ if (!command) return null;
325
+
326
+ if (command.status !== 'pending') return command;
327
+
328
+ const content = await capturePaneContent(command.paneId, 1000);
329
+
330
+ if (command.rawMode) {
331
+ command.result = 'Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.';
332
+ return command;
333
+ }
334
+
335
+ // Find the last occurrence of the markers
336
+ const startIndex = content.lastIndexOf(startMarkerText);
337
+ const endIndex = content.lastIndexOf(endMarkerPrefix);
338
+
339
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
340
+ command.result = "Command output could not be captured properly";
341
+ return command;
342
+ }
343
+
344
+ // Extract exit code from the end marker line
345
+ const endLine = content.substring(endIndex).split('\n')[0];
346
+ const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
347
+ const exitCodeMatch = endLine.match(endMarkerRegex);
348
+
349
+ if (exitCodeMatch) {
350
+ const exitCode = parseInt(exitCodeMatch[1], 10);
351
+
352
+ command.status = exitCode === 0 ? 'completed' : 'error';
353
+ command.exitCode = exitCode;
354
+
355
+ // Extract output between the start and end markers
356
+ const outputStart = startIndex + startMarkerText.length;
357
+ const outputContent = content.substring(outputStart, endIndex).trim();
358
+
359
+ command.result = outputContent.substring(outputContent.indexOf('\n') + 1).trim();
360
+
361
+ // Update in map
362
+ activeCommands.set(commandId, command);
363
+ }
364
+
365
+ return command;
366
+ }
367
+
368
+ // Get command by ID
369
+ export function getCommand(commandId: string): CommandExecution | null {
370
+ return activeCommands.get(commandId) || null;
371
+ }
372
+
373
+ // Get all active command IDs
374
+ export function getActiveCommandIds(): string[] {
375
+ return Array.from(activeCommands.keys());
376
+ }
377
+
378
+ // Clean up completed commands older than a certain time
379
+ export function cleanupOldCommands(maxAgeMinutes: number = 60): void {
380
+ const now = new Date();
381
+
382
+ for (const [id, command] of activeCommands.entries()) {
383
+ const ageMinutes = (now.getTime() - command.startTime.getTime()) / (1000 * 60);
384
+
385
+ if (command.status !== 'pending' && ageMinutes > maxAgeMinutes) {
386
+ activeCommands.delete(id);
387
+ }
388
+ }
389
+ }
390
+
391
+ function getEndMarkerText(): string {
392
+ return shellConfig.type === 'fish'
393
+ ? `${endMarkerPrefix}$status`
394
+ : `${endMarkerPrefix}$?`;
395
+ }
396
+
397
+ /**
398
+ * Get the current session ID when running inside tmux
399
+ */
400
+ export async function getCurrentSessionId(): Promise<string> {
401
+ return await executeTmux(`display-message -p '#{session_id}'`);
402
+ }
403
+
404
+ /**
405
+ * Rename a window
406
+ */
407
+ export async function renameWindow(windowId: string, newName: string): Promise<void> {
408
+ await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
409
+ }
410
+
@@ -0,0 +1,15 @@
1
+ // Runtime version (baked in at build time)
2
+ export const VERSION = '0.260201.2240';
3
+
4
+ // Generate version string from current datetime
5
+ // Format: 0.YYMMDD.HHMM (e.g., 0.260201.1430 = Feb 1, 2026 at 14:30)
6
+ export function generateVersion(): string {
7
+ const now = new Date();
8
+ const yy = String(now.getFullYear()).slice(-2);
9
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
10
+ const dd = String(now.getDate()).padStart(2, '0');
11
+ const hh = String(now.getHours()).padStart(2, '0');
12
+ const min = String(now.getMinutes()).padStart(2, '0');
13
+
14
+ return `0.${yy}${mm}${dd}.${hh}${min}`;
15
+ }
@@ -0,0 +1,104 @@
1
+ import { password as passwordPrompt } from '@inquirer/prompts';
2
+ import { testConnection, Model } from './api-client.js';
3
+ import { saveConfig, getDefaultApiUrl, configExists, loadConfig } from './config.js';
4
+ import { Config, Profile } from '../types/config.js';
5
+ import { pickProfileModels, promptText } from './picker.js';
6
+
7
+ export async function runSetupWizard(): Promise<void> {
8
+ console.log('\nšŸ”§ Claudio Setup\n');
9
+
10
+ // 1. Prompt for API URL
11
+ const defaultUrl = getDefaultApiUrl();
12
+ const apiUrl = await promptText(`API URL`, defaultUrl);
13
+
14
+ // 2. Prompt for API key (masked)
15
+ const apiKey = await passwordPrompt({
16
+ message: 'API Key:',
17
+ mask: '*',
18
+ });
19
+
20
+ if (!apiKey) {
21
+ console.error('\nāŒ API key is required');
22
+ process.exit(1);
23
+ }
24
+
25
+ // 3. Test connection
26
+ process.stdout.write('\nTesting connection... ');
27
+ const result = await testConnection(apiUrl, apiKey);
28
+
29
+ if (!result.success) {
30
+ console.log('āŒ');
31
+ console.error(`\nāŒ ${result.message}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ console.log(`āœ“ Connected (${result.modelCount} models available)`);
36
+
37
+ // 4. Create first profile
38
+ console.log('\nCreate your first profile:\n');
39
+
40
+ const profileName = await promptText('Profile name', 'main');
41
+ if (!profileName) {
42
+ console.error('\nāŒ Profile name is required');
43
+ process.exit(1);
44
+ }
45
+
46
+ const models = result.models;
47
+ const { opus, sonnet, haiku } = await pickProfileModels(models);
48
+
49
+ const profile: Profile = { opus, sonnet, haiku };
50
+ const profiles: Record<string, Profile> = { [profileName]: profile };
51
+
52
+ // 5. Save config with first profile as default
53
+ const config: Config = {
54
+ apiUrl,
55
+ apiKey,
56
+ defaultProfile: profileName,
57
+ profiles,
58
+ };
59
+
60
+ await saveConfig(config);
61
+
62
+ console.log(`\nāœ“ Profile "${profileName}" created and set as default`);
63
+ console.log(`\nRun \`claudio\` to launch, or \`claudio profiles add\` to create more.`);
64
+ }
65
+
66
+ export async function runAddProfileWizard(): Promise<void> {
67
+ if (!configExists()) {
68
+ console.error('āŒ No config found. Run `claudio setup` first.');
69
+ process.exit(1);
70
+ }
71
+
72
+ const config = await loadConfig();
73
+
74
+ // Test connection to get models
75
+ process.stdout.write('Fetching models... ');
76
+ const result = await testConnection(config.apiUrl, config.apiKey);
77
+
78
+ if (!result.success) {
79
+ console.log('āŒ');
80
+ console.error(`\nāŒ ${result.message}`);
81
+ process.exit(1);
82
+ }
83
+
84
+ console.log(`āœ“ (${result.modelCount} models)`);
85
+ console.log('');
86
+
87
+ const profileName = await promptText('Profile name');
88
+ if (!profileName) {
89
+ console.error('\nāŒ Profile name is required');
90
+ process.exit(1);
91
+ }
92
+
93
+ if (config.profiles[profileName]) {
94
+ console.error(`\nāŒ Profile "${profileName}" already exists`);
95
+ process.exit(1);
96
+ }
97
+
98
+ const { opus, sonnet, haiku } = await pickProfileModels(result.models);
99
+
100
+ config.profiles[profileName] = { opus, sonnet, haiku };
101
+ await saveConfig(config);
102
+
103
+ console.log(`\nāœ“ Profile "${profileName}" created`);
104
+ }