teleportation-cli 1.4.4 → 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.
- package/.gemini/hooks/after_agent.mjs +190 -0
- package/.gemini/hooks/after_agent.test.mjs +121 -0
- package/.gemini/hooks/after_tool.mjs +126 -0
- package/.gemini/hooks/before_tool.mjs +276 -0
- package/.gemini/hooks/session_end.mjs +158 -0
- package/.gemini/hooks/session_start.mjs +193 -0
- package/.gemini/hooks/shared/config.mjs +67 -0
- package/.gemini/hooks/test-hooks.mjs +254 -0
- package/package.json +4 -1
- package/teleportation-cli.cjs +5 -0
- package/teleportation.uhr.json +76 -0
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teleportation-cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
4
4
|
"description": "Remote approval system for Claude Code - approve AI coding changes from your phone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "teleportation-cli.cjs",
|
|
@@ -55,6 +55,9 @@
|
|
|
55
55
|
"!lib/**/*.log",
|
|
56
56
|
".claude/hooks/*.mjs",
|
|
57
57
|
"!.claude/hooks/*.test.mjs",
|
|
58
|
+
".gemini/hooks/*.mjs",
|
|
59
|
+
".gemini/hooks/shared/*.mjs",
|
|
60
|
+
"teleportation.uhr.json",
|
|
58
61
|
"scripts/sync-transcripts.sh",
|
|
59
62
|
"teleportation-cli.cjs",
|
|
60
63
|
"README.md",
|
package/teleportation-cli.cjs
CHANGED
|
@@ -3506,6 +3506,11 @@ async function commandInstallHooks() {
|
|
|
3506
3506
|
console.log(c.dim(' Directory: ~/.gemini/hooks/'));
|
|
3507
3507
|
}
|
|
3508
3508
|
|
|
3509
|
+
if (result.cursorHooksInstalled > 0) {
|
|
3510
|
+
console.log(c.green(` ✅ Cursor IDE hooks installed`));
|
|
3511
|
+
console.log(c.dim(' Config: ~/.cursor/hooks.json'));
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3509
3514
|
if (result.libFilesInstalled > 0) {
|
|
3510
3515
|
console.log(c.green(` ✅ ${result.libFilesInstalled} shared library files installed`));
|
|
3511
3516
|
}
|