teleportation-cli 1.4.3 → 1.4.5

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.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI SessionEnd Hook
4
+ *
5
+ * This hook executes when a Gemini CLI session ends.
6
+ * It mirrors the Claude Code session_end hook for Teleportation.
7
+ */
8
+
9
+ import { stdin, stdout, exit, env } from 'node:process';
10
+ import { appendFileSync, existsSync } from 'node:fs';
11
+ import { fileURLToPath } from 'url';
12
+ import { dirname, join } from 'path';
13
+ import { homedir, tmpdir } from 'node:os';
14
+ import { loadConfig } from './shared/config.mjs';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ // Security: Session ID validation to prevent command injection
20
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ // Security: Max input size to prevent memory exhaustion
22
+ const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
23
+
24
+ // Read all stdin
25
+ const readStdin = () => new Promise((resolve, reject) => {
26
+ let data = '';
27
+ stdin.setEncoding('utf8');
28
+ stdin.on('data', chunk => {
29
+ data += chunk;
30
+ if (data.length > MAX_INPUT_SIZE) {
31
+ reject(new Error('Input too large'));
32
+ }
33
+ });
34
+ stdin.on('end', () => resolve(data));
35
+ stdin.on('error', reject);
36
+ });
37
+
38
+ // Logging
39
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
40
+ const log = (msg) => {
41
+ const timestamp = new Date().toISOString();
42
+ const logMsg = `[${timestamp}] [gemini:session_end] ${msg}\n`;
43
+ try {
44
+ appendFileSync(hookLogFile, logMsg);
45
+ } catch (e) {
46
+ // Silently ignore
47
+ }
48
+ };
49
+
50
+ (async () => {
51
+ log('=== SessionEnd hook invoked ===');
52
+
53
+ try {
54
+ const raw = await readStdin();
55
+ let input;
56
+ try {
57
+ input = JSON.parse(raw || '{}');
58
+ } catch (e) {
59
+ log(`Invalid JSON input: ${e.message}`);
60
+ return exit(0);
61
+ }
62
+
63
+ const { session_id, reason, stats } = input;
64
+ const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
65
+
66
+ log(`Session: ${teleportSessionId}, Reason: ${reason}`);
67
+
68
+ // Load config from shared module
69
+ const config = await loadConfig(log);
70
+ const RELAY_API_URL = config.relayApiUrl;
71
+ const RELAY_API_KEY = config.relayApiKey;
72
+
73
+ // NOTE: Heartbeat processes are no longer spawned per-session.
74
+ // The daemon handles heartbeats inline (PRD-0025 migration).
75
+
76
+ // If no relay configured, just exit
77
+ if (!RELAY_API_URL || !RELAY_API_KEY) {
78
+ log('No relay configured, exiting');
79
+ return exit(0);
80
+ }
81
+
82
+ // Log session end to timeline
83
+ const timelineEvent = {
84
+ session_id: teleportSessionId,
85
+ type: 'session_ended',
86
+ data: {
87
+ reason: reason || 'normal_exit',
88
+ stats: stats || {},
89
+ timestamp: Date.now()
90
+ },
91
+ source: 'gemini-cli',
92
+ timestamp: Date.now(),
93
+ };
94
+
95
+ try {
96
+ await fetch(`${RELAY_API_URL}/api/timeline`, {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ 'Authorization': `Bearer ${RELAY_API_KEY}`,
101
+ },
102
+ body: JSON.stringify(timelineEvent),
103
+ signal: AbortSignal.timeout(5000),
104
+ });
105
+ log(`Session end logged to timeline`);
106
+ } catch (e) {
107
+ log(`Failed to log session end: ${e.message}`);
108
+ }
109
+
110
+ // Update session state
111
+ try {
112
+ await fetch(`${RELAY_API_URL}/api/sessions/${teleportSessionId}/daemon-state`, {
113
+ method: 'PATCH',
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
117
+ },
118
+ body: JSON.stringify({
119
+ status: 'stopped',
120
+ is_away: false,
121
+ stopped_reason: 'session_end'
122
+ }),
123
+ signal: AbortSignal.timeout(2000)
124
+ });
125
+ } catch (e) {}
126
+
127
+ // Unregister from local daemon
128
+ const DAEMON_PORT = env.TELEPORTATION_DAEMON_PORT || '3050';
129
+ try {
130
+ await fetch(`http://localhost:${DAEMON_PORT}/sessions/deregister`, {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({ session_id: teleportSessionId }),
134
+ signal: AbortSignal.timeout(2000),
135
+ });
136
+ log(`Session unregistered from daemon`);
137
+ } catch (e) {}
138
+
139
+ // Deregister session with relay API
140
+ try {
141
+ await fetch(`${RELAY_API_URL}/api/sessions/deregister`, {
142
+ method: 'POST',
143
+ headers: {
144
+ 'Content-Type': 'application/json',
145
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
146
+ },
147
+ body: JSON.stringify({ session_id: teleportSessionId })
148
+ });
149
+ } catch (e) {}
150
+
151
+ stdout.write('{}');
152
+ return exit(0);
153
+
154
+ } catch (e) {
155
+ log(`Hook error: ${e.message}`);
156
+ return exit(0);
157
+ }
158
+ })();
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI SessionStart Hook
4
+ *
5
+ * This hook executes when a Gemini CLI session starts.
6
+ * It mirrors the Claude Code session_start hook for Teleportation.
7
+ */
8
+
9
+ import { stdin, stdout, exit, env } from 'node:process';
10
+ import * as fs from 'node:fs';
11
+ import { appendFileSync, existsSync } from 'node:fs';
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, join } from 'path';
14
+ import { homedir } from 'os';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { loadConfig } from './shared/config.mjs';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ // Security: Max input size to prevent memory exhaustion
22
+ const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
23
+
24
+ // Read all stdin
25
+ const readStdin = () => new Promise((resolve, reject) => {
26
+ let data = '';
27
+ stdin.setEncoding('utf8');
28
+ stdin.on('data', chunk => {
29
+ data += chunk;
30
+ if (data.length > MAX_INPUT_SIZE) {
31
+ reject(new Error('Input too large'));
32
+ }
33
+ });
34
+ stdin.on('end', () => resolve(data));
35
+ stdin.on('error', reject);
36
+ });
37
+
38
+ // Logging
39
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
40
+ const log = (msg) => {
41
+ const timestamp = new Date().toISOString();
42
+ const logMsg = `[${timestamp}] [gemini:session_start] ${msg}\n`;
43
+ try {
44
+ appendFileSync(hookLogFile, logMsg);
45
+ } catch (e) {
46
+ // Silently ignore
47
+ }
48
+ };
49
+
50
+ // Lazy-load metadata extraction
51
+ let extractSessionMetadata = null;
52
+ async function getSessionMetadata(cwd) {
53
+ if (!extractSessionMetadata) {
54
+ const possiblePaths = [
55
+ join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
56
+ join(homedir(), '.teleportation', 'lib', 'session', 'metadata.js'),
57
+ ];
58
+
59
+ for (const path of possiblePaths) {
60
+ try {
61
+ const mod = await import('file://' + path);
62
+ extractSessionMetadata = mod.extractSessionMetadata;
63
+ break;
64
+ } catch (e) {
65
+ // Try next path
66
+ }
67
+ }
68
+ }
69
+
70
+ if (!extractSessionMetadata) return { cwd };
71
+
72
+ try {
73
+ return await extractSessionMetadata(cwd);
74
+ } catch (e) {
75
+ return { cwd };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Spawn the heartbeat background process
81
+ * NOTE: Heartbeat processes are no longer spawned per-session.
82
+ * The daemon handles heartbeats inline (PRD-0025 migration).
83
+ * This function is kept as a no-op for backward compatibility.
84
+ */
85
+ function spawnHeartbeat(sessionId, config) {
86
+ // No-op: daemon handles heartbeats inline now
87
+ return;
88
+ }
89
+ }
90
+
91
+ (async () => {
92
+ log('=== SessionStart hook invoked ===');
93
+
94
+ try {
95
+ const raw = await readStdin();
96
+ let input;
97
+ try {
98
+ input = JSON.parse(raw || '{}');
99
+ } catch (e) {
100
+ log(`Invalid JSON input: ${e.message}`);
101
+ return exit(0);
102
+ }
103
+
104
+ const { session_id, model, cwd, owner_id } = input;
105
+ const workingDir = cwd || process.cwd();
106
+
107
+ // Security: Use UUID instead of timestamp to prevent collisions
108
+ const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || `gemini-${randomUUID().substring(0, 8)}`;
109
+
110
+ log(`Session: ${teleportSessionId}, Model: ${model}, CWD: ${workingDir}`);
111
+
112
+ // Load config from shared module
113
+ const config = await loadConfig(log);
114
+ const RELAY_API_URL = config.relayApiUrl;
115
+ const RELAY_API_KEY = config.relayApiKey;
116
+
117
+ // If no relay configured, just log locally
118
+ if (!RELAY_API_URL || !RELAY_API_KEY) {
119
+ log('No relay configured, skipping session registration');
120
+ stdout.write(JSON.stringify({ session_id: teleportSessionId }));
121
+ return exit(0);
122
+ }
123
+
124
+ // Extract enhanced session metadata (Parity with Claude)
125
+ const meta = await getSessionMetadata(workingDir);
126
+ meta.session_id = teleportSessionId;
127
+ meta.gemini_session_id = session_id;
128
+ meta.current_model = model;
129
+
130
+ // Register session with relay
131
+ const sessionPayload = {
132
+ session_id: teleportSessionId,
133
+ meta: {
134
+ ...meta,
135
+ source: 'gemini-cli' // Ensure source is in meta for relay
136
+ },
137
+ owner_id: owner_id || null,
138
+ registered_at: Date.now(),
139
+ };
140
+
141
+ try {
142
+ await fetch(`${RELAY_API_URL}/api/sessions/register`, {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Content-Type': 'application/json',
146
+ 'Authorization': `Bearer ${RELAY_API_KEY}`,
147
+ },
148
+ body: JSON.stringify(sessionPayload),
149
+ signal: AbortSignal.timeout(10000),
150
+ });
151
+ log(`Session ${teleportSessionId} registered with relay`);
152
+
153
+ // Start heartbeat process
154
+ spawnHeartbeat(teleportSessionId, config);
155
+ } catch (e) {
156
+ log(`Failed to register session: ${e.message}`);
157
+ }
158
+
159
+ // Also register with local daemon if running
160
+ const DAEMON_PORT = env.TELEPORTATION_DAEMON_PORT || '3050';
161
+ try {
162
+ await fetch(`http://localhost:${DAEMON_PORT}/sessions/register`, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/json' },
165
+ body: JSON.stringify({
166
+ session_id: teleportSessionId,
167
+ gemini_session_id: session_id,
168
+ cwd: workingDir,
169
+ meta: {
170
+ ...meta,
171
+ source: 'gemini-cli' // Pass source to daemon too
172
+ }
173
+ }),
174
+ signal: AbortSignal.timeout(2000),
175
+ });
176
+ log(`Session registered with local daemon`);
177
+ } catch (e) {
178
+ log(`Daemon not available: ${e.message}`);
179
+ }
180
+
181
+ // Output session ID for Gemini CLI to use
182
+ stdout.write(JSON.stringify({
183
+ session_id: teleportSessionId,
184
+ registered: true,
185
+ }));
186
+ return exit(0);
187
+
188
+ } catch (e) {
189
+ log(`Hook error: ${e.message}`);
190
+ stdout.write('{}');
191
+ return exit(0);
192
+ }
193
+ })();
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared configuration loader for Gemini hooks
3
+ */
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { env } from 'node:process';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname } from 'node:path';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ /**
15
+ * Load configuration for Gemini hooks
16
+ * Priority:
17
+ * 1. Environment variables
18
+ * 2. Claude's config-loader
19
+ * 3. Legacy config file (~/.teleportation-config.json)
20
+ */
21
+ export async function loadConfig(log = () => {}) {
22
+ // Priority 1: Environment variables (Crucial for testing/overrides)
23
+ if (env.RELAY_API_URL && env.RELAY_API_KEY) {
24
+ return {
25
+ relayApiUrl: env.RELAY_API_URL,
26
+ relayApiKey: env.RELAY_API_KEY,
27
+ slackWebhookUrl: env.SLACK_WEBHOOK_URL || '',
28
+ };
29
+ }
30
+
31
+ // Priority 2: Claude's config-loader (Shared system config)
32
+ try {
33
+ // Shared module is in .gemini/hooks/shared/
34
+ // config-loader is in .claude/hooks/
35
+ const configLoaderPath = join(__dirname, '..', '..', '..', '.claude', 'hooks', 'config-loader.mjs');
36
+ if (existsSync(configLoaderPath)) {
37
+ const { loadConfig } = await import('file://' + configLoaderPath);
38
+ const config = await loadConfig();
39
+ if (config.relayApiUrl && config.relayApiKey) {
40
+ return config;
41
+ }
42
+ }
43
+ } catch (e) {
44
+ log(`Config loader failed: ${e.message}`);
45
+ }
46
+
47
+ // Priority 3: Legacy config file
48
+ const legacyConfigPath = join(homedir(), '.teleportation-config.json');
49
+ if (existsSync(legacyConfigPath)) {
50
+ try {
51
+ const config = JSON.parse(readFileSync(legacyConfigPath, 'utf8'));
52
+ return {
53
+ relayApiUrl: config.relay_api_url || '',
54
+ relayApiKey: config.relay_api_key || '',
55
+ slackWebhookUrl: config.slack_webhook_url || '',
56
+ };
57
+ } catch (e) {
58
+ log(`Legacy config failed: ${e.message}`);
59
+ }
60
+ }
61
+
62
+ return {
63
+ relayApiUrl: '',
64
+ relayApiKey: '',
65
+ slackWebhookUrl: '',
66
+ };
67
+ }
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test script for Gemini CLI hooks
4
+ *
5
+ * Tests each hook individually by simulating the input they receive
6
+ * from Gemini CLI and verifying the output.
7
+ *
8
+ * Usage:
9
+ * node .gemini/hooks/test-hooks.mjs
10
+ *
11
+ * Or with relay configured:
12
+ * RELAY_API_URL=http://localhost:3030 RELAY_API_KEY=your-key node .gemini/hooks/test-hooks.mjs
13
+ */
14
+
15
+ import { spawn } from 'child_process';
16
+ import { fileURLToPath } from 'url';
17
+ import { dirname, join } from 'path';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+
22
+ // Helper to run a hook with input and capture output
23
+ async function runHook(hookName, input, timeoutMs = 10000) {
24
+ return new Promise((resolve, reject) => {
25
+ const hookPath = join(__dirname, `${hookName}.mjs`);
26
+ const proc = spawn('node', [hookPath], {
27
+ stdio: ['pipe', 'pipe', 'pipe'],
28
+ env: {
29
+ ...process.env,
30
+ TELEPORTATION_SESSION_ID: 'test-session-' + Date.now(),
31
+ },
32
+ });
33
+
34
+ let stdout = '';
35
+ let stderr = '';
36
+
37
+ proc.stdout.on('data', (data) => {
38
+ stdout += data.toString();
39
+ });
40
+
41
+ proc.stderr.on('data', (data) => {
42
+ stderr += data.toString();
43
+ });
44
+
45
+ const timeout = setTimeout(() => {
46
+ proc.kill('SIGTERM');
47
+ reject(new Error(`Hook ${hookName} timed out after ${timeoutMs}ms`));
48
+ }, timeoutMs);
49
+
50
+ proc.on('close', (code) => {
51
+ clearTimeout(timeout);
52
+ resolve({ code, stdout, stderr });
53
+ });
54
+
55
+ proc.on('error', (err) => {
56
+ clearTimeout(timeout);
57
+ reject(err);
58
+ });
59
+
60
+ // Send input
61
+ proc.stdin.write(JSON.stringify(input));
62
+ proc.stdin.end();
63
+ });
64
+ }
65
+
66
+ // Test cases
67
+ async function testBeforeTool() {
68
+ console.log('\n=== Testing before_tool.mjs ===\n');
69
+
70
+ // Test 1: Safe tool (should auto-approve)
71
+ console.log('1. Testing safe tool (read_file)...');
72
+ try {
73
+ const result = await runHook('before_tool', {
74
+ tool_name: 'read_file',
75
+ tool_input: { path: '/tmp/test.txt' },
76
+ session_id: 'test-session',
77
+ });
78
+
79
+ console.log(` Exit code: ${result.code}`);
80
+ console.log(` Output: ${result.stdout}`);
81
+
82
+ const output = JSON.parse(result.stdout || '{}');
83
+ if (output.action === 'ALLOW') {
84
+ console.log(' ✓ Safe tool auto-approved');
85
+ } else {
86
+ console.log(` ✗ Expected ALLOW, got ${output.action}`);
87
+ }
88
+ } catch (e) {
89
+ console.log(` ✗ Error: ${e.message}`);
90
+ }
91
+
92
+ // Test 2: Safe shell command (git status)
93
+ console.log('\n2. Testing safe shell command (git status)...');
94
+ try {
95
+ const result = await runHook('before_tool', {
96
+ tool_name: 'run_shell_command',
97
+ tool_input: { command: 'git status' },
98
+ session_id: 'test-session',
99
+ });
100
+
101
+ console.log(` Exit code: ${result.code}`);
102
+ console.log(` Output: ${result.stdout}`);
103
+
104
+ const output = JSON.parse(result.stdout || '{}');
105
+ if (output.action === 'ALLOW') {
106
+ console.log(' ✓ Safe shell command auto-approved');
107
+ } else {
108
+ console.log(` ✗ Expected ALLOW, got ${output.action}`);
109
+ }
110
+ } catch (e) {
111
+ console.log(` ✗ Error: ${e.message}`);
112
+ }
113
+
114
+ // Test 3: Potentially dangerous tool (should ask or check relay)
115
+ console.log('\n3. Testing potentially dangerous tool (write_file)...');
116
+ try {
117
+ const result = await runHook('before_tool', {
118
+ tool_name: 'write_file',
119
+ tool_input: { path: '/tmp/test.txt', content: 'hello' },
120
+ session_id: 'test-session',
121
+ });
122
+
123
+ console.log(` Exit code: ${result.code}`);
124
+ console.log(` Output: ${result.stdout}`);
125
+
126
+ const output = JSON.parse(result.stdout || '{}');
127
+ console.log(` Action: ${output.action}, Reason: ${output.reason}`);
128
+ // Without relay, should either ALLOW (no relay) or ASK_USER (user present)
129
+ if (output.action === 'ALLOW' || output.action === 'ASK_USER') {
130
+ console.log(' ✓ Handled correctly (no relay or user present)');
131
+ } else {
132
+ console.log(` Note: Got ${output.action}`);
133
+ }
134
+ } catch (e) {
135
+ console.log(` ✗ Error: ${e.message}`);
136
+ }
137
+ }
138
+
139
+ async function testAfterTool() {
140
+ console.log('\n=== Testing after_tool.mjs ===\n');
141
+
142
+ console.log('1. Testing tool result logging...');
143
+ try {
144
+ const result = await runHook('after_tool', {
145
+ tool_name: 'read_file',
146
+ tool_input: { path: '/tmp/test.txt' },
147
+ tool_output: 'file contents here',
148
+ success: true,
149
+ duration_ms: 50,
150
+ session_id: 'test-session',
151
+ });
152
+
153
+ console.log(` Exit code: ${result.code}`);
154
+ console.log(` Output: ${result.stdout}`);
155
+
156
+ if (result.code === 0) {
157
+ console.log(' ✓ After tool hook completed');
158
+ } else {
159
+ console.log(` ✗ Hook failed with code ${result.code}`);
160
+ }
161
+ } catch (e) {
162
+ console.log(` ✗ Error: ${e.message}`);
163
+ }
164
+ }
165
+
166
+ async function testSessionStart() {
167
+ console.log('\n=== Testing session_start.mjs ===\n');
168
+
169
+ console.log('1. Testing session registration...');
170
+ try {
171
+ const result = await runHook('session_start', {
172
+ session_id: 'gemini-test-' + Date.now(),
173
+ model: 'gemini-2.5-flash',
174
+ cwd: process.cwd(),
175
+ });
176
+
177
+ console.log(` Exit code: ${result.code}`);
178
+ console.log(` Output: ${result.stdout}`);
179
+
180
+ const output = JSON.parse(result.stdout || '{}');
181
+ if (output.session_id) {
182
+ console.log(` ✓ Session registered: ${output.session_id}`);
183
+ } else {
184
+ console.log(' ✗ No session_id in output');
185
+ }
186
+ } catch (e) {
187
+ console.log(` ✗ Error: ${e.message}`);
188
+ }
189
+ }
190
+
191
+ async function testSessionEnd() {
192
+ console.log('\n=== Testing session_end.mjs ===\n');
193
+
194
+ console.log('1. Testing session cleanup...');
195
+ try {
196
+ const result = await runHook('session_end', {
197
+ session_id: 'test-session',
198
+ reason: 'user_exit',
199
+ stats: {
200
+ total_tokens: 1000,
201
+ duration_ms: 60000,
202
+ },
203
+ });
204
+
205
+ console.log(` Exit code: ${result.code}`);
206
+ console.log(` Output: ${result.stdout}`);
207
+
208
+ if (result.code === 0) {
209
+ console.log(' ✓ Session end hook completed');
210
+ } else {
211
+ console.log(` ✗ Hook failed with code ${result.code}`);
212
+ }
213
+ } catch (e) {
214
+ console.log(` ✗ Error: ${e.message}`);
215
+ }
216
+ }
217
+
218
+ // Check hook log
219
+ async function showHookLog() {
220
+ console.log('\n=== Hook Log ===\n');
221
+ try {
222
+ const { readFileSync } = await import('fs');
223
+ const log = readFileSync('/tmp/teleportation-gemini-hook.log', 'utf8');
224
+ const lines = log.split('\n').slice(-30); // Last 30 lines
225
+ console.log(lines.join('\n'));
226
+ } catch (e) {
227
+ console.log('No hook log found (this is normal for first run)');
228
+ }
229
+ }
230
+
231
+ // Main
232
+ async function main() {
233
+ console.log('╔════════════════════════════════════════════════════════════╗');
234
+ console.log('║ Gemini CLI Hooks Test Suite ║');
235
+ console.log('╚════════════════════════════════════════════════════════════╝');
236
+
237
+ console.log('\nEnvironment:');
238
+ console.log(` RELAY_API_URL: ${process.env.RELAY_API_URL || '(not set)'}`);
239
+ console.log(` RELAY_API_KEY: ${process.env.RELAY_API_KEY ? '***' : '(not set)'}`);
240
+ console.log(` CWD: ${process.cwd()}`);
241
+
242
+ await testBeforeTool();
243
+ await testAfterTool();
244
+ await testSessionStart();
245
+ await testSessionEnd();
246
+
247
+ await showHookLog();
248
+
249
+ console.log('\n╔════════════════════════════════════════════════════════════╗');
250
+ console.log('║ Test Complete ║');
251
+ console.log('╚════════════════════════════════════════════════════════════╝\n');
252
+ }
253
+
254
+ main().catch(console.error);