teleportation-cli 1.4.4 → 1.5.0

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,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI BeforeTool Hook
4
+ *
5
+ * This hook executes BEFORE any tool runs in Gemini CLI.
6
+ * It mirrors the Claude Code pre_tool_use hook for Teleportation.
7
+ */
8
+
9
+ import { stdin, stdout, exit, env } from 'node:process';
10
+ import { appendFileSync } from 'node:fs';
11
+ import { fileURLToPath } from 'url';
12
+ import { dirname } from 'path';
13
+ import { loadConfig } from './shared/config.mjs';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ // Security: Max input size to prevent memory exhaustion
19
+ const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
20
+
21
+ // Read all stdin
22
+ const readStdin = () => new Promise((resolve, reject) => {
23
+ let data = '';
24
+ stdin.setEncoding('utf8');
25
+ stdin.on('data', chunk => {
26
+ data += chunk;
27
+ if (data.length > MAX_INPUT_SIZE) {
28
+ reject(new Error('Input too large'));
29
+ }
30
+ });
31
+ stdin.on('end', () => resolve(data));
32
+ stdin.on('error', reject);
33
+ });
34
+
35
+ // Sleep helper
36
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
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:before_tool] ${msg}\n`;
43
+ try {
44
+ appendFileSync(hookLogFile, logMsg);
45
+ } catch (e) {
46
+ // Silently ignore
47
+ }
48
+ };
49
+
50
+ // Fetch JSON helper
51
+ const fetchJson = async (url, opts) => {
52
+ const res = await fetch(url, opts);
53
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
54
+ return res.json();
55
+ };
56
+
57
+ // Safe tools that can be auto-approved (read-only operations)
58
+ const SAFE_TOOLS = new Set([
59
+ 'read_file',
60
+ 'list_directory',
61
+ 'search_files',
62
+ 'grep_search',
63
+ 'file_search',
64
+ 'web_search',
65
+ 'web_fetch',
66
+ 'get_file_info',
67
+ ]);
68
+
69
+ // Safe shell commands (read-only)
70
+ const SAFE_SHELL_COMMANDS = [
71
+ /^git\s+(status|log|diff|show|branch|remote|config\s+--get)/,
72
+ /^ls(\s|$)/,
73
+ /^pwd(\s|$)/,
74
+ /^cat\s/,
75
+ /^head\s/,
76
+ /^tail\s/,
77
+ /^wc\s/,
78
+ /^echo\s/,
79
+ /^which\s/,
80
+ /^type\s/,
81
+ /^file\s/,
82
+ /^stat\s/,
83
+ ];
84
+
85
+ function isSafeTool(toolName, toolInput) {
86
+ // Check if tool is in safe list
87
+ if (SAFE_TOOLS.has(toolName.toLowerCase())) {
88
+ return true;
89
+ }
90
+
91
+ // Check shell commands
92
+ if (toolName === 'run_shell_command' || toolName === 'bash' || toolName === 'shell') {
93
+ const command = toolInput?.command || toolInput?.cmd || '';
94
+
95
+ // Security: Check for command chaining/injection patterns
96
+ if (/[;&|`$()]/.test(command)) {
97
+ return false;
98
+ }
99
+
100
+ // Security: Check for redirects that could overwrite files
101
+ if (/[><]/.test(command)) {
102
+ return false;
103
+ }
104
+
105
+ // Only allow if matches a safe pattern
106
+ for (const pattern of SAFE_SHELL_COMMANDS) {
107
+ if (pattern.test(command)) {
108
+ return true;
109
+ }
110
+ }
111
+ }
112
+
113
+ return false;
114
+ }
115
+
116
+ // Output decision to Gemini CLI
117
+ function outputDecision(action, reason = '') {
118
+ const decision = { action, reason };
119
+ stdout.write(JSON.stringify(decision));
120
+ log(`Decision: ${action} - ${reason}`);
121
+ }
122
+
123
+ (async () => {
124
+ log('=== BeforeTool hook invoked ===');
125
+
126
+ try {
127
+ const raw = await readStdin();
128
+ let input;
129
+ try {
130
+ input = JSON.parse(raw || '{}');
131
+ } catch (e) {
132
+ log(`Invalid JSON input: ${e.message}`);
133
+ outputDecision('ALLOW', 'Invalid input, allowing by default');
134
+ return exit(0);
135
+ }
136
+
137
+ const { tool_name, tool_input, session_id } = input;
138
+ const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
139
+ log(`Tool: ${tool_name}, Session (input): ${session_id}, TeleportSession: ${teleportSessionId}`);
140
+
141
+ // Load config from shared module
142
+ const config = await loadConfig(log);
143
+ const RELAY_API_URL = config.relayApiUrl;
144
+ const RELAY_API_KEY = config.relayApiKey;
145
+
146
+ log(`Using Relay: ${RELAY_API_URL}`);
147
+
148
+ // If no relay configured, allow all (local-only mode)
149
+ if (!RELAY_API_URL || !RELAY_API_KEY) {
150
+ log('No relay configured, allowing tool');
151
+ outputDecision('ALLOW', 'No relay configured');
152
+ return exit(0);
153
+ }
154
+
155
+ // Check if tool is safe (auto-approve)
156
+ if (isSafeTool(tool_name, tool_input)) {
157
+ log(`Safe tool ${tool_name}, auto-approving`);
158
+ outputDecision('ALLOW', 'Safe tool auto-approved');
159
+ return exit(0);
160
+ }
161
+
162
+ // Check if user is away (should use remote approval)
163
+ let isAway = false;
164
+ try {
165
+ const stateUrl = `${RELAY_API_URL}/api/sessions/${teleportSessionId}/daemon-state`;
166
+ log(`Checking away status at: ${stateUrl}`);
167
+
168
+ const response = await fetch(stateUrl, {
169
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
170
+ signal: AbortSignal.timeout(5000),
171
+ });
172
+
173
+ if (response.ok) {
174
+ const state = await response.json();
175
+ isAway = !!state.is_away;
176
+ log(`Session away status: ${isAway}`);
177
+ } else {
178
+ log(`Away check failed: HTTP ${response.status}`);
179
+ }
180
+ } catch (e) {
181
+ log(`Could not check away status: ${e.message}`);
182
+ // If we can't check, assume user is present
183
+ }
184
+
185
+ // If user is present, ask them locally
186
+ if (!isAway) {
187
+ log('User is present, asking locally');
188
+ outputDecision('ASK_USER', 'User is present');
189
+ return exit(0);
190
+ }
191
+
192
+ // User is away - create remote approval request
193
+ log('User is away, creating remote approval request');
194
+
195
+ const approvalPayload = {
196
+ session_id: teleportSessionId,
197
+ tool_name,
198
+ tool_input,
199
+ status: 'pending',
200
+ source: 'gemini-cli',
201
+ meta: {
202
+ cwd: process.cwd(),
203
+ timestamp: Date.now(),
204
+ },
205
+ };
206
+
207
+ let approvalId;
208
+ try {
209
+ const approval = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
210
+ method: 'POST',
211
+ headers: {
212
+ 'Content-Type': 'application/json',
213
+ 'Authorization': `Bearer ${RELAY_API_KEY}`,
214
+ },
215
+ body: JSON.stringify(approvalPayload),
216
+ signal: AbortSignal.timeout(10000),
217
+ });
218
+ approvalId = approval.id;
219
+ log(`Created approval request: ${approvalId}`);
220
+ } catch (e) {
221
+ log(`Failed to create approval: ${e.message}`);
222
+ // If relay is down, allow (fail-safe for autonomous operation)
223
+ outputDecision('ALLOW', 'Relay unavailable, auto-approved');
224
+ return exit(0);
225
+ }
226
+
227
+ // Poll for approval decision
228
+ // Handle environment variable with proper parsing and bounds
229
+ const POLL_TIMEOUT_MS = Math.max(5000, Math.min(3600000,
230
+ parseInt(env.GEMINI_APPROVAL_TIMEOUT_MS || '60000', 10) || 60000
231
+ ));
232
+ const POLL_INTERVAL_MS = Math.max(100, Math.min(30000,
233
+ parseInt(env.GEMINI_POLL_INTERVAL_MS || '2000', 10) || 2000
234
+ ));
235
+
236
+ log(`Polling for approval: timeout=${POLL_TIMEOUT_MS}ms, interval=${POLL_INTERVAL_MS}ms`);
237
+
238
+ const startTime = Date.now();
239
+
240
+ while (Date.now() - startTime < POLL_TIMEOUT_MS) {
241
+ try {
242
+ const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
243
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
244
+ signal: AbortSignal.timeout(5000),
245
+ });
246
+
247
+ if (status.status === 'allowed') {
248
+ log(`Approval ${approvalId} allowed`);
249
+ outputDecision('ALLOW', 'Remote approval granted');
250
+ return exit(0);
251
+ } else if (status.status === 'denied') {
252
+ log(`Approval ${approvalId} denied`);
253
+ outputDecision('DENY', 'Remote approval denied');
254
+ return exit(0);
255
+ }
256
+
257
+ // Still pending, wait and poll again
258
+ await sleep(POLL_INTERVAL_MS);
259
+ } catch (e) {
260
+ log(`Poll error: ${e.message}`);
261
+ await sleep(POLL_INTERVAL_MS);
262
+ }
263
+ }
264
+
265
+ // Timeout - let daemon handle it
266
+ log(`Approval timeout, handing off to daemon`);
267
+ outputDecision('DENY', 'Approval timeout - daemon will handle');
268
+ return exit(0);
269
+
270
+ } catch (e) {
271
+ log(`Hook error: ${e.message}`);
272
+ // On error, allow (fail-safe)
273
+ outputDecision('ALLOW', `Error: ${e.message}`);
274
+ return exit(0);
275
+ }
276
+ })();
@@ -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
+ }