@tina_summer/codepocket 2.0.0

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.
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CodePocket — 你的全能 AI 随身控制台
5
+ *
6
+ * npx codepocket
7
+ *
8
+ * Powered by Local Claude Code.
9
+ */
10
+
11
+ const { WebSocket } = require('ws')
12
+ const { spawn, execSync } = require('child_process')
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const os = require('os')
16
+ const readline = require('readline')
17
+ const { startMcpServer, handlePhoneActionResponse: onPhoneActionResponse } = require('../src/mcp-server')
18
+ const { runTestCli } = require('../src/mcp-client')
19
+
20
+ // ── Config ──
21
+
22
+ const CONFIG_DIR = path.join(os.homedir(), '.codepocket')
23
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
24
+ const SESSION_FILE = path.join(CONFIG_DIR, 'session.json')
25
+
26
+ function loadConfig() {
27
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) } catch { return {} }
28
+ }
29
+
30
+ function saveConfig(cfg) {
31
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
32
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2))
33
+ }
34
+
35
+ function injectMcpConfig(port) {
36
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json')
37
+ let settings = {}
38
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) } catch {}
39
+ if (!settings.mcpServers) settings.mcpServers = {}
40
+ if (settings.mcpServers['codepocket-phone']) return // already configured
41
+ settings.mcpServers['codepocket-phone'] = {
42
+ type: 'sse',
43
+ url: `http://127.0.0.1:${port}/sse`,
44
+ }
45
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
46
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
47
+ console.log(` [✓] MCP 配置已写入 ${settingsPath}`)
48
+ }
49
+
50
+ // ── Parse args ──
51
+
52
+ const args = process.argv.slice(2)
53
+
54
+ let hubUrl = process.env.CPKT_HUB_URL || process.env.HAPI_HUB_URL || 'http://127.0.0.1:3006'
55
+ let relayUrl = process.env.RELAY_WS_URL || ''
56
+ let relayKey = process.env.RELAY_KEY || ''
57
+ let noHub = false
58
+
59
+ // Handle 'connect' subcommand (connector mode: hub + relay + MCP)
60
+ const CONNECT_ARGS = ['connect', 'c']
61
+
62
+ // Handle 'test' subcommand (doesn't need hub/relay)
63
+ if (args[0] === 'test') {
64
+ runTestCli(args.slice(1))
65
+ return
66
+ }
67
+
68
+ if (!CONNECT_ARGS.includes(args[0])) {
69
+ // Default or any other subcommand → pass through to hub binary (runs Claude)
70
+ const hubBin = findHubBin()
71
+ if (hubBin) {
72
+ const child = spawn(hubBin, args, {
73
+ stdio: 'inherit',
74
+ env: {
75
+ ...process.env,
76
+ HAPI_HOME: path.join(os.homedir(), '.codepocket', 'hub'),
77
+ },
78
+ })
79
+ child.on('exit', (code) => process.exit(code || 0))
80
+ } else {
81
+ console.error('[!] 找不到 Hub 二进制')
82
+ process.exit(1)
83
+ }
84
+ return
85
+ }
86
+
87
+ const connectArgs = args[0] === 'connect' || args[0] === 'c' ? args.slice(1) : args
88
+
89
+ for (let i = 0; i < connectArgs.length; i++) {
90
+ const arg = connectArgs[i]
91
+ if (arg.startsWith('--hub=')) hubUrl = arg.slice(6).replace(/\/+$/, '')
92
+ if (arg === '--hub' && connectArgs[i + 1]) { hubUrl = connectArgs[++i].replace(/\/+$/, '') }
93
+ if (arg.startsWith('--relay=')) relayUrl = arg.slice(8)
94
+ if (arg === '--relay' && connectArgs[i + 1]) relayUrl = connectArgs[++i]
95
+ if (arg.startsWith('--key=')) relayKey = arg.slice(6)
96
+ if (arg === '--key' && connectArgs[i + 1]) relayKey = connectArgs[++i]
97
+ if (arg === '--no-hub') noHub = true
98
+ }
99
+
100
+ const saved = loadConfig()
101
+ if (relayKey) {
102
+ saveConfig({ ...saved, relayKey, relayUrl: relayUrl || saved.relayUrl || 'wss://bkstory.cn/ws' })
103
+ } else {
104
+ relayKey = saved.relayKey || ''
105
+ }
106
+ if (!relayUrl) relayUrl = saved.relayUrl || 'wss://bkstory.cn/ws'
107
+
108
+ let jwt = ''
109
+ let relayWs = null
110
+
111
+ // ── Step 1: Start hub if needed ──
112
+
113
+ function startHub() {
114
+ return new Promise((resolve, reject) => {
115
+ if (noHub) { resolve(); return }
116
+
117
+ // Check if hub already running
118
+ try {
119
+ const result = execSync(`curl -s -o /dev/null -w "%{http_code}" ${hubUrl}/ 2>/dev/null`).toString().trim()
120
+ if (result === '200') {
121
+ console.log(' [✓] Hub 已在运行')
122
+ resolve()
123
+ return
124
+ }
125
+ } catch {}
126
+
127
+ console.log(' 启动 Hub...')
128
+ const hubBin = findHubBin()
129
+ if (!hubBin) {
130
+ console.error(' [!] 找不到 Hub 二进制')
131
+ reject(new Error('Hub binary not found'))
132
+ return
133
+ }
134
+
135
+ const hub = spawn(hubBin, ['hub', 'start'], {
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ detached: false,
138
+ env: {
139
+ ...process.env,
140
+ HAPI_HOME: path.join(os.homedir(), '.codepocket', 'hub'),
141
+ },
142
+ })
143
+
144
+ let started = false
145
+ hub.stdout.on('data', (data) => {
146
+ const text = data.toString()
147
+ if (!started) process.stdout.write(' ')
148
+ process.stdout.write(text)
149
+ })
150
+ hub.stderr.on('data', (data) => {
151
+ const text = data.toString()
152
+ if (text.includes('listening') || text.includes('ready') || text.includes('started')) {
153
+ if (!started) { started = true; resolve() }
154
+ }
155
+ })
156
+
157
+ // Wait up to 10s for hub
158
+ setTimeout(() => {
159
+ if (!started) {
160
+ // Try connecting
161
+ try {
162
+ execSync(`curl -s -o /dev/null -w "%{http_code}" ${hubUrl}/ 2>/dev/null`)
163
+ started = true
164
+ resolve()
165
+ } catch {
166
+ console.log(' [i] Hub 启动中,继续连接...')
167
+ resolve()
168
+ }
169
+ }
170
+ }, 8000)
171
+
172
+ hub.on('exit', (code) => {
173
+ if (!started && code !== 0) {
174
+ reject(new Error(`Hub exited with code ${code}`))
175
+ }
176
+ })
177
+ })
178
+ }
179
+
180
+ function findHubBin() {
181
+ // 1. Bundled binary in vendor/
182
+ const bundledBin = path.join(__dirname, '..', 'vendor', process.platform === 'win32' ? 'cpkt-hub.exe' : 'cpkt-hub')
183
+ if (fs.existsSync(bundledBin)) return bundledBin
184
+
185
+ // 2. Global install
186
+ try { const r = execSync('which cpkt-hub 2>/dev/null').toString().trim(); if (r) return r } catch {}
187
+
188
+ return null
189
+ }
190
+
191
+ // ── Step 2: Authenticate ──
192
+
193
+ async function findHubToken() {
194
+ const hubHome = process.env.HAPI_HOME || path.join(os.homedir(), '.codepocket', 'hub')
195
+ const settingsPath = path.join(hubHome, 'settings.json')
196
+ try {
197
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
198
+ if (settings.cliApiToken) return settings.cliApiToken
199
+ } catch {}
200
+ if (process.env.CLI_API_TOKEN) return process.env.CLI_API_TOKEN
201
+ throw new Error('找不到 Hub token')
202
+ }
203
+
204
+ async function authenticateHub(token) {
205
+ const res = await fetch(`${hubUrl}/api/auth`, {
206
+ method: 'POST',
207
+ headers: { 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({ accessToken: token }),
209
+ })
210
+ if (!res.ok) throw new Error(`Hub auth failed: ${res.status}`)
211
+ return (await res.json()).token
212
+ }
213
+
214
+ // ── Step 3: SSE stream ──
215
+
216
+ async function startSSE() {
217
+ const url = `${hubUrl}/api/events?token=${encodeURIComponent(jwt)}&all=true`
218
+ try {
219
+ const res = await fetch(url, { headers: { Accept: 'text/event-stream' } })
220
+ if (!res.ok || !res.body) { setTimeout(startSSE, 5000); return }
221
+ console.log(' [✓] SSE 已连接,监听事件...')
222
+ const reader = res.body.getReader()
223
+ const decoder = new TextDecoder()
224
+ let buffer = ''
225
+ async function pump() {
226
+ while (true) {
227
+ const { done, value } = await reader.read()
228
+ if (done) break
229
+ buffer += decoder.decode(value, { stream: true })
230
+ const lines = buffer.split('\n')
231
+ buffer = lines.pop() || ''
232
+ for (const line of lines) {
233
+ if (line.startsWith('data:')) {
234
+ try {
235
+ const event = JSON.parse(line.slice(5).trim())
236
+ console.log(`[SSE] ${event.type || 'unknown'}`)
237
+ sendToRelay({ type: 'sse-event', event })
238
+ } catch {}
239
+ }
240
+ }
241
+ }
242
+ setTimeout(startSSE, 5000)
243
+ }
244
+ pump().catch(() => { setTimeout(startSSE, 5000) })
245
+ } catch { setTimeout(startSSE, 5000) }
246
+ }
247
+
248
+ // ── Step 4: Relay connection ──
249
+
250
+ let relayPingInterval = null
251
+
252
+ function connectRelay() {
253
+ let savedSession = null
254
+ try { savedSession = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8')) } catch {}
255
+
256
+ // Clean up old connection
257
+ if (relayPingInterval) { clearInterval(relayPingInterval); relayPingInterval = null }
258
+ if (relayWs) {
259
+ try { relayWs.removeAllListeners() } catch {}
260
+ try { relayWs.close() } catch {}
261
+ relayWs = null
262
+ }
263
+
264
+ relayWs = new WebSocket(relayUrl)
265
+
266
+ relayWs.on('open', () => {
267
+ relayWs.send(JSON.stringify({
268
+ type: 'register',
269
+ key: relayKey,
270
+ sessionToken: savedSession?.sessionToken || undefined,
271
+ }))
272
+ // Heartbeat every 30s to detect stale connections
273
+ relayPingInterval = setInterval(() => {
274
+ if (relayWs?.readyState === WebSocket.OPEN) {
275
+ relayWs.ping()
276
+ }
277
+ }, 30000)
278
+ })
279
+
280
+ relayWs.on('message', (raw) => {
281
+ let msg
282
+ try { msg = JSON.parse(raw.toString()) } catch { return }
283
+ switch (msg.type) {
284
+ case 'registered': {
285
+ try {
286
+ fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true })
287
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({ sessionToken: msg.sessionToken, hubUrl, relayUrl }))
288
+ } catch {}
289
+ if (msg.reconnected) {
290
+ console.log('\n [✓] 已重新连接\n')
291
+ } else {
292
+ console.log(' [✓] 已连接中继,等待小程序...\n')
293
+ }
294
+ break
295
+ }
296
+ case 'client-connected': {
297
+ console.log(' [✓] 小程序已连接')
298
+ break
299
+ }
300
+ case 'rest-request': {
301
+ forwardToHub(msg.requestId, msg.method, msg.path, msg.body)
302
+ break
303
+ }
304
+ case 'auth-request': {
305
+ handleAuthRequest(msg.requestId, msg.accessToken)
306
+ break
307
+ }
308
+ case 'register-error': {
309
+ console.error(` [!] ${msg.error}`)
310
+ break
311
+ }
312
+ case 'phone-action-response': {
313
+ onPhoneActionResponse(msg)
314
+ break
315
+ }
316
+ }
317
+ })
318
+
319
+ relayWs.on('close', () => {
320
+ if (relayPingInterval) { clearInterval(relayPingInterval); relayPingInterval = null }
321
+ relayWs = null
322
+ console.log('[relay] 连接断开,3秒后重连...')
323
+ setTimeout(connectRelay, 3000)
324
+ })
325
+ relayWs.on('error', (err) => {
326
+ console.error('[relay] 连接错误:', err.message)
327
+ relayWs?.close()
328
+ })
329
+ }
330
+
331
+ function sendToRelay(msg) {
332
+ if (relayWs?.readyState === WebSocket.OPEN) relayWs.send(JSON.stringify(msg))
333
+ }
334
+
335
+ async function doForwardToHub(requestId, method, p, body) {
336
+ const init = {
337
+ method,
338
+ headers: { 'Content-Type': 'application/json', ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}) },
339
+ }
340
+ if (body && method !== 'GET') init.body = JSON.stringify(body)
341
+ return fetch(`${hubUrl}${p}`, init)
342
+ }
343
+
344
+ async function forwardToHub(requestId, method, p, body) {
345
+ try {
346
+ let res = await doForwardToHub(requestId, method, p, body)
347
+ // JWT expired — re-authenticate and retry
348
+ if (res.status === 401) {
349
+ console.log('[REST] JWT expired, re-authenticating...')
350
+ try {
351
+ const token = await findHubToken()
352
+ jwt = await authenticateHub(token)
353
+ res = await doForwardToHub(requestId, method, p, body)
354
+ } catch (authErr) {
355
+ console.error('[REST] Re-auth failed:', authErr.message)
356
+ }
357
+ }
358
+ const data = await res.json().catch(() => ({}))
359
+ sendToRelay({ type: 'rest-response', requestId, status: res.status, data })
360
+ } catch (err) {
361
+ console.error(`[REST] ${method} ${p} failed:`, err.message, err.cause?.message || '')
362
+ sendToRelay({ type: 'rest-response', requestId, status: 502, data: { error: err.message } })
363
+ }
364
+ }
365
+
366
+ async function handleAuthRequest(requestId, accessToken) {
367
+ try {
368
+ const res = await fetch(`${hubUrl}/api/auth`, {
369
+ method: 'POST',
370
+ headers: { 'Content-Type': 'application/json' },
371
+ body: JSON.stringify({ accessToken }),
372
+ })
373
+ sendToRelay({ type: 'auth-response', requestId, ok: res.ok, data: await res.json() })
374
+ } catch (err) {
375
+ sendToRelay({ type: 'auth-response', requestId, ok: false, data: { error: err.message } })
376
+ }
377
+ }
378
+
379
+ // ── Main ──
380
+
381
+ async function main() {
382
+ console.log()
383
+ console.log(' ╔══════════════════════════════════════╗')
384
+ console.log(' ║ CodePocket ║')
385
+ console.log(' ╚══════════════════════════════════════╝')
386
+ console.log()
387
+
388
+ if (!relayKey) {
389
+ console.log(' [!] 未找到配置,请输入 Key')
390
+ console.log(' 在 CodePocket 小程序中获取 Key →')
391
+ relayKey = await new Promise((resolve) => {
392
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
393
+ rl.question(' Key: ', (answer) => {
394
+ rl.close()
395
+ resolve(answer.trim())
396
+ })
397
+ })
398
+ if (!relayKey) {
399
+ console.error('')
400
+ console.error(' [!] Key 不能为空')
401
+ process.exit(1)
402
+ }
403
+ saveConfig({ ...loadConfig(), relayKey, relayUrl })
404
+ console.log('')
405
+ console.log(' [✓] 配置已保存')
406
+ console.log('')
407
+ }
408
+
409
+ console.log(` Relay: ${relayUrl}`)
410
+ console.log(` Key: ${relayKey.slice(0, 8)}...`)
411
+ console.log()
412
+
413
+ // Step 1: Start hub
414
+ console.log('[1/3] 检查 Hub...')
415
+ try {
416
+ await startHub()
417
+ } catch (e) {
418
+ console.error(` [!] ${e.message}`)
419
+ console.error(' 你可以用 --no-hub 跳过自动启动')
420
+ process.exit(1)
421
+ }
422
+
423
+ // Step 1.5: Start MCP server
424
+ console.log('[1.5/3] 启动 MCP server...')
425
+ try {
426
+ const mcpPort = await startMcpServer(sendToRelay)
427
+ injectMcpConfig(mcpPort)
428
+ } catch (e) {
429
+ console.error(` [!] MCP server 启动失败: ${e.message}`)
430
+ console.error(' 手机工具功能将不可用')
431
+ }
432
+
433
+ // Step 2: Authenticate
434
+ console.log('[2/3] 认证...')
435
+ try {
436
+ const token = await findHubToken()
437
+ jwt = await authenticateHub(token)
438
+ console.log(' [✓] 认证成功')
439
+ } catch (e) {
440
+ console.error(` [!] ${e.message}`)
441
+ process.exit(1)
442
+ }
443
+
444
+ // Step 3: Connect relay
445
+ console.log('[3/3] 连接中继...')
446
+ startSSE()
447
+ connectRelay()
448
+ }
449
+
450
+ main().catch((err) => {
451
+ console.error('[!] 启动失败:', err.message)
452
+ process.exit(1)
453
+ })