teleportation-cli 1.0.0 → 1.0.2

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.
@@ -9,6 +9,15 @@
9
9
  import { stdin, stdout, exit, env } from 'node:process';
10
10
  import { tmpdir } from 'os';
11
11
  import { join } from 'path';
12
+ import { appendFile } from 'fs/promises';
13
+
14
+ const HOOK_LOG = '/tmp/teleportation-hook.log';
15
+ const log = async (msg) => {
16
+ const ts = new Date().toISOString();
17
+ try {
18
+ await appendFile(HOOK_LOG, `[${ts}] [UserPromptSubmit] ${msg}\n`);
19
+ } catch {}
20
+ };
12
21
 
13
22
  const readStdin = () => new Promise((resolve, reject) => {
14
23
  let data = '';
@@ -18,6 +27,12 @@ const readStdin = () => new Promise((resolve, reject) => {
18
27
  stdin.on('error', reject);
19
28
  });
20
29
 
30
+ const fetchJson = async (url, opts) => {
31
+ const res = await fetch(url, opts);
32
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
33
+ return res.json();
34
+ };
35
+
21
36
  (async () => {
22
37
  let input = {};
23
38
  try {
@@ -31,6 +46,45 @@ const readStdin = () => new Promise((resolve, reject) => {
31
46
 
32
47
  const { session_id, prompt } = input;
33
48
 
49
+ // Clear away mode only on actual user activity (prompt submit), not on tool attempts.
50
+ // Also support /away and /back here in case they are handled as prompts.
51
+ if (session_id && prompt && typeof prompt === 'string') {
52
+ const trimmed = prompt.trim();
53
+ const lowered = trimmed.toLowerCase();
54
+
55
+ let desiredAway = null;
56
+ if (lowered === '/away' || lowered === 'teleportation away') desiredAway = true;
57
+ else if (lowered === '/back' || lowered === 'teleportation back') desiredAway = false;
58
+ else if (trimmed.length > 0) desiredAway = false;
59
+
60
+ if (desiredAway !== null) {
61
+ try {
62
+ const { loadConfig } = await import('./config-loader.mjs');
63
+ const config = await loadConfig();
64
+
65
+ const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
66
+ const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
67
+
68
+ if (RELAY_API_URL && RELAY_API_KEY) {
69
+ await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
70
+ method: 'PATCH',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
74
+ },
75
+ body: JSON.stringify({ is_away: desiredAway })
76
+ });
77
+ }
78
+ } catch (e) {
79
+ // Always log failures to hook log file for debugging (not just in DEBUG mode)
80
+ log(`Failed to update away state: ${e.message}`);
81
+ if (env.DEBUG) {
82
+ console.error(`[UserPromptSubmit] Failed to update away state: ${e.message}`);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
34
88
  // Check if user is running /model command
35
89
  if (prompt && typeof prompt === 'string') {
36
90
  const trimmed = prompt.trim().toLowerCase();
@@ -85,6 +139,63 @@ const readStdin = () => new Promise((resolve, reject) => {
85
139
  }
86
140
  }
87
141
 
142
+ // Log user message to timeline (skip special commands that are already logged differently)
143
+ if (session_id && prompt && typeof prompt === 'string') {
144
+ const trimmed = prompt.trim();
145
+ const lowered = trimmed.toLowerCase();
146
+
147
+ // Skip special commands - they are handled above or have their own logging
148
+ const isSpecialCommand =
149
+ lowered === '/away' || lowered === 'teleportation away' ||
150
+ lowered === '/back' || lowered === 'teleportation back' ||
151
+ lowered === '/model' || lowered.startsWith('/model ');
152
+
153
+ if (!isSpecialCommand && trimmed.length > 0) {
154
+ try {
155
+ const { loadConfig } = await import('./config-loader.mjs');
156
+ const config = await loadConfig();
157
+ const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
158
+ const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
159
+
160
+ if (RELAY_API_URL && RELAY_API_KEY) {
161
+ // Truncate prompt to 2000 chars for storage efficiency
162
+ const MAX_PROMPT_LENGTH = 2000;
163
+ const truncatedPrompt = trimmed.length > MAX_PROMPT_LENGTH
164
+ ? trimmed.substring(0, MAX_PROMPT_LENGTH)
165
+ : trimmed;
166
+
167
+ await fetch(`${RELAY_API_URL}/api/timeline`, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'Content-Type': 'application/json',
171
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
172
+ },
173
+ body: JSON.stringify({
174
+ session_id,
175
+ type: 'user_message',
176
+ data: {
177
+ prompt: truncatedPrompt,
178
+ full_length: trimmed.length,
179
+ truncated: trimmed.length > MAX_PROMPT_LENGTH,
180
+ timestamp: Date.now()
181
+ }
182
+ })
183
+ });
184
+
185
+ if (env.DEBUG) {
186
+ log(`Logged user_message to timeline for session ${session_id}`);
187
+ }
188
+ }
189
+ } catch (e) {
190
+ // Non-critical - log error but don't block prompt submission
191
+ log(`Failed to log user message to timeline: ${e.message}`);
192
+ if (env.DEBUG) {
193
+ console.error(`[UserPromptSubmit] Failed to log user message: ${e.message}`);
194
+ }
195
+ }
196
+ }
197
+ }
198
+
88
199
  // Always suppress output from this hook
89
200
  try { stdout.write(JSON.stringify({ suppressOutput: true })); } catch {}
90
201
  return exit(0);
package/README.md CHANGED
@@ -10,13 +10,29 @@ Teleportation enables developers to approve Claude Code actions remotely from an
10
10
 
11
11
  ## Installation
12
12
 
13
- ### Method 1: Quick Install (curl)
13
+ ### Method 1: npm (Recommended)
14
+
15
+ ```bash
16
+ # Install globally via npm
17
+ npm install -g teleportation-cli
18
+
19
+ # Verify installation
20
+ teleportation --version
21
+ ```
22
+
23
+ **Requirements:**
24
+ - **Bun >=1.3.0** - [Install Bun](https://bun.sh/docs/installation)
25
+ - **Claude Code CLI** - [Install Claude Code](https://claude.ai/code)
26
+
27
+ > **Note:** While the package uses npm for distribution, it requires Bun as the runtime. Make sure to install Bun first.
28
+
29
+ ### Method 2: Quick Install (curl)
14
30
 
15
31
  ```bash
16
32
  curl -fsSL https://raw.githubusercontent.com/dundas/teleportation-private/main/scripts/install.sh | bash
17
33
  ```
18
34
 
19
- ### Method 2: From Source (Recommended)
35
+ ### Method 3: From Source
20
36
 
21
37
  **Requirements:** Bun >=1.3.0
22
38
 
@@ -31,15 +47,10 @@ bun install
31
47
  bun link # Makes 'teleportation' command available globally
32
48
  ```
33
49
 
34
- ### Method 3: GitHub Releases
50
+ ### Method 4: GitHub Releases
35
51
 
36
52
  Download the latest release from [GitHub Releases](https://github.com/dundas/teleportation-private/releases/latest) and extract to `~/.teleportation/`.
37
53
 
38
- ## Requirements
39
-
40
- - **Bun >=1.3.0** (JavaScript runtime)
41
- - **Claude Code CLI** installed
42
-
43
54
  ### Installing Bun
44
55
 
45
56
  ```bash
@@ -77,16 +88,19 @@ bun install
77
88
  ## Quick Start
78
89
 
79
90
  ```bash
80
- # 1. Enable hooks
91
+ # 1. Install via npm
92
+ npm install -g teleportation-cli
93
+
94
+ # 2. Enable hooks
81
95
  teleportation on
82
96
 
83
- # 2. Authenticate with your account
97
+ # 3. Authenticate with your account
84
98
  teleportation login
85
99
 
86
- # 3. Check status
100
+ # 4. Check status
87
101
  teleportation status
88
102
 
89
- # 4. Start Claude Code - approvals will be routed to your phone!
103
+ # 5. Start Claude Code - approvals will be routed to your phone!
90
104
  ```
91
105
 
92
106
  ## Usage
@@ -229,6 +243,16 @@ bun run dev:all
229
243
  - **Multi-tenant isolation**: Your data is isolated from other users
230
244
  - **Privacy-preserving**: Session existence not revealed to unauthorized users
231
245
 
246
+ ## Known Limitations
247
+
248
+ ### Session Registry Race Condition
249
+
250
+ The remote session registry uses file-based storage (`~/.teleportation/remote-sessions/sessions.json`). There is a theoretical race condition if multiple processes attempt to register the same session ID simultaneously. The check for existing sessions and the save operation are not atomic.
251
+
252
+ **Impact**: This is an acceptable limitation for the current single-user CLI use case.
253
+
254
+ **Future mitigation**: Future versions may implement file locking (e.g., using `proper-lockfile` package) or migrate to a database (e.g., SQLite) for multi-user or concurrent scenarios.
255
+
232
256
  ## License
233
257
 
234
258
  MIT
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Claude Code API Key Extractor
3
+ *
4
+ * Extracts the ANTHROPIC_API_KEY from the user's local Claude Code installation.
5
+ * Tries multiple methods to find the key.
6
+ */
7
+
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+ import fs from 'fs/promises';
11
+ import os from 'os';
12
+ import path from 'path';
13
+
14
+ const execAsync = promisify(exec);
15
+
16
+ /**
17
+ * Try to get API key from macOS keychain
18
+ */
19
+ async function getFromKeychain() {
20
+ try {
21
+ // Try common keychain entries for Claude/Anthropic
22
+ const services = [
23
+ 'claude.ai',
24
+ 'anthropic.com',
25
+ 'Claude',
26
+ 'Anthropic',
27
+ 'claude-code',
28
+ ];
29
+
30
+ for (const service of services) {
31
+ try {
32
+ const { stdout } = await execAsync(`security find-generic-password -s "${service}" -w 2>/dev/null`);
33
+ if (stdout && stdout.trim().startsWith('sk-ant-')) {
34
+ return stdout.trim();
35
+ }
36
+ } catch {}
37
+ }
38
+
39
+ // Try finding by account name
40
+ const accounts = ['api-key', 'apikey', 'token'];
41
+ for (const acct of accounts) {
42
+ try {
43
+ const { stdout } = await execAsync(`security find-generic-password -a "${acct}" -w 2>/dev/null`);
44
+ if (stdout && stdout.trim().startsWith('sk-ant-')) {
45
+ return stdout.trim();
46
+ }
47
+ } catch {}
48
+ }
49
+
50
+ return null;
51
+ } catch (error) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Try to get API key from environment
58
+ */
59
+ function getFromEnvironment() {
60
+ return process.env.ANTHROPIC_API_KEY ||
61
+ process.env.CLAUDE_API_KEY ||
62
+ process.env.CLAUDE_CODE_API_KEY ||
63
+ null;
64
+ }
65
+
66
+ /**
67
+ * Try to get API key from Claude Code config files
68
+ */
69
+ async function getFromClaudeConfig() {
70
+ try {
71
+ const homeDir = os.homedir();
72
+ const configPaths = [
73
+ path.join(homeDir, '.claude', 'config.json'),
74
+ path.join(homeDir, '.claude', 'settings.json'),
75
+ path.join(homeDir, '.claude', 'credentials.json'),
76
+ path.join(homeDir, '.config', 'claude', 'config.json'),
77
+ ];
78
+
79
+ for (const configPath of configPaths) {
80
+ try {
81
+ const content = await fs.readFile(configPath, 'utf-8');
82
+ const config = JSON.parse(content);
83
+
84
+ // Check various possible key names
85
+ const key = config.apiKey ||
86
+ config.anthropicApiKey ||
87
+ config.ANTHROPIC_API_KEY ||
88
+ config.api_key ||
89
+ config.token;
90
+
91
+ if (key && key.startsWith('sk-ant-')) {
92
+ return key;
93
+ }
94
+ } catch {}
95
+ }
96
+
97
+ return null;
98
+ } catch (error) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Try to get API key from running Claude Code process environment
105
+ */
106
+ async function getFromRunningProcess() {
107
+ try {
108
+ // Get PID of running claude process
109
+ const { stdout: pids } = await execAsync(`pgrep -f "claude" 2>/dev/null || echo ""`);
110
+
111
+ if (!pids.trim()) {
112
+ return null;
113
+ }
114
+
115
+ // Try to read environment from process
116
+ for (const pid of pids.trim().split('\n')) {
117
+ if (!pid) continue;
118
+
119
+ try {
120
+ // On macOS, we can try to read process environment via ps
121
+ const { stdout } = await execAsync(`ps eww ${pid} 2>/dev/null | grep -o "ANTHROPIC_API_KEY=[^ ]*" | cut -d= -f2`);
122
+ if (stdout && stdout.trim().startsWith('sk-ant-')) {
123
+ return stdout.trim();
124
+ }
125
+ } catch {}
126
+ }
127
+
128
+ return null;
129
+ } catch (error) {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Main function to extract Claude Code API key
136
+ * Tries multiple methods in order of reliability
137
+ *
138
+ * @returns {Promise<string|null>} API key if found, null otherwise
139
+ */
140
+ export async function extractClaudeApiKey() {
141
+ console.log('[claude-key-extractor] Attempting to extract API key from local Claude Code installation...');
142
+
143
+ // Method 1: Check environment first (fastest)
144
+ let apiKey = getFromEnvironment();
145
+ if (apiKey) {
146
+ console.log('[claude-key-extractor] ✅ Found API key in environment variables');
147
+ return apiKey;
148
+ }
149
+
150
+ // Method 2: Check Claude Code config files
151
+ apiKey = await getFromClaudeConfig();
152
+ if (apiKey) {
153
+ console.log('[claude-key-extractor] ✅ Found API key in Claude Code config');
154
+ return apiKey;
155
+ }
156
+
157
+ // Method 3: Check macOS keychain
158
+ apiKey = await getFromKeychain();
159
+ if (apiKey) {
160
+ console.log('[claude-key-extractor] ✅ Found API key in macOS keychain');
161
+ return apiKey;
162
+ }
163
+
164
+ // Method 4: Try to read from running process
165
+ apiKey = await getFromRunningProcess();
166
+ if (apiKey) {
167
+ console.log('[claude-key-extractor] ✅ Found API key in running Claude Code process');
168
+ return apiKey;
169
+ }
170
+
171
+ console.log('[claude-key-extractor] ⚠️ Could not find API key');
172
+ console.log('[claude-key-extractor] Please set ANTHROPIC_API_KEY environment variable');
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Get API key with fallback message
178
+ */
179
+ export async function getClaudeApiKeyOrPrompt() {
180
+ const apiKey = await extractClaudeApiKey();
181
+
182
+ if (!apiKey) {
183
+ console.log('\n' + '='.repeat(60));
184
+ console.log('ANTHROPIC_API_KEY not found!');
185
+ console.log('='.repeat(60));
186
+ console.log('\nPlease set your API key using one of these methods:\n');
187
+ console.log('1. Environment variable:');
188
+ console.log(' export ANTHROPIC_API_KEY="sk-ant-..."');
189
+ console.log('\n2. Get your key from: https://console.anthropic.com/settings/keys');
190
+ console.log('\n3. If you\'re logged into Claude Code, try:');
191
+ console.log(' claude config show # (if supported)');
192
+ console.log('='.repeat(60) + '\n');
193
+ }
194
+
195
+ return apiKey;
196
+ }
@@ -27,8 +27,13 @@ async function getEncryptionKey(keyPath = DEFAULT_KEY_PATH) {
27
27
  // For Windows: use Credential Manager or fallback to file
28
28
 
29
29
  const platform = process.platform;
30
-
31
- if (platform === 'darwin') {
30
+
31
+ // Only attempt keychain when using the default key path. This allows tests and
32
+ // advanced callers to provide a custom key path without depending on keychain
33
+ // availability/behavior.
34
+ const shouldUseKeychain = keyPath === DEFAULT_KEY_PATH;
35
+
36
+ if (shouldUseKeychain && platform === 'darwin') {
32
37
  // macOS - try to use keychain
33
38
  try {
34
39
  const { execSync } = await import('child_process');