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.
- package/.claude/hooks/pre_tool_use.mjs +47 -10
- package/.claude/hooks/session-register.mjs +12 -5
- package/.claude/hooks/session_end.mjs +28 -0
- package/.claude/hooks/session_start.mjs +57 -1
- package/.claude/hooks/stop.mjs +30 -18
- package/lib/config/manager.js +45 -1
- package/lib/daemon/session-file-registry.js +207 -0
- package/lib/daemon/task-executor-v2.js +38 -9
- package/lib/daemon/teleportation-daemon.js +287 -17
- package/lib/daemon/transcript-ingestion.js +160 -9
- package/lib/daemon/utils.js +0 -9
- package/lib/install/installer.js +126 -3
- package/lib/install/uhr-installer.js +32 -18
- package/lib/intelligence/benchmark.js +240 -0
- package/lib/intelligence/index.js +29 -0
- package/lib/intelligence/rebuild-policies.js +169 -0
- package/lib/intelligence/schema.js +259 -0
- package/lib/intelligence/transcript-mine.js +339 -0
- package/lib/session/metadata.js +23 -5
- package/lib/transcript-sync/lifecycle.js +88 -0
- package/lib/transcript-sync/repo-context.js +45 -0
- package/lib/transcript-sync/worker.js +233 -0
- package/lib/utils/log-sanitizer.js +65 -0
- package/package.json +2 -1
- package/scripts/sync-transcripts.sh +272 -0
- package/teleportation-cli.cjs +295 -4
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/daemon/utils.js
CHANGED
|
@@ -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
|
*/
|
package/lib/install/installer.js
CHANGED
|
@@ -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
|
-
|
|
687
|
-
|
|
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
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
141
|
+
execSync(`"${uhrBin}" uninstall "${serviceName}"`, {
|
|
128
142
|
encoding: 'utf8',
|
|
129
143
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
144
|
timeout: 30000,
|