@simonyea/holysheep-cli 1.6.1 → 1.6.4

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/README.md CHANGED
@@ -67,17 +67,26 @@ You'll be prompted for your API Key (`cr_xxx`), then select the tools to configu
67
67
 
68
68
  [OpenClaw](https://openclaw.ai) is a powerful AI agent gateway with a web dashboard. After running `hs setup`:
69
69
 
70
- 1. A new terminal window opens running the OpenClaw Gateway
71
- 2. Open your browser: **http://127.0.0.1:18789/**
72
- 3. Start chatting no token required
70
+ 1. HolySheep configures OpenClaw to use HolySheep API
71
+ 2. The OpenClaw Gateway starts on **`http://127.0.0.1:18789/` by default**
72
+ 3. If `18789` is occupied, `hs setup` automatically picks the next available local port
73
+ 4. Open the exact browser URL shown in the terminal and start chatting — no token required
74
+
75
+ **Default OpenClaw model:** `gpt-5.4`
73
76
 
74
77
  > **Keep the gateway window open** while using OpenClaw. The gateway must be running for the browser UI to work.
75
78
 
79
+ > **OpenClaw itself requires Node.js 20+**. If setup fails, first check `node --version`.
80
+
76
81
  To restart the gateway later:
77
82
  ```bash
78
- npx openclaw gateway --port 18789
83
+ openclaw gateway --port <shown-port>
84
+ # or
85
+ npx openclaw gateway --port <shown-port>
79
86
  ```
80
87
 
88
+ If you forget the port, check `~/.openclaw/openclaw.json` (`gateway.port`) or run `hs doctor`.
89
+
81
90
  ### Commands
82
91
 
83
92
  | Command | Description |
@@ -145,18 +154,27 @@ hs setup
145
154
 
146
155
  **`hs setup` 配置完成后:**
147
156
 
148
- 1. 自动弹出一个新终端窗口,运行 OpenClaw Gateway
149
- 2. 打开浏览器访问:**http://127.0.0.1:18789/**
150
- 3. 直接开始聊天,无需填写 token
157
+ 1. HolySheep 会自动把 OpenClaw 接到 HolySheep API
158
+ 2. 默认启动在 **`http://127.0.0.1:18789/`**
159
+ 3. 如果 `18789` 被占用,`hs setup` 会自动切换到下一个可用本地端口
160
+ 4. 按终端里显示的准确地址打开浏览器,直接开始聊天,无需填写 token
161
+
162
+ **OpenClaw 默认模型:** `gpt-5.4`
151
163
 
152
164
  > ⚠️ **保持 Gateway 窗口开启**,关闭后 Gateway 停止,浏览器界面无法使用。
153
165
 
166
+ > ⚠️ **OpenClaw 自身要求 Node.js 20+**。如果配置失败,请先运行 `node --version` 检查版本。
167
+
154
168
  **下次启动 Gateway:**
155
169
  ```bash
156
- npx openclaw gateway --port 18789
170
+ openclaw gateway --port <显示的端口>
171
+ # 或
172
+ npx openclaw gateway --port <显示的端口>
157
173
  ```
158
174
 
159
- **使用的模型:** `claude-sonnet-4-6`(通过 HolySheep 中转)
175
+ 如果忘了端口,可以查看 `~/.openclaw/openclaw.json` 里的 `gateway.port`,或直接运行 `hs doctor`。
176
+
177
+ **默认模型:** `gpt-5.4`(可在 OpenClaw 内切换到 Claude 模型)
160
178
 
161
179
  ### 命令说明
162
180
 
@@ -185,18 +203,24 @@ A: 在 [holysheep.ai](https://holysheep.ai) 注册后,在「API 密钥」页
185
203
  A: 支持,需要 Node.js 16+。如果 `hs` 命令找不到,请重启终端,或直接用 `npx @simonyea/holysheep-cli@latest setup`。
186
204
 
187
205
  **Q: OpenClaw Gateway 窗口可以最小化吗?**
188
- A: 可以最小化,但不能关闭。关闭后 Gateway 停止,需重新运行 `npx openclaw gateway --port 18789`。
206
+ A: 可以最小化,但不能关闭。关闭后 Gateway 停止,需要按 `hs setup` / `hs doctor` 显示的端口重新运行 `openclaw gateway --port <端口>` 或 `npx openclaw gateway --port <端口>`。
207
+
208
+ **Q: 18789 端口被占用怎么办?**
209
+ A: `hs setup` 会自动切换到下一个可用本地端口,并把准确访问地址打印出来;也可以运行 `hs doctor` 查看当前 `gateway.port` 和端口占用情况。
189
210
 
190
211
  **Q: 如何恢复原来的配置?**
191
212
  A: 运行 `hs reset` 清除所有 HolySheep 相关配置。
192
213
 
193
214
  **Q: OpenClaw 安装失败?**
194
- A: OpenClaw 需要 Node.js 20+,运行 `node --version` 确认版本后重试。
215
+ A: OpenClaw 需要 Node.js 20+,运行 `node --version` 确认版本后重试;如果全局安装失败,`hs setup` 也会尽量回退到 `npx openclaw` 继续配置。
195
216
 
196
217
  ---
197
218
 
198
219
  ## Changelog
199
220
 
221
+ - **v1.6.4** — 修复 OpenClaw 的 npx 运行时检测,避免配置后页面仍卡在 Unauthorized / 未连接状态
222
+ - **v1.6.3** — OpenClaw 默认模型改为 GPT-5.4,并继续保留 Claude 模型切换能力
223
+ - **v1.6.2** — 修复 OpenClaw 配置误判与 npx 回退,端口冲突时自动切换空闲端口,并补充 Doctor 诊断
200
224
  - **v1.6.0** — 新增 Droid CLI 一键配置,默认写入 GPT-5.4 / Sonnet 4.6 / Opus 4.6 / MiniMax 2.7 Highspeed / Haiku 4.5
201
225
  - **v1.5.2** — OpenClaw 安装失败(无 git 环境)时自动降级为 npx 模式继续配置
202
226
  - **v1.5.0** — OpenClaw gateway 无需 token,直接浏览器打开 http://127.0.0.1:18789/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "1.6.1",
3
+ "version": "1.6.4",
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",
@@ -12,9 +12,11 @@ async function doctor() {
12
12
  console.log(chalk.gray('━'.repeat(50)))
13
13
  console.log()
14
14
 
15
+ const nodeMajor = parseInt(process.version.slice(1), 10)
16
+
15
17
  // Node.js 版本
16
18
  const nodeVer = process.version
17
- const nodeOk = parseInt(nodeVer.slice(1)) >= 16
19
+ const nodeOk = nodeMajor >= 16
18
20
  printCheck(nodeOk, `Node.js ${nodeVer}`, nodeOk ? '' : '需要 >= 16')
19
21
 
20
22
  // API Key
@@ -23,32 +25,38 @@ async function doctor() {
23
25
 
24
26
  // 环境变量
25
27
  const envAnthropicKey = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
26
- const envOpenAIKey = process.env.OPENAI_API_KEY
28
+ const envOpenAIKey = process.env.OPENAI_API_KEY
27
29
  const envAnthropicUrl = process.env.ANTHROPIC_BASE_URL
28
- const envOpenAIUrl = process.env.OPENAI_BASE_URL
30
+ const envOpenAIUrl = process.env.OPENAI_BASE_URL
29
31
 
30
32
  console.log()
31
33
  console.log(chalk.bold('环境变量:'))
32
34
  printCheck(!!envAnthropicKey, 'ANTHROPIC_API_KEY / AUTH_TOKEN', envAnthropicKey ? maskKey(envAnthropicKey) : '未设置')
33
35
  printCheck(!!envAnthropicUrl, 'ANTHROPIC_BASE_URL', envAnthropicUrl || '未设置')
34
- printCheck(!!envOpenAIKey, 'OPENAI_API_KEY', envOpenAIKey ? maskKey(envOpenAIKey) : '未设置')
35
- printCheck(!!envOpenAIUrl, 'OPENAI_BASE_URL', envOpenAIUrl || '未设置')
36
+ printCheck(!!envOpenAIKey, 'OPENAI_API_KEY', envOpenAIKey ? maskKey(envOpenAIKey) : '未设置')
37
+ printCheck(!!envOpenAIUrl, 'OPENAI_BASE_URL', envOpenAIUrl || '未设置')
36
38
 
37
39
  // 各工具检查
38
40
  console.log()
39
41
  console.log(chalk.bold('工具状态:'))
40
42
 
41
43
  for (const tool of TOOLS) {
42
- const installed = tool.checkInstalled()
44
+ const installState = getInstallState(tool)
45
+ const installed = installState.installed
43
46
  const configured = installed ? tool.isConfigured() : null
44
- const version = installed ? getVersion(tool.id) : null
47
+ const version = installState.version
48
+ const suffix = installState.detail ? chalk.gray(` (${installState.detail})`) : ''
45
49
 
46
50
  if (!installed) {
47
51
  console.log(` ${chalk.gray('○')} ${chalk.gray(tool.name.padEnd(20))} ${chalk.gray('未安装')} ${chalk.gray(`— ${tool.installCmd}`)}`)
48
52
  } else if (configured) {
49
- console.log(` ${chalk.green('✓')} ${chalk.green(tool.name.padEnd(20))} ${chalk.gray(version || '已安装')} ${chalk.green('已配置 HolySheep')}`)
53
+ console.log(` ${chalk.green('✓')} ${chalk.green(tool.name.padEnd(20))} ${chalk.gray(version || '已安装')}${suffix} ${chalk.green('已配置 HolySheep')}`)
50
54
  } else {
51
- console.log(` ${chalk.yellow('!')} ${chalk.yellow(tool.name.padEnd(20))} ${chalk.gray(version || '已安装')} ${chalk.yellow('未配置')} ${chalk.gray(`— 运行 hs setup`)}`)
55
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(tool.name.padEnd(20))} ${chalk.gray(version || '已安装')}${suffix} ${chalk.yellow('未配置')} ${chalk.gray('— 运行 hs setup')}`)
56
+ }
57
+
58
+ if (tool.id === 'openclaw' && installed) {
59
+ printOpenClawDetails(tool, installState, nodeMajor)
52
60
  }
53
61
  }
54
62
 
@@ -79,32 +87,119 @@ async function doctor() {
79
87
  }
80
88
 
81
89
  function printCheck(ok, label, detail = '') {
82
- const icon = ok ? chalk.green('✓') : chalk.red('✗')
83
- const lbl = ok ? chalk.green(label.padEnd(35)) : chalk.red(label.padEnd(35))
84
- const det = detail ? chalk.gray(detail) : ''
90
+ const icon = ok ? chalk.green('✓') : chalk.red('✗')
91
+ const lbl = ok ? chalk.green(label.padEnd(35)) : chalk.red(label.padEnd(35))
92
+ const det = detail ? chalk.gray(detail) : ''
85
93
  console.log(` ${icon} ${lbl} ${det}`)
86
94
  }
87
95
 
96
+ function printOpenClawDetails(tool, installState, nodeMajor) {
97
+ const details = []
98
+ const gatewayPort = typeof tool.getGatewayPort === 'function' ? tool.getGatewayPort() : 18789
99
+ const primaryModel = typeof tool.getPrimaryModel === 'function' ? tool.getPrimaryModel() : ''
100
+ const listeners = typeof tool.getPortListeners === 'function' ? tool.getPortListeners(gatewayPort) : []
101
+ const foreignListeners = listeners.filter((item) => !String(item.command || '').toLowerCase().includes('openclaw'))
102
+
103
+ if (installState.detail === 'npx fallback') {
104
+ details.push({
105
+ level: 'info',
106
+ text: '未检测到全局 openclaw,当前将通过 npx 运行',
107
+ })
108
+ }
109
+
110
+ details.push(
111
+ nodeMajor >= 20
112
+ ? { level: 'ok', text: `OpenClaw Node 版本要求满足(当前 ${process.version})` }
113
+ : { level: 'warn', text: `OpenClaw 建议 Node.js >= 20(当前 ${process.version})` }
114
+ )
115
+
116
+ if (primaryModel) {
117
+ details.push({
118
+ level: 'info',
119
+ text: `当前默认模型:${primaryModel}`,
120
+ })
121
+ }
122
+
123
+ if (foreignListeners.length) {
124
+ const occupiedBy = foreignListeners
125
+ .slice(0, 2)
126
+ .map((item) => `${item.command}(${item.pid})`)
127
+ .join(', ')
128
+ details.push({
129
+ level: 'warn',
130
+ text: `Gateway 端口 ${gatewayPort} 被其他进程占用:${occupiedBy}`,
131
+ })
132
+ } else if (listeners.length) {
133
+ details.push({
134
+ level: 'ok',
135
+ text: `Gateway 端口 ${gatewayPort} 当前由 OpenClaw 占用`,
136
+ })
137
+ } else {
138
+ details.push({
139
+ level: 'info',
140
+ text: `Gateway 端口 ${gatewayPort} 当前空闲;如刚完成配置,可运行 ${tool.launchCmd}`,
141
+ })
142
+ }
143
+
144
+ details.forEach((detail) => {
145
+ const icon = detail.level === 'ok'
146
+ ? chalk.green('↳')
147
+ : detail.level === 'warn'
148
+ ? chalk.yellow('↳')
149
+ : chalk.gray('↳')
150
+ const text = detail.level === 'ok'
151
+ ? chalk.green(detail.text)
152
+ : detail.level === 'warn'
153
+ ? chalk.yellow(detail.text)
154
+ : chalk.gray(detail.text)
155
+ console.log(` ${icon} ${text}`)
156
+ })
157
+ }
158
+
88
159
  function maskKey(key) {
89
160
  if (!key || key.length < 8) return '****'
90
161
  return key.slice(0, 6) + '...' + key.slice(-4)
91
162
  }
92
163
 
93
- function getVersion(toolId) {
164
+ function getInstallState(tool) {
165
+ if (tool.id === 'openclaw' && typeof tool.detectRuntime === 'function') {
166
+ const runtime = tool.detectRuntime()
167
+ return {
168
+ installed: runtime.available,
169
+ version: runtime.version,
170
+ detail: runtime.via === 'npx' ? 'npx fallback' : '',
171
+ }
172
+ }
173
+
174
+ const installed = tool.checkInstalled()
175
+ return {
176
+ installed,
177
+ version: installed ? getVersion(tool) : null,
178
+ detail: '',
179
+ }
180
+ }
181
+
182
+ function getVersion(tool) {
183
+ if (typeof tool.getVersion === 'function') {
184
+ return tool.getVersion()
185
+ }
186
+
94
187
  const cmds = {
95
188
  'claude-code': 'claude --version',
96
- 'codex': 'codex --version',
97
- 'droid': 'droid --version',
98
- 'gemini-cli': 'gemini --version',
99
- 'opencode': 'opencode --version',
100
- 'openclaw': 'openclaw --version',
101
- 'aider': 'aider --version',
189
+ 'codex': 'codex --version',
190
+ 'droid': 'droid --version',
191
+ 'gemini-cli': 'gemini --version',
192
+ 'opencode': 'opencode --version',
193
+ 'openclaw': 'openclaw --version',
194
+ 'aider': 'aider --version',
102
195
  }
103
- const cmd = cmds[toolId]
196
+ const cmd = cmds[tool.id]
104
197
  if (!cmd) return null
105
198
  try {
106
199
  return execSync(cmd, { stdio: 'pipe' }).toString().trim().split('\n')[0].slice(0, 30)
107
- } catch { return null }
200
+ } catch {
201
+ return null
202
+ }
108
203
  }
109
204
 
110
205
  module.exports = doctor
@@ -195,7 +195,7 @@ async function setup(options) {
195
195
  justInstalled.add(tool.id)
196
196
  } else if (tool.id === 'openclaw') {
197
197
  // openclaw 安装失败时(如无 git),改用 npx 模式继续配置
198
- // checkInstalled() 里已有 npx fallback,标记为已安装
198
+ // 这里直接标记为本次可配置,具体执行阶段再走 npx fallback
199
199
  console.log(chalk.yellow(` ⚠️ 全局安装失败,将使用 npx openclaw 代替`))
200
200
  tool._useNpx = true
201
201
  justInstalled.add(tool.id)
@@ -1,221 +1,488 @@
1
1
  /**
2
2
  * OpenClaw 适配器 (v2 — 基于实测的正确配置格式)
3
3
  *
4
- * 正确方案:custom-api-key provider,配置在 models.providers
5
- * provider name 自动生成为 "custom-api-{hostname}"
6
- * 模型引用格式: "custom-api-holysheep-ai/claude-sonnet-4-6"
7
- *
8
- * 必须的 onboard 参数:
9
- * --accept-risk --auth-choice custom-api-key
10
- * --custom-base-url --custom-api-key --custom-model-id --custom-compatibility anthropic
11
- * --install-daemon
4
+ * 正确方案:写入 HolySheep OpenAI + Anthropic 双 provider,
5
+ * 默认模型固定为 GPT-5.4,同时保留 Claude 模型供 /model 切换。
12
6
  */
13
- const fs = require('fs')
7
+ const fs = require('fs')
14
8
  const path = require('path')
15
- const os = require('os')
9
+ const os = require('os')
16
10
  const { spawnSync, spawn, execSync } = require('child_process')
11
+ const { commandExists } = require('../utils/which')
17
12
 
18
13
  const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw')
19
- const CONFIG_FILE = path.join(OPENCLAW_DIR, 'openclaw.json')
20
- const isWin = process.platform === 'win32'
14
+ const CONFIG_FILE = path.join(OPENCLAW_DIR, 'openclaw.json')
15
+ const isWin = process.platform === 'win32'
16
+ const DEFAULT_GATEWAY_PORT = 18789
17
+ const MAX_PORT_SCAN = 20
18
+ const OPENCLAW_DEFAULT_MODEL = 'gpt-5.4'
19
+ const OPENCLAW_DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6'
20
+
21
+ function hasOpenClawBinary() {
22
+ return commandExists('openclaw')
23
+ }
24
+
25
+ function hasNpx() {
26
+ return commandExists('npx')
27
+ }
28
+
29
+ function getRunner(preferNpx = false) {
30
+ if (!preferNpx && hasOpenClawBinary()) {
31
+ return { cmd: 'openclaw', argsPrefix: [], shell: false, label: 'openclaw', via: 'binary' }
32
+ }
33
+ if (hasNpx()) {
34
+ return { cmd: 'npx', argsPrefix: ['openclaw'], shell: isWin, label: 'npx openclaw', via: 'npx' }
35
+ }
36
+ if (hasOpenClawBinary()) {
37
+ return { cmd: 'openclaw', argsPrefix: [], shell: false, label: 'openclaw', via: 'binary' }
38
+ }
39
+ return null
40
+ }
41
+
42
+ /** 运行 openclaw CLI(优先全局命令,可切换到 npx 回退) */
43
+ function runOpenClaw(args, opts = {}) {
44
+ const runner = getRunner(Boolean(opts.preferNpx))
45
+ if (!runner) {
46
+ return { status: 1, stdout: '', stderr: 'OpenClaw CLI not found' }
47
+ }
48
+
49
+ return spawnSync(runner.cmd, [...runner.argsPrefix, ...args], {
50
+ shell: runner.shell,
51
+ timeout: opts.timeout || 30000,
52
+ stdio: opts.stdio || 'pipe',
53
+ encoding: 'utf8',
54
+ })
55
+ }
56
+
57
+ function spawnOpenClaw(args, opts = {}) {
58
+ const runner = getRunner(Boolean(opts.preferNpx))
59
+ if (!runner) throw new Error('OpenClaw CLI not found')
60
+
61
+ const { preferNpx: _preferNpx, ...spawnOpts } = opts
62
+ return spawn(runner.cmd, [...runner.argsPrefix, ...args], {
63
+ shell: runner.shell,
64
+ ...spawnOpts,
65
+ })
66
+ }
67
+
68
+ function getPreferredRuntime() {
69
+ return module.exports._useNpx || !hasOpenClawBinary()
70
+ }
71
+
72
+ function firstLine(text) {
73
+ return String(text || '').trim().split('\n')[0] || ''
74
+ }
75
+
76
+ function getOpenClawVersion(preferNpx = false) {
77
+ const result = runOpenClaw(['--version'], { preferNpx, timeout: preferNpx ? 60000 : 15000 })
78
+ if (result.status !== 0) return null
79
+ return firstLine(result.stdout)
80
+ }
21
81
 
22
- /** 运行 openclaw CLI */
23
- function npx(...args) {
24
- return isWin
25
- ? spawnSync('npx', ['openclaw', ...args], { shell: true, timeout: 30000, stdio: 'pipe' })
26
- : spawnSync('openclaw', args, { shell: false, timeout: 30000, stdio: 'pipe' })
82
+ function detectRuntime() {
83
+ const preferNpx = getPreferredRuntime()
84
+ const preferredRunner = getRunner(preferNpx)
85
+
86
+ if (preferredRunner) {
87
+ return {
88
+ available: true,
89
+ via: preferredRunner.via,
90
+ command: preferredRunner.label,
91
+ version: getOpenClawVersion(preferNpx),
92
+ }
93
+ }
94
+
95
+ const fallbackRunner = getRunner(true)
96
+ if (fallbackRunner) {
97
+ return {
98
+ available: true,
99
+ via: fallbackRunner.via,
100
+ command: fallbackRunner.label,
101
+ version: getOpenClawVersion(true),
102
+ }
103
+ }
104
+
105
+ return { available: false, via: null, command: null, version: null }
27
106
  }
28
107
 
29
108
  function readConfig() {
30
109
  try {
31
110
  if (fs.existsSync(CONFIG_FILE)) {
32
111
  const raw = fs.readFileSync(CONFIG_FILE, 'utf8')
33
- // 去掉 JSON5 注释再解析
34
- return JSON.parse(raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''))
112
+ try {
113
+ return JSON.parse(raw)
114
+ } catch {
115
+ // 兼容极少数带注释的配置,但不要误伤 https:// 之类的 URL
116
+ return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''))
117
+ }
35
118
  }
36
119
  } catch {}
37
120
  return {}
38
121
  }
39
122
 
40
- module.exports = {
41
- name: 'OpenClaw',
42
- id: 'openclaw',
123
+ function getConfiguredGatewayPort(config = readConfig()) {
124
+ const port = Number(config?.gateway?.port)
125
+ return Number.isInteger(port) && port > 0 ? port : DEFAULT_GATEWAY_PORT
126
+ }
43
127
 
44
- checkInstalled() {
45
- if (require('../utils/which').commandExists('openclaw')) return true
128
+ function getConfiguredPrimaryModel(config = readConfig()) {
129
+ return config?.agents?.defaults?.model?.primary || ''
130
+ }
131
+
132
+ function isPortInUse(port) {
133
+ try {
46
134
  if (isWin) {
47
- try {
48
- execSync('npx openclaw --version', { stdio: 'ignore', timeout: 15000, shell: true })
49
- return true
50
- } catch {}
135
+ const out = execSync(`netstat -ano | findstr :${port}`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
136
+ return out.trim().length > 0
51
137
  }
138
+
139
+ execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN`, { shell: true, stdio: 'ignore' })
140
+ return true
141
+ } catch {
52
142
  return false
53
- },
143
+ }
144
+ }
54
145
 
55
- isConfigured() {
56
- const cfg = JSON.stringify(readConfig())
57
- return cfg.includes('holysheep.ai')
58
- },
146
+ function listPortListeners(port) {
147
+ try {
148
+ if (isWin) {
149
+ const out = execSync(`netstat -ano | findstr :${port}`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
150
+ return out
151
+ .trim()
152
+ .split('\n')
153
+ .filter(Boolean)
154
+ .map((line) => {
155
+ const parts = line.trim().split(/\s+/)
156
+ return { pid: parts[parts.length - 1], command: 'pid', detail: parts[1] || '' }
157
+ })
158
+ }
59
159
 
60
- configure(apiKey, baseUrl, _baseUrlOpenAI, primaryModel, selectedModels) {
61
- const chalk = require('chalk')
62
- console.log(chalk.gray('\n ⚙️ 正在配置 OpenClaw...'))
160
+ const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
161
+ return out
162
+ .trim()
163
+ .split('\n')
164
+ .slice(1)
165
+ .filter(Boolean)
166
+ .map((line) => {
167
+ const parts = line.trim().split(/\s+/)
168
+ return {
169
+ command: parts[0] || 'unknown',
170
+ pid: parts[1] || '?',
171
+ detail: parts[parts.length - 1] || '',
172
+ }
173
+ })
174
+ } catch {
175
+ return []
176
+ }
177
+ }
63
178
 
64
- // 1. 先停旧 gateway(必须在 onboard 之前,否则 hot-reload 会覆盖新 token)
65
- npx('gateway', 'stop')
66
- // 强制 kill 占用 18789 端口的进程(gateway stop 停不掉手动启动的进程)
67
- try {
68
- if (isWin) {
69
- // netstat 找 pid,然后 taskkill
70
- const out = execSync('netstat -ano | findstr :18789', { shell: true, encoding: 'utf8', stdio: 'pipe' })
71
- const match = out.match(/\s(\d+)\s*$/)
72
- if (match) execSync(`taskkill /F /PID ${match[1]}`, { shell: true, stdio: 'ignore' })
73
- } else {
74
- execSync('lsof -ti:18789 | xargs kill -9 2>/dev/null || true', { shell: true, stdio: 'ignore' })
75
- }
76
- } catch {}
77
- // 等 1s 让端口释放
78
- const t0 = Date.now(); while (Date.now() - t0 < 1000) {}
179
+ function findAvailableGatewayPort(startPort = DEFAULT_GATEWAY_PORT) {
180
+ for (let offset = 0; offset < MAX_PORT_SCAN; offset++) {
181
+ const port = startPort + offset
182
+ if (!isPortInUse(port)) return port
183
+ }
184
+ return null
185
+ }
79
186
 
80
- // 2. 删除旧配置,确保 onboard 会重新写入
81
- try { fs.unlinkSync(CONFIG_FILE) } catch {}
187
+ function getLaunchCommand(port = getConfiguredGatewayPort()) {
188
+ const runtime = module.exports._lastRuntimeCommand || (hasOpenClawBinary() ? 'openclaw' : 'npx openclaw')
189
+ return `${runtime} gateway --port ${port}`
190
+ }
82
191
 
83
- // 3. openclaw 官方 onboard 命令写入正确配置
84
- // 这会生成完整的 models.providers.custom-api-holysheep-ai 配置
85
- console.log(chalk.gray(' 写入配置...'))
86
- const result = npx(
87
- 'onboard',
88
- '--non-interactive',
89
- '--accept-risk',
90
- '--auth-choice', 'custom-api-key',
91
- '--custom-base-url', baseUrl,
92
- '--custom-api-key', apiKey,
93
- '--custom-model-id', primaryModel || 'claude-sonnet-4-6',
94
- '--custom-compatibility', 'anthropic',
95
- '--install-daemon',
96
- )
192
+ function buildProviderName(baseUrl, prefix) {
193
+ const hostname = new URL(baseUrl).hostname.replace(/\./g, '-')
194
+ return `${prefix}-${hostname}`
195
+ }
97
196
 
98
- if (result.status !== 0) {
99
- // onboard 失败时 fallback:手写最小化配置
100
- console.log(chalk.yellow(' ⚠️ onboard 失败,使用备用配置...'))
101
- _writeFallbackConfig(apiKey, baseUrl, selectedModels, primaryModel)
102
- }
197
+ function buildModelEntry(id) {
198
+ return {
199
+ id,
200
+ name: `${id} (HolySheep)`,
201
+ reasoning: false,
202
+ input: ['text'],
203
+ contextWindow: 200000,
204
+ maxTokens: id.startsWith('gpt-') ? 8192 : 16000,
205
+ }
206
+ }
103
207
 
104
- // 4. 关闭 gateway token 认证(直接打开浏览器无需 token)
105
- _disableGatewayAuth()
208
+ function buildManagedPlan(apiKey, baseUrlAnthropic, baseUrlOpenAI, selectedModels) {
209
+ const requestedModels = Array.isArray(selectedModels) && selectedModels.length > 0
210
+ ? selectedModels
211
+ : [OPENCLAW_DEFAULT_MODEL, OPENCLAW_DEFAULT_CLAUDE_MODEL]
106
212
 
107
- // 5. 启动 Gateway
108
- console.log(chalk.gray(' → 正在启动 Gateway...'))
109
- const ok = _startGateway()
213
+ const openaiModels = requestedModels.filter((model) => model.startsWith('gpt-'))
214
+ if (!openaiModels.includes(OPENCLAW_DEFAULT_MODEL)) {
215
+ openaiModels.unshift(OPENCLAW_DEFAULT_MODEL)
216
+ }
110
217
 
111
- if (ok) {
112
- console.log(chalk.green(' ✓ OpenClaw Gateway 已启动'))
113
- } else {
114
- console.log(chalk.yellow(' ⚠️ Gateway 启动中,稍等几秒后刷新浏览器'))
115
- }
218
+ const claudeModels = requestedModels.filter((model) => model.startsWith('claude-'))
219
+ if (claudeModels.length === 0) {
220
+ claudeModels.push(OPENCLAW_DEFAULT_CLAUDE_MODEL)
221
+ }
116
222
 
117
- const dashUrl = 'http://127.0.0.1:18789/'
118
- console.log(chalk.cyan('\n 浏览器打开(无需 token):'))
119
- console.log(chalk.bold.cyan(` ${dashUrl}`))
223
+ const openaiProviderName = buildProviderName(baseUrlOpenAI, 'custom-openai')
224
+ const anthropicProviderName = buildProviderName(baseUrlAnthropic, 'custom-anthropic')
120
225
 
121
- return { file: CONFIG_FILE, hot: false }
122
- },
226
+ const providers = {
227
+ [openaiProviderName]: {
228
+ baseUrl: baseUrlOpenAI,
229
+ apiKey,
230
+ api: 'openai-completions',
231
+ models: openaiModels.map(buildModelEntry),
232
+ },
233
+ [anthropicProviderName]: {
234
+ baseUrl: baseUrlAnthropic,
235
+ apiKey,
236
+ api: 'anthropic-messages',
237
+ models: claudeModels.map(buildModelEntry),
238
+ },
239
+ }
123
240
 
124
- reset() {
125
- try { fs.unlinkSync(CONFIG_FILE) } catch {}
126
- },
241
+ const managedModelRefs = [
242
+ ...openaiModels.map((id) => `${openaiProviderName}/${id}`),
243
+ ...claudeModels.map((id) => `${anthropicProviderName}/${id}`),
244
+ ]
127
245
 
128
- getConfigPath() { return CONFIG_FILE },
129
- hint: 'Gateway 已启动,打开浏览器即可使用',
130
- launchCmd: null,
131
- get launchNote() {
132
- return '🌐 打开浏览器: http://127.0.0.1:18789/'
133
- },
134
- installCmd: 'npm install -g openclaw@latest',
135
- docsUrl: 'https://docs.openclaw.ai',
246
+ return {
247
+ providers,
248
+ managedModelRefs,
249
+ primaryRef: `${openaiProviderName}/${OPENCLAW_DEFAULT_MODEL}`,
250
+ }
251
+ }
252
+
253
+ function isHolySheepProvider(provider) {
254
+ return typeof provider?.baseUrl === 'string' && provider.baseUrl.includes('api.holysheep.ai')
136
255
  }
137
256
 
138
- /** onboard 失败时的备用配置(基于实测的正确格式) */
139
- function _writeFallbackConfig(apiKey, baseUrl, selectedModels, primaryModel) {
257
+ function writeManagedConfig(baseConfig, apiKey, baseUrlAnthropic, baseUrlOpenAI, selectedModels, gatewayPort) {
140
258
  fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
141
259
 
142
- const hostname = new URL(baseUrl).hostname.replace(/\./g, '-')
143
- const providerName = `custom-api-${hostname}`
260
+ const plan = buildManagedPlan(apiKey, baseUrlAnthropic, baseUrlOpenAI, selectedModels)
261
+ const existingProviders = baseConfig?.models?.providers || {}
262
+ const managedProviderIds = Object.entries(existingProviders)
263
+ .filter(([, provider]) => isHolySheepProvider(provider))
264
+ .map(([providerId]) => providerId)
144
265
 
145
- // 默认配置的 Claude 模型列表(只注册 Claude 系列,MiniMax 用独立 provider)
146
- const claudeModels = (selectedModels || ['claude-sonnet-4-6'])
147
- .filter(m => m.startsWith('claude-'))
148
- if (claudeModels.length === 0) claudeModels.push('claude-sonnet-4-6')
266
+ const preservedProviders = Object.fromEntries(
267
+ Object.entries(existingProviders).filter(([, provider]) => !isHolySheepProvider(provider))
268
+ )
149
269
 
150
- const primary = primaryModel || claudeModels[0]
270
+ const existingModelMap = baseConfig?.agents?.defaults?.models || {}
271
+ const preservedModelMap = Object.fromEntries(
272
+ Object.entries(existingModelMap).filter(([ref]) => {
273
+ return !managedProviderIds.some((providerId) => ref.startsWith(`${providerId}/`))
274
+ })
275
+ )
151
276
 
152
- const config = {
277
+ const managedModelMap = Object.fromEntries(plan.managedModelRefs.map((ref) => [ref, {}]))
278
+
279
+ const nextConfig = {
280
+ ...baseConfig,
153
281
  models: {
282
+ ...(baseConfig.models || {}),
154
283
  mode: 'merge',
155
284
  providers: {
156
- [providerName]: {
157
- baseUrl,
158
- apiKey,
159
- api: 'anthropic-messages',
160
- models: claudeModels.map(id => ({
161
- id,
162
- name: `${id} (HolySheep)`,
163
- reasoning: false,
164
- input: ['text'],
165
- contextWindow: 200000,
166
- maxTokens: 16000,
167
- })),
168
- }
169
- }
285
+ ...preservedProviders,
286
+ ...plan.providers,
287
+ },
170
288
  },
171
289
  agents: {
290
+ ...(baseConfig.agents || {}),
172
291
  defaults: {
173
- model: { primary: `${providerName}/${primary}` }
174
- }
292
+ ...(baseConfig.agents?.defaults || {}),
293
+ model: {
294
+ ...(baseConfig.agents?.defaults?.model || {}),
295
+ primary: plan.primaryRef,
296
+ },
297
+ models: {
298
+ ...preservedModelMap,
299
+ ...managedModelMap,
300
+ },
301
+ },
175
302
  },
176
303
  gateway: {
304
+ ...(baseConfig.gateway || {}),
177
305
  mode: 'local',
178
- port: 18789,
306
+ port: gatewayPort,
179
307
  bind: 'loopback',
180
- auth: { mode: 'none' }, // 无需 token,本地访问直接打开
181
- }
308
+ auth: {
309
+ ...(baseConfig.gateway?.auth || {}),
310
+ mode: 'none',
311
+ },
312
+ },
182
313
  }
183
314
 
184
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8')
315
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(nextConfig, null, 2), 'utf8')
316
+ return plan
185
317
  }
186
318
 
187
- /** openclaw config set 把 gateway auth 改成 none */
188
- function _disableGatewayAuth() {
319
+ function _disableGatewayAuth(preferNpx = false) {
189
320
  try {
190
- npx('config', 'set', 'gateway.auth.mode', 'none')
321
+ runOpenClaw(['config', 'set', 'gateway.auth.mode', 'none'], { preferNpx })
191
322
  } catch {}
192
323
  }
193
324
 
194
- /** 启动 Gateway 后台进程 */
195
- function _startGateway() {
196
- if (isWin) {
197
- spawnSync('cmd /c start cmd /k "npx openclaw gateway --port 18789"', [], {
198
- shell: true, timeout: 5000, stdio: 'ignore',
199
- })
200
- } else {
201
- const child = spawn('openclaw', ['gateway', '--port', '18789'], {
202
- detached: true, stdio: 'ignore',
325
+ function _installGatewayService(port, preferNpx = false) {
326
+ const result = runOpenClaw(['gateway', 'install', '--force', '--port', String(port)], {
327
+ preferNpx,
328
+ timeout: 60000,
329
+ })
330
+ return result.status === 0
331
+ }
332
+
333
+ function _startGateway(port, preferNpx = false, preferService = true) {
334
+ const serviceResult = preferService
335
+ ? runOpenClaw(['gateway', 'start'], { preferNpx, timeout: 20000 })
336
+ : { status: 1 }
337
+
338
+ if (serviceResult.status !== 0) {
339
+ const child = spawnOpenClaw(['gateway', '--port', String(port)], {
340
+ preferNpx,
341
+ detached: true,
342
+ stdio: 'ignore',
203
343
  })
204
344
  child.unref()
205
345
  }
206
346
 
207
- // 等待最多 8 秒
208
347
  for (let i = 0; i < 8; i++) {
209
- const t = Date.now(); while (Date.now() - t < 1000) {}
348
+ const t0 = Date.now()
349
+ while (Date.now() - t0 < 1000) {}
350
+
210
351
  try {
211
352
  execSync(
212
353
  isWin
213
- ? 'powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:18789/ -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"'
214
- : 'curl -sf http://127.0.0.1:18789/ -o /dev/null --max-time 1',
354
+ ? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/ -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
355
+ : `curl -sf http://127.0.0.1:${port}/ -o /dev/null --max-time 1`,
215
356
  { stdio: 'ignore', timeout: 3000 }
216
357
  )
217
358
  return true
218
359
  } catch {}
219
360
  }
361
+
220
362
  return false
221
363
  }
364
+
365
+ module.exports = {
366
+ name: 'OpenClaw',
367
+ id: 'openclaw',
368
+
369
+ checkInstalled() {
370
+ return hasOpenClawBinary()
371
+ },
372
+
373
+ detectRuntime,
374
+
375
+ getVersion() {
376
+ return detectRuntime().version
377
+ },
378
+
379
+ isConfigured() {
380
+ const cfg = JSON.stringify(readConfig())
381
+ return cfg.includes('holysheep.ai')
382
+ },
383
+
384
+ configure(apiKey, baseUrlAnthropic, baseUrlOpenAI, _primaryModel, selectedModels) {
385
+ const chalk = require('chalk')
386
+ console.log(chalk.gray('\n ⚙️ 正在配置 OpenClaw...'))
387
+
388
+ const runtime = detectRuntime()
389
+ if (!runtime.available) {
390
+ throw new Error('未检测到 OpenClaw;请先全局安装,或确保 npx 可用')
391
+ }
392
+ this._lastRuntimeCommand = runtime.command
393
+
394
+ runOpenClaw(['gateway', 'stop'], { preferNpx: runtime.via === 'npx' })
395
+
396
+ const gatewayPort = findAvailableGatewayPort(DEFAULT_GATEWAY_PORT)
397
+ if (!gatewayPort) {
398
+ throw new Error(`找不到可用端口(已检查 ${DEFAULT_GATEWAY_PORT}-${DEFAULT_GATEWAY_PORT + MAX_PORT_SCAN - 1})`)
399
+ }
400
+ this._lastGatewayPort = gatewayPort
401
+
402
+ if (gatewayPort !== DEFAULT_GATEWAY_PORT) {
403
+ console.log(chalk.yellow(` ⚠️ 端口 ${DEFAULT_GATEWAY_PORT} 已占用,自动切换到 ${gatewayPort}`))
404
+ const listeners = listPortListeners(DEFAULT_GATEWAY_PORT)
405
+ if (listeners.length) {
406
+ const summary = listeners
407
+ .slice(0, 2)
408
+ .map((item) => `${item.command}(${item.pid})`)
409
+ .join(', ')
410
+ console.log(chalk.gray(` 占用进程: ${summary}`))
411
+ }
412
+ }
413
+
414
+ try { fs.unlinkSync(CONFIG_FILE) } catch {}
415
+
416
+ console.log(chalk.gray(' → 写入配置...'))
417
+ const result = runOpenClaw([
418
+ 'onboard',
419
+ '--non-interactive',
420
+ '--accept-risk',
421
+ '--auth-choice', 'custom-api-key',
422
+ '--custom-base-url', baseUrlOpenAI,
423
+ '--custom-api-key', apiKey,
424
+ '--custom-model-id', OPENCLAW_DEFAULT_MODEL,
425
+ '--custom-compatibility', 'openai',
426
+ '--gateway-port', String(gatewayPort),
427
+ '--install-daemon',
428
+ ], { preferNpx: runtime.via === 'npx' })
429
+
430
+ if (result.status !== 0) {
431
+ console.log(chalk.yellow(' ⚠️ onboard 失败,使用备用配置...'))
432
+ }
433
+
434
+ writeManagedConfig(
435
+ result.status === 0 ? readConfig() : {},
436
+ apiKey,
437
+ baseUrlAnthropic,
438
+ baseUrlOpenAI,
439
+ selectedModels,
440
+ gatewayPort,
441
+ )
442
+
443
+ _disableGatewayAuth(runtime.via === 'npx')
444
+ const serviceReady = _installGatewayService(gatewayPort, runtime.via === 'npx')
445
+
446
+ console.log(chalk.gray(' → 正在启动 Gateway...'))
447
+ const ok = _startGateway(gatewayPort, runtime.via === 'npx', serviceReady)
448
+
449
+ if (ok) {
450
+ console.log(chalk.green(' ✓ OpenClaw Gateway 已启动'))
451
+ } else {
452
+ console.log(chalk.yellow(' ⚠️ Gateway 启动中,稍等几秒后刷新浏览器'))
453
+ }
454
+
455
+ const dashUrl = `http://127.0.0.1:${gatewayPort}/`
456
+ console.log(chalk.cyan('\n → 浏览器打开(无需 token):'))
457
+ console.log(chalk.bold.cyan(` ${dashUrl}`))
458
+ console.log(chalk.gray(` 默认模型: ${OPENCLAW_DEFAULT_MODEL}`))
459
+
460
+ return {
461
+ file: CONFIG_FILE,
462
+ hot: false,
463
+ dashboardUrl: dashUrl,
464
+ gatewayPort,
465
+ launchCmd: getLaunchCommand(gatewayPort),
466
+ }
467
+ },
468
+
469
+ reset() {
470
+ try { fs.unlinkSync(CONFIG_FILE) } catch {}
471
+ },
472
+
473
+ getConfigPath() { return CONFIG_FILE },
474
+ getGatewayPort() { return getConfiguredGatewayPort() },
475
+ getPrimaryModel() { return getConfiguredPrimaryModel() },
476
+ getPortListeners(port = getConfiguredGatewayPort()) { return listPortListeners(port) },
477
+ get hint() {
478
+ return `Gateway 已启动,默认模型为 ${getConfiguredPrimaryModel() || OPENCLAW_DEFAULT_MODEL}`
479
+ },
480
+ get launchCmd() {
481
+ return getLaunchCommand(getConfiguredGatewayPort())
482
+ },
483
+ get launchNote() {
484
+ return `🌐 打开浏览器: http://127.0.0.1:${getConfiguredGatewayPort()}/`
485
+ },
486
+ installCmd: 'npm install -g openclaw@latest',
487
+ docsUrl: 'https://docs.openclaw.ai',
488
+ }