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