@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,130 @@
1
+ import { executeTmux } from './tmux.js';
2
+
3
+ export interface TmuxHook {
4
+ event: string;
5
+ command: string;
6
+ }
7
+
8
+ /**
9
+ * Set a tmux hook for a specific event
10
+ */
11
+ export async function setHook(event: string, command: string): Promise<void> {
12
+ await executeTmux(`set-hook -g ${event} '${command.replace(/'/g, "'\\''")}'`);
13
+ }
14
+
15
+ /**
16
+ * Remove a tmux hook
17
+ */
18
+ export async function removeHook(event: string): Promise<void> {
19
+ await executeTmux(`set-hook -gu ${event}`);
20
+ }
21
+
22
+ /**
23
+ * List all tmux hooks
24
+ * Note: tmux doesn't have a direct way to list hooks, so we query common hooks
25
+ */
26
+ export async function listHooks(): Promise<TmuxHook[]> {
27
+ // Common tmux hooks
28
+ const commonHooks = [
29
+ 'after-bind-key',
30
+ 'after-capture-pane',
31
+ 'after-copy-mode',
32
+ 'after-display-message',
33
+ 'after-display-panes',
34
+ 'after-kill-pane',
35
+ 'after-list-buffers',
36
+ 'after-list-clients',
37
+ 'after-list-keys',
38
+ 'after-list-panes',
39
+ 'after-list-sessions',
40
+ 'after-list-windows',
41
+ 'after-load-buffer',
42
+ 'after-lock-server',
43
+ 'after-new-session',
44
+ 'after-new-window',
45
+ 'after-paste-buffer',
46
+ 'after-pipe-pane',
47
+ 'after-queue',
48
+ 'after-refresh-client',
49
+ 'after-rename-session',
50
+ 'after-rename-window',
51
+ 'after-resize-pane',
52
+ 'after-resize-window',
53
+ 'after-save-buffer',
54
+ 'after-select-layout',
55
+ 'after-select-pane',
56
+ 'after-select-window',
57
+ 'after-send-keys',
58
+ 'after-set-buffer',
59
+ 'after-set-environment',
60
+ 'after-set-option',
61
+ 'after-show-environment',
62
+ 'after-show-messages',
63
+ 'after-show-options',
64
+ 'after-split-window',
65
+ 'after-unbind-key',
66
+ 'alert-activity',
67
+ 'alert-bell',
68
+ 'alert-silence',
69
+ 'client-attached',
70
+ 'client-detached',
71
+ 'client-resized',
72
+ 'client-session-changed',
73
+ 'pane-died',
74
+ 'pane-exited',
75
+ 'pane-focus-in',
76
+ 'pane-focus-out',
77
+ 'pane-mode-changed',
78
+ 'pane-set-clipboard',
79
+ 'session-closed',
80
+ 'session-created',
81
+ 'session-renamed',
82
+ 'session-window-changed',
83
+ 'window-linked',
84
+ 'window-pane-changed',
85
+ 'window-renamed',
86
+ 'window-unlinked',
87
+ ];
88
+
89
+ const hooks: TmuxHook[] = [];
90
+
91
+ // Query each hook to see if it's set
92
+ for (const event of commonHooks) {
93
+ try {
94
+ const command = await executeTmux(`show-hooks -g ${event}`);
95
+ if (command.trim()) {
96
+ // Parse the output (format: "event command")
97
+ const parts = command.trim().split(' ');
98
+ if (parts.length >= 2) {
99
+ hooks.push({
100
+ event: parts[0],
101
+ command: parts.slice(1).join(' '),
102
+ });
103
+ }
104
+ }
105
+ } catch (error) {
106
+ // Hook not set, continue
107
+ continue;
108
+ }
109
+ }
110
+
111
+ return hooks;
112
+ }
113
+
114
+ /**
115
+ * Get a specific hook's command
116
+ */
117
+ export async function getHook(event: string): Promise<string | null> {
118
+ try {
119
+ const command = await executeTmux(`show-hooks -g ${event}`);
120
+ if (command.trim()) {
121
+ const parts = command.trim().split(' ');
122
+ if (parts.length >= 2) {
123
+ return parts.slice(1).join(' ');
124
+ }
125
+ }
126
+ return null;
127
+ } catch (error) {
128
+ return null;
129
+ }
130
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Hook Script Manager
3
+ *
4
+ * Generates and manages the bash hook script at ~/.claude/hooks/genie-bash-hook.sh
5
+ * This script intercepts Bash tool calls and rewrites them to go through term exec.
6
+ */
7
+
8
+ import { existsSync, unlinkSync, writeFileSync, chmodSync } from 'fs';
9
+ import {
10
+ ensureClaudeHooksDir,
11
+ getGenieHookScriptPath,
12
+ contractClaudePath,
13
+ } from './claude-settings.js';
14
+ import { loadGenieConfig } from './genie-config.js';
15
+
16
+ /**
17
+ * Check if the hook script exists
18
+ */
19
+ export function hookScriptExists(): boolean {
20
+ return existsSync(getGenieHookScriptPath());
21
+ }
22
+
23
+ /**
24
+ * Get the hook script path (re-export for convenience)
25
+ */
26
+ export function getHookScriptPath(): string {
27
+ return getGenieHookScriptPath();
28
+ }
29
+
30
+ /**
31
+ * Get the hook script path for display (with ~ for home)
32
+ */
33
+ export function getHookScriptDisplayPath(): string {
34
+ return contractClaudePath(getGenieHookScriptPath());
35
+ }
36
+
37
+ /**
38
+ * Generate the hook script content
39
+ *
40
+ * The script:
41
+ * 1. Reads JSON from stdin (Claude Code sends hook event)
42
+ * 2. Extracts the command from the Bash tool input
43
+ * 3. Wraps the command in `term exec genie:shell '...'`
44
+ * 4. Outputs JSON in Claude Code's expected format
45
+ */
46
+ export async function generateHookScript(): Promise<string> {
47
+ // Load config to get session/window names
48
+ const config = await loadGenieConfig();
49
+ const sessionName = config.hooks.collaborative?.sessionName || 'genie';
50
+ const windowName = config.hooks.collaborative?.windowName || 'shell';
51
+ const target = `${sessionName}:${windowName}`;
52
+
53
+ // The bash script that will be executed by Claude Code
54
+ // Uses jq to parse and transform the JSON
55
+ const script = `#!/bin/bash
56
+ #
57
+ # Genie Bash Hook for Claude Code
58
+ #
59
+ # This hook intercepts Bash tool calls and rewrites them to execute
60
+ # through tmux via 'term exec', making all terminal operations visible
61
+ # in a shared tmux session.
62
+ #
63
+ # Generated by: genie hooks install
64
+ # Target session: ${target}
65
+ #
66
+
67
+ # Read the entire input from stdin
68
+ INPUT=$(cat)
69
+
70
+ # Extract the tool name to verify this is a Bash call
71
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
72
+
73
+ # If not a Bash tool call, allow it to proceed unchanged
74
+ if [ "$TOOL_NAME" != "Bash" ]; then
75
+ echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
76
+ exit 0
77
+ fi
78
+
79
+ # Extract the command from the tool input
80
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
81
+
82
+ # If no command, allow it (shouldn't happen but be safe)
83
+ if [ -z "$COMMAND" ]; then
84
+ echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
85
+ exit 0
86
+ fi
87
+
88
+ # Escape single quotes in the command for safe shell embedding
89
+ # Replace ' with '\\'\\''
90
+ ESCAPED_COMMAND=$(echo "$COMMAND" | sed "s/'/'\\\\'\\\\''/" )
91
+
92
+ # Build the wrapped command that goes through term exec
93
+ WRAPPED_COMMAND="term exec ${target} '\${ESCAPED_COMMAND}'"
94
+
95
+ # Extract other fields from the original input to preserve them
96
+ TIMEOUT=$(echo "$INPUT" | jq -r '.tool_input.timeout // empty')
97
+ DESCRIPTION=$(echo "$INPUT" | jq -r '.tool_input.description // empty')
98
+ RUN_IN_BACKGROUND=$(echo "$INPUT" | jq -r '.tool_input.run_in_background // empty')
99
+
100
+ # Build the updated input object
101
+ UPDATED_INPUT=$(jq -n \\
102
+ --arg cmd "$WRAPPED_COMMAND" \\
103
+ --arg timeout "$TIMEOUT" \\
104
+ --arg desc "$DESCRIPTION" \\
105
+ --arg background "$RUN_IN_BACKGROUND" \\
106
+ '{
107
+ command: $cmd,
108
+ timeout: (if $timeout != "" then ($timeout | tonumber) else null end),
109
+ description: (if $desc != "" then ("[term] " + $desc) else null end),
110
+ run_in_background: (if $background == "true" then true elif $background == "false" then false else null end)
111
+ } | with_entries(select(.value != null))')
112
+
113
+ # Output the hook response in the correct format
114
+ jq -n \\
115
+ --argjson updatedInput "$UPDATED_INPUT" \\
116
+ '{
117
+ "hookSpecificOutput": {
118
+ "hookEventName": "PreToolUse",
119
+ "permissionDecision": "allow",
120
+ "updatedInput": $updatedInput,
121
+ "additionalContext": "Human can observe: tmux attach -t ${sessionName}"
122
+ }
123
+ }'
124
+ `;
125
+
126
+ return script;
127
+ }
128
+
129
+ /**
130
+ * Write the hook script to disk
131
+ */
132
+ export async function writeHookScript(): Promise<void> {
133
+ ensureClaudeHooksDir();
134
+
135
+ const scriptPath = getGenieHookScriptPath();
136
+ const scriptContent = await generateHookScript();
137
+
138
+ writeFileSync(scriptPath, scriptContent, 'utf-8');
139
+
140
+ // Make the script executable
141
+ chmodSync(scriptPath, 0o755);
142
+ }
143
+
144
+ /**
145
+ * Remove the hook script from disk
146
+ */
147
+ export function removeHookScript(): void {
148
+ const scriptPath = getGenieHookScriptPath();
149
+ if (existsSync(scriptPath)) {
150
+ unlinkSync(scriptPath);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Test the hook script with sample input
156
+ * Returns { success: boolean, output?: string, error?: string }
157
+ */
158
+ export async function testHookScript(): Promise<{
159
+ success: boolean;
160
+ output?: string;
161
+ error?: string;
162
+ }> {
163
+ const scriptPath = getGenieHookScriptPath();
164
+
165
+ if (!existsSync(scriptPath)) {
166
+ return {
167
+ success: false,
168
+ error: 'Hook script not found. Run "genie hooks install" first.',
169
+ };
170
+ }
171
+
172
+ // Sample input that mimics what Claude Code sends
173
+ const sampleInput = JSON.stringify({
174
+ hook_event_name: 'PreToolUse',
175
+ tool_name: 'Bash',
176
+ tool_input: {
177
+ command: 'echo "Hello, World!"',
178
+ description: 'Test command',
179
+ },
180
+ });
181
+
182
+ try {
183
+ const { $ } = await import('bun');
184
+
185
+ // Run the script with sample input
186
+ const result = await $`echo ${sampleInput} | ${scriptPath}`.quiet();
187
+
188
+ if (result.exitCode !== 0) {
189
+ return {
190
+ success: false,
191
+ error: `Script exited with code ${result.exitCode}`,
192
+ output: result.stderr.toString(),
193
+ };
194
+ }
195
+
196
+ const output = result.stdout.toString().trim();
197
+
198
+ // Try to parse the output as JSON
199
+ try {
200
+ const parsed = JSON.parse(output);
201
+
202
+ // Validate the structure
203
+ if (!parsed.hookSpecificOutput) {
204
+ return {
205
+ success: false,
206
+ error: 'Output missing hookSpecificOutput field',
207
+ output,
208
+ };
209
+ }
210
+
211
+ if (parsed.hookSpecificOutput.hookEventName !== 'PreToolUse') {
212
+ return {
213
+ success: false,
214
+ error: 'Output has wrong hookEventName',
215
+ output,
216
+ };
217
+ }
218
+
219
+ if (!['allow', 'deny', 'ask'].includes(parsed.hookSpecificOutput.permissionDecision)) {
220
+ return {
221
+ success: false,
222
+ error: 'Output has invalid permissionDecision',
223
+ output,
224
+ };
225
+ }
226
+
227
+ // Check that the command was wrapped
228
+ if (parsed.hookSpecificOutput.updatedInput?.command) {
229
+ const cmd = parsed.hookSpecificOutput.updatedInput.command;
230
+ if (!cmd.includes('term exec')) {
231
+ return {
232
+ success: false,
233
+ error: 'Command was not wrapped with term exec',
234
+ output,
235
+ };
236
+ }
237
+ }
238
+
239
+ return {
240
+ success: true,
241
+ output,
242
+ };
243
+ } catch (parseError: any) {
244
+ return {
245
+ success: false,
246
+ error: `Output is not valid JSON: ${parseError.message}`,
247
+ output,
248
+ };
249
+ }
250
+ } catch (error: any) {
251
+ return {
252
+ success: false,
253
+ error: `Failed to run hook script: ${error.message}`,
254
+ };
255
+ }
256
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Hook Composition Utilities
3
+ *
4
+ * Functions for merging multiple hook configurations together.
5
+ */
6
+
7
+ import type { HookConfig } from './presets/collaborative.js';
8
+
9
+ /**
10
+ * Deep merge two hook configurations
11
+ *
12
+ * Hooks are composed by concatenating their hook arrays.
13
+ * This means all hooks from both configs will run in order.
14
+ */
15
+ export function mergeHooks(base: HookConfig, override: HookConfig): HookConfig {
16
+ const result: HookConfig = { ...base };
17
+
18
+ // Merge PreToolUse hooks
19
+ if (override.PreToolUse) {
20
+ result.PreToolUse = [...(base.PreToolUse || []), ...override.PreToolUse];
21
+ }
22
+
23
+ // Merge PostToolUse hooks
24
+ if (override.PostToolUse) {
25
+ result.PostToolUse = [...(base.PostToolUse || []), ...override.PostToolUse];
26
+ }
27
+
28
+ // Merge PermissionRequest hooks
29
+ if (override.PermissionRequest) {
30
+ result.PermissionRequest = [...(base.PermissionRequest || []), ...override.PermissionRequest];
31
+ }
32
+
33
+ return result;
34
+ }
35
+
36
+ /**
37
+ * Compose multiple hook configurations into one
38
+ *
39
+ * Hooks are executed in the order they appear in the array.
40
+ * First preset's hooks run first.
41
+ */
42
+ export function composeHooks(...configs: HookConfig[]): HookConfig {
43
+ return configs.reduce((acc, config) => mergeHooks(acc, config), {});
44
+ }
45
+
46
+ /**
47
+ * Check if a hook config is empty (no hooks defined)
48
+ */
49
+ export function isEmptyHookConfig(config: HookConfig): boolean {
50
+ return (
51
+ (!config.PreToolUse || config.PreToolUse.length === 0) &&
52
+ (!config.PostToolUse || config.PostToolUse.length === 0) &&
53
+ (!config.PermissionRequest || config.PermissionRequest.length === 0)
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Count total number of hooks in a config
59
+ */
60
+ export function countHooks(config: HookConfig): number {
61
+ let count = 0;
62
+ if (config.PreToolUse) {
63
+ count += config.PreToolUse.reduce((sum, entry) => sum + entry.hooks.length, 0);
64
+ }
65
+ if (config.PostToolUse) {
66
+ count += config.PostToolUse.reduce((sum, entry) => sum + entry.hooks.length, 0);
67
+ }
68
+ if (config.PermissionRequest) {
69
+ count += config.PermissionRequest.reduce((sum, entry) => sum + entry.hooks.length, 0);
70
+ }
71
+ return count;
72
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Hook Preset Library
3
+ *
4
+ * Provides reusable hook configurations that can be composed
5
+ * to enforce agent behaviors architecturally (without prompting).
6
+ *
7
+ * Usage:
8
+ * import { loadHooksFromConfig, presets, composeHooks } from './hooks';
9
+ *
10
+ * // Load hooks based on genie config
11
+ * const hooks = await loadHooksFromConfig();
12
+ *
13
+ * // Or compose manually
14
+ * const hooks = composeHooks(
15
+ * presets.collaborative(),
16
+ * presets.audited({ logPath: '~/.genie/audit.log' })
17
+ * );
18
+ */
19
+
20
+ import { loadGenieConfig } from '../genie-config.js';
21
+ import type {
22
+ GenieConfig,
23
+ PresetName,
24
+ CollaborativeConfig,
25
+ SupervisedConfig,
26
+ SandboxedConfig,
27
+ AuditedConfig,
28
+ } from '../../types/genie-config.js';
29
+
30
+ // Re-export types
31
+ export type { HookConfig, HookCallback, HookInput, HookOutput } from './presets/collaborative.js';
32
+
33
+ // Re-export utilities
34
+ export { mergeHooks, composeHooks, isEmptyHookConfig, countHooks } from './compose.js';
35
+ export { escapeForSingleQuotes, escapeForDoubleQuotes, singleQuote, doubleQuote } from './utils/escape.js';
36
+
37
+ // Import presets
38
+ import { collaborativeHooks, type CollaborativeHookConfig } from './presets/collaborative.js';
39
+ import { supervisedHooks, type SupervisedHookConfig } from './presets/supervised.js';
40
+ import { sandboxedHooks, type SandboxedHookConfig } from './presets/sandboxed.js';
41
+ import { auditedHooks, type AuditedHookConfig } from './presets/audited.js';
42
+
43
+ // Re-export preset types
44
+ export type { CollaborativeHookConfig } from './presets/collaborative.js';
45
+ export type { SupervisedHookConfig } from './presets/supervised.js';
46
+ export type { SandboxedHookConfig } from './presets/sandboxed.js';
47
+ export type { AuditedHookConfig } from './presets/audited.js';
48
+
49
+ // Import compose utilities
50
+ import { composeHooks } from './compose.js';
51
+ import type { HookConfig } from './presets/collaborative.js';
52
+
53
+ /**
54
+ * All available presets as functions
55
+ */
56
+ export const presets = {
57
+ collaborative: collaborativeHooks,
58
+ supervised: supervisedHooks,
59
+ sandboxed: sandboxedHooks,
60
+ audited: auditedHooks,
61
+ } as const;
62
+
63
+ /**
64
+ * Preset factory type
65
+ */
66
+ export type PresetFactory = typeof presets[PresetName];
67
+
68
+ /**
69
+ * Load hooks from genie config file
70
+ *
71
+ * Reads ~/.genie/config.json and builds a combined hook configuration
72
+ * from all enabled presets.
73
+ *
74
+ * @returns Combined hook configuration from all enabled presets
75
+ */
76
+ export async function loadHooksFromConfig(): Promise<HookConfig> {
77
+ const config = await loadGenieConfig();
78
+ return buildHooksFromConfig(config);
79
+ }
80
+
81
+ /**
82
+ * Build hooks from a genie config object
83
+ *
84
+ * This is useful for testing or when you already have the config loaded.
85
+ */
86
+ export function buildHooksFromConfig(config: GenieConfig): HookConfig {
87
+ const enabledPresets = config.hooks.enabled;
88
+
89
+ if (enabledPresets.length === 0) {
90
+ return {};
91
+ }
92
+
93
+ const hookConfigs: HookConfig[] = [];
94
+
95
+ for (const presetName of enabledPresets) {
96
+ const presetConfig = config.hooks[presetName] || {};
97
+ const presetFactory = presets[presetName];
98
+
99
+ if (presetFactory) {
100
+ hookConfigs.push(presetFactory(presetConfig as any));
101
+ }
102
+ }
103
+
104
+ return composeHooks(...hookConfigs);
105
+ }
106
+
107
+ /**
108
+ * Get a human-readable description of the enabled hooks
109
+ */
110
+ export function describeEnabledHooks(config: GenieConfig): string[] {
111
+ const descriptions: string[] = [];
112
+
113
+ for (const presetName of config.hooks.enabled) {
114
+ switch (presetName) {
115
+ case 'collaborative': {
116
+ const collabConfig: Partial<CollaborativeConfig> = config.hooks.collaborative || {};
117
+ const session = collabConfig.sessionName || 'genie';
118
+ const window = collabConfig.windowName || 'shell';
119
+ descriptions.push(`Collaborative: Bash → term exec ${session}:${window}`);
120
+ break;
121
+ }
122
+ case 'supervised': {
123
+ const supervisedConfig: Partial<SupervisedConfig> = config.hooks.supervised || {};
124
+ const tools = supervisedConfig.alwaysAsk || ['Write', 'Edit'];
125
+ descriptions.push(`Supervised: ${tools.join(', ')} require approval`);
126
+ break;
127
+ }
128
+ case 'sandboxed': {
129
+ const sandboxConfig: Partial<SandboxedConfig> = config.hooks.sandboxed || {};
130
+ const paths = sandboxConfig.allowedPaths || ['~/projects', '/tmp'];
131
+ descriptions.push(`Sandboxed: Restricted to ${paths.join(', ')}`);
132
+ break;
133
+ }
134
+ case 'audited': {
135
+ const auditConfig: Partial<AuditedConfig> = config.hooks.audited || {};
136
+ const logPath = auditConfig.logPath || '~/.genie/audit.log';
137
+ descriptions.push(`Audited: Logging to ${logPath}`);
138
+ break;
139
+ }
140
+ }
141
+ }
142
+
143
+ return descriptions;
144
+ }
145
+
146
+ /**
147
+ * Check if any hooks are enabled
148
+ */
149
+ export function hasEnabledHooks(config: GenieConfig): boolean {
150
+ return config.hooks.enabled.length > 0;
151
+ }
152
+
153
+ /**
154
+ * Parse hook names from a comma-separated string
155
+ */
156
+ export function parseHookNames(input: string): PresetName[] {
157
+ const validPresets: PresetName[] = ['collaborative', 'supervised', 'sandboxed', 'audited'];
158
+ const names = input.split(',').map((s) => s.trim().toLowerCase());
159
+
160
+ return names.filter((name): name is PresetName =>
161
+ validPresets.includes(name as PresetName)
162
+ );
163
+ }