teleportation-cli 1.1.1 → 1.1.3
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/.claude/hooks/permission_request.mjs +24 -0
- package/.claude/hooks/post_tool_use.mjs +59 -11
- package/.claude/hooks/pre_tool_use.mjs +74 -2
- package/.claude/hooks/session_end.mjs +29 -0
- package/.claude/hooks/user_prompt_submit.mjs +29 -2
- package/lib/config/manager.js +137 -26
- package/package.json +2 -1
- package/teleportation-cli.cjs +171 -91
|
@@ -330,6 +330,30 @@ const fetchJson = async (url, opts) => {
|
|
|
330
330
|
|
|
331
331
|
if (status.status === 'denied') {
|
|
332
332
|
log('Remote approval: DENIED');
|
|
333
|
+
// Log tool_denied event to timeline
|
|
334
|
+
try {
|
|
335
|
+
await fetchJson(`${RELAY_API_URL}/api/timeline`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: {
|
|
338
|
+
'Content-Type': 'application/json',
|
|
339
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
340
|
+
},
|
|
341
|
+
body: JSON.stringify({
|
|
342
|
+
session_id,
|
|
343
|
+
type: 'tool_denied',
|
|
344
|
+
data: {
|
|
345
|
+
tool_name,
|
|
346
|
+
tool_input: tool_input || {},
|
|
347
|
+
reason: status.decision_reason || 'Denied remotely via Teleportation',
|
|
348
|
+
approval_id: approvalId,
|
|
349
|
+
timestamp: Date.now()
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
});
|
|
353
|
+
log(`Logged tool_denied event for ${tool_name}`);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
log(`Failed to log tool_denied: ${e.message}`);
|
|
356
|
+
}
|
|
333
357
|
const out = {
|
|
334
358
|
hookSpecificOutput: {
|
|
335
359
|
hookEventName: 'PermissionRequest',
|
|
@@ -102,6 +102,62 @@ const fetchJson = async (url, opts) => {
|
|
|
102
102
|
log(`Failed to clear pending approvals: ${e.message}`);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Detect if this tool execution resulted in an error
|
|
106
|
+
const hasError = (() => {
|
|
107
|
+
if (!tool_output) return false;
|
|
108
|
+
// Check for is_error flag
|
|
109
|
+
if (tool_output?.is_error) return true;
|
|
110
|
+
// Check for error property
|
|
111
|
+
if (tool_output?.error) return true;
|
|
112
|
+
// Check for common error patterns in string output
|
|
113
|
+
if (typeof tool_output === 'string') {
|
|
114
|
+
const lower = tool_output.toLowerCase();
|
|
115
|
+
return lower.includes('error:') || lower.includes('exception:') || lower.includes('failed:');
|
|
116
|
+
}
|
|
117
|
+
// Check for stderr content
|
|
118
|
+
if (tool_output?.stderr && tool_output.stderr.trim()) return true;
|
|
119
|
+
return false;
|
|
120
|
+
})();
|
|
121
|
+
|
|
122
|
+
const outputPreview = (() => {
|
|
123
|
+
if (!tool_output) return null;
|
|
124
|
+
try {
|
|
125
|
+
const stringified = JSON.stringify(tool_output);
|
|
126
|
+
const truncated = stringified.slice(0, TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH);
|
|
127
|
+
return stringified.length > TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH ? truncated + '...' : truncated;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return '[Unserializable output]';
|
|
130
|
+
}
|
|
131
|
+
})();
|
|
132
|
+
|
|
133
|
+
// If there's an error, log a separate tool_error event for visibility
|
|
134
|
+
if (hasError) {
|
|
135
|
+
try {
|
|
136
|
+
const errorMessage = tool_output?.error || tool_output?.stderr ||
|
|
137
|
+
(typeof tool_output === 'string' ? tool_output.slice(0, 500) : 'Tool execution failed');
|
|
138
|
+
await fetchJson(`${RELAY_API_URL}/api/timeline`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
143
|
+
},
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
session_id,
|
|
146
|
+
type: 'tool_error',
|
|
147
|
+
data: {
|
|
148
|
+
tool_name,
|
|
149
|
+
tool_input,
|
|
150
|
+
error: typeof errorMessage === 'string' ? errorMessage.slice(0, 1000) : JSON.stringify(errorMessage).slice(0, 1000),
|
|
151
|
+
timestamp: Date.now()
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
});
|
|
155
|
+
log(`Logged tool_error event for ${tool_name}`);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
log(`Failed to log tool_error: ${e.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
105
161
|
// Record tool execution to timeline
|
|
106
162
|
try {
|
|
107
163
|
await fetchJson(`${RELAY_API_URL}/api/timeline`, {
|
|
@@ -116,21 +172,13 @@ const fetchJson = async (url, opts) => {
|
|
|
116
172
|
data: {
|
|
117
173
|
tool_name,
|
|
118
174
|
tool_input,
|
|
175
|
+
has_error: hasError,
|
|
119
176
|
// Include truncated output for context
|
|
120
|
-
tool_output_preview:
|
|
121
|
-
if (!tool_output) return null;
|
|
122
|
-
try {
|
|
123
|
-
const stringified = JSON.stringify(tool_output);
|
|
124
|
-
const truncated = stringified.slice(0, TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH);
|
|
125
|
-
return stringified.length > TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH ? truncated + '...' : truncated;
|
|
126
|
-
} catch (e) {
|
|
127
|
-
return '[Unserializable output]';
|
|
128
|
-
}
|
|
129
|
-
})()
|
|
177
|
+
tool_output_preview: outputPreview
|
|
130
178
|
}
|
|
131
179
|
})
|
|
132
180
|
});
|
|
133
|
-
log(`Recorded tool execution: ${tool_name}`);
|
|
181
|
+
log(`Recorded tool execution: ${tool_name}${hasError ? ' (with error)' : ''}`);
|
|
134
182
|
} catch (e) {
|
|
135
183
|
log(`Failed to record to timeline: ${e.message}`);
|
|
136
184
|
}
|
|
@@ -319,7 +319,7 @@ const fetchJson = async (url, opts) => {
|
|
|
319
319
|
// Log model change to timeline
|
|
320
320
|
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
321
321
|
try {
|
|
322
|
-
await fetch(`${RELAY_API_URL}/api/timeline
|
|
322
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
323
323
|
method: 'POST',
|
|
324
324
|
headers: {
|
|
325
325
|
'Content-Type': 'application/json',
|
|
@@ -327,7 +327,7 @@ const fetchJson = async (url, opts) => {
|
|
|
327
327
|
},
|
|
328
328
|
body: JSON.stringify({
|
|
329
329
|
session_id,
|
|
330
|
-
|
|
330
|
+
type: 'model_changed',
|
|
331
331
|
data: {
|
|
332
332
|
previous_model: lastModel,
|
|
333
333
|
new_model: meta.current_model,
|
|
@@ -393,6 +393,32 @@ const fetchJson = async (url, opts) => {
|
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
// 3. Log tool_use event to timeline (before execution)
|
|
397
|
+
// This shows what Claude is attempting to do
|
|
398
|
+
if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
|
|
399
|
+
try {
|
|
400
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
headers: {
|
|
403
|
+
'Content-Type': 'application/json',
|
|
404
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
405
|
+
},
|
|
406
|
+
body: JSON.stringify({
|
|
407
|
+
session_id,
|
|
408
|
+
type: 'tool_use',
|
|
409
|
+
data: {
|
|
410
|
+
tool_name,
|
|
411
|
+
tool_input: tool_input || {},
|
|
412
|
+
timestamp: Date.now()
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
});
|
|
416
|
+
log(`Logged tool_use event for ${tool_name}`);
|
|
417
|
+
} catch (e) {
|
|
418
|
+
log(`Failed to log tool_use: ${e.message}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
396
422
|
// Check for pending results from daemon execution
|
|
397
423
|
if (session_id && RELAY_API_URL && RELAY_API_KEY && CONTEXT_DELIVERY_ENABLED) {
|
|
398
424
|
try {
|
|
@@ -463,6 +489,29 @@ const fetchJson = async (url, opts) => {
|
|
|
463
489
|
|
|
464
490
|
if (tool_input.__teleportation_away) {
|
|
465
491
|
await updateSessionState({ is_away: true });
|
|
492
|
+
// Log away_mode_changed event to timeline
|
|
493
|
+
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
494
|
+
try {
|
|
495
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
496
|
+
method: 'POST',
|
|
497
|
+
headers: {
|
|
498
|
+
'Content-Type': 'application/json',
|
|
499
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
500
|
+
},
|
|
501
|
+
body: JSON.stringify({
|
|
502
|
+
session_id,
|
|
503
|
+
type: 'away_mode_changed',
|
|
504
|
+
data: {
|
|
505
|
+
is_away: true,
|
|
506
|
+
timestamp: Date.now()
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
});
|
|
510
|
+
log(`Logged away_mode_changed (away=true) to timeline`);
|
|
511
|
+
} catch (e) {
|
|
512
|
+
log(`Failed to log away_mode_changed: ${e.message}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
466
515
|
const out = {
|
|
467
516
|
hookSpecificOutput: {
|
|
468
517
|
hookEventName: 'PreToolUse',
|
|
@@ -477,6 +526,29 @@ const fetchJson = async (url, opts) => {
|
|
|
477
526
|
|
|
478
527
|
if (tool_input.__teleportation_back) {
|
|
479
528
|
await updateSessionState({ is_away: false });
|
|
529
|
+
// Log away_mode_changed event to timeline
|
|
530
|
+
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
531
|
+
try {
|
|
532
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: {
|
|
535
|
+
'Content-Type': 'application/json',
|
|
536
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
537
|
+
},
|
|
538
|
+
body: JSON.stringify({
|
|
539
|
+
session_id,
|
|
540
|
+
type: 'away_mode_changed',
|
|
541
|
+
data: {
|
|
542
|
+
is_away: false,
|
|
543
|
+
timestamp: Date.now()
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
});
|
|
547
|
+
log(`Logged away_mode_changed (away=false) to timeline`);
|
|
548
|
+
} catch (e) {
|
|
549
|
+
log(`Failed to log away_mode_changed: ${e.message}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
480
552
|
const out = {
|
|
481
553
|
hookSpecificOutput: {
|
|
482
554
|
hookEventName: 'PreToolUse',
|
|
@@ -202,6 +202,35 @@ const readStdin = () => new Promise((resolve, reject) => {
|
|
|
202
202
|
is_away: false,
|
|
203
203
|
stopped_reason: 'session_end'
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
// Log session_end event to timeline
|
|
207
|
+
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
208
|
+
try {
|
|
209
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
'Content-Type': 'application/json',
|
|
213
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
session_id,
|
|
217
|
+
type: 'session_end',
|
|
218
|
+
data: {
|
|
219
|
+
reason: 'normal_exit',
|
|
220
|
+
timestamp: Date.now()
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
});
|
|
224
|
+
if (env.DEBUG) {
|
|
225
|
+
console.error(`[SessionEnd] Logged session_end event to timeline`);
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
// Non-critical - don't block session end
|
|
229
|
+
if (env.DEBUG) {
|
|
230
|
+
console.error(`[SessionEnd] Failed to log session_end event: ${e.message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
205
234
|
}
|
|
206
235
|
|
|
207
236
|
// Deregister session with daemon
|
|
@@ -74,6 +74,33 @@ const fetchJson = async (url, opts) => {
|
|
|
74
74
|
},
|
|
75
75
|
body: JSON.stringify({ is_away: desiredAway })
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
// Log away_mode_changed event to timeline (only for explicit /away or /back commands)
|
|
79
|
+
// Don't log for implicit "back" when user submits a regular prompt
|
|
80
|
+
if (lowered === '/away' || lowered === 'teleportation away' ||
|
|
81
|
+
lowered === '/back' || lowered === 'teleportation back') {
|
|
82
|
+
try {
|
|
83
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
session_id,
|
|
91
|
+
type: 'away_mode_changed',
|
|
92
|
+
data: {
|
|
93
|
+
is_away: desiredAway,
|
|
94
|
+
source: 'user_prompt',
|
|
95
|
+
timestamp: Date.now()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
});
|
|
99
|
+
log(`Logged away_mode_changed (away=${desiredAway}) to timeline`);
|
|
100
|
+
} catch (timelineErr) {
|
|
101
|
+
log(`Failed to log away_mode_changed: ${timelineErr.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
77
104
|
}
|
|
78
105
|
} catch (e) {
|
|
79
106
|
// Always log failures to hook log file for debugging (not just in DEBUG mode)
|
|
@@ -114,7 +141,7 @@ const fetchJson = async (url, opts) => {
|
|
|
114
141
|
const RELAY_API_KEY = config.relayApiKey;
|
|
115
142
|
|
|
116
143
|
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
117
|
-
await fetch(`${RELAY_API_URL}/api/timeline
|
|
144
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
118
145
|
method: 'POST',
|
|
119
146
|
headers: {
|
|
120
147
|
'Content-Type': 'application/json',
|
|
@@ -122,7 +149,7 @@ const fetchJson = async (url, opts) => {
|
|
|
122
149
|
},
|
|
123
150
|
body: JSON.stringify({
|
|
124
151
|
session_id,
|
|
125
|
-
|
|
152
|
+
type: 'model_change_requested',
|
|
126
153
|
data: {
|
|
127
154
|
command: prompt,
|
|
128
155
|
timestamp: Date.now()
|
package/lib/config/manager.js
CHANGED
|
@@ -10,6 +10,21 @@ import { homedir } from 'os';
|
|
|
10
10
|
|
|
11
11
|
const DEFAULT_CONFIG_PATH = join(homedir(), '.teleportation', 'config.json');
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Configuration keys that cannot be modified by users.
|
|
15
|
+
* These are managed exclusively by system commands (login, setup).
|
|
16
|
+
*
|
|
17
|
+
* Protected keys:
|
|
18
|
+
* - relay.url: Managed by 'teleportation login'
|
|
19
|
+
* - relay.apiKey: Managed by 'teleportation login' (not in config, but protected for consistency)
|
|
20
|
+
*
|
|
21
|
+
* @type {Set<string>}
|
|
22
|
+
*/
|
|
23
|
+
const PROTECTED_KEYS = new Set([
|
|
24
|
+
'relay.url',
|
|
25
|
+
'relay.apiKey', // Not in config file currently, but protected for future
|
|
26
|
+
]);
|
|
27
|
+
|
|
13
28
|
// Default configuration
|
|
14
29
|
const DEFAULT_CONFIG = {
|
|
15
30
|
relay: {
|
|
@@ -142,6 +157,54 @@ function getNestedValue(obj, path) {
|
|
|
142
157
|
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Set nested value in object using dot notation
|
|
162
|
+
* @param {Object} obj - Object to modify
|
|
163
|
+
* @param {string} path - Dot-notation path (e.g., 'relay.url')
|
|
164
|
+
* @param {any} value - Value to set
|
|
165
|
+
*/
|
|
166
|
+
function setNestedValue(obj, path, value) {
|
|
167
|
+
const parts = path.split('.');
|
|
168
|
+
const lastPart = parts.pop();
|
|
169
|
+
let current = obj;
|
|
170
|
+
|
|
171
|
+
for (const part of parts) {
|
|
172
|
+
if (!current[part] || typeof current[part] !== 'object') {
|
|
173
|
+
current[part] = {};
|
|
174
|
+
}
|
|
175
|
+
current = current[part];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
current[lastPart] = value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if a configuration key is protected from user modification.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} key - Configuration key to check
|
|
185
|
+
* @returns {boolean} True if key is protected
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* isProtectedKey('relay.url'); // true
|
|
189
|
+
* isProtectedKey('notifications.sound'); // false
|
|
190
|
+
* isProtectedKey('relay.url.custom'); // true (prefix match)
|
|
191
|
+
*/
|
|
192
|
+
function isProtectedKey(key) {
|
|
193
|
+
// Check exact match
|
|
194
|
+
if (PROTECTED_KEYS.has(key)) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check prefix match (e.g., 'relay.url.custom' matches 'relay.url')
|
|
199
|
+
for (const protectedKey of PROTECTED_KEYS) {
|
|
200
|
+
if (key.startsWith(protectedKey + '.')) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
145
208
|
/**
|
|
146
209
|
* Auto-fix common configuration issues
|
|
147
210
|
*/
|
|
@@ -204,14 +267,57 @@ async function loadConfig() {
|
|
|
204
267
|
|
|
205
268
|
|
|
206
269
|
/**
|
|
207
|
-
* Save configuration
|
|
270
|
+
* Save configuration with protection against unauthorized changes.
|
|
271
|
+
*
|
|
272
|
+
* Validates that protected keys have not been modified before saving.
|
|
273
|
+
* This prevents bypassing protection via config edit.
|
|
274
|
+
*
|
|
275
|
+
* @param {Object} config - Configuration object to save
|
|
276
|
+
* @throws {Error} If any protected keys have been modified
|
|
208
277
|
*/
|
|
209
278
|
async function saveConfig(config) {
|
|
210
279
|
await mkdir(dirname(DEFAULT_CONFIG_PATH), { recursive: true });
|
|
211
|
-
|
|
280
|
+
|
|
281
|
+
// Load existing config to check for protected key changes
|
|
282
|
+
let existingConfig;
|
|
283
|
+
try {
|
|
284
|
+
const content = await readFile(DEFAULT_CONFIG_PATH, 'utf8');
|
|
285
|
+
existingConfig = JSON.parse(content);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
if (e.code !== 'ENOENT') {
|
|
288
|
+
// If file exists but we can't read it, re-throw
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
// File doesn't exist yet, so no protected keys to check
|
|
292
|
+
existingConfig = null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// If existing config exists, validate protected keys haven't changed
|
|
296
|
+
if (existingConfig) {
|
|
297
|
+
const violations = [];
|
|
298
|
+
|
|
299
|
+
for (const protectedKey of PROTECTED_KEYS) {
|
|
300
|
+
const existingValue = getNestedValue(existingConfig, protectedKey);
|
|
301
|
+
const newValue = getNestedValue(config, protectedKey);
|
|
302
|
+
|
|
303
|
+
// Only check if key exists in existing config
|
|
304
|
+
if (existingValue !== undefined && JSON.stringify(existingValue) !== JSON.stringify(newValue)) {
|
|
305
|
+
violations.push(protectedKey);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (violations.length > 0) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Cannot modify protected configuration key${violations.length > 1 ? 's' : ''}: ${violations.join(', ')}. ` +
|
|
312
|
+
`These settings are managed by 'teleportation login'. ` +
|
|
313
|
+
`For help, run: teleportation login --help`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
212
318
|
// Merge with defaults
|
|
213
319
|
const merged = deepMerge(DEFAULT_CONFIG, config);
|
|
214
|
-
|
|
320
|
+
|
|
215
321
|
// Save as JSON for now (easier to parse)
|
|
216
322
|
await writeFile(
|
|
217
323
|
DEFAULT_CONFIG_PATH,
|
|
@@ -267,40 +373,45 @@ async function getConfigValue(path) {
|
|
|
267
373
|
}
|
|
268
374
|
|
|
269
375
|
/**
|
|
270
|
-
* Set a config value by dot-notation path
|
|
376
|
+
* Set a config value by dot-notation path.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} path - Configuration key (dot-notation path, e.g., 'relay.timeout')
|
|
379
|
+
* @param {any} value - Value to set
|
|
380
|
+
* @throws {Error} If key is protected or path is invalid
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* // This works:
|
|
384
|
+
* await setConfigValue('notifications.sound', true);
|
|
385
|
+
*
|
|
386
|
+
* // This throws:
|
|
387
|
+
* await setConfigValue('relay.url', 'http://example.com');
|
|
388
|
+
* // Error: Cannot modify protected configuration key 'relay.url'
|
|
271
389
|
*/
|
|
272
390
|
async function setConfigValue(path, value) {
|
|
391
|
+
// FIRST: Check if key is protected (library-level security)
|
|
392
|
+
if (isProtectedKey(path)) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`Cannot modify protected configuration key '${path}'. ` +
|
|
395
|
+
`This setting is managed by 'teleportation login'. ` +
|
|
396
|
+
`For help, run: teleportation login --help`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
273
400
|
// Validate path doesn't contain dangerous properties (prototype pollution protection)
|
|
274
401
|
if (path.includes('__proto__') || path.includes('constructor') || path.includes('prototype')) {
|
|
275
402
|
throw new Error('Invalid config path: cannot contain __proto__, constructor, or prototype');
|
|
276
403
|
}
|
|
277
|
-
|
|
404
|
+
|
|
278
405
|
// Validate path format (only alphanumeric, dots, underscores, hyphens)
|
|
279
406
|
if (!/^[a-zA-Z0-9._-]+$/.test(path)) {
|
|
280
407
|
throw new Error('Invalid config path format: only alphanumeric characters, dots, underscores, and hyphens allowed');
|
|
281
408
|
}
|
|
282
|
-
|
|
409
|
+
|
|
283
410
|
const config = await loadConfig();
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// Navigate/create nested objects
|
|
289
|
-
for (const part of parts) {
|
|
290
|
-
// Additional validation for each part
|
|
291
|
-
if (part.includes('__proto__') || part.includes('constructor') || part.includes('prototype')) {
|
|
292
|
-
throw new Error(`Invalid config path part: ${part}`);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (!current[part] || typeof current[part] !== 'object') {
|
|
296
|
-
current[part] = {};
|
|
297
|
-
}
|
|
298
|
-
current = current[part];
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Set the value
|
|
302
|
-
current[lastPart] = value;
|
|
303
|
-
|
|
411
|
+
|
|
412
|
+
// Use helper function to set nested value
|
|
413
|
+
setNestedValue(config, path, value);
|
|
414
|
+
|
|
304
415
|
await saveConfig(config);
|
|
305
416
|
}
|
|
306
417
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teleportation-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
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",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"vitest": "^4.0.9"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
+
"@rollup/rollup-darwin-x64": "^4.54.0",
|
|
63
64
|
"dotenv": "^17.2.3"
|
|
64
65
|
}
|
|
65
66
|
}
|
package/teleportation-cli.cjs
CHANGED
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const { execSync } = require('child_process');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
|
|
10
|
-
const CLI_VERSION = '1.1.
|
|
10
|
+
const CLI_VERSION = '1.1.3';
|
|
11
11
|
const HOME_DIR = os.homedir();
|
|
12
12
|
// Teleportation project directory (for development)
|
|
13
13
|
// In production, hooks will be installed globally
|
|
@@ -1894,7 +1894,7 @@ async function commandConfig(args) {
|
|
|
1894
1894
|
|
|
1895
1895
|
if (!key || valueStr === undefined) {
|
|
1896
1896
|
console.log(c.red('❌ Error: Please specify key and value\n'));
|
|
1897
|
-
console.log(c.cyan('Example: teleportation config set
|
|
1897
|
+
console.log(c.cyan('Example: teleportation config set notifications.sound true\n'));
|
|
1898
1898
|
return;
|
|
1899
1899
|
}
|
|
1900
1900
|
|
|
@@ -1909,9 +1909,15 @@ async function commandConfig(args) {
|
|
|
1909
1909
|
else if (/^\d+$/.test(valueStr)) value = parseInt(valueStr, 10);
|
|
1910
1910
|
else if (/^\d+\.\d+$/.test(valueStr)) value = parseFloat(valueStr);
|
|
1911
1911
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1912
|
+
|
|
1913
|
+
// Set config value with proper error handling
|
|
1914
|
+
try {
|
|
1915
|
+
await setConfigValue(key, value);
|
|
1916
|
+
console.log(c.green(`✅ Set ${key} = ${JSON.stringify(value)}\n`));
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
console.log(c.red(`❌ ${error.message}\n`));
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1915
1921
|
|
|
1916
1922
|
} else if (subcommand === 'edit') {
|
|
1917
1923
|
const editor = process.env.EDITOR || 'vi';
|
|
@@ -2747,99 +2753,142 @@ async function commandInstallHooks() {
|
|
|
2747
2753
|
// Step 3: Update settings.json
|
|
2748
2754
|
console.log(c.yellow('Step 3: Updating Claude Code settings...\n'));
|
|
2749
2755
|
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2756
|
+
try {
|
|
2757
|
+
// Use SettingsManager for proper hook management
|
|
2758
|
+
const settingsManagerPath = path.join(TELEPORTATION_DIR, 'lib', 'settings', 'manager.js');
|
|
2759
|
+
const { SettingsManager } = await import('file://' + settingsManagerPath);
|
|
2760
|
+
const settingsManager = new SettingsManager(globalSettings);
|
|
2761
|
+
|
|
2762
|
+
// Remove ALL existing Teleportation hooks (regardless of path)
|
|
2763
|
+
// This prevents accumulation of hooks from different installation locations
|
|
2764
|
+
const removeResult = await settingsManager.removeTeleportationHooks();
|
|
2765
|
+
if (removeResult.hooksRemoved > 0) {
|
|
2766
|
+
console.log(c.cyan(` Removed ${removeResult.hooksRemoved} existing Teleportation hook(s)\n`));
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// Add new hooks pointing to global hooks directory
|
|
2770
|
+
const addResult = await settingsManager.addHooks(globalHooksDir);
|
|
2771
|
+
console.log(c.green(` ✅ Added ${addResult.hooksAdded} hook(s) to settings\n`));
|
|
2772
|
+
|
|
2773
|
+
// Deduplicate in case there are any remaining duplicates
|
|
2774
|
+
const dedupeResult = await settingsManager.deduplicate();
|
|
2775
|
+
if (dedupeResult.duplicatesRemoved > 0) {
|
|
2776
|
+
console.log(c.cyan(` Removed ${dedupeResult.duplicatesRemoved} duplicate hook(s)\n`));
|
|
2777
|
+
}
|
|
2778
|
+
} catch (e) {
|
|
2779
|
+
console.log(c.red(` ❌ Failed to update settings: ${e.message}\n`));
|
|
2780
|
+
|
|
2781
|
+
try {
|
|
2782
|
+
// Fallback to manual merge if SettingsManager fails
|
|
2783
|
+
console.log(c.yellow(' Attempting fallback method...\n'));
|
|
2784
|
+
|
|
2785
|
+
const quotePath = (p) => JSON.stringify(p);
|
|
2786
|
+
|
|
2787
|
+
const hooksConfig = {
|
|
2788
|
+
PreToolUse: [{
|
|
2789
|
+
matcher: ".*",
|
|
2790
|
+
hooks: [{
|
|
2791
|
+
type: "command",
|
|
2792
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'pre_tool_use.mjs'))}`
|
|
2793
|
+
}]
|
|
2794
|
+
}],
|
|
2795
|
+
PostToolUse: [{
|
|
2796
|
+
matcher: ".*",
|
|
2797
|
+
hooks: [{
|
|
2798
|
+
type: "command",
|
|
2799
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'post_tool_use.mjs'))}`
|
|
2800
|
+
}]
|
|
2801
|
+
}],
|
|
2802
|
+
PermissionRequest: [{
|
|
2803
|
+
matcher: ".*",
|
|
2804
|
+
hooks: [{
|
|
2805
|
+
type: "command",
|
|
2806
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'permission_request.mjs'))}`
|
|
2807
|
+
}]
|
|
2808
|
+
}],
|
|
2809
|
+
Stop: [{
|
|
2810
|
+
matcher: ".*",
|
|
2811
|
+
hooks: [{
|
|
2812
|
+
type: "command",
|
|
2813
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'stop.mjs'))}`
|
|
2814
|
+
}]
|
|
2815
|
+
}],
|
|
2816
|
+
SessionStart: [{
|
|
2817
|
+
matcher: ".*",
|
|
2818
|
+
hooks: [{
|
|
2819
|
+
type: "command",
|
|
2820
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'session_start.mjs'))}`
|
|
2821
|
+
}]
|
|
2822
|
+
}],
|
|
2823
|
+
SessionEnd: [{
|
|
2824
|
+
matcher: ".*",
|
|
2825
|
+
hooks: [{
|
|
2826
|
+
type: "command",
|
|
2827
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'session_end.mjs'))}`
|
|
2828
|
+
}]
|
|
2829
|
+
}],
|
|
2830
|
+
Notification: [{
|
|
2831
|
+
matcher: ".*",
|
|
2832
|
+
hooks: [{
|
|
2833
|
+
type: "command",
|
|
2834
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'notification.mjs'))}`
|
|
2835
|
+
}]
|
|
2836
|
+
}],
|
|
2837
|
+
UserPromptSubmit: [{
|
|
2838
|
+
matcher: ".*",
|
|
2839
|
+
hooks: [{
|
|
2840
|
+
type: "command",
|
|
2841
|
+
command: `node ${quotePath(path.join(globalHooksDir, 'user_prompt_submit.mjs'))}`
|
|
2842
|
+
}]
|
|
2808
2843
|
}]
|
|
2809
|
-
}
|
|
2810
|
-
};
|
|
2844
|
+
};
|
|
2811
2845
|
|
|
2812
|
-
try {
|
|
2813
2846
|
let existingSettings = {};
|
|
2814
|
-
|
|
2847
|
+
|
|
2815
2848
|
// Load existing settings if present
|
|
2816
2849
|
if (fs.existsSync(globalSettings)) {
|
|
2817
2850
|
try {
|
|
2818
2851
|
const content = fs.readFileSync(globalSettings, 'utf8');
|
|
2819
2852
|
existingSettings = JSON.parse(content);
|
|
2820
2853
|
console.log(c.cyan(' Found existing settings, merging...\n'));
|
|
2821
|
-
} catch (
|
|
2854
|
+
} catch (err) {
|
|
2822
2855
|
console.log(c.yellow(` ⚠️ Could not parse existing settings, creating new...\n`));
|
|
2823
2856
|
}
|
|
2824
2857
|
}
|
|
2825
2858
|
|
|
2826
|
-
//
|
|
2859
|
+
// Load isTeleportationHook pattern for filtering
|
|
2860
|
+
let isTeleportationHook;
|
|
2861
|
+
try {
|
|
2862
|
+
const settingsManagerPath = path.join(TELEPORTATION_DIR, 'lib', 'settings', 'manager.js');
|
|
2863
|
+
const settingsModule = await import('file://' + settingsManagerPath);
|
|
2864
|
+
isTeleportationHook = settingsModule.isTeleportationHook;
|
|
2865
|
+
} catch (err) {
|
|
2866
|
+
// Log the specific error for debugging
|
|
2867
|
+
console.log(c.yellow(` ⚠️ Could not load SettingsManager: ${err.message}`));
|
|
2868
|
+
console.log(c.yellow(' Using simplified hook detection pattern\n'));
|
|
2869
|
+
|
|
2870
|
+
// More robust fallback pattern matching the regex in SettingsManager
|
|
2871
|
+
// Matches: .claude/hooks/(pre_tool_use|post_tool_use|permission_request|stop|session_start|session_end|notification|user_prompt_submit).mjs
|
|
2872
|
+
isTeleportationHook = (cmd) => {
|
|
2873
|
+
if (!cmd || typeof cmd !== 'string') return false;
|
|
2874
|
+
return /\.claude\/hooks\/(pre_tool_use|post_tool_use|permission_request|stop|session_start|session_end|notification|user_prompt_submit)\.mjs/.test(cmd);
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// Merge hooks - preserve NON-teleportation user hooks, remove ALL teleportation hooks
|
|
2827
2879
|
const mergeHookArrays = (existing, incoming) => {
|
|
2828
2880
|
if (!existing || !Array.isArray(existing)) return incoming;
|
|
2829
2881
|
if (!incoming || !Array.isArray(incoming)) return existing;
|
|
2830
|
-
|
|
2831
|
-
//
|
|
2832
|
-
const
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
// Combine: existing (non-duplicate) + incoming (teleportation hooks)
|
|
2842
|
-
return [...filteredExisting, ...incoming];
|
|
2882
|
+
|
|
2883
|
+
// Filter out ALL teleportation hooks (regardless of path)
|
|
2884
|
+
const nonTeleportationHooks = existing.filter(matcher => {
|
|
2885
|
+
if (!matcher.hooks || !Array.isArray(matcher.hooks)) return true;
|
|
2886
|
+
// Keep matcher only if it has non-teleportation hooks
|
|
2887
|
+
return !matcher.hooks.every(h => h.command && isTeleportationHook(h.command));
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2890
|
+
// Combine: existing (non-teleportation) + incoming (new teleportation hooks)
|
|
2891
|
+
return [...nonTeleportationHooks, ...incoming];
|
|
2843
2892
|
};
|
|
2844
2893
|
|
|
2845
2894
|
// Merge all hook types with warnings about user hooks
|
|
@@ -2851,12 +2900,9 @@ async function commandInstallHooks() {
|
|
|
2851
2900
|
|
|
2852
2901
|
// Find user-defined hooks (not from teleportation)
|
|
2853
2902
|
const userHooks = existingHooksForType.filter(h => {
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
cmd.includes('.claude/hooks') ||
|
|
2858
|
-
cmd.includes('~/.claude/hooks')
|
|
2859
|
-
);
|
|
2903
|
+
if (!h.hooks || !Array.isArray(h.hooks)) return true;
|
|
2904
|
+
// Keep only hooks that are NOT teleportation hooks
|
|
2905
|
+
return !h.hooks.every(hh => hh.command && isTeleportationHook(hh.command));
|
|
2860
2906
|
});
|
|
2861
2907
|
|
|
2862
2908
|
if (userHooks.length > 0) {
|
|
@@ -2884,9 +2930,43 @@ async function commandInstallHooks() {
|
|
|
2884
2930
|
// Write settings
|
|
2885
2931
|
fs.writeFileSync(globalSettings, JSON.stringify(mergedSettings, null, 2));
|
|
2886
2932
|
console.log(c.green(' ✅ ~/.claude/settings.json updated\n'));
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2933
|
+
|
|
2934
|
+
// Deduplicate in fallback path (Gap #2)
|
|
2935
|
+
console.log(c.yellow(' Running deduplication check...\n'));
|
|
2936
|
+
let duplicatesRemoved = 0;
|
|
2937
|
+
|
|
2938
|
+
for (const [hookType, matchers] of Object.entries(mergedSettings.hooks || {})) {
|
|
2939
|
+
if (!Array.isArray(matchers)) continue;
|
|
2940
|
+
|
|
2941
|
+
const seenCommands = new Set();
|
|
2942
|
+
const uniqueMatchers = [];
|
|
2943
|
+
|
|
2944
|
+
for (const matcher of matchers) {
|
|
2945
|
+
const commands = (matcher.hooks || []).map(h => h.command).filter(Boolean);
|
|
2946
|
+
const isUnique = !commands.some(cmd => seenCommands.has(cmd));
|
|
2947
|
+
|
|
2948
|
+
if (isUnique) {
|
|
2949
|
+
uniqueMatchers.push(matcher);
|
|
2950
|
+
commands.forEach(cmd => seenCommands.add(cmd));
|
|
2951
|
+
} else {
|
|
2952
|
+
duplicatesRemoved++;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
mergedSettings.hooks[hookType] = uniqueMatchers;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
if (duplicatesRemoved > 0) {
|
|
2960
|
+
fs.writeFileSync(globalSettings, JSON.stringify(mergedSettings, null, 2));
|
|
2961
|
+
console.log(c.cyan(` Removed ${duplicatesRemoved} duplicate(s) in fallback\n`));
|
|
2962
|
+
} else {
|
|
2963
|
+
console.log(c.green(' ✅ No duplicates found\n'));
|
|
2964
|
+
}
|
|
2965
|
+
} catch (fallbackErr) {
|
|
2966
|
+
console.log(c.red(` ❌ Fallback also failed: ${fallbackErr.message}\n`));
|
|
2967
|
+
console.log(c.red(' Please report this issue at: https://github.com/dundas/teleportation-private/issues\n'));
|
|
2968
|
+
process.exit(1);
|
|
2969
|
+
}
|
|
2890
2970
|
}
|
|
2891
2971
|
|
|
2892
2972
|
// Summary
|