@simonyea/holysheep-cli 1.7.3 → 1.7.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
5
5
  "keywords": [
6
6
  "openai-china",
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+
3
+ const { spawn } = require('child_process')
4
+ const {
5
+ BASE_URL_ANTHROPIC,
6
+ getApiKey,
7
+ } = require('../utils/config')
8
+ const {
9
+ closeSession,
10
+ getLocalProxyUrl,
11
+ startProcessProxy,
12
+ readConfig,
13
+ } = require('../tools/claude-process-proxy')
14
+
15
+ async function runClaude(args = []) {
16
+ const config = readConfig()
17
+ const apiKey = config.apiKey || getApiKey()
18
+ if (!apiKey) {
19
+ throw new Error('Missing API Key. Run hs setup first.')
20
+ }
21
+
22
+ const { server, port, sessionId } = startProcessProxy({})
23
+ const proxyUrl = getLocalProxyUrl(port)
24
+
25
+ const env = {
26
+ ...process.env,
27
+ ANTHROPIC_AUTH_TOKEN: apiKey,
28
+ ANTHROPIC_BASE_URL: BASE_URL_ANTHROPIC,
29
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
30
+ HOLYSHEEP_CLAUDE_PROCESS_PROXY: '1',
31
+ HOLYSHEEP_CLAUDE_SESSION_ID: sessionId,
32
+ HTTP_PROXY: proxyUrl,
33
+ HTTPS_PROXY: proxyUrl,
34
+ ALL_PROXY: proxyUrl,
35
+ NO_PROXY: '127.0.0.1,localhost',
36
+ }
37
+
38
+ const child = spawn('claude', args, {
39
+ stdio: 'inherit',
40
+ env,
41
+ shell: process.platform === 'win32',
42
+ })
43
+
44
+ const cleanup = async () => {
45
+ try {
46
+ server.close()
47
+ } catch {}
48
+ await closeSession(undefined, sessionId)
49
+ }
50
+
51
+ process.on('SIGINT', () => child.kill('SIGINT'))
52
+ process.on('SIGTERM', () => child.kill('SIGTERM'))
53
+
54
+ return await new Promise((resolve, reject) => {
55
+ child.once('error', async (error) => {
56
+ await cleanup()
57
+ reject(error)
58
+ })
59
+ child.once('exit', async (code, signal) => {
60
+ await cleanup()
61
+ if (signal) process.kill(process.pid, signal)
62
+ resolve(code || 0)
63
+ })
64
+ })
65
+ }
66
+
67
+ module.exports = runClaude
@@ -3,7 +3,7 @@
3
3
  */
4
4
  const chalk = require('chalk')
5
5
  const { execSync } = require('child_process')
6
- const { getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI } = require('../utils/config')
6
+ const { getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY } = require('../utils/config')
7
7
  const TOOLS = require('../tools')
8
8
 
9
9
  async function doctor() {
@@ -59,7 +59,7 @@ async function doctor() {
59
59
  printOpenClawDetails(tool, installState, nodeMajor)
60
60
  }
61
61
  if (tool.id === 'claude-code' && installed && configured) {
62
- printClaudeBridgeDetails(tool)
62
+ printClaudeProcessProxyDetails(tool)
63
63
  }
64
64
  }
65
65
 
@@ -159,34 +159,13 @@ function printOpenClawDetails(tool, installState, nodeMajor) {
159
159
  })
160
160
  }
161
161
 
162
- function printClaudeBridgeDetails(tool) {
163
- const port = typeof tool.getBridgePort === 'function' ? tool.getBridgePort() : null
164
- if (!port) return
165
- const bridgeConfig = typeof tool.getBridgeConfig === 'function' ? tool.getBridgeConfig() : {}
166
-
167
- let healthy = false
168
- try {
169
- execSync(
170
- process.platform === 'win32'
171
- ? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/health -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
172
- : `curl -sf http://127.0.0.1:${port}/health -o /dev/null --max-time 1`,
173
- { stdio: 'ignore', timeout: 3000 }
174
- )
175
- healthy = true
176
- } catch {}
177
-
178
- const icon = healthy ? chalk.green('↳') : chalk.yellow('↳')
179
- const text = healthy
180
- ? chalk.green(`Claude Bridge 已运行:127.0.0.1:${port}`)
181
- : chalk.yellow(`Claude Bridge 未运行:127.0.0.1:${port}(可运行 hs claude-bridge)`)
182
- console.log(` ${icon} ${text}`)
183
-
184
- const relayUrl = bridgeConfig.relayUrl || bridgeConfig.controlPlaneUrl || ''
185
- if (relayUrl) {
186
- console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude Relay: ${relayUrl}`)}`)
187
- } else {
188
- console.log(` ${chalk.gray('↳')} ${chalk.gray('Claude Relay: 未配置,当前仍直连 Anthropic 兼容入口')}`)
189
- }
162
+ function printClaudeProcessProxyDetails(tool) {
163
+ const proxyConfig = typeof tool.getProcessProxyConfig === 'function' ? tool.getProcessProxyConfig() : {}
164
+ const relayUrl = proxyConfig.controlPlaneUrl || proxyConfig.relayUrl || BASE_URL_CLAUDE_RELAY
165
+ const mode = proxyConfig.proxyMode || 'unknown'
166
+ console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude 启动方式:hs claude`)}`)
167
+ console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude 代理模式:${mode}`)}`)
168
+ console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude Relay: ${relayUrl || '未配置'}`)}`)
190
169
  }
191
170
 
192
171
  function maskKey(key) {
@@ -27,19 +27,19 @@ const AUTO_INSTALL = {
27
27
  function getWindowsImmediateLaunchCmd(tool) {
28
28
  if (process.platform !== 'win32' || !tool?.launchCmd) return null
29
29
 
30
- const cmdBin = tool.launchCmd.split(' ')[0]
30
+ const [cmdBin, ...cmdArgs] = tool.launchCmd.split(' ')
31
31
 
32
32
  try {
33
33
  const npmPrefix = execSync('npm prefix -g', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
34
34
  if (npmPrefix) {
35
35
  const directCmd = `${npmPrefix}\\${cmdBin}.cmd`
36
- return `& "${directCmd}"`
36
+ return `& "${directCmd}"${cmdArgs.length ? ` ${cmdArgs.join(' ')}` : ''}`
37
37
  }
38
38
  } catch {}
39
39
 
40
40
  const appData = process.env.APPDATA
41
41
  if (appData) {
42
- return `& "${appData}\\npm\\${cmdBin}.cmd"`
42
+ return `& "${appData}\\npm\\${cmdBin}.cmd"${cmdArgs.length ? ` ${cmdArgs.join(' ')}` : ''}`
43
43
  }
44
44
 
45
45
  return null
package/src/index.js CHANGED
@@ -50,6 +50,7 @@ function printBanner() {
50
50
 
51
51
  program
52
52
  .name('hs')
53
+ .enablePositionalOptions()
53
54
  .description('一键配置所有 AI 编程工具接入 HolySheep API')
54
55
  .version(pkg.version, '-v, --version')
55
56
  .addHelpText('before', `
@@ -167,18 +168,16 @@ program
167
168
  })
168
169
  })
169
170
 
170
- // ── claude-bridge ────────────────────────────────────────────────────────────
171
+ // ── claude ──────────────────────────────────────────────────────────────────
171
172
  program
172
- .command('claude-bridge')
173
- .description('启动 HolySheep 的 Claude Code 本地桥接服务')
174
- .option('--port <port>', '指定桥接服务端口')
175
- .option('--host <host>', '指定桥接服务监听地址')
176
- .action((opts) => {
177
- const { startBridge } = require('./tools/claude-bridge')
178
- startBridge({
179
- port: opts.port ? Number(opts.port) : null,
180
- host: opts.host || '127.0.0.1',
181
- })
173
+ .command('claude [args...]')
174
+ .allowUnknownOption(true)
175
+ .passThroughOptions()
176
+ .description('通过 HolySheep 整进程代理启动 Claude Code')
177
+ .action(async (args = []) => {
178
+ const runClaude = require('./commands/claude')
179
+ const code = await runClaude(args)
180
+ process.exit(code)
182
181
  })
183
182
 
184
183
  // 默认:无命令时显示帮助 + 提示 setup
@@ -16,14 +16,7 @@ const {
16
16
  BASE_URL_ANTHROPIC,
17
17
  BASE_URL_CLAUDE_RELAY,
18
18
  } = require('../utils/config')
19
- const {
20
- DEFAULT_BRIDGE_PORT,
21
- getBridgeBaseUrl,
22
- getConfiguredBridgePort,
23
- readBridgeConfig,
24
- spawnBridge,
25
- writeBridgeConfig,
26
- } = require('./claude-bridge')
19
+ const { readConfig, writeConfig } = require('./claude-process-proxy')
27
20
 
28
21
  const SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.json')
29
22
 
@@ -51,21 +44,21 @@ module.exports = {
51
44
  },
52
45
  isConfigured() {
53
46
  const s = readSettings()
54
- const bridge = readBridgeConfig()
55
- const bridgePort = getConfiguredBridgePort(bridge)
47
+ const bridge = readConfig()
56
48
  return Boolean(
57
49
  s.env?.ANTHROPIC_AUTH_TOKEN &&
58
- s.env?.ANTHROPIC_BASE_URL === getBridgeBaseUrl(bridgePort) &&
50
+ s.env?.ANTHROPIC_BASE_URL === BASE_URL_ANTHROPIC &&
59
51
  bridge.apiKey &&
60
- bridge.bridgeSecret
52
+ bridge.bridgeSecret &&
53
+ bridge.controlPlaneUrl
61
54
  )
62
55
  },
63
56
  configure(apiKey, baseUrl) {
64
- const bridge = readBridgeConfig()
65
- const bridgePort = getConfiguredBridgePort(bridge)
57
+ const bridge = readConfig()
66
58
  const relayUrl = bridge.relayUrl || BASE_URL_CLAUDE_RELAY || ''
67
- writeBridgeConfig({
68
- port: bridgePort || DEFAULT_BRIDGE_PORT,
59
+ writeConfig({
60
+ port: bridge.port || 14555,
61
+ processProxyPort: bridge.processProxyPort || 14556,
69
62
  apiKey,
70
63
  baseUrlAnthropic: baseUrl || BASE_URL_ANTHROPIC,
71
64
  controlPlaneUrl: relayUrl || baseUrl || BASE_URL_ANTHROPIC,
@@ -76,20 +69,18 @@ module.exports = {
76
69
  nodeId: bridge.nodeId || '',
77
70
  sessionMode: bridge.sessionMode || 'pinned-node',
78
71
  installSource: 'holysheep-cli',
72
+ proxyMode: 'claude-process',
79
73
  })
80
74
 
81
- if (!spawnBridge(bridgePort)) {
82
- throw new Error('HolySheep Claude Bridge 启动失败')
83
- }
84
-
85
75
  const settings = readSettings()
86
76
  if (!settings.env) settings.env = {}
87
77
  settings.env.ANTHROPIC_AUTH_TOKEN = apiKey
88
- settings.env.ANTHROPIC_BASE_URL = getBridgeBaseUrl(bridgePort)
78
+ settings.env.ANTHROPIC_BASE_URL = BASE_URL_ANTHROPIC
89
79
  settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
90
- settings.env.HOLYSHEEP_CLAUDE_BRIDGE = '1'
80
+ settings.env.HOLYSHEEP_CLAUDE_LAUNCHER = 'hs'
91
81
  // 清理旧的同义字段
92
82
  delete settings.env.ANTHROPIC_API_KEY
83
+ delete settings.env.HOLYSHEEP_CLAUDE_BRIDGE
93
84
  writeSettings(settings)
94
85
  return { file: SETTINGS_FILE, hot: true }
95
86
  },
@@ -101,20 +92,18 @@ module.exports = {
101
92
  delete settings.env.ANTHROPIC_BASE_URL
102
93
  delete settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
103
94
  delete settings.env.HOLYSHEEP_CLAUDE_BRIDGE
95
+ delete settings.env.HOLYSHEEP_CLAUDE_LAUNCHER
104
96
  }
105
97
  writeSettings(settings)
106
98
  },
107
99
  getConfigPath() { return SETTINGS_FILE },
108
- hint: '通过 HolySheep 本地 Bridge 接入,支持热切换,无需重启终端',
109
- launchCmd: 'claude',
100
+ hint: '通过 hs claude 以整进程代理方式启动 Claude Code',
101
+ launchCmd: 'hs claude',
110
102
  installCmd: process.platform === 'win32'
111
103
  ? 'irm https://claude.ai/install.ps1 | iex'
112
104
  : 'curl -fsSL https://claude.ai/install.sh | bash',
113
105
  docsUrl: 'https://docs.anthropic.com/claude-code',
114
- getBridgePort() {
115
- return getConfiguredBridgePort()
116
- },
117
- getBridgeConfig() {
118
- return readBridgeConfig()
106
+ getProcessProxyConfig() {
107
+ return readConfig()
119
108
  },
120
109
  }
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const fs = require('fs')
5
+ const http = require('http')
6
+ const https = require('https')
7
+ const net = require('net')
8
+ const path = require('path')
9
+ const os = require('os')
10
+ const crypto = require('crypto')
11
+ const { URL } = require('url')
12
+ const fetch = global.fetch || require('node-fetch')
13
+
14
+ const HOLYSHEEP_DIR = path.join(os.homedir(), '.holysheep')
15
+ const CONFIG_PATH = path.join(HOLYSHEEP_DIR, 'claude-proxy.json')
16
+ const DEFAULT_PROXY_PORT = 14556
17
+
18
+ function ensureDir() {
19
+ if (!fs.existsSync(HOLYSHEEP_DIR)) fs.mkdirSync(HOLYSHEEP_DIR, { recursive: true })
20
+ }
21
+
22
+ function readConfig() {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
25
+ } catch {
26
+ return {}
27
+ }
28
+ }
29
+
30
+ function writeConfig(data) {
31
+ ensureDir()
32
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8')
33
+ }
34
+
35
+ function getProcessProxyPort(config = readConfig()) {
36
+ const value = Number(config.processProxyPort)
37
+ return Number.isInteger(value) && value > 0 ? value : DEFAULT_PROXY_PORT
38
+ }
39
+
40
+ function getLocalProxyUrl(port = getProcessProxyPort()) {
41
+ return `http://127.0.0.1:${port}`
42
+ }
43
+
44
+ function getControlPlaneUrl(config) {
45
+ return String(config.controlPlaneUrl || config.relayUrl || '').replace(/\/+$/, '')
46
+ }
47
+
48
+ const leaseCache = new Map()
49
+
50
+ async function readJsonResponse(response) {
51
+ const chunks = []
52
+ for await (const chunk of response) chunks.push(Buffer.from(chunk))
53
+ if (!chunks.length) return null
54
+ try {
55
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ async function requestSessionLease(config, sessionId) {
62
+ const cached = leaseCache.get(sessionId)
63
+ if (cached?.expiresAt && new Date(cached.expiresAt).getTime() - Date.now() > 30_000) {
64
+ return cached
65
+ }
66
+
67
+ const controlPlaneUrl = getControlPlaneUrl(config)
68
+ if (!controlPlaneUrl) throw new Error('Claude relay control plane is not configured')
69
+
70
+ const response = await fetch(`${controlPlaneUrl}/session/open`, {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'application/json' },
73
+ body: JSON.stringify({
74
+ sessionId,
75
+ bridgeId: config.bridgeId || 'local-bridge',
76
+ deviceId: config.deviceId || '',
77
+ installSource: config.installSource || 'holysheep-cli',
78
+ proxyMode: 'claude-process',
79
+ }),
80
+ })
81
+
82
+ const payload = await response.json().catch(() => null)
83
+ if (!response.ok || !payload?.success || !payload?.data?.ticket) {
84
+ throw new Error(payload?.error?.message || `Failed to open Claude session (HTTP ${response.status})`)
85
+ }
86
+ leaseCache.set(sessionId, payload.data)
87
+ return payload.data
88
+ }
89
+
90
+ function buildAuthHeaders(config, lease) {
91
+ return {
92
+ 'x-hs-bridge-id': config.bridgeId || 'local-bridge',
93
+ 'x-hs-device-id': config.deviceId || '',
94
+ 'x-hs-install-source': config.installSource || 'holysheep-cli',
95
+ 'x-hs-session-id': lease.sessionId,
96
+ 'x-hs-bridge-ticket': lease.ticket,
97
+ 'x-hs-node-id': lease.nodeId || '',
98
+ }
99
+ }
100
+
101
+ function deriveNodeProxyUrl(lease) {
102
+ if (lease.nodeProxyUrl) return String(lease.nodeProxyUrl)
103
+ if (!lease.nodeBaseUrl) throw new Error('Lease does not include node proxy information')
104
+ const upstream = new URL(String(lease.nodeBaseUrl))
105
+ const proxyPort = upstream.port === '3101' ? '3129' : upstream.port
106
+ upstream.port = proxyPort || '3129'
107
+ upstream.pathname = ''
108
+ upstream.search = ''
109
+ upstream.hash = ''
110
+ return upstream.toString().replace(/\/+$/, '')
111
+ }
112
+
113
+ function createConnectTunnel(proxyUrl, target, headers) {
114
+ return new Promise((resolve, reject) => {
115
+ const upstream = new URL(proxyUrl)
116
+ const request = http.request({
117
+ host: upstream.hostname,
118
+ port: Number(upstream.port || 80),
119
+ method: 'CONNECT',
120
+ path: target,
121
+ headers,
122
+ })
123
+
124
+ request.once('connect', (response, socket, head) => {
125
+ if (response.statusCode !== 200) {
126
+ socket.destroy()
127
+ return reject(new Error(`Upstream proxy CONNECT failed (HTTP ${response.statusCode})`))
128
+ }
129
+ if (head?.length) socket.unshift(head)
130
+ resolve(socket)
131
+ })
132
+ request.once('error', reject)
133
+ request.end()
134
+ })
135
+ }
136
+
137
+ function pipeWithCleanup(a, b) {
138
+ a.pipe(b)
139
+ b.pipe(a)
140
+ const close = () => {
141
+ a.destroy()
142
+ b.destroy()
143
+ }
144
+ a.once('error', close)
145
+ b.once('error', close)
146
+ a.once('close', close)
147
+ b.once('close', close)
148
+ }
149
+
150
+ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH }) {
151
+ const server = http.createServer(async (clientReq, clientRes) => {
152
+ try {
153
+ const config = readConfig(configPath)
154
+ const lease = await requestSessionLease(config, sessionId)
155
+ const nodeProxyUrl = deriveNodeProxyUrl(lease)
156
+ const headers = {
157
+ ...buildAuthHeaders(config, lease),
158
+ host: new URL(clientReq.url).host,
159
+ }
160
+
161
+ const upstream = new URL(nodeProxyUrl)
162
+ const forwardReq = http.request({
163
+ host: upstream.hostname,
164
+ port: Number(upstream.port || 80),
165
+ method: clientReq.method,
166
+ path: clientReq.url,
167
+ headers: {
168
+ ...clientReq.headers,
169
+ ...headers,
170
+ connection: 'close',
171
+ },
172
+ }, (forwardRes) => {
173
+ clientRes.writeHead(forwardRes.statusCode || 502, forwardRes.headers)
174
+ forwardRes.pipe(clientRes)
175
+ })
176
+
177
+ forwardReq.once('error', (error) => {
178
+ clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
179
+ clientRes.end(error.message || 'Proxy error')
180
+ })
181
+
182
+ clientReq.pipe(forwardReq)
183
+ } catch (error) {
184
+ clientRes.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
185
+ clientRes.end(error.message || 'Proxy error')
186
+ }
187
+ })
188
+
189
+ server.on('connect', async (req, clientSocket, head) => {
190
+ try {
191
+ const config = readConfig(configPath)
192
+ const lease = await requestSessionLease(config, sessionId)
193
+ const target = String(req.url || '').trim()
194
+ const [host, rawPort] = target.split(':')
195
+ const port = Number(rawPort || 443)
196
+ if (!host || !Number.isInteger(port) || ![80, 443].includes(port)) {
197
+ clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
198
+ return clientSocket.destroy()
199
+ }
200
+
201
+ const upstreamSocket = await createConnectTunnel(
202
+ deriveNodeProxyUrl(lease),
203
+ target,
204
+ buildAuthHeaders(config, lease)
205
+ )
206
+
207
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
208
+ if (head?.length) upstreamSocket.write(head)
209
+ pipeWithCleanup(clientSocket, upstreamSocket)
210
+ } catch (error) {
211
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain; charset=utf-8\r\n\r\n${error.message}`)
212
+ clientSocket.destroy()
213
+ }
214
+ })
215
+
216
+ return server
217
+ }
218
+
219
+ function startProcessProxy({ port = null, sessionId = null, configPath = CONFIG_PATH } = {}) {
220
+ const config = readConfig(configPath)
221
+ const listenPort = port || getProcessProxyPort(config)
222
+ const effectiveSessionId = sessionId || crypto.randomUUID()
223
+ const server = createProcessProxyServer({ sessionId: effectiveSessionId, configPath })
224
+ server.listen(listenPort, '127.0.0.1')
225
+ return { server, port: listenPort, sessionId: effectiveSessionId }
226
+ }
227
+
228
+ async function closeSession(configPath, sessionId) {
229
+ if (!sessionId) return
230
+ const config = readConfig(configPath)
231
+ const controlPlaneUrl = getControlPlaneUrl(config)
232
+ if (!controlPlaneUrl) return
233
+ try {
234
+ await fetch(`${controlPlaneUrl}/session/close`, {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ sessionId }),
238
+ })
239
+ } catch {}
240
+ }
241
+
242
+ module.exports = {
243
+ CONFIG_PATH,
244
+ DEFAULT_PROXY_PORT,
245
+ closeSession,
246
+ getLocalProxyUrl,
247
+ getProcessProxyPort,
248
+ getControlPlaneUrl,
249
+ readConfig,
250
+ requestSessionLease,
251
+ startProcessProxy,
252
+ writeConfig,
253
+ }
@@ -1,331 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict'
3
-
4
- const fs = require('fs')
5
- const http = require('http')
6
- const path = require('path')
7
- const os = require('os')
8
- const crypto = require('crypto')
9
- const { spawn, execSync } = require('child_process')
10
- const fetch = global.fetch || require('node-fetch')
11
-
12
- const HOLYSHEEP_DIR = path.join(os.homedir(), '.holysheep')
13
- const CLAUDE_BRIDGE_CONFIG_FILE = path.join(HOLYSHEEP_DIR, 'claude-bridge.json')
14
- const DEFAULT_BRIDGE_PORT = 14555
15
- const isWin = process.platform === 'win32'
16
- const leaseCache = new Map()
17
-
18
- function ensureDir() {
19
- if (!fs.existsSync(HOLYSHEEP_DIR)) fs.mkdirSync(HOLYSHEEP_DIR, { recursive: true })
20
- }
21
-
22
- function parseArgs(argv) {
23
- const args = { port: null, host: '127.0.0.1', config: CLAUDE_BRIDGE_CONFIG_FILE }
24
- for (let i = 0; i < argv.length; i++) {
25
- const value = argv[i]
26
- if (value === '--port') args.port = Number(argv[++i])
27
- else if (value === '--host') args.host = argv[++i]
28
- else if (value === '--config') args.config = argv[++i]
29
- }
30
- return args
31
- }
32
-
33
- function readBridgeConfig(configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
34
- try {
35
- return JSON.parse(fs.readFileSync(configPath, 'utf8'))
36
- } catch {
37
- return {}
38
- }
39
- }
40
-
41
- function writeBridgeConfig(data, configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
42
- ensureDir()
43
- fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf8')
44
- }
45
-
46
- function getConfiguredBridgePort(config = readBridgeConfig()) {
47
- const port = Number(config.port)
48
- return Number.isInteger(port) && port > 0 ? port : DEFAULT_BRIDGE_PORT
49
- }
50
-
51
- function getBridgeBaseUrl(port = getConfiguredBridgePort()) {
52
- return `http://127.0.0.1:${port}`
53
- }
54
-
55
- function sendJson(res, statusCode, payload) {
56
- res.writeHead(statusCode, {
57
- 'content-type': 'application/json; charset=utf-8',
58
- 'cache-control': 'no-store',
59
- })
60
- res.end(JSON.stringify(payload))
61
- }
62
-
63
- function readRawBody(req) {
64
- return new Promise((resolve, reject) => {
65
- const chunks = []
66
- let size = 0
67
- req.on('data', (chunk) => {
68
- size += chunk.length
69
- if (size > 20 * 1024 * 1024) {
70
- reject(new Error('Request body too large'))
71
- req.destroy()
72
- return
73
- }
74
- chunks.push(chunk)
75
- })
76
- req.on('end', () => resolve(Buffer.concat(chunks)))
77
- req.on('error', reject)
78
- })
79
- }
80
-
81
- function stripBearer(value) {
82
- const raw = String(value || '').trim()
83
- if (!raw) return ''
84
- if (/^Bearer\s+/i.test(raw)) return raw.replace(/^Bearer\s+/i, '').trim()
85
- return raw
86
- }
87
-
88
- function buildBodyHash(body) {
89
- return crypto.createHash('sha256').update(body || Buffer.alloc(0)).digest('hex')
90
- }
91
-
92
- function buildBridgeSignature(req, config, body, timestamp) {
93
- const secret = String(config.bridgeSecret || '').trim()
94
- if (!secret) return ''
95
-
96
- const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
97
- const canonical = [
98
- String(req.method || 'GET').toUpperCase(),
99
- url.pathname + (url.search || ''),
100
- String(timestamp),
101
- buildBodyHash(body),
102
- String(config.bridgeId || '')
103
- ].join('\n')
104
-
105
- return crypto.createHmac('sha256', secret).update(canonical).digest('hex')
106
- }
107
-
108
- function getBridgeUpstream(config) {
109
- return String(config.relayUrl || config.controlPlaneUrl || config.baseUrlAnthropic || '').replace(/\/+$/, '')
110
- }
111
-
112
- function parseJsonBody(body) {
113
- if (!body || !body.length) return null
114
- try {
115
- return JSON.parse(body.toString('utf8'))
116
- } catch {
117
- return null
118
- }
119
- }
120
-
121
- function extractSessionId(parsedBody, config) {
122
- const headerSessionId =
123
- parsedBody?.metadata?.session_id ||
124
- parsedBody?.session_id ||
125
- parsedBody?.metadata?.conversation_id ||
126
- null
127
- if (headerSessionId) return String(headerSessionId)
128
-
129
- const metadataUserId = parsedBody?.metadata?.user_id
130
- if (typeof metadataUserId === 'string' && metadataUserId.trim()) {
131
- return metadataUserId.trim()
132
- }
133
-
134
- return `${config.bridgeId || 'local-bridge'}:default`
135
- }
136
-
137
- function isRelayMode(config) {
138
- const upstream = getBridgeUpstream(config)
139
- return /\/claude-relay(?:\/|$)/.test(upstream)
140
- }
141
-
142
- async function requestSessionLease(config, sessionId, parsedBody) {
143
- const controlPlaneUrl = getBridgeUpstream(config)
144
- const cached = leaseCache.get(sessionId)
145
- if (cached && cached.expiresAt && new Date(cached.expiresAt).getTime() - Date.now() > 30_000) {
146
- return cached
147
- }
148
-
149
- const response = await fetch(`${controlPlaneUrl}/session/open`, {
150
- method: 'POST',
151
- headers: {
152
- 'content-type': 'application/json',
153
- },
154
- body: JSON.stringify({
155
- sessionId,
156
- bridgeId: config.bridgeId || 'local-bridge',
157
- deviceId: config.deviceId || '',
158
- model: parsedBody?.model || null,
159
- installSource: config.installSource || 'holysheep-cli',
160
- }),
161
- })
162
-
163
- const payload = await response.json().catch(() => null)
164
- if (!response.ok || !payload?.success || !payload?.data?.nodeBaseUrl || !payload?.data?.ticket) {
165
- const message =
166
- payload?.error?.message ||
167
- payload?.message ||
168
- `Failed to open Claude relay session (HTTP ${response.status})`
169
- throw new Error(message)
170
- }
171
-
172
- leaseCache.set(sessionId, payload.data)
173
- return payload.data
174
- }
175
-
176
- function buildUpstreamHeaders(req, config, body) {
177
- const headers = {}
178
- for (const [key, value] of Object.entries(req.headers || {})) {
179
- const normalized = key.toLowerCase()
180
- if (normalized === 'host') continue
181
- if (normalized === 'content-length') continue
182
- headers[normalized] = value
183
- }
184
-
185
- const token = stripBearer(headers.authorization) || headers['x-api-key'] || config.apiKey || ''
186
- delete headers.authorization
187
- if (token) headers['x-api-key'] = token
188
- if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01'
189
- headers['user-agent'] = 'holysheep-claude-bridge/1.0'
190
- headers['x-hs-bridge-id'] = config.bridgeId || 'local-bridge'
191
- headers['x-hs-install-source'] = config.installSource || 'holysheep-cli'
192
- if (config.deviceId) headers['x-hs-device-id'] = config.deviceId
193
- if (config.userToken) headers['x-hs-user-token'] = config.userToken
194
- if (config.nodeId) headers['x-hs-node-id'] = config.nodeId
195
- if (config.sessionMode) headers['x-hs-session-mode'] = config.sessionMode
196
- const timestamp = Math.floor(Date.now() / 1000)
197
- headers['x-hs-bridge-timestamp'] = String(timestamp)
198
- const signature = buildBridgeSignature(req, config, body, timestamp)
199
- if (signature) headers['x-hs-bridge-signature'] = signature
200
- if (body && body.length > 0) headers['content-length'] = String(body.length)
201
-
202
- return headers
203
- }
204
-
205
- function createBridgeServer(configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
206
- return http.createServer(async (req, res) => {
207
- if (req.method === 'OPTIONS') {
208
- res.writeHead(204, {
209
- 'access-control-allow-origin': '*',
210
- 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
211
- 'access-control-allow-headers': 'content-type,authorization,x-api-key,anthropic-version',
212
- })
213
- return res.end()
214
- }
215
-
216
- try {
217
- const config = readBridgeConfig(configPath)
218
- const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
219
- const port = getConfiguredBridgePort(config)
220
-
221
- if (req.method === 'GET' && url.pathname === '/health') {
222
- return sendJson(res, 200, {
223
- ok: true,
224
- port,
225
- upstream: getBridgeUpstream(config),
226
- relayUrl: config.relayUrl || '',
227
- mode: config.relayUrl ? 'relay' : 'direct',
228
- bridgeId: config.bridgeId || null,
229
- nodeId: config.nodeId || null,
230
- installSource: config.installSource || 'holysheep-cli',
231
- })
232
- }
233
-
234
- let upstreamBase = getBridgeUpstream(config)
235
- if (!upstreamBase) {
236
- return sendJson(res, 500, { error: { message: 'Bridge upstream is not configured' } })
237
- }
238
-
239
- const body = await readRawBody(req)
240
- const parsedBody = parseJsonBody(body)
241
- if (isRelayMode(config) && req.method !== 'GET' && url.pathname !== '/health') {
242
- const sessionId = extractSessionId(parsedBody, config)
243
- const lease = await requestSessionLease(config, sessionId, parsedBody)
244
- upstreamBase = String(lease.nodeBaseUrl || '').replace(/\/+$/, '')
245
- req.headers['x-hs-session-id'] = sessionId
246
- req.headers['x-hs-bridge-ticket'] = lease.ticket
247
- req.headers['x-hs-node-id'] = lease.nodeId || ''
248
- }
249
- const upstreamUrl = `${upstreamBase}${url.pathname}${url.search || ''}`
250
- const upstream = await fetch(upstreamUrl, {
251
- method: req.method,
252
- headers: buildUpstreamHeaders(req, config, body),
253
- body: body.length > 0 ? body : undefined,
254
- })
255
-
256
- res.writeHead(upstream.status, {
257
- 'content-type': upstream.headers.get('content-type') || 'application/json; charset=utf-8',
258
- 'cache-control': upstream.headers.get('cache-control') || 'no-store',
259
- })
260
-
261
- if (upstream.body && typeof upstream.body.pipe === 'function') {
262
- upstream.body.pipe(res)
263
- return
264
- }
265
-
266
- const text = await upstream.text()
267
- res.end(text)
268
- } catch (error) {
269
- return sendJson(res, 500, { error: { message: error.message || 'Claude bridge error' } })
270
- }
271
- })
272
- }
273
-
274
- function waitForBridge(port) {
275
- for (let i = 0; i < 12; i++) {
276
- const t0 = Date.now()
277
- while (Date.now() - t0 < 500) {}
278
-
279
- try {
280
- execSync(
281
- isWin
282
- ? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/health -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
283
- : `curl -sf http://127.0.0.1:${port}/health -o /dev/null --max-time 1`,
284
- { stdio: 'ignore', timeout: 3000 }
285
- )
286
- return true
287
- } catch {}
288
- }
289
-
290
- return false
291
- }
292
-
293
- function startBridge(args = parseArgs(process.argv.slice(2))) {
294
- const config = readBridgeConfig(args.config)
295
- const port = args.port || getConfiguredBridgePort(config)
296
- const host = args.host || '127.0.0.1'
297
- const server = createBridgeServer(args.config)
298
-
299
- server.listen(port, host, () => {
300
- process.stdout.write(`HolySheep Claude bridge listening on http://${host}:${port}\n`)
301
- })
302
-
303
- return server
304
- }
305
-
306
- function spawnBridge(port = getConfiguredBridgePort()) {
307
- if (waitForBridge(port)) return true
308
-
309
- const scriptPath = path.join(__dirname, '..', 'index.js')
310
- const child = spawn(process.execPath, [scriptPath, 'claude-bridge', '--port', String(port)], {
311
- detached: true,
312
- stdio: 'ignore',
313
- })
314
- child.unref()
315
-
316
- return waitForBridge(port)
317
- }
318
-
319
- module.exports = {
320
- CLAUDE_BRIDGE_CONFIG_FILE,
321
- DEFAULT_BRIDGE_PORT,
322
- createBridgeServer,
323
- getBridgeBaseUrl,
324
- getConfiguredBridgePort,
325
- parseArgs,
326
- readBridgeConfig,
327
- spawnBridge,
328
- startBridge,
329
- waitForBridge,
330
- writeBridgeConfig,
331
- }