iivo-sub 0.1.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.
- package/README.md +33 -0
- package/bin/iivo-sub.js +854 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# iivo-sub
|
|
2
|
+
|
|
3
|
+
Interactive quick configuration tool for IIVO internal AI gateway clients.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx iivo-sub
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Local development:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node cli/iivo-sub/bin/iivo-sub.js
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The CLI asks for:
|
|
16
|
+
|
|
17
|
+
- API Key
|
|
18
|
+
- API Host, defaulting to `https://sub.iivo.net`
|
|
19
|
+
- Model
|
|
20
|
+
- Quick configuration scenario: OpenClaw, Hermes, Codex, or Claude Code
|
|
21
|
+
|
|
22
|
+
In an interactive terminal, press `Esc` to return to the previous step.
|
|
23
|
+
|
|
24
|
+
The main menu includes:
|
|
25
|
+
|
|
26
|
+
- Quick configuration
|
|
27
|
+
- Backup configuration
|
|
28
|
+
- Restore backup
|
|
29
|
+
- Exit
|
|
30
|
+
|
|
31
|
+
Backup configuration lets you choose Codex, Claude Code, Hermes, OpenClaw, or all targets. You can name the backup yourself; the default name is `<date-time>_<target>`.
|
|
32
|
+
|
|
33
|
+
Existing config files are backed up under `~/.iivo-sub/backups/` before being replaced.
|
package/bin/iivo-sub.js
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import readline from 'node:readline'
|
|
7
|
+
|
|
8
|
+
const VERSION = '0.1.0'
|
|
9
|
+
const APP_DIR = path.join(os.homedir(), '.iivo-sub')
|
|
10
|
+
const SCRIPTED_INPUT = !process.stdin.isTTY ? fs.readFileSync(0, 'utf8').split(/\r?\n/) : null
|
|
11
|
+
let scriptedInputIndex = 0
|
|
12
|
+
const DEFAULT_HOST = 'https://sub.iivo.net'
|
|
13
|
+
const HOST_OPTIONS = [
|
|
14
|
+
{ label: 'sub.iivo.net', value: 'https://sub.iivo.net' },
|
|
15
|
+
{ label: 'ai.iivo.net', value: 'https://ai.iivo.net' },
|
|
16
|
+
{ label: '手动输入 / Custom', value: '__custom__' }
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const MODEL_OPTIONS = [
|
|
20
|
+
'gpt-5.3-codex',
|
|
21
|
+
'gpt-5.3-codex-spark',
|
|
22
|
+
'gpt-5.2',
|
|
23
|
+
'gpt-5.5',
|
|
24
|
+
'claude-sonnet-4-6',
|
|
25
|
+
'claude-opus-4-6',
|
|
26
|
+
'gemini-2.5-pro',
|
|
27
|
+
'gemini-2.5-flash',
|
|
28
|
+
'手动输入 / Custom'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const SCENARIOS = [
|
|
32
|
+
{ label: 'OpenClaw', value: 'openclaw' },
|
|
33
|
+
{ label: 'Hermes', value: 'hermes' },
|
|
34
|
+
{ label: 'Codex', value: 'codex' },
|
|
35
|
+
{ label: 'Claude Code', value: 'claude-code' }
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const MAIN_MENU = [
|
|
39
|
+
{ label: '快速配置', value: 'quick' },
|
|
40
|
+
{ label: '备份配置', value: 'backup' },
|
|
41
|
+
{ label: '恢复备份', value: 'restore' },
|
|
42
|
+
{ label: '退出', value: 'exit' }
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const BACKUP_TARGETS = [
|
|
46
|
+
{ label: 'Codex', value: 'codex' },
|
|
47
|
+
{ label: 'Claude Code', value: 'claude-code' },
|
|
48
|
+
{ label: 'Hermes', value: 'hermes' },
|
|
49
|
+
{ label: 'OpenClaw', value: 'openclaw' },
|
|
50
|
+
{ label: '全部备份', value: 'all' }
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const RESTORE_CONFIRM_OPTIONS = [
|
|
54
|
+
{ label: '确认恢复', value: 'yes' },
|
|
55
|
+
{ label: '取消', value: 'no' }
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const colors = {
|
|
59
|
+
reset: '\x1b[0m',
|
|
60
|
+
dim: '\x1b[2m',
|
|
61
|
+
cyan: '\x1b[36m',
|
|
62
|
+
green: '\x1b[32m',
|
|
63
|
+
yellow: '\x1b[33m',
|
|
64
|
+
red: '\x1b[31m',
|
|
65
|
+
magenta: '\x1b[35m',
|
|
66
|
+
bold: '\x1b[1m'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function c(color, text) {
|
|
70
|
+
return `${colors[color] || ''}${text}${colors.reset}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clear() {
|
|
74
|
+
if (process.stdout.isTTY) {
|
|
75
|
+
process.stdout.write('\x1b[2J\x1b[0f')
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function banner() {
|
|
80
|
+
const lines = [
|
|
81
|
+
`IIVO SUB 助手 v${VERSION}`,
|
|
82
|
+
'AI 基站快速配置工具'
|
|
83
|
+
]
|
|
84
|
+
const width = 42
|
|
85
|
+
const border = '─'.repeat(width)
|
|
86
|
+
console.log(c('magenta', `┌${border}┐`))
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
const visual = stringWidth(line)
|
|
89
|
+
const left = Math.floor((width - visual) / 2)
|
|
90
|
+
const right = width - visual - left
|
|
91
|
+
console.log(c('magenta', '│') + ' '.repeat(left) + c('bold', line) + ' '.repeat(right) + c('magenta', '│'))
|
|
92
|
+
}
|
|
93
|
+
console.log(c('magenta', `└${border}┘`))
|
|
94
|
+
console.log()
|
|
95
|
+
console.log(`中转站地址: ${c('green', DEFAULT_HOST)}`)
|
|
96
|
+
console.log()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stringWidth(value) {
|
|
100
|
+
let width = 0
|
|
101
|
+
for (const char of [...value]) {
|
|
102
|
+
width += /[\u4e00-\u9fff\uff00-\uffef]/.test(char) ? 2 : 1
|
|
103
|
+
}
|
|
104
|
+
return width
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function question(prompt, { mask = false, defaultValue = '' } = {}) {
|
|
108
|
+
if (SCRIPTED_INPUT) {
|
|
109
|
+
const answer = SCRIPTED_INPUT[scriptedInputIndex++] ?? ''
|
|
110
|
+
const fullPrompt = defaultValue ? `${prompt} (${defaultValue}): ` : `${prompt}: `
|
|
111
|
+
process.stdout.write(fullPrompt)
|
|
112
|
+
process.stdout.write(mask ? '********\n' : `${answer}\n`)
|
|
113
|
+
return Promise.resolve(answer.trim() || defaultValue)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
117
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
118
|
+
const fullPrompt = defaultValue ? `${prompt} ${c('dim', `(${defaultValue})`)}: ` : `${prompt}: `
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
rl.question(fullPrompt, (answer) => {
|
|
121
|
+
rl.close()
|
|
122
|
+
resolve(answer.trim() || defaultValue)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return interactiveQuestion(prompt, { mask, defaultValue })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function interactiveQuestion(prompt, { mask = false, defaultValue = '' } = {}) {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
let buffer = ''
|
|
133
|
+
const promptText = defaultValue ? `${prompt} ${c('dim', `(${defaultValue})`)}: ` : `${prompt}: `
|
|
134
|
+
const wasRaw = process.stdin.isRaw
|
|
135
|
+
|
|
136
|
+
readline.emitKeypressEvents(process.stdin)
|
|
137
|
+
process.stdin.setRawMode(true)
|
|
138
|
+
process.stdout.write(promptText)
|
|
139
|
+
|
|
140
|
+
const cleanup = (value) => {
|
|
141
|
+
process.stdin.off('keypress', onKey)
|
|
142
|
+
process.stdin.setRawMode(wasRaw)
|
|
143
|
+
process.stdout.write('\n')
|
|
144
|
+
resolve(value)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const onKey = (str, key) => {
|
|
148
|
+
if (key?.ctrl && key.name === 'c') {
|
|
149
|
+
cleanup('__exit__')
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (key?.name === 'escape') {
|
|
153
|
+
cleanup(null)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (key?.name === 'return') {
|
|
157
|
+
cleanup(buffer.trim() || defaultValue)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (key?.name === 'backspace') {
|
|
161
|
+
if (buffer.length > 0) {
|
|
162
|
+
buffer = [...buffer].slice(0, -1).join('')
|
|
163
|
+
process.stdout.write('\b \b')
|
|
164
|
+
}
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (!str || key?.name === 'tab') return
|
|
168
|
+
|
|
169
|
+
buffer += str
|
|
170
|
+
process.stdout.write(mask ? '*'.repeat([...str].length) : str)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
process.stdin.on('keypress', onKey)
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function select(title, options, { allowEsc = true } = {}) {
|
|
178
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
179
|
+
console.log(title)
|
|
180
|
+
options.forEach((item, index) => console.log(`${index + 1}. ${item.label ?? item}`))
|
|
181
|
+
const answer = await question('请输入序号')
|
|
182
|
+
const index = Number(answer) - 1
|
|
183
|
+
const item = options[index]
|
|
184
|
+
return item && Object.prototype.hasOwnProperty.call(item, 'value') ? item.value : (item ?? null)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let index = 0
|
|
188
|
+
readline.emitKeypressEvents(process.stdin)
|
|
189
|
+
const wasRaw = process.stdin.isRaw
|
|
190
|
+
process.stdin.setRawMode(true)
|
|
191
|
+
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const render = () => {
|
|
194
|
+
clear()
|
|
195
|
+
banner()
|
|
196
|
+
console.log(`${title} ${c('cyan', '[使用上下方向键移动,回车确认,Esc 返回上一层]')}`)
|
|
197
|
+
options.forEach((item, itemIndex) => {
|
|
198
|
+
const label = item.label ?? item
|
|
199
|
+
const prefix = itemIndex === index ? c('cyan', '>') : ' '
|
|
200
|
+
const text = itemIndex === index ? c('cyan', label) : label
|
|
201
|
+
console.log(`${prefix} ${text}`)
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const cleanup = (value) => {
|
|
206
|
+
process.stdin.off('keypress', onKey)
|
|
207
|
+
process.stdin.setRawMode(wasRaw)
|
|
208
|
+
resolve(value)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const onKey = (_str, key) => {
|
|
212
|
+
if (key.name === 'up') {
|
|
213
|
+
index = (index - 1 + options.length) % options.length
|
|
214
|
+
render()
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
if (key.name === 'down') {
|
|
218
|
+
index = (index + 1) % options.length
|
|
219
|
+
render()
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (key.name === 'return') {
|
|
223
|
+
const item = options[index]
|
|
224
|
+
cleanup(item && Object.prototype.hasOwnProperty.call(item, 'value') ? item.value : item)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (allowEsc && key.name === 'escape') {
|
|
228
|
+
cleanup(null)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (key.ctrl && key.name === 'c') {
|
|
232
|
+
cleanup('__exit__')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
render()
|
|
237
|
+
process.stdin.on('keypress', onKey)
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function normalizeHost(input) {
|
|
242
|
+
const trimmed = String(input || '').trim()
|
|
243
|
+
if (!trimmed) return DEFAULT_HOST
|
|
244
|
+
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
|
245
|
+
return withProtocol.replace(/\/+$/, '')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function ensureDir(dir) {
|
|
249
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fileExists(filePath) {
|
|
253
|
+
return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function configTargets() {
|
|
257
|
+
return {
|
|
258
|
+
codex: [
|
|
259
|
+
path.join(os.homedir(), '.codex', 'config.toml'),
|
|
260
|
+
path.join(os.homedir(), '.codex', 'auth.json'),
|
|
261
|
+
path.join(APP_DIR, 'codex.env'),
|
|
262
|
+
path.join(APP_DIR, 'codex.ps1'),
|
|
263
|
+
path.join(APP_DIR, 'codex.cmd')
|
|
264
|
+
],
|
|
265
|
+
'claude-code': [
|
|
266
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
267
|
+
detectShellProfile(),
|
|
268
|
+
path.join(APP_DIR, 'claude-code.env'),
|
|
269
|
+
path.join(APP_DIR, 'claude-code.ps1'),
|
|
270
|
+
path.join(APP_DIR, 'claude-code.cmd')
|
|
271
|
+
],
|
|
272
|
+
hermes: [
|
|
273
|
+
path.join(os.homedir(), '.hermes', 'iivo-sub.json'),
|
|
274
|
+
path.join(os.homedir(), '.hermes', '.env'),
|
|
275
|
+
path.join(APP_DIR, 'hermes.env'),
|
|
276
|
+
path.join(APP_DIR, 'hermes.ps1'),
|
|
277
|
+
path.join(APP_DIR, 'hermes.cmd')
|
|
278
|
+
],
|
|
279
|
+
openclaw: [
|
|
280
|
+
path.join(os.homedir(), '.openclaw', 'iivo-sub.json'),
|
|
281
|
+
path.join(os.homedir(), '.openclaw', 'iivo-sub.env'),
|
|
282
|
+
path.join(APP_DIR, 'openclaw.env'),
|
|
283
|
+
path.join(APP_DIR, 'openclaw.ps1'),
|
|
284
|
+
path.join(APP_DIR, 'openclaw.cmd')
|
|
285
|
+
]
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function filesForBackupTarget(target) {
|
|
290
|
+
const targets = configTargets()
|
|
291
|
+
if (target === 'all') {
|
|
292
|
+
return Object.values(targets).flat()
|
|
293
|
+
}
|
|
294
|
+
return targets[target] ?? []
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function uniqueFiles(files) {
|
|
298
|
+
return [...new Set(files)]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function backupTargetLabel(value) {
|
|
302
|
+
return BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function timestampForName() {
|
|
306
|
+
const now = new Date()
|
|
307
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
308
|
+
return [
|
|
309
|
+
now.getFullYear(),
|
|
310
|
+
pad(now.getMonth() + 1),
|
|
311
|
+
pad(now.getDate())
|
|
312
|
+
].join('-') + '_' + [
|
|
313
|
+
pad(now.getHours()),
|
|
314
|
+
pad(now.getMinutes()),
|
|
315
|
+
pad(now.getSeconds())
|
|
316
|
+
].join('-')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function safeBackupName(value) {
|
|
320
|
+
return String(value || '')
|
|
321
|
+
.trim()
|
|
322
|
+
.replace(/[\\/]/g, '-')
|
|
323
|
+
.replace(/[\x00-\x1f<>:"|?*]/g, '-')
|
|
324
|
+
.replace(/\s+/g, '_')
|
|
325
|
+
.replace(/^-+|-+$/g, '')
|
|
326
|
+
.slice(0, 120)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function toBackupFileName(filePath) {
|
|
330
|
+
return path.relative(os.homedir(), filePath).replaceAll(path.sep, '__')
|
|
331
|
+
|| path.basename(filePath)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function copyConfigToBackup(filePath, backupRoot) {
|
|
335
|
+
const backupFile = toBackupFileName(filePath)
|
|
336
|
+
const backupPath = path.join(backupRoot, 'files', backupFile)
|
|
337
|
+
ensureDir(path.dirname(backupPath))
|
|
338
|
+
fs.copyFileSync(filePath, backupPath)
|
|
339
|
+
return {
|
|
340
|
+
source: filePath,
|
|
341
|
+
backup: path.relative(backupRoot, backupPath)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function loadBackupManifest(dir) {
|
|
346
|
+
const manifestPath = path.join(dir, 'manifest.json')
|
|
347
|
+
if (!fileExists(manifestPath)) return null
|
|
348
|
+
try {
|
|
349
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
350
|
+
} catch {
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function writeFileWithBackup(filePath, content, backupRoot) {
|
|
356
|
+
ensureDir(path.dirname(filePath))
|
|
357
|
+
if (fs.existsSync(filePath)) {
|
|
358
|
+
const relative = path.relative(os.homedir(), filePath).replaceAll(path.sep, '__')
|
|
359
|
+
const backupPath = path.join(backupRoot, relative || path.basename(filePath))
|
|
360
|
+
ensureDir(path.dirname(backupPath))
|
|
361
|
+
fs.copyFileSync(filePath, backupPath)
|
|
362
|
+
}
|
|
363
|
+
fs.writeFileSync(filePath, content, 'utf8')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function appendManagedBlock(filePath, block, marker, backupRoot) {
|
|
367
|
+
ensureDir(path.dirname(filePath))
|
|
368
|
+
let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
|
|
369
|
+
if (fs.existsSync(filePath)) {
|
|
370
|
+
const relative = path.relative(os.homedir(), filePath).replaceAll(path.sep, '__')
|
|
371
|
+
fs.copyFileSync(filePath, path.join(backupRoot, relative || path.basename(filePath)))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const start = `# >>> ${marker} >>>`
|
|
375
|
+
const end = `# <<< ${marker} <<<`
|
|
376
|
+
const managed = `${start}\n${block.trim()}\n${end}`
|
|
377
|
+
const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`)
|
|
378
|
+
content = pattern.test(content)
|
|
379
|
+
? content.replace(pattern, managed)
|
|
380
|
+
: `${content.replace(/\s*$/, '')}\n\n${managed}\n`
|
|
381
|
+
fs.writeFileSync(filePath, content, 'utf8')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function escapeRegExp(value) {
|
|
385
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function json(value) {
|
|
389
|
+
return `${JSON.stringify(value, null, 2)}\n`
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function shellExports(config) {
|
|
393
|
+
return Object.entries(config)
|
|
394
|
+
.map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
|
|
395
|
+
.join('\n')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function powershellExports(config) {
|
|
399
|
+
return Object.entries(config)
|
|
400
|
+
.map(([key, value]) => `$env:${key}=${JSON.stringify(String(value))}`)
|
|
401
|
+
.join('\n')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function cmdExports(config) {
|
|
405
|
+
return Object.entries(config)
|
|
406
|
+
.map(([key, value]) => `set ${key}=${String(value)}`)
|
|
407
|
+
.join('\r\n')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function codexToml({ host, model }) {
|
|
411
|
+
return `model_provider = "OpenAI"
|
|
412
|
+
model = "${model}"
|
|
413
|
+
review_model = "${model}"
|
|
414
|
+
model_reasoning_effort = "xhigh"
|
|
415
|
+
disable_response_storage = true
|
|
416
|
+
network_access = "enabled"
|
|
417
|
+
windows_wsl_setup_acknowledged = true
|
|
418
|
+
|
|
419
|
+
[model_providers.OpenAI]
|
|
420
|
+
name = "OpenAI"
|
|
421
|
+
base_url = "${host}"
|
|
422
|
+
wire_api = "responses"
|
|
423
|
+
requires_openai_auth = true
|
|
424
|
+
|
|
425
|
+
[features]
|
|
426
|
+
goals = true
|
|
427
|
+
`
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function claudeSettings({ host, apiKey }) {
|
|
431
|
+
return json({
|
|
432
|
+
env: {
|
|
433
|
+
ANTHROPIC_BASE_URL: host,
|
|
434
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
435
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
436
|
+
CLAUDE_CODE_ATTRIBUTION_HEADER: '0'
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function commonEnv({ host, apiKey, model }) {
|
|
442
|
+
const baseRoot = host.replace(/\/v1\/?$/, '').replace(/\/+$/, '')
|
|
443
|
+
return {
|
|
444
|
+
IIVO_SUB_API_HOST: baseRoot,
|
|
445
|
+
IIVO_SUB_MODEL: model,
|
|
446
|
+
OPENAI_BASE_URL: `${baseRoot}/v1`,
|
|
447
|
+
OPENAI_API_KEY: apiKey,
|
|
448
|
+
ANTHROPIC_BASE_URL: baseRoot,
|
|
449
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
450
|
+
GOOGLE_GEMINI_BASE_URL: `${baseRoot}/v1beta`,
|
|
451
|
+
GEMINI_API_KEY: apiKey,
|
|
452
|
+
GEMINI_MODEL: model
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function createPlan({ scenario, host, apiKey, model }) {
|
|
457
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
458
|
+
const backupRoot = path.join(APP_DIR, 'backups', timestamp)
|
|
459
|
+
const files = []
|
|
460
|
+
const baseConfig = { scenario, host, model, configured_at: new Date().toISOString() }
|
|
461
|
+
|
|
462
|
+
files.push({
|
|
463
|
+
path: path.join(APP_DIR, 'config.json'),
|
|
464
|
+
content: json({ ...baseConfig, api_key_hint: maskKey(apiKey) }),
|
|
465
|
+
mode: 'write'
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
if (scenario === 'codex') {
|
|
469
|
+
files.push({
|
|
470
|
+
path: path.join(os.homedir(), '.codex', 'config.toml'),
|
|
471
|
+
content: codexToml({ host, model }),
|
|
472
|
+
mode: 'write'
|
|
473
|
+
})
|
|
474
|
+
files.push({
|
|
475
|
+
path: path.join(os.homedir(), '.codex', 'auth.json'),
|
|
476
|
+
content: json({ OPENAI_API_KEY: apiKey }),
|
|
477
|
+
mode: 'write'
|
|
478
|
+
})
|
|
479
|
+
} else if (scenario === 'claude-code') {
|
|
480
|
+
files.push({
|
|
481
|
+
path: path.join(os.homedir(), '.claude', 'settings.json'),
|
|
482
|
+
content: claudeSettings({ host, apiKey }),
|
|
483
|
+
mode: 'write'
|
|
484
|
+
})
|
|
485
|
+
const marker = 'iivo-sub claude-code'
|
|
486
|
+
files.push({
|
|
487
|
+
path: detectShellProfile(),
|
|
488
|
+
content: shellExports({
|
|
489
|
+
ANTHROPIC_BASE_URL: host,
|
|
490
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
491
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
492
|
+
CLAUDE_CODE_ATTRIBUTION_HEADER: '0'
|
|
493
|
+
}),
|
|
494
|
+
mode: 'managed',
|
|
495
|
+
marker
|
|
496
|
+
})
|
|
497
|
+
} else if (scenario === 'openclaw') {
|
|
498
|
+
const env = commonEnv({ host, apiKey, model })
|
|
499
|
+
files.push({
|
|
500
|
+
path: path.join(os.homedir(), '.openclaw', 'iivo-sub.json'),
|
|
501
|
+
content: json({ ...baseConfig, env }),
|
|
502
|
+
mode: 'write'
|
|
503
|
+
})
|
|
504
|
+
files.push({
|
|
505
|
+
path: path.join(os.homedir(), '.openclaw', 'iivo-sub.env'),
|
|
506
|
+
content: `${shellExports(env)}\n`,
|
|
507
|
+
mode: 'write'
|
|
508
|
+
})
|
|
509
|
+
} else if (scenario === 'hermes') {
|
|
510
|
+
const env = commonEnv({ host, apiKey, model })
|
|
511
|
+
files.push({
|
|
512
|
+
path: path.join(os.homedir(), '.hermes', 'iivo-sub.json'),
|
|
513
|
+
content: json({ ...baseConfig, env }),
|
|
514
|
+
mode: 'write'
|
|
515
|
+
})
|
|
516
|
+
files.push({
|
|
517
|
+
path: path.join(os.homedir(), '.hermes', '.env'),
|
|
518
|
+
content: `${Object.entries(env).map(([key, value]) => `${key}=${String(value)}`).join('\n')}\n`,
|
|
519
|
+
mode: 'write'
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
files.push({
|
|
524
|
+
path: path.join(APP_DIR, `${scenario}.env`),
|
|
525
|
+
content: `${shellExports(commonEnv({ host, apiKey, model }))}\n`,
|
|
526
|
+
mode: 'write'
|
|
527
|
+
})
|
|
528
|
+
files.push({
|
|
529
|
+
path: path.join(APP_DIR, `${scenario}.ps1`),
|
|
530
|
+
content: `${powershellExports(commonEnv({ host, apiKey, model }))}\n`,
|
|
531
|
+
mode: 'write'
|
|
532
|
+
})
|
|
533
|
+
files.push({
|
|
534
|
+
path: path.join(APP_DIR, `${scenario}.cmd`),
|
|
535
|
+
content: `${cmdExports(commonEnv({ host, apiKey, model }))}\r\n`,
|
|
536
|
+
mode: 'write'
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
return { backupRoot, files }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function detectShellProfile() {
|
|
543
|
+
const shell = process.env.SHELL || ''
|
|
544
|
+
if (shell.includes('zsh')) return path.join(os.homedir(), '.zshrc')
|
|
545
|
+
if (shell.includes('bash')) return path.join(os.homedir(), '.bashrc')
|
|
546
|
+
return path.join(os.homedir(), '.profile')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function maskKey(key) {
|
|
550
|
+
if (key.length <= 8) return '********'
|
|
551
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function quickConfig() {
|
|
555
|
+
const state = {
|
|
556
|
+
apiKey: '',
|
|
557
|
+
host: DEFAULT_HOST,
|
|
558
|
+
model: 'gpt-5.3-codex',
|
|
559
|
+
scenario: ''
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let step = 0
|
|
563
|
+
while (step < 4) {
|
|
564
|
+
if (step === 0) {
|
|
565
|
+
clear()
|
|
566
|
+
banner()
|
|
567
|
+
const apiKey = await question('请输入 API Key [Esc 返回主菜单]', { mask: true })
|
|
568
|
+
if (apiKey === '__exit__') return '__exit__'
|
|
569
|
+
if (apiKey === null) return
|
|
570
|
+
if (!apiKey) {
|
|
571
|
+
console.log(c('red', 'API Key 不能为空'))
|
|
572
|
+
await pause()
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
state.apiKey = apiKey
|
|
576
|
+
step = 1
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (step === 1) {
|
|
581
|
+
const hostChoice = await select('请选择 API Host:', HOST_OPTIONS)
|
|
582
|
+
if (hostChoice === '__exit__') return '__exit__'
|
|
583
|
+
if (hostChoice === null) {
|
|
584
|
+
step = 0
|
|
585
|
+
continue
|
|
586
|
+
}
|
|
587
|
+
if (hostChoice === '__custom__') {
|
|
588
|
+
clear()
|
|
589
|
+
banner()
|
|
590
|
+
const customHost = await question('请输入 API Host [Esc 返回上一步]', { defaultValue: state.host || DEFAULT_HOST })
|
|
591
|
+
if (customHost === '__exit__') return '__exit__'
|
|
592
|
+
if (customHost === null) continue
|
|
593
|
+
state.host = normalizeHost(customHost)
|
|
594
|
+
} else {
|
|
595
|
+
state.host = normalizeHost(hostChoice)
|
|
596
|
+
}
|
|
597
|
+
step = 2
|
|
598
|
+
continue
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (step === 2) {
|
|
602
|
+
const modelChoice = await select('请选择模型:', MODEL_OPTIONS)
|
|
603
|
+
if (modelChoice === '__exit__') return '__exit__'
|
|
604
|
+
if (modelChoice === null) {
|
|
605
|
+
step = 1
|
|
606
|
+
continue
|
|
607
|
+
}
|
|
608
|
+
if (modelChoice === '手动输入 / Custom') {
|
|
609
|
+
clear()
|
|
610
|
+
banner()
|
|
611
|
+
const customModel = await question('请输入模型名 [Esc 返回上一步]', { defaultValue: state.model || 'gpt-5.3-codex' })
|
|
612
|
+
if (customModel === '__exit__') return '__exit__'
|
|
613
|
+
if (customModel === null) continue
|
|
614
|
+
state.model = String(customModel).trim()
|
|
615
|
+
} else {
|
|
616
|
+
state.model = modelChoice
|
|
617
|
+
}
|
|
618
|
+
step = 3
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const scenario = await select('请选择要快速配置的场景:', SCENARIOS)
|
|
623
|
+
if (scenario === '__exit__') return '__exit__'
|
|
624
|
+
if (scenario === null) {
|
|
625
|
+
step = 2
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
state.scenario = scenario
|
|
629
|
+
step = 4
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const { apiKey, host, model, scenario } = state
|
|
633
|
+
const plan = createPlan({ scenario, host, apiKey, model })
|
|
634
|
+
ensureDir(plan.backupRoot)
|
|
635
|
+
for (const file of plan.files) {
|
|
636
|
+
if (file.mode === 'managed') {
|
|
637
|
+
appendManagedBlock(file.path, file.content, file.marker, plan.backupRoot)
|
|
638
|
+
} else {
|
|
639
|
+
writeFileWithBackup(file.path, file.content, plan.backupRoot)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
clear()
|
|
644
|
+
banner()
|
|
645
|
+
console.log(c('green', '配置完成。'))
|
|
646
|
+
console.log()
|
|
647
|
+
console.log(`场景: ${scenarioLabel(scenario)}`)
|
|
648
|
+
console.log(`API Host: ${host}`)
|
|
649
|
+
console.log(`模型: ${model}`)
|
|
650
|
+
console.log(`API Key: ${maskKey(apiKey)}`)
|
|
651
|
+
console.log()
|
|
652
|
+
console.log(c('cyan', '写入文件:'))
|
|
653
|
+
for (const file of plan.files) {
|
|
654
|
+
console.log(`- ${file.path}`)
|
|
655
|
+
}
|
|
656
|
+
console.log()
|
|
657
|
+
console.log(c('yellow', `备份目录: ${plan.backupRoot}`))
|
|
658
|
+
console.log()
|
|
659
|
+
console.log('临时环境变量也已生成,可按需 source:')
|
|
660
|
+
console.log(c('cyan', `source ${path.join(APP_DIR, `${scenario}.env`)}`))
|
|
661
|
+
await pause()
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function scenarioLabel(value) {
|
|
665
|
+
return SCENARIOS.find((item) => item.value === value)?.label ?? value
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function backupConfig() {
|
|
669
|
+
clear()
|
|
670
|
+
banner()
|
|
671
|
+
const target = await select('请选择备份目标:', BACKUP_TARGETS)
|
|
672
|
+
if (target === '__exit__') return '__exit__'
|
|
673
|
+
if (!target) return
|
|
674
|
+
|
|
675
|
+
const defaultName = `${timestampForName()}_${target}`
|
|
676
|
+
clear()
|
|
677
|
+
banner()
|
|
678
|
+
const rawName = await question('请输入备份名称 [Esc 返回上一步]', { defaultValue: defaultName })
|
|
679
|
+
if (rawName === '__exit__') return '__exit__'
|
|
680
|
+
if (rawName === null) return backupConfig()
|
|
681
|
+
|
|
682
|
+
const name = safeBackupName(rawName) || defaultName
|
|
683
|
+
const backupRoot = path.join(APP_DIR, 'backups', name)
|
|
684
|
+
if (fs.existsSync(backupRoot)) {
|
|
685
|
+
console.log(c('red', `备份名称已存在: ${name}`))
|
|
686
|
+
await pause()
|
|
687
|
+
return backupConfig()
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const candidates = uniqueFiles(filesForBackupTarget(target))
|
|
691
|
+
const existing = candidates.filter(fileExists)
|
|
692
|
+
if (existing.length === 0) {
|
|
693
|
+
console.log(c('yellow', `没有找到可备份的 ${backupTargetLabel(target)} 配置。`))
|
|
694
|
+
await pause()
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
ensureDir(backupRoot)
|
|
699
|
+
const files = existing.map((filePath) => copyConfigToBackup(filePath, backupRoot))
|
|
700
|
+
const manifest = {
|
|
701
|
+
name,
|
|
702
|
+
target,
|
|
703
|
+
target_label: backupTargetLabel(target),
|
|
704
|
+
created_at: new Date().toISOString(),
|
|
705
|
+
files
|
|
706
|
+
}
|
|
707
|
+
fs.writeFileSync(path.join(backupRoot, 'manifest.json'), json(manifest), 'utf8')
|
|
708
|
+
|
|
709
|
+
clear()
|
|
710
|
+
banner()
|
|
711
|
+
console.log(c('green', '备份完成。'))
|
|
712
|
+
console.log()
|
|
713
|
+
console.log(`备份名称: ${name}`)
|
|
714
|
+
console.log(`备份目标: ${backupTargetLabel(target)}`)
|
|
715
|
+
console.log(`备份目录: ${backupRoot}`)
|
|
716
|
+
console.log()
|
|
717
|
+
console.log(c('cyan', '已备份文件:'))
|
|
718
|
+
for (const file of files) {
|
|
719
|
+
console.log(`- ${file.source}`)
|
|
720
|
+
}
|
|
721
|
+
await pause()
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function restoreBackup() {
|
|
725
|
+
clear()
|
|
726
|
+
banner()
|
|
727
|
+
const backupDir = path.join(APP_DIR, 'backups')
|
|
728
|
+
if (!fs.existsSync(backupDir)) {
|
|
729
|
+
console.log(c('yellow', '暂无备份。'))
|
|
730
|
+
return pause()
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const entries = fs.readdirSync(backupDir, { withFileTypes: true })
|
|
734
|
+
.filter((entry) => entry.isDirectory())
|
|
735
|
+
.map((entry) => entry.name)
|
|
736
|
+
.sort()
|
|
737
|
+
.reverse()
|
|
738
|
+
|
|
739
|
+
if (entries.length === 0) {
|
|
740
|
+
console.log(c('yellow', '暂无备份。'))
|
|
741
|
+
return pause()
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const selected = await select('请选择要查看的备份:', [
|
|
745
|
+
...entries.map((name) => ({ label: name, value: name })),
|
|
746
|
+
{ label: '返回', value: null }
|
|
747
|
+
])
|
|
748
|
+
if (!selected) return
|
|
749
|
+
|
|
750
|
+
clear()
|
|
751
|
+
banner()
|
|
752
|
+
const selectedDir = path.join(backupDir, selected)
|
|
753
|
+
const manifest = loadBackupManifest(selectedDir)
|
|
754
|
+
if (!manifest) {
|
|
755
|
+
console.log(c('cyan', `备份目录: ${selectedDir}`))
|
|
756
|
+
console.log()
|
|
757
|
+
for (const file of fs.readdirSync(selectedDir)) {
|
|
758
|
+
console.log(`- ${path.join(selectedDir, file)}`)
|
|
759
|
+
}
|
|
760
|
+
console.log()
|
|
761
|
+
console.log('这是旧格式自动备份,仅支持查看文件。需要恢复时,请手动复制对应文件覆盖目标配置。')
|
|
762
|
+
await pause()
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
console.log(c('cyan', `备份目录: ${selectedDir}`))
|
|
767
|
+
console.log(`备份名称: ${manifest.name || selected}`)
|
|
768
|
+
console.log(`备份目标: ${manifest.target_label || manifest.target || '未知'}`)
|
|
769
|
+
console.log(`创建时间: ${manifest.created_at || '未知'}`)
|
|
770
|
+
console.log()
|
|
771
|
+
console.log(c('cyan', '将恢复以下文件:'))
|
|
772
|
+
for (const file of manifest.files || []) {
|
|
773
|
+
console.log(`- ${file.source}`)
|
|
774
|
+
}
|
|
775
|
+
console.log()
|
|
776
|
+
|
|
777
|
+
const confirm = await select('确认恢复这个备份吗?当前同名配置会先自动备份。', RESTORE_CONFIRM_OPTIONS)
|
|
778
|
+
if (confirm === '__exit__') return '__exit__'
|
|
779
|
+
if (confirm !== 'yes') return
|
|
780
|
+
|
|
781
|
+
const safetyBackup = path.join(APP_DIR, 'backups', `${timestampForName()}_pre-restore_${safeBackupName(manifest.name || selected)}`)
|
|
782
|
+
ensureDir(safetyBackup)
|
|
783
|
+
const restored = []
|
|
784
|
+
for (const file of manifest.files || []) {
|
|
785
|
+
const source = file.source
|
|
786
|
+
const backupPath = path.join(selectedDir, file.backup)
|
|
787
|
+
if (!source || !fileExists(backupPath)) continue
|
|
788
|
+
if (fileExists(source)) {
|
|
789
|
+
copyConfigToBackup(source, safetyBackup)
|
|
790
|
+
}
|
|
791
|
+
ensureDir(path.dirname(source))
|
|
792
|
+
fs.copyFileSync(backupPath, source)
|
|
793
|
+
restored.push(source)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
clear()
|
|
797
|
+
banner()
|
|
798
|
+
console.log(c('green', '恢复完成。'))
|
|
799
|
+
console.log()
|
|
800
|
+
for (const file of restored) {
|
|
801
|
+
console.log(`- ${file}`)
|
|
802
|
+
}
|
|
803
|
+
console.log()
|
|
804
|
+
console.log(c('yellow', `恢复前安全备份目录: ${safetyBackup}`))
|
|
805
|
+
await pause()
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function pause() {
|
|
809
|
+
await question('按回车继续')
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function main() {
|
|
813
|
+
ensureDir(APP_DIR)
|
|
814
|
+
while (true) {
|
|
815
|
+
const action = await select('请选择操作:', MAIN_MENU, { allowEsc: false })
|
|
816
|
+
if (action === '__exit__' || action === 'exit') break
|
|
817
|
+
if (action === 'quick') {
|
|
818
|
+
const result = await quickConfig()
|
|
819
|
+
if (result === '__exit__') break
|
|
820
|
+
}
|
|
821
|
+
if (action === 'backup') {
|
|
822
|
+
const result = await backupConfig()
|
|
823
|
+
if (result === '__exit__') break
|
|
824
|
+
}
|
|
825
|
+
if (action === 'restore') {
|
|
826
|
+
const result = await restoreBackup()
|
|
827
|
+
if (result === '__exit__') break
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
clear()
|
|
831
|
+
console.log('已退出 iivo-sub。')
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function releaseStdin() {
|
|
835
|
+
if (process.stdin.isTTY) {
|
|
836
|
+
try {
|
|
837
|
+
process.stdin.setRawMode(false)
|
|
838
|
+
} catch {
|
|
839
|
+
// stdin may already be detached in non-interactive shells.
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
process.stdin.removeAllListeners('keypress')
|
|
843
|
+
process.stdin.pause()
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
main()
|
|
847
|
+
.then(() => {
|
|
848
|
+
releaseStdin()
|
|
849
|
+
})
|
|
850
|
+
.catch((error) => {
|
|
851
|
+
releaseStdin()
|
|
852
|
+
console.error(c('red', error?.stack || String(error)))
|
|
853
|
+
process.exitCode = 1
|
|
854
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "iivo-sub",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "IIVO AI gateway quick configuration CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"iivo-sub": "bin/iivo-sub.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|