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.
@@ -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/log`, {
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
- event_type: 'model_changed',
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/log`, {
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
- event_type: 'model_change_requested',
152
+ type: 'model_change_requested',
126
153
  data: {
127
154
  command: prompt,
128
155
  timestamp: Date.now()
@@ -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
- const parts = path.split('.');
285
- const lastPart = parts.pop();
286
- let current = config;
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.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
  }
@@ -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.0';
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 relay.url http://example.com:3030\n'));
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
- await setConfigValue(key, value);
1914
- console.log(c.green(`✅ Set ${key} = ${JSON.stringify(value)}\n`));
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
- // Safely quote paths - JSON.stringify escapes special chars to prevent command injection
2751
- const quotePath = (p) => JSON.stringify(p);
2752
-
2753
- const hooksConfig = {
2754
- PreToolUse: [{
2755
- matcher: ".*",
2756
- hooks: [{
2757
- type: "command",
2758
- command: `node ${quotePath(path.join(globalHooksDir, 'pre_tool_use.mjs'))}`
2759
- }]
2760
- }],
2761
- PostToolUse: [{
2762
- matcher: ".*",
2763
- hooks: [{
2764
- type: "command",
2765
- command: `node ${quotePath(path.join(globalHooksDir, 'post_tool_use.mjs'))}`
2766
- }]
2767
- }],
2768
- PermissionRequest: [{
2769
- matcher: ".*",
2770
- hooks: [{
2771
- type: "command",
2772
- command: `node ${quotePath(path.join(globalHooksDir, 'permission_request.mjs'))}`
2773
- }]
2774
- }],
2775
- Stop: [{
2776
- matcher: ".*",
2777
- hooks: [{
2778
- type: "command",
2779
- command: `node ${quotePath(path.join(globalHooksDir, 'stop.mjs'))}`
2780
- }]
2781
- }],
2782
- SessionStart: [{
2783
- matcher: ".*",
2784
- hooks: [{
2785
- type: "command",
2786
- command: `node ${quotePath(path.join(globalHooksDir, 'session_start.mjs'))}`
2787
- }]
2788
- }],
2789
- SessionEnd: [{
2790
- matcher: ".*",
2791
- hooks: [{
2792
- type: "command",
2793
- command: `node ${quotePath(path.join(globalHooksDir, 'session_end.mjs'))}`
2794
- }]
2795
- }],
2796
- Notification: [{
2797
- matcher: ".*",
2798
- hooks: [{
2799
- type: "command",
2800
- command: `node ${quotePath(path.join(globalHooksDir, 'notification.mjs'))}`
2801
- }]
2802
- }],
2803
- UserPromptSubmit: [{
2804
- matcher: ".*",
2805
- hooks: [{
2806
- type: "command",
2807
- command: `node ${quotePath(path.join(globalHooksDir, 'user_prompt_submit.mjs'))}`
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 (e) {
2854
+ } catch (err) {
2822
2855
  console.log(c.yellow(` ⚠️ Could not parse existing settings, creating new...\n`));
2823
2856
  }
2824
2857
  }
2825
2858
 
2826
- // Merge hooks intelligently - preserve user hooks, avoid duplicates
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
- // Get commands from incoming hooks to check for duplicates
2832
- const incomingCommands = new Set(
2833
- incoming.flatMap(h => (h.hooks || []).map(hh => hh.command))
2834
- );
2835
-
2836
- // Filter out existing hooks that have the same command (will be replaced)
2837
- const filteredExisting = existing.filter(h =>
2838
- !(h.hooks || []).some(hh => incomingCommands.has(hh.command))
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
- const commands = (h.hooks || []).map(hh => hh.command || '');
2855
- return !commands.some(cmd =>
2856
- cmd.includes('teleportation') ||
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
- } catch (e) {
2888
- console.log(c.red(` ❌ Failed to update settings: ${e.message}\n`));
2889
- process.exit(1);
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