@automagik/genie 0.260202.530 → 0.260202.1607
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/dist/claudio.js +44 -45
- package/dist/genie.js +58 -135
- package/dist/term.js +71 -66
- package/install.sh +43 -7
- package/package.json +1 -1
- package/src/claudio.ts +31 -21
- package/src/commands/launch.ts +12 -68
- package/src/genie-commands/doctor.ts +327 -0
- package/src/genie-commands/setup.ts +317 -199
- package/src/genie-commands/uninstall.ts +176 -0
- package/src/genie.ts +24 -44
- package/src/lib/claude-settings.ts +22 -64
- package/src/lib/genie-config.ts +169 -57
- package/src/lib/version.ts +1 -1
- package/src/term-commands/exec.ts +28 -6
- package/src/term-commands/read.ts +6 -1
- package/src/term-commands/shortcuts.ts +14 -14
- package/src/term.ts +12 -2
- package/src/types/genie-config.ts +49 -81
- package/src/genie-commands/hooks.ts +0 -317
- package/src/lib/hook-script.ts +0 -263
- package/src/lib/hooks/compose.ts +0 -72
- package/src/lib/hooks/index.ts +0 -163
- package/src/lib/hooks/presets/audited.ts +0 -191
- package/src/lib/hooks/presets/collaborative.ts +0 -143
- package/src/lib/hooks/presets/sandboxed.ts +0 -153
- package/src/lib/hooks/presets/supervised.ts +0 -66
- package/src/lib/hooks/utils/escape.ts +0 -46
package/src/lib/hook-script.ts
DELETED
|
@@ -1,263 +0,0 @@
|
|
|
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
|
-
# Don't wrap commands that are already term exec calls (avoid infinite recursion)
|
|
89
|
-
if [[ "$COMMAND" == *"term exec "* ]] || [[ "$COMMAND" == *"/term.js exec"* ]] || [[ "$COMMAND" == *".local/bin/term "* ]] || [[ "$COMMAND" == *".genie/bin/term"* ]]; then
|
|
90
|
-
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
91
|
-
exit 0
|
|
92
|
-
fi
|
|
93
|
-
|
|
94
|
-
# Escape single quotes in the command for safe shell embedding
|
|
95
|
-
# Replace ' with '\\'\\''
|
|
96
|
-
ESCAPED_COMMAND=$(echo "$COMMAND" | sed "s/'/'\\\\'\\\\''/" )
|
|
97
|
-
|
|
98
|
-
# Build the wrapped command that goes through term exec
|
|
99
|
-
# Uses full path since Claude's subprocess may not have full PATH
|
|
100
|
-
WRAPPED_COMMAND="\$HOME/.local/bin/term exec ${sessionName}:\\\${CLAUDIO_SESSION:-claude} '\${ESCAPED_COMMAND}'"
|
|
101
|
-
|
|
102
|
-
# Extract other fields from the original input to preserve them
|
|
103
|
-
TIMEOUT=$(echo "$INPUT" | jq -r '.tool_input.timeout // empty')
|
|
104
|
-
DESCRIPTION=$(echo "$INPUT" | jq -r '.tool_input.description // empty')
|
|
105
|
-
RUN_IN_BACKGROUND=$(echo "$INPUT" | jq -r '.tool_input.run_in_background // empty')
|
|
106
|
-
|
|
107
|
-
# Build the updated input object
|
|
108
|
-
UPDATED_INPUT=$(jq -n \\
|
|
109
|
-
--arg cmd "$WRAPPED_COMMAND" \\
|
|
110
|
-
--arg timeout "$TIMEOUT" \\
|
|
111
|
-
--arg desc "$DESCRIPTION" \\
|
|
112
|
-
--arg background "$RUN_IN_BACKGROUND" \\
|
|
113
|
-
'{
|
|
114
|
-
command: $cmd,
|
|
115
|
-
timeout: (if $timeout != "" then ($timeout | tonumber) else null end),
|
|
116
|
-
description: (if $desc != "" then ("[term] " + $desc) else null end),
|
|
117
|
-
run_in_background: (if $background == "true" then true elif $background == "false" then false else null end)
|
|
118
|
-
} | with_entries(select(.value != null))')
|
|
119
|
-
|
|
120
|
-
# Output the hook response in the correct format
|
|
121
|
-
jq -n \\
|
|
122
|
-
--argjson updatedInput "$UPDATED_INPUT" \\
|
|
123
|
-
'{
|
|
124
|
-
"hookSpecificOutput": {
|
|
125
|
-
"hookEventName": "PreToolUse",
|
|
126
|
-
"permissionDecision": "allow",
|
|
127
|
-
"updatedInput": $updatedInput,
|
|
128
|
-
"additionalContext": "Human can observe: tmux attach -t ${sessionName}"
|
|
129
|
-
}
|
|
130
|
-
}'
|
|
131
|
-
`;
|
|
132
|
-
|
|
133
|
-
return script;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Write the hook script to disk
|
|
138
|
-
*/
|
|
139
|
-
export async function writeHookScript(): Promise<void> {
|
|
140
|
-
ensureClaudeHooksDir();
|
|
141
|
-
|
|
142
|
-
const scriptPath = getGenieHookScriptPath();
|
|
143
|
-
const scriptContent = await generateHookScript();
|
|
144
|
-
|
|
145
|
-
writeFileSync(scriptPath, scriptContent, 'utf-8');
|
|
146
|
-
|
|
147
|
-
// Make the script executable
|
|
148
|
-
chmodSync(scriptPath, 0o755);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Remove the hook script from disk
|
|
153
|
-
*/
|
|
154
|
-
export function removeHookScript(): void {
|
|
155
|
-
const scriptPath = getGenieHookScriptPath();
|
|
156
|
-
if (existsSync(scriptPath)) {
|
|
157
|
-
unlinkSync(scriptPath);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Test the hook script with sample input
|
|
163
|
-
* Returns { success: boolean, output?: string, error?: string }
|
|
164
|
-
*/
|
|
165
|
-
export async function testHookScript(): Promise<{
|
|
166
|
-
success: boolean;
|
|
167
|
-
output?: string;
|
|
168
|
-
error?: string;
|
|
169
|
-
}> {
|
|
170
|
-
const scriptPath = getGenieHookScriptPath();
|
|
171
|
-
|
|
172
|
-
if (!existsSync(scriptPath)) {
|
|
173
|
-
return {
|
|
174
|
-
success: false,
|
|
175
|
-
error: 'Hook script not found. Run "genie hooks install" first.',
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Sample input that mimics what Claude Code sends
|
|
180
|
-
const sampleInput = JSON.stringify({
|
|
181
|
-
hook_event_name: 'PreToolUse',
|
|
182
|
-
tool_name: 'Bash',
|
|
183
|
-
tool_input: {
|
|
184
|
-
command: 'echo "Hello, World!"',
|
|
185
|
-
description: 'Test command',
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
const { $ } = await import('bun');
|
|
191
|
-
|
|
192
|
-
// Run the script with sample input
|
|
193
|
-
const result = await $`echo ${sampleInput} | ${scriptPath}`.quiet();
|
|
194
|
-
|
|
195
|
-
if (result.exitCode !== 0) {
|
|
196
|
-
return {
|
|
197
|
-
success: false,
|
|
198
|
-
error: `Script exited with code ${result.exitCode}`,
|
|
199
|
-
output: result.stderr.toString(),
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const output = result.stdout.toString().trim();
|
|
204
|
-
|
|
205
|
-
// Try to parse the output as JSON
|
|
206
|
-
try {
|
|
207
|
-
const parsed = JSON.parse(output);
|
|
208
|
-
|
|
209
|
-
// Validate the structure
|
|
210
|
-
if (!parsed.hookSpecificOutput) {
|
|
211
|
-
return {
|
|
212
|
-
success: false,
|
|
213
|
-
error: 'Output missing hookSpecificOutput field',
|
|
214
|
-
output,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (parsed.hookSpecificOutput.hookEventName !== 'PreToolUse') {
|
|
219
|
-
return {
|
|
220
|
-
success: false,
|
|
221
|
-
error: 'Output has wrong hookEventName',
|
|
222
|
-
output,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (!['allow', 'deny', 'ask'].includes(parsed.hookSpecificOutput.permissionDecision)) {
|
|
227
|
-
return {
|
|
228
|
-
success: false,
|
|
229
|
-
error: 'Output has invalid permissionDecision',
|
|
230
|
-
output,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Check that the command was wrapped
|
|
235
|
-
if (parsed.hookSpecificOutput.updatedInput?.command) {
|
|
236
|
-
const cmd = parsed.hookSpecificOutput.updatedInput.command;
|
|
237
|
-
if (!cmd.includes('term exec')) {
|
|
238
|
-
return {
|
|
239
|
-
success: false,
|
|
240
|
-
error: 'Command was not wrapped with term exec',
|
|
241
|
-
output,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
success: true,
|
|
248
|
-
output,
|
|
249
|
-
};
|
|
250
|
-
} catch (parseError: any) {
|
|
251
|
-
return {
|
|
252
|
-
success: false,
|
|
253
|
-
error: `Output is not valid JSON: ${parseError.message}`,
|
|
254
|
-
output,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
} catch (error: any) {
|
|
258
|
-
return {
|
|
259
|
-
success: false,
|
|
260
|
-
error: `Failed to run hook script: ${error.message}`,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
}
|
package/src/lib/hooks/compose.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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
|
-
}
|
package/src/lib/hooks/index.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Audited Hook Preset
|
|
3
|
-
*
|
|
4
|
-
* Logs all tool executions to a file for later review.
|
|
5
|
-
* Uses PostToolUse hooks to capture tool inputs and outputs.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* const hooks = auditedHooks({ logPath: '~/.genie/audit.log' });
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
12
|
-
import { dirname, resolve } from 'path';
|
|
13
|
-
import { homedir } from 'os';
|
|
14
|
-
import type { AuditedConfig } from '../../../types/genie-config.js';
|
|
15
|
-
import type { HookConfig, HookInput, HookOutput } from './collaborative.js';
|
|
16
|
-
|
|
17
|
-
export interface AuditedHookConfig {
|
|
18
|
-
logPath: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Default configuration for audited hooks
|
|
23
|
-
*/
|
|
24
|
-
export const DEFAULT_AUDITED_CONFIG: AuditedHookConfig = {
|
|
25
|
-
logPath: '~/.genie/audit.log',
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Expand ~ to home directory
|
|
30
|
-
*/
|
|
31
|
-
function expandTilde(path: string): string {
|
|
32
|
-
if (path.startsWith('~/')) {
|
|
33
|
-
return homedir() + path.slice(1);
|
|
34
|
-
}
|
|
35
|
-
if (path === '~') {
|
|
36
|
-
return homedir();
|
|
37
|
-
}
|
|
38
|
-
return path;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Format a tool input/output for logging
|
|
43
|
-
* Truncates very long values to avoid log bloat
|
|
44
|
-
*/
|
|
45
|
-
function formatForLog(value: unknown, maxLength: number = 1000): string {
|
|
46
|
-
try {
|
|
47
|
-
const str = JSON.stringify(value);
|
|
48
|
-
if (str.length > maxLength) {
|
|
49
|
-
return str.slice(0, maxLength) + '...[truncated]';
|
|
50
|
-
}
|
|
51
|
-
return str;
|
|
52
|
-
} catch {
|
|
53
|
-
return String(value);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Audit log entry
|
|
59
|
-
*/
|
|
60
|
-
interface AuditEntry {
|
|
61
|
-
timestamp: string;
|
|
62
|
-
tool: string;
|
|
63
|
-
input: unknown;
|
|
64
|
-
response?: unknown;
|
|
65
|
-
duration_ms?: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Append an entry to the audit log
|
|
70
|
-
*/
|
|
71
|
-
function appendToLog(logPath: string, entry: AuditEntry): void {
|
|
72
|
-
const expandedPath = resolve(expandTilde(logPath));
|
|
73
|
-
const dir = dirname(expandedPath);
|
|
74
|
-
|
|
75
|
-
// Ensure directory exists
|
|
76
|
-
if (!existsSync(dir)) {
|
|
77
|
-
mkdirSync(dir, { recursive: true });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const logLine = JSON.stringify({
|
|
81
|
-
...entry,
|
|
82
|
-
input: formatForLog(entry.input),
|
|
83
|
-
response: entry.response ? formatForLog(entry.response) : undefined,
|
|
84
|
-
}) + '\n';
|
|
85
|
-
|
|
86
|
-
appendFileSync(expandedPath, logLine, 'utf-8');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Track tool start times for duration calculation
|
|
90
|
-
const toolStartTimes = new Map<string, number>();
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Create audited hooks configuration
|
|
94
|
-
*
|
|
95
|
-
* This hook captures all tool executions using PostToolUse hooks and
|
|
96
|
-
* logs them to a file in JSONL format for later review.
|
|
97
|
-
*/
|
|
98
|
-
export function auditedHooks(config: Partial<AuditedConfig> = {}): HookConfig {
|
|
99
|
-
const logPath = config.logPath || DEFAULT_AUDITED_CONFIG.logPath;
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
PreToolUse: [
|
|
103
|
-
{
|
|
104
|
-
// Match all tools
|
|
105
|
-
matcher: /.*/,
|
|
106
|
-
timeout: 10,
|
|
107
|
-
hooks: [
|
|
108
|
-
async (input: HookInput, toolUseID, _options): Promise<HookOutput> => {
|
|
109
|
-
// Record start time for duration calculation
|
|
110
|
-
if (toolUseID) {
|
|
111
|
-
toolStartTimes.set(toolUseID, Date.now());
|
|
112
|
-
}
|
|
113
|
-
return { continue: true };
|
|
114
|
-
},
|
|
115
|
-
],
|
|
116
|
-
},
|
|
117
|
-
],
|
|
118
|
-
PostToolUse: [
|
|
119
|
-
{
|
|
120
|
-
// Match all tools
|
|
121
|
-
matcher: /.*/,
|
|
122
|
-
timeout: 10,
|
|
123
|
-
hooks: [
|
|
124
|
-
async (input: HookInput, toolUseID, _options): Promise<HookOutput> => {
|
|
125
|
-
// Type guard - only process PostToolUse events
|
|
126
|
-
if (input.hook_event_name !== 'PostToolUse') {
|
|
127
|
-
return { continue: true };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Calculate duration if we have a start time
|
|
131
|
-
let duration_ms: number | undefined;
|
|
132
|
-
if (toolUseID && toolStartTimes.has(toolUseID)) {
|
|
133
|
-
duration_ms = Date.now() - toolStartTimes.get(toolUseID)!;
|
|
134
|
-
toolStartTimes.delete(toolUseID);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const entry: AuditEntry = {
|
|
138
|
-
timestamp: new Date().toISOString(),
|
|
139
|
-
tool: input.tool_name || 'unknown',
|
|
140
|
-
input: input.tool_input,
|
|
141
|
-
response: input.tool_response,
|
|
142
|
-
duration_ms,
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
appendToLog(logPath, entry);
|
|
147
|
-
} catch (error) {
|
|
148
|
-
// Don't fail the tool call if logging fails
|
|
149
|
-
console.warn(`Audit log warning: ${error}`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return { continue: true };
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Read the audit log as an array of entries
|
|
162
|
-
* Useful for analysis and review
|
|
163
|
-
*/
|
|
164
|
-
export function readAuditLog(logPath: string = DEFAULT_AUDITED_CONFIG.logPath): AuditEntry[] {
|
|
165
|
-
const expandedPath = resolve(expandTilde(logPath));
|
|
166
|
-
|
|
167
|
-
if (!existsSync(expandedPath)) {
|
|
168
|
-
return [];
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const { readFileSync } = require('fs');
|
|
172
|
-
const content = readFileSync(expandedPath, 'utf-8');
|
|
173
|
-
const lines = content.trim().split('\n').filter(Boolean);
|
|
174
|
-
|
|
175
|
-
return lines.map((line: string) => {
|
|
176
|
-
try {
|
|
177
|
-
return JSON.parse(line);
|
|
178
|
-
} catch {
|
|
179
|
-
return { error: 'parse_failed', raw: line };
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Clear the audit log
|
|
186
|
-
*/
|
|
187
|
-
export function clearAuditLog(logPath: string = DEFAULT_AUDITED_CONFIG.logPath): void {
|
|
188
|
-
const expandedPath = resolve(expandTilde(logPath));
|
|
189
|
-
const { writeFileSync } = require('fs');
|
|
190
|
-
writeFileSync(expandedPath, '', 'utf-8');
|
|
191
|
-
}
|