teleportation-cli 1.4.0 → 1.4.1

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.
@@ -7,9 +7,12 @@
7
7
  * @module lib/daemon/transcript-ingestion
8
8
  */
9
9
 
10
- import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
10
+ import { readFile, readdir, writeFile, mkdir, appendFile } from 'fs/promises';
11
11
  import { homedir, tmpdir } from 'os';
12
12
  import { join } from 'path';
13
+ import { createHash } from 'node:crypto';
14
+ import { sanitizeEventData } from '../utils/log-sanitizer.js';
15
+ import { normalizeTranscriptEvents, normalizeTranscriptEntry } from '../intelligence/schema.js';
13
16
 
14
17
  // ============================================================================
15
18
  // Configuration Constants
@@ -131,6 +134,26 @@ const RETRY_BASE_DELAY_MS = 100;
131
134
  * Set TELEPORTATION_DEBUG=true to enable verbose logging to tmpdir()
132
135
  */
133
136
  const DEBUG = process.env.TELEPORTATION_DEBUG === 'true';
137
+ const ENABLE_REMOTE_INTELLIGENCE_INGEST = process.env.TELEPORTATION_INTELLIGENCE_REMOTE_INGEST === 'true';
138
+ const ENABLE_LOCAL_INTELLIGENCE_SPOOL = process.env.TELEPORTATION_INTELLIGENCE_LOCAL_SPOOL !== 'false';
139
+ const INTELLIGENCE_SPOOL_DIR = join(tmpdir(), 'teleportation-intelligence');
140
+ const INTELLIGENCE_BATCH_PATH = '/api/intelligence/transcripts/batch';
141
+
142
+ /**
143
+ * Generate a deterministic UUID-formatted event ID from an input string and event index.
144
+ * Uses the same sha256 + UUID-dash format as stop.mjs for consistency, enabling
145
+ * server-side deduplication via ON CONFLICT (id) DO NOTHING.
146
+ *
147
+ * Format: 8-4-4-4-12 hex characters (matches stop.mjs deterministicEventId)
148
+ *
149
+ * @param {string} input - Unique input (msg UUID or session:msg:event composite key)
150
+ * @param {number} eventIndex - Event index within the message
151
+ * @returns {string} UUID-formatted hex string (36 characters with dashes)
152
+ */
153
+ function deterministicEventId(input, eventIndex) {
154
+ const hash = createHash('sha256').update(`${input}:${eventIndex}`).digest('hex');
155
+ return [hash.slice(0, 8), hash.slice(8, 12), hash.slice(12, 16), hash.slice(16, 20), hash.slice(20, 32)].join('-');
156
+ }
134
157
 
135
158
  /**
136
159
  * Local cursor directory for tracking last-processed transcript index.
@@ -279,6 +302,10 @@ async function pushEventsInChunks(events, relayApiUrl, apiKey, parent_session_id
279
302
  events: chunk.map(event => ({
280
303
  type: event.type,
281
304
  source: event.source,
305
+ // Top-level timestamp and id for relay batch endpoint
306
+ // (logTimelineEventsBatch accepts clientTimestamp and clientId)
307
+ timestamp: event.timestamp,
308
+ id: event.id || undefined,
282
309
  data: {
283
310
  ...event.meta,
284
311
  task_id,
@@ -318,11 +345,16 @@ async function pushEventsInChunks(events, relayApiUrl, apiKey, parent_session_id
318
345
  session_id: parent_session_id,
319
346
  type: event.type,
320
347
  source: event.source,
348
+ // Top-level timestamp and id kept for forward-compat; also inside data
349
+ // because relay's /api/timeline passes only `data` to logTimelineEvent()
350
+ timestamp: event.timestamp,
351
+ id: event.id || undefined,
321
352
  data: {
322
353
  ...event.meta,
323
354
  task_id,
324
355
  claude_session_id,
325
356
  timestamp: event.timestamp,
357
+ id: event.id || undefined,
326
358
  }
327
359
  })
328
360
  });
@@ -420,9 +452,10 @@ function parseTimestamp(entry) {
420
452
  * Supports both old format (message at root) and new format (message nested)
421
453
  * @param {Array} transcript - Transcript messages
422
454
  * @param {number} fromIndex - Start extracting from this index (to avoid duplicates)
423
- * @returns {Array} Timeline events to push
455
+ * @param {string} sessionId - Session ID for deterministic fallback IDs (when entry has no uuid)
456
+ * @returns {Array} Timeline events to push (each with deterministic `id` field)
424
457
  */
425
- function extractTimelineEvents(transcript, fromIndex = 0) {
458
+ function extractTimelineEvents(transcript, fromIndex = 0, sessionId = '') {
426
459
  const events = [];
427
460
 
428
461
  console.log(`[transcript] extractTimelineEvents: processing ${transcript.length - fromIndex} messages from index ${fromIndex}`);
@@ -452,6 +485,13 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
452
485
  const content = message.content;
453
486
  const timestamp = parseTimestamp(entry);
454
487
 
488
+ // Deterministic ID generation (Bug 3):
489
+ // Use entry.uuid (Claude Code transcript UUID) when available,
490
+ // fall back to entry.message.uuid, or a composite key from sessionId:messageIndex.
491
+ const msgUuid = entry.uuid || message.uuid || null;
492
+ const msgKey = msgUuid || `${sessionId}:${i}`;
493
+ let msgEventIndex = 0;
494
+
455
495
  // Extract assistant responses
456
496
  // Schema matches stop hook batch: data.message (canonical field name)
457
497
  if (role === 'assistant' && content) {
@@ -465,6 +505,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
465
505
  type: 'assistant_response',
466
506
  source: 'cli_interactive',
467
507
  timestamp,
508
+ id: deterministicEventId(msgKey, msgEventIndex++),
468
509
  meta: {
469
510
  message: trimmedContent.slice(0, MAX_ASSISTANT_RESPONSE_LENGTH),
470
511
  full_length: trimmedContent.length,
@@ -487,6 +528,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
487
528
  type: 'tool_use',
488
529
  source: 'cli_interactive',
489
530
  timestamp,
531
+ id: deterministicEventId(msgKey, msgEventIndex++),
490
532
  meta: {
491
533
  tool_name: toolUse.name,
492
534
  tool_use_id: toolUse.id,
@@ -517,6 +559,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
517
559
  type: isError ? 'tool_failed' : 'tool_completed',
518
560
  source: 'cli_interactive',
519
561
  timestamp,
562
+ id: deterministicEventId(msgKey, msgEventIndex++),
520
563
  meta: {
521
564
  tool_use_id: toolResult.tool_use_id,
522
565
  tool_name: toolInfo?.name || null,
@@ -532,7 +575,29 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
532
575
  }
533
576
 
534
577
  console.log(`[transcript] extractTimelineEvents: found ${events.length} events`);
535
- return events;
578
+
579
+ // Sanitize all event meta to redact secrets (API keys, tokens, passwords, etc.)
580
+ return events.map(event => ({
581
+ ...event,
582
+ meta: sanitizeEventData(event.meta),
583
+ }));
584
+ }
585
+
586
+ /**
587
+ * Convert timeline events to canonical transcript intelligence events.
588
+ * Returns [] on normalization failures to preserve ingestion behavior.
589
+ *
590
+ * @param {Array} events - Raw timeline events from extractTimelineEvents
591
+ * @param {Object} context - Normalization context
592
+ * @returns {Array} Normalized intelligence events
593
+ */
594
+ function normalizeTimelineEventsForIntelligence(events, context = {}) {
595
+ try {
596
+ return normalizeTranscriptEvents(events, context);
597
+ } catch (error) {
598
+ console.warn(`[transcript] Intelligence normalization failed: ${error.message}`);
599
+ return [];
600
+ }
536
601
  }
537
602
 
538
603
  /**
@@ -598,12 +663,13 @@ async function updateLastIngestedIndex(task_id, session_id, lastIndex, config) {
598
663
  * @param {string} options.task_id - Task ID (for event metadata)
599
664
  * @param {string} options.cwd - Working directory (to derive project slug)
600
665
  * @param {Object} options.config - Config with relayApiUrl, apiKey
666
+ * @param {Function} [options.onNormalizedEvents] - Optional callback for normalized intelligence events
601
667
  * @param {boolean} options.realTimeMode - If true, only process last 10 events (fast, for hooks)
602
668
  * @param {number} options.maxEvents - Maximum events to process (default: 100 for daemon, 10 for realTime)
603
669
  * @returns {Promise<Object>} Result { events_pushed: number }
604
670
  */
605
671
  export async function ingestTranscriptToTimeline(options) {
606
- const { claude_session_id, parent_session_id, task_id, cwd, config, realTimeMode = false, maxEvents } = options;
672
+ const { claude_session_id, parent_session_id, task_id, cwd, config, onNormalizedEvents, realTimeMode = false, maxEvents } = options;
607
673
  const { relayApiUrl, apiKey } = config;
608
674
 
609
675
  // Determine max events based on mode
@@ -738,13 +804,29 @@ export async function ingestTranscriptToTimeline(options) {
738
804
  }
739
805
 
740
806
  // 3. Extract only NEW events from determined index
741
- const allEvents = extractTimelineEvents(transcript, fromIndex);
807
+ const allEvents = extractTimelineEvents(transcript, fromIndex, parent_session_id);
742
808
 
743
809
  log(`Extracted ${allEvents.length} events from transcript`);
810
+
744
811
  if (allEvents.length === 0) {
745
812
  log(`No new events since index ${fromIndex} - returning`);
746
813
  console.log(`[transcript] No new events since index ${fromIndex} (transcript length: ${transcript.length})`);
747
- return { events_pushed: 0 };
814
+ return { events_pushed: 0, normalized_events: 0 };
815
+ }
816
+
817
+ const normalizedEvents = normalizeTimelineEventsForIntelligence(allEvents, {
818
+ session_id: parent_session_id,
819
+ task_id,
820
+ provider: 'claude-code',
821
+ });
822
+
823
+ log(`Normalized ${normalizedEvents.length} intelligence events`);
824
+ if (typeof onNormalizedEvents === 'function') {
825
+ try {
826
+ await onNormalizedEvents(normalizedEvents);
827
+ } catch (error) {
828
+ log(`Failed to deliver normalized events: ${error.message}`);
829
+ }
748
830
  }
749
831
 
750
832
  // 4. Limit events to process (most recent N events)
@@ -787,7 +869,7 @@ export async function ingestTranscriptToTimeline(options) {
787
869
  }
788
870
  }
789
871
 
790
- return { events_pushed: successCount, events_failed: failCount };
872
+ return { events_pushed: successCount, events_failed: failCount, normalized_events: normalizedEvents.length };
791
873
  }
792
874
 
793
875
  /**
@@ -801,4 +883,73 @@ export async function getTranscriptLength(claude_session_id) {
801
883
  }
802
884
 
803
885
  // Export internals for unit testing
804
- export { parseTimestamp, extractTimelineEvents, readCursor, writeCursor };
886
+ /**
887
+ * Extract normalized transcript entries for the intelligence pipeline.
888
+ * Operates on raw transcript messages (not timeline events).
889
+ */
890
+ function extractNormalizedTranscriptEntries(transcript, fromIndex = 0, sessionId = '', harness = 'claude-code') {
891
+ const normalized = [];
892
+ for (let i = fromIndex; i < transcript.length; i++) {
893
+ normalized.push(normalizeTranscriptEntry(transcript[i], {
894
+ sessionId,
895
+ messageIndex: i,
896
+ harness,
897
+ }));
898
+ }
899
+ return normalized;
900
+ }
901
+
902
+ function toSessionFilename(sessionId) {
903
+ const safe = typeof sessionId === 'string' && sessionId.trim()
904
+ ? sessionId.trim().replace(/[^a-zA-Z0-9._-]/g, '-')
905
+ : 'unknown-session';
906
+ return `${safe}.jsonl`;
907
+ }
908
+
909
+ /**
910
+ * Append normalized transcript entries to a local JSONL spool file.
911
+ * Enabled by default; disable with TELEPORTATION_INTELLIGENCE_LOCAL_SPOOL=false.
912
+ */
913
+ async function appendLocalIntelligenceSpool(sessionId, entries, log = () => {}) {
914
+ if (!ENABLE_LOCAL_INTELLIGENCE_SPOOL || entries.length === 0) {
915
+ return { written: 0, skipped: true };
916
+ }
917
+ await mkdir(INTELLIGENCE_SPOOL_DIR, { recursive: true });
918
+ const filePath = join(INTELLIGENCE_SPOOL_DIR, toSessionFilename(sessionId));
919
+ const lines = entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
920
+ await appendFile(filePath, lines, 'utf8');
921
+ log(`Wrote ${entries.length} normalized entries to local spool: ${filePath}`);
922
+ return { written: entries.length, skipped: false, filePath };
923
+ }
924
+
925
+ /**
926
+ * Push normalized transcript entries to the relay intelligence endpoint.
927
+ * Off by default; enable with TELEPORTATION_INTELLIGENCE_REMOTE_INGEST=true.
928
+ */
929
+ async function pushNormalizedEntriesRemote(entries, relayApiUrl, apiKey, sessionId, log = () => {}) {
930
+ if (!ENABLE_REMOTE_INTELLIGENCE_INGEST || entries.length === 0) {
931
+ return { pushed: 0, skipped: true };
932
+ }
933
+ try {
934
+ const response = await fetch(`${relayApiUrl}${INTELLIGENCE_BATCH_PATH}`, {
935
+ method: 'POST',
936
+ headers: {
937
+ 'Content-Type': 'application/json',
938
+ 'Authorization': `Bearer ${apiKey}`,
939
+ },
940
+ body: JSON.stringify({ session_id: sessionId, entries }),
941
+ });
942
+ if (!response.ok) {
943
+ const body = await response.text().catch(() => '');
944
+ console.warn(`[transcript] Intelligence remote ingest failed: ${response.status} ${body}`);
945
+ return { pushed: 0, skipped: false, failed: true, status: response.status };
946
+ }
947
+ log(`Pushed ${entries.length} normalized entries to remote intelligence endpoint`);
948
+ return { pushed: entries.length, skipped: false };
949
+ } catch (error) {
950
+ console.warn(`[transcript] Intelligence remote ingest error: ${error.message}`);
951
+ return { pushed: 0, skipped: false, failed: true, error: error.message };
952
+ }
953
+ }
954
+
955
+ export { parseTimestamp, extractTimelineEvents, extractNormalizedTranscriptEntries, readCursor, writeCursor, pushEventsInChunks, deterministicEventId, normalizeTimelineEventsForIntelligence, appendLocalIntelligenceSpool, pushNormalizedEntriesRemote };
@@ -33,15 +33,6 @@ export function truncateOutput(output, label) {
33
33
  return output.slice(0, MAX_OUTPUT_SIZE) + `\n\n... (output truncated, total size: ${output.length} characters) ...`;
34
34
  }
35
35
 
36
- /**
37
- * Sanitize for log (remove sensitive info)
38
- */
39
- export function sanitizeForLog(data) {
40
- if (!data) return data;
41
- // Simple sanitization - in a real app, use a more robust library
42
- return String(data).replace(/Bearer\s+[a-zA-Z0-9._-]+/g, 'Bearer [REDACTED]');
43
- }
44
-
45
36
  /**
46
37
  * Validation helpers
47
38
  */
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { copyFile, mkdir, chmod, readFile, writeFile, stat, readdir } from 'fs/promises';
13
+ import { existsSync } from 'fs';
13
14
  import { join, dirname, resolve } from 'path';
14
15
  import { fileURLToPath } from 'url';
15
16
  import { homedir } from 'os';
@@ -79,6 +80,28 @@ export function checkClaudeCode() {
79
80
  };
80
81
  }
81
82
 
83
+ /**
84
+ * Check if Cursor IDE is installed by detecting ~/.cursor directory or cursor binary
85
+ */
86
+ export function checkCursorIde() {
87
+ const cursorDir = join(HOME_DIR, '.cursor');
88
+ if (existsSync(cursorDir)) {
89
+ return { valid: true, path: cursorDir };
90
+ }
91
+ try {
92
+ const cursorPath = execSync('which cursor', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
93
+ if (cursorPath) {
94
+ return { valid: true, path: cursorPath };
95
+ }
96
+ } catch (e) {
97
+ // Cursor binary not found
98
+ }
99
+ return {
100
+ valid: false,
101
+ error: 'Cursor IDE not found. Install Cursor or ensure ~/.cursor directory exists.'
102
+ };
103
+ }
104
+
82
105
  /**
83
106
  * Check if Gemini CLI is installed
84
107
  */
@@ -307,6 +330,88 @@ export async function installGeminiHooks(sourceGeminiHooksDir) {
307
330
  };
308
331
  }
309
332
 
333
+ /**
334
+ * Install Teleportation hooks for Cursor IDE.
335
+ * Writes/merges ~/.cursor/hooks.json to point at the installed hook scripts.
336
+ * Cursor reads this file natively — no "Third-party skills" toggle required.
337
+ *
338
+ * @param {string} sourceHooksDir - Absolute path to the installed .claude/hooks/ directory
339
+ */
340
+ export async function installCursorHooks(sourceHooksDir) {
341
+ const cursorDir = join(HOME_DIR, '.cursor');
342
+ const hooksJsonPath = join(cursorDir, 'hooks.json');
343
+ await mkdir(cursorDir, { recursive: true });
344
+
345
+ // Build the native Cursor hooks.json format
346
+ const hooksConfig = {
347
+ preToolUse: [{ command: `bun ${join(sourceHooksDir, 'pre_tool_use.mjs')}` }],
348
+ postToolUse: [{ command: `bun ${join(sourceHooksDir, 'post_tool_use.mjs')}` }],
349
+ sessionStart: [{ command: `bun ${join(sourceHooksDir, 'session_start.mjs')}` }],
350
+ sessionEnd: [{ command: `bun ${join(sourceHooksDir, 'session_end.mjs')}` }],
351
+ stop: [{ command: `bun ${join(sourceHooksDir, 'stop.mjs')}` }],
352
+ beforeSubmitPrompt: [{ command: `bun ${join(sourceHooksDir, 'user_prompt_submit.mjs')}` }],
353
+ };
354
+
355
+ // Merge with any existing hooks.json to preserve other tools' hooks
356
+ let existing = { version: 1, hooks: {} };
357
+ try {
358
+ const content = await readFile(hooksJsonPath, 'utf8');
359
+ existing = JSON.parse(content);
360
+ existing.hooks = existing.hooks || {};
361
+ } catch (e) {
362
+ // No existing file — start fresh
363
+ }
364
+
365
+ // Overwrite only Teleportation-managed hooks; leave others untouched
366
+ const merged = {
367
+ ...existing,
368
+ version: 1,
369
+ hooks: { ...existing.hooks, ...hooksConfig },
370
+ };
371
+
372
+ await writeFile(hooksJsonPath, JSON.stringify(merged, null, 2));
373
+ return { hooksJsonPath, hooksInstalled: Object.keys(hooksConfig) };
374
+ }
375
+
376
+ /**
377
+ * Remove Teleportation hooks from ~/.cursor/hooks.json without deleting the file.
378
+ * Preserves hooks belonging to other tools.
379
+ */
380
+ export async function uninstallCursorHooks() {
381
+ const hooksJsonPath = join(HOME_DIR, '.cursor', 'hooks.json');
382
+ const teleportationKeys = ['preToolUse', 'postToolUse', 'sessionStart', 'sessionEnd', 'stop', 'beforeSubmitPrompt'];
383
+ const teleportationHookFiles = [
384
+ 'pre_tool_use.mjs',
385
+ 'post_tool_use.mjs',
386
+ 'session_start.mjs',
387
+ 'session_end.mjs',
388
+ 'stop.mjs',
389
+ 'user_prompt_submit.mjs',
390
+ ];
391
+
392
+ try {
393
+ const content = await readFile(hooksJsonPath, 'utf8');
394
+ const parsed = JSON.parse(content);
395
+ if (parsed.hooks) {
396
+ for (const key of teleportationKeys) {
397
+ if (!Array.isArray(parsed.hooks[key])) continue;
398
+ parsed.hooks[key] = parsed.hooks[key].filter((entry) => {
399
+ const command = entry?.command || '';
400
+ return !teleportationHookFiles.some((file) => command.includes(file));
401
+ });
402
+ if (parsed.hooks[key].length === 0) {
403
+ delete parsed.hooks[key];
404
+ }
405
+ }
406
+ }
407
+ await writeFile(hooksJsonPath, JSON.stringify(parsed, null, 2));
408
+ return { success: true, hooksJsonPath };
409
+ } catch (e) {
410
+ if (e.code === 'ENOENT') return { success: true, hooksJsonPath }; // Nothing to remove
411
+ throw e;
412
+ }
413
+ }
414
+
310
415
  /**
311
416
  * Copy daemon files to ~/.teleportation/daemon/
312
417
  */
@@ -662,6 +767,7 @@ export async function verifyInstallation() {
662
767
  * @param {Object} [options] - Installation options
663
768
  * @param {boolean} [options.includeClaude] - Force include Claude hooks (overrides detection)
664
769
  * @param {boolean} [options.includeGemini] - Force include Gemini hooks (overrides detection)
770
+ * @param {boolean} [options.includeCursor] - Force include Cursor hooks (overrides detection)
665
771
  */
666
772
  export async function install(sourceHooksDir, options = {}) {
667
773
  // Pre-flight checks
@@ -673,6 +779,7 @@ export async function install(sourceHooksDir, options = {}) {
673
779
  // Check available CLIs
674
780
  const claudeCheck = checkClaudeCode();
675
781
  const geminiCheck = checkGeminiCli();
782
+ const cursorCheck = checkCursorIde();
676
783
 
677
784
  // Resolve what to install based on options and detection
678
785
  const shouldInstallClaude = options.includeClaude !== undefined
@@ -683,11 +790,15 @@ export async function install(sourceHooksDir, options = {}) {
683
790
  ? options.includeGemini
684
791
  : geminiCheck.valid;
685
792
 
686
- if (!shouldInstallClaude && !shouldInstallGemini) {
687
- if (options.includeClaude === false && options.includeGemini === false) {
793
+ const shouldInstallCursor = options.includeCursor !== undefined
794
+ ? options.includeCursor
795
+ : cursorCheck.valid;
796
+
797
+ if (!shouldInstallClaude && !shouldInstallGemini && !shouldInstallCursor) {
798
+ if (options.includeClaude === false && options.includeGemini === false && options.includeCursor === false) {
688
799
  throw new Error('No targets selected for installation.');
689
800
  }
690
- throw new Error('Neither Claude Code nor Gemini CLI found. Please install one of them first, or specify a target.');
801
+ throw new Error('Neither Claude Code, Gemini CLI, nor Cursor IDE found. Please install one of them first, or specify a target.');
691
802
  }
692
803
 
693
804
  // Create directories
@@ -695,6 +806,7 @@ export async function install(sourceHooksDir, options = {}) {
695
806
 
696
807
  let hooksInstalled = 0;
697
808
  let geminiHooksInstalled = 0;
809
+ let cursorHooksInstalled = 0;
698
810
 
699
811
  // 1. Install Claude hooks
700
812
  if (shouldInstallClaude) {
@@ -719,6 +831,16 @@ export async function install(sourceHooksDir, options = {}) {
719
831
  }
720
832
  }
721
833
 
834
+ // 3. Install Cursor hooks
835
+ if (shouldInstallCursor) {
836
+ try {
837
+ const cursorResult = await installCursorHooks(resolve(getProjectHooksDir()));
838
+ cursorHooksInstalled = cursorResult.hooksInstalled.length;
839
+ } catch (e) {
840
+ console.warn(`Warning: Cursor hooks failed to install: ${e.message}`);
841
+ }
842
+ }
843
+
722
844
  // Install daemon (still goes to ~/.teleportation/daemon/)
723
845
  const daemonResult = await installDaemon();
724
846
  if (daemonResult.failed.length > 0) {
@@ -753,6 +875,7 @@ export async function install(sourceHooksDir, options = {}) {
753
875
  success: true,
754
876
  hooksInstalled,
755
877
  geminiHooksInstalled,
878
+ cursorHooksInstalled,
756
879
  daemonInstalled: daemonResult.installed.length,
757
880
  libFilesInstalled: libResult.installed.length,
758
881
  settingsFile: getProjectSettings(),
@@ -13,35 +13,41 @@
13
13
  */
14
14
 
15
15
  import { execSync } from 'child_process';
16
- import { join } from 'path';
16
+ import { join, resolve, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
17
18
  import { tmpdir } from 'os';
18
19
 
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ // Absolute path to the local uhr binary shipped with teleportation
22
+ const LOCAL_UHR_BIN = resolve(__dirname, '..', '..', 'node_modules', '.bin', 'uhr');
23
+
19
24
  /**
20
- * Check if the UHR CLI is available on this system.
21
- *
22
- * Checks two locations:
23
- * 1. `uhr` on the system PATH (via `which uhr`)
24
- * 2. `node_modules/.bin/uhr` (local project install)
25
- *
26
- * @returns {Promise<boolean>} true if UHR CLI is reachable
25
+ * Resolve the uhr binary path, preferring PATH then the local node_modules install.
26
+ * Returns the binary path string, or null if not found.
27
+ * @returns {Promise<string|null>}
27
28
  */
28
- export async function isUhrAvailable() {
29
+ export async function resolveUhrBin() {
29
30
  // Check PATH first
30
31
  try {
31
- execSync('which uhr', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
32
- return true;
32
+ const found = execSync('which uhr', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
33
+ if (found) return found;
33
34
  } catch (_) {
34
35
  // Not in PATH
35
36
  }
36
37
 
37
- // Check local node_modules
38
+ // Check absolute path to local node_modules/.bin/uhr
38
39
  try {
39
- const localBin = join('node_modules', '.bin', 'uhr');
40
- const file = Bun.file(localBin);
41
- return await file.exists();
40
+ const file = Bun.file(LOCAL_UHR_BIN);
41
+ if (await file.exists()) return LOCAL_UHR_BIN;
42
42
  } catch (_) {
43
- return false;
43
+ // continue
44
44
  }
45
+
46
+ return null;
47
+ }
48
+
49
+ export async function isUhrAvailable() {
50
+ return (await resolveUhrBin()) !== null;
45
51
  }
46
52
 
47
53
  /**
@@ -89,9 +95,14 @@ export async function installViaUhr(manifestPath, hooksDir, options = {}) {
89
95
  }
90
96
 
91
97
  // 5. Run uhr install
98
+ const uhrBin = await resolveUhrBin();
99
+ if (!uhrBin) {
100
+ return { success: false, reason: 'UHR CLI not found (checked PATH and node_modules/.bin/uhr)' };
101
+ }
102
+
92
103
  const warnings = [];
93
104
  try {
94
- const output = execSync(`uhr install ${tempManifestPath}`, {
105
+ const output = execSync(`"${uhrBin}" install "${tempManifestPath}"`, {
95
106
  encoding: 'utf8',
96
107
  stdio: ['ignore', 'pipe', 'pipe'],
97
108
  timeout: 30000,
@@ -123,8 +134,11 @@ export async function installViaUhr(manifestPath, hooksDir, options = {}) {
123
134
  * @returns {Promise<{success: boolean}>}
124
135
  */
125
136
  export async function uninstallViaUhr(serviceName) {
137
+ const uhrBin = await resolveUhrBin();
138
+ if (!uhrBin) return { success: false };
139
+
126
140
  try {
127
- execSync(`uhr uninstall ${serviceName}`, {
141
+ execSync(`"${uhrBin}" uninstall "${serviceName}"`, {
128
142
  encoding: 'utf8',
129
143
  stdio: ['ignore', 'pipe', 'pipe'],
130
144
  timeout: 30000,