teleportation-cli 1.3.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/permission_request.mjs +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +255 -289
- package/.claude/hooks/session-register.mjs +44 -29
- package/.claude/hooks/session_end.mjs +29 -3
- package/.claude/hooks/session_start.mjs +57 -1
- package/.claude/hooks/stop.mjs +245 -242
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/config/manager.js +45 -1
- package/lib/daemon/session-file-registry.js +207 -0
- package/lib/daemon/task-executor-v2.js +239 -29
- package/lib/daemon/teleportation-daemon.js +469 -29
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +310 -51
- 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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'fs/promises';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { homedir, hostname } from 'os';
|
|
7
|
+
import { dirname, join, resolve } from 'path';
|
|
8
|
+
import { loadConfig } from '../config/manager.js';
|
|
9
|
+
import { getRepoSyncContext } from './repo-context.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
const TELEPORTATION_DIR = process.env.TELEPORTATION_DIR || resolve(__dirname, '..', '..');
|
|
14
|
+
const SYNC_SCRIPT = join(TELEPORTATION_DIR, 'scripts', 'sync-transcripts.sh');
|
|
15
|
+
const STATE_PATH = join(homedir(), '.teleportation', 'transcript-sync-state.json');
|
|
16
|
+
|
|
17
|
+
function nowIso() {
|
|
18
|
+
return new Date().toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defaultMachineId() {
|
|
22
|
+
return (hostname() || 'unknown-machine').split('.')[0];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function writeState(state) {
|
|
26
|
+
await mkdir(join(homedir(), '.teleportation'), { recursive: true });
|
|
27
|
+
await writeFile(STATE_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readState() {
|
|
31
|
+
try {
|
|
32
|
+
const content = await readFile(STATE_PATH, 'utf8');
|
|
33
|
+
return JSON.parse(content);
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function loadSyncConfig() {
|
|
40
|
+
const config = await loadConfig();
|
|
41
|
+
const ts = config.transcriptSync || {};
|
|
42
|
+
const repoContext = getRepoSyncContext(TELEPORTATION_DIR);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
enabled: ts.enabled !== false,
|
|
46
|
+
intervalMs: typeof ts.intervalMs === 'number' ? ts.intervalMs : 300000,
|
|
47
|
+
machineId: defaultMachineId(),
|
|
48
|
+
mirrorDir: repoContext.mirrorDir,
|
|
49
|
+
mode: ts.mode || 'local',
|
|
50
|
+
peer: repoContext.peer,
|
|
51
|
+
peerMirrorDir: repoContext.peerMirrorDir,
|
|
52
|
+
repoKey: repoContext.repoKey
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildSyncArgs(syncConfig) {
|
|
57
|
+
const args = [
|
|
58
|
+
'--machine-id', syncConfig.machineId,
|
|
59
|
+
'--mirror-dir', syncConfig.mirrorDir,
|
|
60
|
+
'--mode', syncConfig.mode
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
if (syncConfig.peer) {
|
|
64
|
+
args.push('--peer', syncConfig.peer);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (syncConfig.peerMirrorDir) {
|
|
68
|
+
args.push('--peer-mirror-dir', syncConfig.peerMirrorDir);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runSync(syncConfig) {
|
|
75
|
+
if (!syncConfig.enabled) {
|
|
76
|
+
return { skipped: true, reason: 'disabled' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const args = buildSyncArgs(syncConfig);
|
|
80
|
+
|
|
81
|
+
return await new Promise((resolvePromise) => {
|
|
82
|
+
const child = spawn(SYNC_SCRIPT, args, {
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
+
env: process.env
|
|
85
|
+
});
|
|
86
|
+
activeChild = child;
|
|
87
|
+
|
|
88
|
+
let stdout = '';
|
|
89
|
+
let stderr = '';
|
|
90
|
+
|
|
91
|
+
child.stdout.on('data', (chunk) => {
|
|
92
|
+
stdout += chunk.toString();
|
|
93
|
+
});
|
|
94
|
+
child.stderr.on('data', (chunk) => {
|
|
95
|
+
stderr += chunk.toString();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
child.on('error', (error) => {
|
|
99
|
+
activeChild = null;
|
|
100
|
+
resolvePromise({
|
|
101
|
+
code: 1,
|
|
102
|
+
stdout,
|
|
103
|
+
stderr: `${stderr}\n${String(error?.message || error)}`
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
child.on('close', (code) => {
|
|
108
|
+
activeChild = null;
|
|
109
|
+
resolvePromise({
|
|
110
|
+
code: code ?? 1,
|
|
111
|
+
stdout,
|
|
112
|
+
stderr
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let stopping = false;
|
|
119
|
+
let timer = null;
|
|
120
|
+
let activeChild = null;
|
|
121
|
+
|
|
122
|
+
async function tick() {
|
|
123
|
+
if (stopping) return;
|
|
124
|
+
|
|
125
|
+
const startedAt = nowIso();
|
|
126
|
+
let syncConfig = null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
syncConfig = await loadSyncConfig();
|
|
130
|
+
const result = await runSync(syncConfig);
|
|
131
|
+
const previousState = await readState();
|
|
132
|
+
const hasRsyncErrors = /rsync\([^)]+\): error:/i.test(result.stderr || '');
|
|
133
|
+
const success = !!result.skipped || (result.code === 0 && !hasRsyncErrors);
|
|
134
|
+
const state = {
|
|
135
|
+
...previousState,
|
|
136
|
+
running: true,
|
|
137
|
+
lastRunAt: startedAt,
|
|
138
|
+
lastSuccessAt: success ? nowIso() : (previousState.lastSuccessAt || null),
|
|
139
|
+
lastErrorAt: success ? (previousState.lastErrorAt || null) : nowIso(),
|
|
140
|
+
lastExitCode: result.code ?? 0,
|
|
141
|
+
lastMode: syncConfig.mode,
|
|
142
|
+
lastPeer: syncConfig.peer,
|
|
143
|
+
machineId: syncConfig.machineId,
|
|
144
|
+
repoKey: syncConfig.repoKey,
|
|
145
|
+
enabled: syncConfig.enabled,
|
|
146
|
+
skipped: !!result.skipped,
|
|
147
|
+
skipReason: result.reason || null,
|
|
148
|
+
lastOutput: (result.stdout || '').slice(-4000),
|
|
149
|
+
lastError: (result.stderr || '').slice(-4000)
|
|
150
|
+
};
|
|
151
|
+
await writeState(state);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const previousState = await readState();
|
|
154
|
+
await writeState({
|
|
155
|
+
...previousState,
|
|
156
|
+
running: true,
|
|
157
|
+
lastRunAt: startedAt,
|
|
158
|
+
lastSuccessAt: previousState.lastSuccessAt || null,
|
|
159
|
+
lastErrorAt: nowIso(),
|
|
160
|
+
lastExitCode: 1,
|
|
161
|
+
enabled: syncConfig?.enabled ?? true,
|
|
162
|
+
machineId: syncConfig?.machineId ?? defaultMachineId(),
|
|
163
|
+
repoKey: syncConfig?.repoKey ?? null,
|
|
164
|
+
lastMode: syncConfig?.mode ?? 'local',
|
|
165
|
+
lastPeer: syncConfig?.peer ?? null,
|
|
166
|
+
skipped: false,
|
|
167
|
+
skipReason: null,
|
|
168
|
+
lastOutput: '',
|
|
169
|
+
lastError: String(error?.message || error)
|
|
170
|
+
});
|
|
171
|
+
} finally {
|
|
172
|
+
let nextIntervalMs = 300000;
|
|
173
|
+
try {
|
|
174
|
+
if (syncConfig && typeof syncConfig.intervalMs === 'number' && syncConfig.intervalMs >= 10000) {
|
|
175
|
+
nextIntervalMs = syncConfig.intervalMs;
|
|
176
|
+
} else {
|
|
177
|
+
const latestConfig = await loadSyncConfig();
|
|
178
|
+
if (typeof latestConfig.intervalMs === 'number' && latestConfig.intervalMs >= 10000) {
|
|
179
|
+
nextIntervalMs = latestConfig.intervalMs;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const previousState = await readState();
|
|
184
|
+
await writeState({
|
|
185
|
+
...previousState,
|
|
186
|
+
running: true,
|
|
187
|
+
lastErrorAt: nowIso(),
|
|
188
|
+
lastExitCode: 1,
|
|
189
|
+
lastError: `Failed to reload transcript sync config: ${String(error?.message || error)}`
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (!stopping) {
|
|
193
|
+
timer = setTimeout(tick, nextIntervalMs);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function shutdown(signal) {
|
|
199
|
+
stopping = true;
|
|
200
|
+
if (timer) {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
timer = null;
|
|
203
|
+
}
|
|
204
|
+
if (activeChild && !activeChild.killed) {
|
|
205
|
+
try {
|
|
206
|
+
activeChild.kill('SIGTERM');
|
|
207
|
+
} catch {
|
|
208
|
+
// Ignore child shutdown errors during teardown.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await writeState({
|
|
213
|
+
running: false,
|
|
214
|
+
stoppedAt: nowIso(),
|
|
215
|
+
stopSignal: signal
|
|
216
|
+
});
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
process.on('SIGTERM', () => {
|
|
221
|
+
shutdown('SIGTERM').catch(() => process.exit(0));
|
|
222
|
+
});
|
|
223
|
+
process.on('SIGINT', () => {
|
|
224
|
+
shutdown('SIGINT').catch(() => process.exit(0));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await writeState({
|
|
228
|
+
running: true,
|
|
229
|
+
startedAt: nowIso(),
|
|
230
|
+
pid: process.pid
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await tick();
|
|
@@ -81,6 +81,71 @@ export function sanitizeForLog(text) {
|
|
|
81
81
|
return sanitized;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Fields in event data objects that may contain user-generated text with secrets.
|
|
86
|
+
* Used by sanitizeEventData() to know which fields to sanitize.
|
|
87
|
+
*/
|
|
88
|
+
export const SANITIZE_TEXT_FIELDS = ['message', 'summary', 'thinking', 'result', 'error', 'stdout', 'stderr', 'content'];
|
|
89
|
+
|
|
90
|
+
// Keys whose values should always be redacted regardless of value content
|
|
91
|
+
const SENSITIVE_KEY_RE = /^(password|passwd|pwd|secret|token|api[_-]?key|authorization|credentials?)$/i;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Recursively sanitize all string values in an object/array.
|
|
95
|
+
* Preserves object structure (unlike JSON stringify/regex/parse which can corrupt JSON).
|
|
96
|
+
* Also redacts values for keys that match sensitive names (password, secret, token, etc.).
|
|
97
|
+
*
|
|
98
|
+
* @param {*} obj - Value to sanitize
|
|
99
|
+
* @param {Function} fn - Text sanitization function
|
|
100
|
+
* @param {string} [parentKey] - The key name from the parent object (for key-based redaction)
|
|
101
|
+
* @returns {*} - Sanitized value with structure preserved
|
|
102
|
+
*/
|
|
103
|
+
function sanitizeObjectValues(obj, fn, parentKey) {
|
|
104
|
+
if (typeof obj === 'string') {
|
|
105
|
+
// If the parent key is sensitive (e.g. "password"), redact the value entirely
|
|
106
|
+
if (parentKey && SENSITIVE_KEY_RE.test(parentKey)) return '***';
|
|
107
|
+
return fn(obj);
|
|
108
|
+
}
|
|
109
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
110
|
+
if (Array.isArray(obj)) return obj.map(item => sanitizeObjectValues(item, fn));
|
|
111
|
+
const result = {};
|
|
112
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
113
|
+
result[k] = sanitizeObjectValues(v, fn, k);
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sanitize event data to remove sensitive information before storing in timeline.
|
|
120
|
+
* Applies sanitizeForLog to known text fields and tool_input (string or object).
|
|
121
|
+
*
|
|
122
|
+
* @param {Object} eventData - The event data object
|
|
123
|
+
* @param {Function} [sanitizer=sanitizeForLog] - Text sanitization function
|
|
124
|
+
* @returns {Object} - Sanitized event data (shallow copy)
|
|
125
|
+
*/
|
|
126
|
+
export function sanitizeEventData(eventData, sanitizer) {
|
|
127
|
+
if (!eventData || typeof eventData !== 'object') return eventData;
|
|
128
|
+
|
|
129
|
+
const fn = sanitizer || sanitizeForLog;
|
|
130
|
+
const sanitized = { ...eventData };
|
|
131
|
+
|
|
132
|
+
for (const field of SANITIZE_TEXT_FIELDS) {
|
|
133
|
+
if (typeof sanitized[field] === 'string') {
|
|
134
|
+
sanitized[field] = fn(sanitized[field]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (sanitized.tool_input) {
|
|
139
|
+
if (typeof sanitized.tool_input === 'string') {
|
|
140
|
+
sanitized.tool_input = fn(sanitized.tool_input);
|
|
141
|
+
} else if (typeof sanitized.tool_input === 'object') {
|
|
142
|
+
sanitized.tool_input = sanitizeObjectValues(sanitized.tool_input, fn);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return sanitized;
|
|
147
|
+
}
|
|
148
|
+
|
|
84
149
|
/**
|
|
85
150
|
* Truncates a string to a maximum length and adds ellipsis if needed
|
|
86
151
|
* @param {string} text - The text to truncate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teleportation-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Remote approval system for Claude Code - approve AI coding changes from your phone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "teleportation-cli.cjs",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"!lib/**/*.log",
|
|
56
56
|
".claude/hooks/*.mjs",
|
|
57
57
|
"!.claude/hooks/*.test.mjs",
|
|
58
|
+
"scripts/sync-transcripts.sh",
|
|
58
59
|
"teleportation-cli.cjs",
|
|
59
60
|
"README.md",
|
|
60
61
|
"LICENSE"
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
usage() {
|
|
6
|
+
cat <<'USAGE'
|
|
7
|
+
Usage:
|
|
8
|
+
./scripts/sync-transcripts.sh [options]
|
|
9
|
+
|
|
10
|
+
Description:
|
|
11
|
+
Mirrors local AI CLI transcripts into a normalized local mirror and optionally
|
|
12
|
+
syncs mirror data to/from a peer machine over ssh+rsync.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--machine-id ID Machine identifier for local mirror folder.
|
|
16
|
+
Default: short hostname
|
|
17
|
+
--mirror-dir PATH Local mirror root.
|
|
18
|
+
Default: $HOME/.teleportation/transcript-mirror
|
|
19
|
+
--peer HOST Peer in ssh format (user@host or host).
|
|
20
|
+
--peer-mirror-dir PATH Peer mirror root path.
|
|
21
|
+
Default: same as --mirror-dir
|
|
22
|
+
--mode MODE Sync mode: local|push|pull|bidirectional
|
|
23
|
+
Default: local
|
|
24
|
+
--dry-run Print actions without writing files.
|
|
25
|
+
-h, --help Show this help text.
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
./scripts/sync-transcripts.sh
|
|
29
|
+
./scripts/sync-transcripts.sh --machine-id mac-mini
|
|
30
|
+
./scripts/sync-transcripts.sh --peer user@laptop.local --mode push
|
|
31
|
+
./scripts/sync-transcripts.sh --peer user@mini.local --mode bidirectional
|
|
32
|
+
USAGE
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
require_cmd() {
|
|
36
|
+
if ! command -v "$1" >/dev/null 2>&1; then
|
|
37
|
+
echo "Error: required command not found: $1" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
human_bytes() {
|
|
43
|
+
local bytes="$1"
|
|
44
|
+
local units=(B KB MB GB TB)
|
|
45
|
+
local i=0
|
|
46
|
+
local value="$bytes"
|
|
47
|
+
|
|
48
|
+
while [ "$value" -ge 1024 ] && [ "$i" -lt 4 ]; do
|
|
49
|
+
value=$((value / 1024))
|
|
50
|
+
i=$((i + 1))
|
|
51
|
+
done
|
|
52
|
+
|
|
53
|
+
printf "%s %s" "$value" "${units[$i]}"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
copy_matches() {
|
|
57
|
+
local base_dir="$1"
|
|
58
|
+
local match_type="$2"
|
|
59
|
+
local match_value="$3"
|
|
60
|
+
local dest_dir="$4"
|
|
61
|
+
local dry_run="$5"
|
|
62
|
+
|
|
63
|
+
local count=0
|
|
64
|
+
local total_bytes=0
|
|
65
|
+
|
|
66
|
+
if [ ! -d "$base_dir" ]; then
|
|
67
|
+
printf "%s\t%s\n" "0" "0"
|
|
68
|
+
return 0
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
local -a find_cmd=(find . -type f)
|
|
72
|
+
case "$match_type" in
|
|
73
|
+
name)
|
|
74
|
+
find_cmd+=(-name "$match_value")
|
|
75
|
+
;;
|
|
76
|
+
path)
|
|
77
|
+
find_cmd+=(-path "$match_value")
|
|
78
|
+
;;
|
|
79
|
+
*)
|
|
80
|
+
echo "Error: unsupported match type '$match_type'" >&2
|
|
81
|
+
return 1
|
|
82
|
+
;;
|
|
83
|
+
esac
|
|
84
|
+
|
|
85
|
+
while IFS= read -r -d '' rel_path; do
|
|
86
|
+
rel_path="${rel_path#./}"
|
|
87
|
+
local src="$base_dir/$rel_path"
|
|
88
|
+
local dest="$dest_dir/$rel_path"
|
|
89
|
+
local dest_parent
|
|
90
|
+
dest_parent="$(dirname "$dest")"
|
|
91
|
+
|
|
92
|
+
local file_size
|
|
93
|
+
file_size="$(stat -f '%z' "$src" 2>/dev/null || stat -c '%s' "$src" 2>/dev/null || echo 0)"
|
|
94
|
+
|
|
95
|
+
if [ "$dry_run" = "true" ]; then
|
|
96
|
+
echo "[dry-run] copy $src -> $dest" >&2
|
|
97
|
+
else
|
|
98
|
+
mkdir -p "$dest_parent"
|
|
99
|
+
rsync -a "$src" "$dest"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
count=$((count + 1))
|
|
103
|
+
total_bytes=$((total_bytes + file_size))
|
|
104
|
+
done < <(cd "$base_dir" && "${find_cmd[@]}" -print0)
|
|
105
|
+
|
|
106
|
+
printf "%s\t%s\n" "$count" "$total_bytes"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
sync_to_peer() {
|
|
110
|
+
local mode="$1"
|
|
111
|
+
local local_root="$2"
|
|
112
|
+
local peer="$3"
|
|
113
|
+
local peer_root="$4"
|
|
114
|
+
local dry_run="$5"
|
|
115
|
+
|
|
116
|
+
if [ -z "$peer" ]; then
|
|
117
|
+
echo "Error: --peer is required for mode '$mode'" >&2
|
|
118
|
+
exit 1
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
if [[ "$peer" == -* ]] || [[ ! "$peer" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]]; then
|
|
122
|
+
echo "Error: invalid --peer value '$peer'" >&2
|
|
123
|
+
exit 1
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
require_cmd ssh
|
|
127
|
+
require_cmd rsync
|
|
128
|
+
|
|
129
|
+
local rsync_flags=(-az --update)
|
|
130
|
+
if [ "$dry_run" = "true" ]; then
|
|
131
|
+
rsync_flags+=(--dry-run)
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
if [ "$mode" = "push" ] || [ "$mode" = "bidirectional" ]; then
|
|
135
|
+
if [[ "$peer_root" == "~/"* ]]; then
|
|
136
|
+
ssh "$peer" "mkdir -p $peer_root"
|
|
137
|
+
else
|
|
138
|
+
ssh "$peer" "mkdir -p '$peer_root'"
|
|
139
|
+
fi
|
|
140
|
+
echo "Sync push: $local_root/ -> $peer:$peer_root/"
|
|
141
|
+
rsync "${rsync_flags[@]}" "$local_root/" "$peer:$peer_root/"
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
if [ "$mode" = "pull" ] || [ "$mode" = "bidirectional" ]; then
|
|
145
|
+
mkdir -p "$local_root"
|
|
146
|
+
echo "Sync pull: $peer:$peer_root/ -> $local_root/"
|
|
147
|
+
rsync "${rsync_flags[@]}" "$peer:$peer_root/" "$local_root/"
|
|
148
|
+
fi
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
main() {
|
|
152
|
+
local machine_id
|
|
153
|
+
machine_id="$(hostname -s 2>/dev/null || hostname)"
|
|
154
|
+
local mirror_dir="${HOME}/.teleportation/transcript-mirror"
|
|
155
|
+
local peer=""
|
|
156
|
+
local peer_mirror_dir=""
|
|
157
|
+
local mode="local"
|
|
158
|
+
local dry_run="false"
|
|
159
|
+
|
|
160
|
+
while [ "$#" -gt 0 ]; do
|
|
161
|
+
case "$1" in
|
|
162
|
+
--machine-id)
|
|
163
|
+
machine_id="$2"
|
|
164
|
+
shift 2
|
|
165
|
+
;;
|
|
166
|
+
--mirror-dir)
|
|
167
|
+
mirror_dir="$2"
|
|
168
|
+
shift 2
|
|
169
|
+
;;
|
|
170
|
+
--peer)
|
|
171
|
+
peer="$2"
|
|
172
|
+
shift 2
|
|
173
|
+
;;
|
|
174
|
+
--peer-mirror-dir)
|
|
175
|
+
peer_mirror_dir="$2"
|
|
176
|
+
shift 2
|
|
177
|
+
;;
|
|
178
|
+
--mode)
|
|
179
|
+
mode="$2"
|
|
180
|
+
shift 2
|
|
181
|
+
;;
|
|
182
|
+
--dry-run)
|
|
183
|
+
dry_run="true"
|
|
184
|
+
shift
|
|
185
|
+
;;
|
|
186
|
+
-h|--help)
|
|
187
|
+
usage
|
|
188
|
+
exit 0
|
|
189
|
+
;;
|
|
190
|
+
*)
|
|
191
|
+
echo "Error: unknown option '$1'" >&2
|
|
192
|
+
usage
|
|
193
|
+
exit 1
|
|
194
|
+
;;
|
|
195
|
+
esac
|
|
196
|
+
done
|
|
197
|
+
|
|
198
|
+
case "$mode" in
|
|
199
|
+
local|push|pull|bidirectional) ;;
|
|
200
|
+
*)
|
|
201
|
+
echo "Error: invalid --mode '$mode' (expected local|push|pull|bidirectional)" >&2
|
|
202
|
+
exit 1
|
|
203
|
+
;;
|
|
204
|
+
esac
|
|
205
|
+
|
|
206
|
+
if [ -z "$peer_mirror_dir" ]; then
|
|
207
|
+
peer_mirror_dir="$mirror_dir"
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
require_cmd find
|
|
211
|
+
require_cmd rsync
|
|
212
|
+
|
|
213
|
+
local machine_root="$mirror_dir/$machine_id"
|
|
214
|
+
local claude_dest="$machine_root/claude"
|
|
215
|
+
local codex_dest="$machine_root/codex"
|
|
216
|
+
local cursor_dest="$machine_root/cursor"
|
|
217
|
+
local gemini_dest="$machine_root/gemini"
|
|
218
|
+
|
|
219
|
+
local claude_src="${HOME}/.claude/projects"
|
|
220
|
+
local codex_src="${HOME}/.codex/sessions"
|
|
221
|
+
local cursor_src="${HOME}/.cursor/projects"
|
|
222
|
+
local gemini_src="${HOME}/.gemini/tmp"
|
|
223
|
+
|
|
224
|
+
if [ "$dry_run" = "false" ]; then
|
|
225
|
+
mkdir -p "$claude_dest" "$codex_dest" "$cursor_dest" "$gemini_dest"
|
|
226
|
+
fi
|
|
227
|
+
|
|
228
|
+
echo "Transcript mirror"
|
|
229
|
+
echo " machine: $machine_id"
|
|
230
|
+
echo " mirror: $mirror_dir"
|
|
231
|
+
echo " mode: $mode"
|
|
232
|
+
[ -n "$peer" ] && echo " peer: $peer"
|
|
233
|
+
echo ""
|
|
234
|
+
|
|
235
|
+
local claude_out codex_out cursor_out gemini_out
|
|
236
|
+
claude_out="$(copy_matches "$claude_src" "name" "*.jsonl" "$claude_dest" "$dry_run")"
|
|
237
|
+
codex_out="$(copy_matches "$codex_src" "name" "*.jsonl" "$codex_dest" "$dry_run")"
|
|
238
|
+
cursor_out="$(copy_matches "$cursor_src" "path" "*/agent-transcripts/*.txt" "$cursor_dest" "$dry_run")"
|
|
239
|
+
gemini_out="$(copy_matches "$gemini_src" "path" "*/chats/session-*.json" "$gemini_dest" "$dry_run")"
|
|
240
|
+
|
|
241
|
+
local claude_count codex_count cursor_count gemini_count
|
|
242
|
+
local claude_bytes codex_bytes cursor_bytes gemini_bytes
|
|
243
|
+
|
|
244
|
+
claude_count="$(echo "$claude_out" | cut -f1)"
|
|
245
|
+
claude_bytes="$(echo "$claude_out" | cut -f2)"
|
|
246
|
+
codex_count="$(echo "$codex_out" | cut -f1)"
|
|
247
|
+
codex_bytes="$(echo "$codex_out" | cut -f2)"
|
|
248
|
+
cursor_count="$(echo "$cursor_out" | cut -f1)"
|
|
249
|
+
cursor_bytes="$(echo "$cursor_out" | cut -f2)"
|
|
250
|
+
gemini_count="$(echo "$gemini_out" | cut -f1)"
|
|
251
|
+
gemini_bytes="$(echo "$gemini_out" | cut -f2)"
|
|
252
|
+
|
|
253
|
+
local total_count total_bytes
|
|
254
|
+
total_count=$((claude_count + codex_count + cursor_count + gemini_count))
|
|
255
|
+
total_bytes=$((claude_bytes + codex_bytes + cursor_bytes + gemini_bytes))
|
|
256
|
+
|
|
257
|
+
echo "Local mirror summary (copied this run):"
|
|
258
|
+
printf " %-8s %6d files %12d bytes\n" "claude" "$claude_count" "$claude_bytes"
|
|
259
|
+
printf " %-8s %6d files %12d bytes\n" "codex" "$codex_count" "$codex_bytes"
|
|
260
|
+
printf " %-8s %6d files %12d bytes\n" "cursor" "$cursor_count" "$cursor_bytes"
|
|
261
|
+
printf " %-8s %6d files %12d bytes\n" "gemini" "$gemini_count" "$gemini_bytes"
|
|
262
|
+
printf " %-8s %6d files %12d bytes (%s)\n" "total" "$total_count" "$total_bytes" "$(human_bytes "$total_bytes")"
|
|
263
|
+
echo ""
|
|
264
|
+
|
|
265
|
+
if [ "$mode" != "local" ]; then
|
|
266
|
+
sync_to_peer "$mode" "$mirror_dir" "$peer" "$peer_mirror_dir" "$dry_run"
|
|
267
|
+
echo ""
|
|
268
|
+
echo "Peer sync complete."
|
|
269
|
+
fi
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
main "$@"
|