@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,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
+ }