@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,109 @@
1
+ export interface Model {
2
+ id: string;
3
+ created: number;
4
+ owned_by: string;
5
+ }
6
+
7
+ export interface ModelsResponse {
8
+ data: Model[];
9
+ object: string;
10
+ }
11
+
12
+ export type ConnectionResult =
13
+ | { success: true; modelCount: number; models: Model[] }
14
+ | { success: false; error: 'auth_failure' | 'network_error' | 'invalid_url' | 'unknown'; message: string };
15
+
16
+ export async function testConnection(apiUrl: string, apiKey: string): Promise<ConnectionResult> {
17
+ try {
18
+ // Validate URL format
19
+ try {
20
+ new URL(apiUrl);
21
+ } catch {
22
+ return {
23
+ success: false,
24
+ error: 'invalid_url',
25
+ message: `Invalid URL format: ${apiUrl}`,
26
+ };
27
+ }
28
+
29
+ const response = await fetch(`${apiUrl}/v1/models`, {
30
+ method: 'GET',
31
+ headers: {
32
+ 'Authorization': `Bearer ${apiKey}`,
33
+ 'Content-Type': 'application/json',
34
+ },
35
+ });
36
+
37
+ if (response.status === 401 || response.status === 403) {
38
+ return {
39
+ success: false,
40
+ error: 'auth_failure',
41
+ message: 'Authentication failed. Check your API key.',
42
+ };
43
+ }
44
+
45
+ if (!response.ok) {
46
+ return {
47
+ success: false,
48
+ error: 'unknown',
49
+ message: `Server returned ${response.status}: ${response.statusText}`,
50
+ };
51
+ }
52
+
53
+ const data = await response.json() as ModelsResponse;
54
+ const models = data.data || [];
55
+
56
+ return {
57
+ success: true,
58
+ modelCount: models.length,
59
+ models,
60
+ };
61
+ } catch (error: any) {
62
+ // Network errors (connection refused, timeout, DNS failure, etc.)
63
+ if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('ECONNREFUSED')) {
64
+ return {
65
+ success: false,
66
+ error: 'network_error',
67
+ message: `Connection refused. Is the server running at ${apiUrl}?`,
68
+ };
69
+ }
70
+ if (error.cause?.code === 'ENOTFOUND' || error.message?.includes('ENOTFOUND')) {
71
+ return {
72
+ success: false,
73
+ error: 'network_error',
74
+ message: `Could not resolve hostname. Check the URL: ${apiUrl}`,
75
+ };
76
+ }
77
+ if (error.name === 'AbortError' || error.message?.includes('timeout')) {
78
+ return {
79
+ success: false,
80
+ error: 'network_error',
81
+ message: 'Connection timed out. Check your network and the server URL.',
82
+ };
83
+ }
84
+ return {
85
+ success: false,
86
+ error: 'network_error',
87
+ message: `Network error: ${error.message || 'Unknown error'}`,
88
+ };
89
+ }
90
+ }
91
+
92
+ export async function getModels(apiUrl: string, apiKey: string): Promise<Model[]> {
93
+ const result = await testConnection(apiUrl, apiKey);
94
+ if (result.success) {
95
+ return result.models;
96
+ }
97
+ throw new Error(result.message);
98
+ }
99
+
100
+ export async function validateApiKeyAndGetModels(
101
+ apiUrl: string,
102
+ apiKey: string
103
+ ): Promise<Model[] | null> {
104
+ const result = await testConnection(apiUrl, apiKey);
105
+ if (result.success) {
106
+ return result.models;
107
+ }
108
+ return null;
109
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Claude Settings Manager
3
+ *
4
+ * Manages ~/.claude/settings.json without breaking existing settings.
5
+ * Uses Zod with passthrough() to preserve unknown fields.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
9
+ import { homedir } from 'os';
10
+ import { join } from 'path';
11
+ import { z } from 'zod';
12
+
13
+ // Claude directory and settings file paths
14
+ const CLAUDE_DIR = join(homedir(), '.claude');
15
+ const CLAUDE_HOOKS_DIR = join(CLAUDE_DIR, 'hooks');
16
+ const CLAUDE_SETTINGS_FILE = join(CLAUDE_DIR, 'settings.json');
17
+
18
+ // Hook entry schema for a single hook command
19
+ const HookCommandSchema = z.object({
20
+ type: z.literal('command'),
21
+ command: z.string(),
22
+ timeout: z.number().optional(),
23
+ }).passthrough();
24
+
25
+ // Matcher hooks schema (array of hook commands for a specific matcher)
26
+ const MatcherHooksSchema = z.object({
27
+ matcher: z.string(),
28
+ hooks: z.array(HookCommandSchema),
29
+ }).passthrough();
30
+
31
+ // Hooks configuration schema
32
+ const HooksConfigSchema = z.object({
33
+ PreToolUse: z.array(MatcherHooksSchema).optional(),
34
+ PostToolUse: z.array(MatcherHooksSchema).optional(),
35
+ }).passthrough();
36
+
37
+ // Full settings schema with passthrough to preserve unknown fields
38
+ const ClaudeSettingsSchema = z.object({
39
+ model: z.string().optional(),
40
+ enabledPlugins: z.record(z.unknown()).optional(),
41
+ hooks: HooksConfigSchema.optional(),
42
+ }).passthrough();
43
+
44
+ export type ClaudeSettings = z.infer<typeof ClaudeSettingsSchema>;
45
+
46
+ // Constants for the genie hook
47
+ export const GENIE_HOOK_SCRIPT_NAME = 'genie-bash-hook.sh';
48
+ export const GENIE_HOOK_MATCHER = 'Bash';
49
+
50
+ /**
51
+ * Get the path to the Claude directory (~/.claude)
52
+ */
53
+ export function getClaudeDir(): string {
54
+ return CLAUDE_DIR;
55
+ }
56
+
57
+ /**
58
+ * Get the path to the Claude hooks directory (~/.claude/hooks)
59
+ */
60
+ export function getClaudeHooksDir(): string {
61
+ return CLAUDE_HOOKS_DIR;
62
+ }
63
+
64
+ /**
65
+ * Get the path to the Claude settings file (~/.claude/settings.json)
66
+ */
67
+ export function getClaudeSettingsPath(): string {
68
+ return CLAUDE_SETTINGS_FILE;
69
+ }
70
+
71
+ /**
72
+ * Get the path to the genie hook script
73
+ */
74
+ export function getGenieHookScriptPath(): string {
75
+ return join(CLAUDE_HOOKS_DIR, GENIE_HOOK_SCRIPT_NAME);
76
+ }
77
+
78
+ /**
79
+ * Check if Claude settings file exists
80
+ */
81
+ export function claudeSettingsExists(): boolean {
82
+ return existsSync(CLAUDE_SETTINGS_FILE);
83
+ }
84
+
85
+ /**
86
+ * Ensure the Claude directory exists
87
+ */
88
+ export function ensureClaudeDir(): void {
89
+ if (!existsSync(CLAUDE_DIR)) {
90
+ mkdirSync(CLAUDE_DIR, { recursive: true });
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Ensure the Claude hooks directory exists
96
+ */
97
+ export function ensureClaudeHooksDir(): void {
98
+ ensureClaudeDir();
99
+ if (!existsSync(CLAUDE_HOOKS_DIR)) {
100
+ mkdirSync(CLAUDE_HOOKS_DIR, { recursive: true });
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Load Claude settings, returning defaults if not found
106
+ */
107
+ export async function loadClaudeSettings(): Promise<ClaudeSettings> {
108
+ if (!existsSync(CLAUDE_SETTINGS_FILE)) {
109
+ return ClaudeSettingsSchema.parse({});
110
+ }
111
+
112
+ try {
113
+ const content = readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8');
114
+ const data = JSON.parse(content);
115
+ return ClaudeSettingsSchema.parse(data);
116
+ } catch (error: any) {
117
+ // If settings are invalid, return defaults but warn
118
+ console.warn(`Warning: Invalid Claude settings, using defaults: ${error.message}`);
119
+ return ClaudeSettingsSchema.parse({});
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Save Claude settings to disk
125
+ */
126
+ export async function saveClaudeSettings(settings: ClaudeSettings): Promise<void> {
127
+ ensureClaudeDir();
128
+
129
+ try {
130
+ const validated = ClaudeSettingsSchema.parse(settings);
131
+ const content = JSON.stringify(validated, null, 2);
132
+ writeFileSync(CLAUDE_SETTINGS_FILE, content, 'utf-8');
133
+ } catch (error: any) {
134
+ throw new Error(`Failed to save Claude settings: ${error.message}`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Create the genie hook entry for Claude settings
140
+ */
141
+ function createGenieHookEntry(scriptPath: string): z.infer<typeof MatcherHooksSchema> {
142
+ return {
143
+ matcher: GENIE_HOOK_MATCHER,
144
+ hooks: [
145
+ {
146
+ type: 'command',
147
+ command: scriptPath,
148
+ timeout: 600,
149
+ },
150
+ ],
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Check if the genie hook is installed in the settings
156
+ */
157
+ export function isGenieHookInstalled(settings: ClaudeSettings): boolean {
158
+ const preToolUse = settings.hooks?.PreToolUse;
159
+ if (!preToolUse || !Array.isArray(preToolUse)) {
160
+ return false;
161
+ }
162
+
163
+ return preToolUse.some((entry) => {
164
+ if (entry.matcher !== GENIE_HOOK_MATCHER) return false;
165
+ if (!Array.isArray(entry.hooks)) return false;
166
+ return entry.hooks.some((hook) =>
167
+ hook.type === 'command' && hook.command?.includes(GENIE_HOOK_SCRIPT_NAME)
168
+ );
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Add the genie hook to Claude settings
174
+ * Returns the modified settings (does not save to disk)
175
+ */
176
+ export function addGenieHook(settings: ClaudeSettings): ClaudeSettings {
177
+ const scriptPath = getGenieHookScriptPath();
178
+ const hookEntry = createGenieHookEntry(scriptPath);
179
+
180
+ // Initialize hooks structure if needed
181
+ if (!settings.hooks) {
182
+ settings.hooks = {};
183
+ }
184
+ if (!settings.hooks.PreToolUse) {
185
+ settings.hooks.PreToolUse = [];
186
+ }
187
+
188
+ // Check if already installed
189
+ if (isGenieHookInstalled(settings)) {
190
+ return settings;
191
+ }
192
+
193
+ // Remove any existing Bash matcher entries that might conflict
194
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
195
+ (entry) => !(entry.matcher === GENIE_HOOK_MATCHER &&
196
+ entry.hooks?.some((h) => h.command?.includes('genie')))
197
+ );
198
+
199
+ // Add the new hook entry
200
+ settings.hooks.PreToolUse.push(hookEntry);
201
+
202
+ return settings;
203
+ }
204
+
205
+ /**
206
+ * Remove the genie hook from Claude settings
207
+ * Returns the modified settings (does not save to disk)
208
+ */
209
+ export function removeGenieHook(settings: ClaudeSettings): ClaudeSettings {
210
+ if (!settings.hooks?.PreToolUse) {
211
+ return settings;
212
+ }
213
+
214
+ // Filter out genie hook entries
215
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((entry) => {
216
+ // Keep entries that don't match our hook
217
+ if (entry.matcher !== GENIE_HOOK_MATCHER) return true;
218
+ if (!Array.isArray(entry.hooks)) return true;
219
+
220
+ // Remove entries that have genie hook commands
221
+ const hasGenieHook = entry.hooks.some((hook) =>
222
+ hook.type === 'command' && hook.command?.includes(GENIE_HOOK_SCRIPT_NAME)
223
+ );
224
+ return !hasGenieHook;
225
+ });
226
+
227
+ // Clean up empty PreToolUse array
228
+ if (settings.hooks.PreToolUse.length === 0) {
229
+ delete settings.hooks.PreToolUse;
230
+ }
231
+
232
+ // Clean up empty hooks object
233
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
234
+ delete settings.hooks;
235
+ }
236
+
237
+ return settings;
238
+ }
239
+
240
+ /**
241
+ * Contract home directory to ~ in a path (for display)
242
+ */
243
+ export function contractClaudePath(path: string): string {
244
+ const home = homedir();
245
+ if (path.startsWith(home + '/')) {
246
+ return '~' + path.slice(home.length);
247
+ }
248
+ if (path === home) {
249
+ return '~';
250
+ }
251
+ return path;
252
+ }
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { Config, ConfigSchema, Profile } from '../types/config.js';
5
+
6
+ const CONFIG_DIR = join(homedir(), '.claudio');
7
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
8
+
9
+ export function getConfigPath(): string {
10
+ return CONFIG_FILE;
11
+ }
12
+
13
+ export function configExists(): boolean {
14
+ return existsSync(CONFIG_FILE);
15
+ }
16
+
17
+ export async function loadConfig(): Promise<Config> {
18
+ if (!existsSync(CONFIG_FILE)) {
19
+ throw new Error('Config file not found. Run "claudio setup" first.');
20
+ }
21
+
22
+ try {
23
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
24
+ const data = JSON.parse(content);
25
+ return ConfigSchema.parse(data);
26
+ } catch (error: any) {
27
+ throw new Error(`Failed to load config: ${error.message}`);
28
+ }
29
+ }
30
+
31
+ export async function saveConfig(config: Config): Promise<void> {
32
+ if (!existsSync(CONFIG_DIR)) {
33
+ mkdirSync(CONFIG_DIR, { recursive: true });
34
+ }
35
+
36
+ try {
37
+ const validated = ConfigSchema.parse(config);
38
+ const content = JSON.stringify(validated, null, 2);
39
+ writeFileSync(CONFIG_FILE, content, 'utf-8');
40
+ } catch (error: any) {
41
+ throw new Error(`Failed to save config: ${error.message}`);
42
+ }
43
+ }
44
+
45
+ export async function deleteConfig(): Promise<void> {
46
+ if (existsSync(CONFIG_FILE)) {
47
+ unlinkSync(CONFIG_FILE);
48
+ }
49
+ }
50
+
51
+ export function getDefaultApiUrl(): string {
52
+ return 'http://10.114.1.119:8317';
53
+ }
54
+
55
+ export function getAnthropicApiUrl(): string {
56
+ return 'https://api.anthropic.com/v1';
57
+ }
58
+
59
+ // Profile CRUD helpers
60
+
61
+ export async function getDefaultProfile(): Promise<string | undefined> {
62
+ const config = await loadConfig();
63
+ return config.defaultProfile;
64
+ }
65
+
66
+ export async function setDefaultProfile(name: string): Promise<void> {
67
+ const config = await loadConfig();
68
+ if (!config.profiles[name]) {
69
+ throw new Error(`Profile "${name}" not found`);
70
+ }
71
+ config.defaultProfile = name;
72
+ await saveConfig(config);
73
+ }
74
+
75
+ export async function addProfile(name: string, profile: Profile): Promise<void> {
76
+ const config = await loadConfig();
77
+ if (config.profiles[name]) {
78
+ throw new Error(`Profile "${name}" already exists`);
79
+ }
80
+ config.profiles[name] = profile;
81
+ await saveConfig(config);
82
+ }
83
+
84
+ export async function removeProfile(name: string): Promise<void> {
85
+ const config = await loadConfig();
86
+ if (!config.profiles[name]) {
87
+ throw new Error(`Profile "${name}" not found`);
88
+ }
89
+ delete config.profiles[name];
90
+ // Clear default if we just deleted it
91
+ if (config.defaultProfile === name) {
92
+ config.defaultProfile = undefined;
93
+ }
94
+ await saveConfig(config);
95
+ }
96
+
97
+ export async function listProfiles(): Promise<{ name: string; profile: Profile; isDefault: boolean }[]> {
98
+ const config = await loadConfig();
99
+ return Object.entries(config.profiles).map(([name, profile]) => ({
100
+ name,
101
+ profile,
102
+ isDefault: config.defaultProfile === name,
103
+ }));
104
+ }
105
+
106
+ export async function getProfile(name: string): Promise<Profile | undefined> {
107
+ const config = await loadConfig();
108
+ return config.profiles[name];
109
+ }
@@ -0,0 +1,164 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import {
5
+ GenieConfig,
6
+ GenieConfigSchema,
7
+ HooksConfig,
8
+ PresetName,
9
+ } from '../types/genie-config.js';
10
+
11
+ const GENIE_DIR = join(homedir(), '.genie');
12
+ const GENIE_CONFIG_FILE = join(GENIE_DIR, 'config.json');
13
+
14
+ /**
15
+ * Get the path to the genie config directory
16
+ */
17
+ export function getGenieDir(): string {
18
+ return GENIE_DIR;
19
+ }
20
+
21
+ /**
22
+ * Get the path to the genie config file
23
+ */
24
+ export function getGenieConfigPath(): string {
25
+ return GENIE_CONFIG_FILE;
26
+ }
27
+
28
+ /**
29
+ * Check if genie config exists
30
+ */
31
+ export function genieConfigExists(): boolean {
32
+ return existsSync(GENIE_CONFIG_FILE);
33
+ }
34
+
35
+ /**
36
+ * Ensure the genie config directory exists
37
+ */
38
+ export function ensureGenieDir(): void {
39
+ if (!existsSync(GENIE_DIR)) {
40
+ mkdirSync(GENIE_DIR, { recursive: true });
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Load genie config, returning defaults if not found
46
+ */
47
+ export async function loadGenieConfig(): Promise<GenieConfig> {
48
+ if (!existsSync(GENIE_CONFIG_FILE)) {
49
+ // Return default config
50
+ return GenieConfigSchema.parse({});
51
+ }
52
+
53
+ try {
54
+ const content = readFileSync(GENIE_CONFIG_FILE, 'utf-8');
55
+ const data = JSON.parse(content);
56
+ return GenieConfigSchema.parse(data);
57
+ } catch (error: any) {
58
+ // If config is invalid, return defaults
59
+ console.warn(`Warning: Invalid genie config, using defaults: ${error.message}`);
60
+ return GenieConfigSchema.parse({});
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Save genie config to disk
66
+ */
67
+ export async function saveGenieConfig(config: GenieConfig): Promise<void> {
68
+ ensureGenieDir();
69
+
70
+ try {
71
+ const validated = GenieConfigSchema.parse(config);
72
+ const content = JSON.stringify(validated, null, 2);
73
+ writeFileSync(GENIE_CONFIG_FILE, content, 'utf-8');
74
+ } catch (error: any) {
75
+ throw new Error(`Failed to save genie config: ${error.message}`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get the default genie config
81
+ */
82
+ export function getDefaultGenieConfig(): GenieConfig {
83
+ return GenieConfigSchema.parse({});
84
+ }
85
+
86
+ /**
87
+ * Check if a hook preset is enabled
88
+ */
89
+ export function isPresetEnabled(config: GenieConfig, preset: PresetName): boolean {
90
+ return config.hooks.enabled.includes(preset);
91
+ }
92
+
93
+ /**
94
+ * Enable a hook preset
95
+ */
96
+ export async function enablePreset(preset: PresetName): Promise<void> {
97
+ const config = await loadGenieConfig();
98
+ if (!config.hooks.enabled.includes(preset)) {
99
+ config.hooks.enabled.push(preset);
100
+ await saveGenieConfig(config);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Disable a hook preset
106
+ */
107
+ export async function disablePreset(preset: PresetName): Promise<void> {
108
+ const config = await loadGenieConfig();
109
+ config.hooks.enabled = config.hooks.enabled.filter((p) => p !== preset);
110
+ await saveGenieConfig(config);
111
+ }
112
+
113
+ /**
114
+ * Get enabled presets
115
+ */
116
+ export async function getEnabledPresets(): Promise<PresetName[]> {
117
+ const config = await loadGenieConfig();
118
+ return config.hooks.enabled;
119
+ }
120
+
121
+ /**
122
+ * Set enabled presets (replaces all)
123
+ */
124
+ export async function setEnabledPresets(presets: PresetName[]): Promise<void> {
125
+ const config = await loadGenieConfig();
126
+ config.hooks.enabled = presets;
127
+ await saveGenieConfig(config);
128
+ }
129
+
130
+ /**
131
+ * Update hooks configuration
132
+ */
133
+ export async function updateHooksConfig(hooks: Partial<HooksConfig>): Promise<void> {
134
+ const config = await loadGenieConfig();
135
+ config.hooks = { ...config.hooks, ...hooks };
136
+ await saveGenieConfig(config);
137
+ }
138
+
139
+ /**
140
+ * Expand ~ to home directory in a path
141
+ */
142
+ export function expandPath(path: string): string {
143
+ if (path.startsWith('~/')) {
144
+ return join(homedir(), path.slice(2));
145
+ }
146
+ if (path === '~') {
147
+ return homedir();
148
+ }
149
+ return path;
150
+ }
151
+
152
+ /**
153
+ * Contract home directory to ~ in a path (for display)
154
+ */
155
+ export function contractPath(path: string): string {
156
+ const home = homedir();
157
+ if (path.startsWith(home + '/')) {
158
+ return '~' + path.slice(home.length);
159
+ }
160
+ if (path === home) {
161
+ return '~';
162
+ }
163
+ return path;
164
+ }