lingyao-ai 0.2.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 ADDED
@@ -0,0 +1,53 @@
1
+ # lingyao-ai
2
+
3
+ A customizable setup CLI that behaves like `npx leishen-ai`, with your own configuration.
4
+
5
+ ## Features
6
+
7
+ - Configure Codex and/or Claude Code in one interactive flow.
8
+ - Optional auto-install checks for `@openai/codex` and `@anthropic-ai/claude-code`.
9
+ - Writes and backs up:
10
+ - `~/.claude/config.json`
11
+ - `~/.codex/config.toml`
12
+ - `~/.codex/auth.json`
13
+ - `~/.codex/.env`
14
+ - shell RC block (`.zshrc`, `.bashrc`, etc.)
15
+ - On Windows, optionally patch VSCode ChatGPT extension model order.
16
+
17
+ ## 1) Customize your config
18
+
19
+ Edit `project.config.json`:
20
+
21
+ - `brand.*`: product name and shell marker.
22
+ - `apiKey.*`: API key format and env variable name.
23
+ - `endpoints.*`: your gateway domain and paths.
24
+ - `cli.*`: min Node version, npm mirror, package names.
25
+ - `claude.*` / `codex.*`: provider defaults and target files.
26
+ - `codex.windowsVscodePatch.*`: Windows extension patch behavior.
27
+
28
+ ## 2) Run locally
29
+
30
+ ```bash
31
+ cd /Users/espresso/Project/lingyao-ai
32
+ node cli.js
33
+ ```
34
+
35
+ Use a custom config file:
36
+
37
+ ```bash
38
+ node cli.js --config /absolute/path/to/project.config.json
39
+ ```
40
+
41
+ ## 3) Publish and use with npx
42
+
43
+ Package name is already set to `lingyao-ai`.
44
+
45
+ ```bash
46
+ npm publish --access public
47
+ ```
48
+
49
+ Then users can run:
50
+
51
+ ```bash
52
+ npx lingyao-ai
53
+ ```
package/cli.js ADDED
@@ -0,0 +1,871 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict'
4
+
5
+ const fs = require('fs')
6
+ const os = require('os')
7
+ const path = require('path')
8
+ const readline = require('readline')
9
+ const { spawnSync } = require('child_process')
10
+
11
+ const DEFAULT_CONFIG_PATH = path.join(__dirname, 'project.config.json')
12
+
13
+ function exitGracefully() {
14
+ console.log('\nCancelled.')
15
+ process.exit(0)
16
+ }
17
+
18
+ process.on('SIGINT', exitGracefully)
19
+
20
+ function showHelp() {
21
+ console.log(`
22
+ Usage:
23
+ node cli.js [--config /abs/path/to/project.config.json]
24
+
25
+ Options:
26
+ -c, --config Use a custom configuration file.
27
+ -h, --help Show help.
28
+ `)
29
+ }
30
+
31
+ function parseArgs(argv) {
32
+ let configPath = DEFAULT_CONFIG_PATH
33
+ for (let i = 0; i < argv.length; i += 1) {
34
+ const arg = argv[i]
35
+ if (arg === '-h' || arg === '--help') {
36
+ showHelp()
37
+ process.exit(0)
38
+ }
39
+ if (arg === '-c' || arg === '--config') {
40
+ const next = argv[i + 1]
41
+ if (!next) {
42
+ throw new Error('Missing value for --config')
43
+ }
44
+ configPath = path.resolve(next)
45
+ i += 1
46
+ }
47
+ }
48
+ return { configPath }
49
+ }
50
+
51
+ function loadConfig(configPath) {
52
+ if (!fs.existsSync(configPath)) {
53
+ throw new Error(`Config file not found: ${configPath}`)
54
+ }
55
+ const raw = fs.readFileSync(configPath, 'utf8')
56
+ const cfg = JSON.parse(raw)
57
+ validateConfig(cfg)
58
+ return cfg
59
+ }
60
+
61
+ function validateConfig(cfg) {
62
+ const required = [
63
+ ['brand.name', cfg?.brand?.name],
64
+ ['brand.shellMarker', cfg?.brand?.shellMarker],
65
+ ['apiKey.regex', cfg?.apiKey?.regex],
66
+ ['apiKey.example', cfg?.apiKey?.example],
67
+ ['apiKey.envKey', cfg?.apiKey?.envKey],
68
+ ['endpoints.baseOrigin', cfg?.endpoints?.baseOrigin],
69
+ ['endpoints.claudePath', cfg?.endpoints?.claudePath],
70
+ ['endpoints.codexPath', cfg?.endpoints?.codexPath],
71
+ ['cli.packages.codex', cfg?.cli?.packages?.codex],
72
+ ['cli.packages.claude', cfg?.cli?.packages?.claude],
73
+ ['claude.configPath', cfg?.claude?.configPath],
74
+ ['claude.primaryApiKey', cfg?.claude?.primaryApiKey],
75
+ ['claude.baseUrlEnvKey', cfg?.claude?.baseUrlEnvKey],
76
+ ['claude.authTokenEnvKey', cfg?.claude?.authTokenEnvKey],
77
+ ['codex.configDir', cfg?.codex?.configDir],
78
+ ['codex.providerName', cfg?.codex?.providerName],
79
+ ['codex.defaultModel', cfg?.codex?.defaultModel],
80
+ ['codex.reasoningEffort', cfg?.codex?.reasoningEffort],
81
+ ['codex.wireApi', cfg?.codex?.wireApi]
82
+ ]
83
+
84
+ const missing = required.filter(([, value]) => !value).map(([key]) => key)
85
+ if (missing.length > 0) {
86
+ throw new Error(`Config missing required fields: ${missing.join(', ')}`)
87
+ }
88
+
89
+ try {
90
+ new RegExp(cfg.apiKey.regex)
91
+ } catch (error) {
92
+ throw new Error(`Invalid apiKey.regex: ${error.message}`)
93
+ }
94
+ }
95
+
96
+ function joinUrl(base, subPath) {
97
+ const left = String(base || '').replace(/\/+$/, '')
98
+ const right = String(subPath || '').replace(/^\/+/, '')
99
+ return right ? `${left}/${right}` : left
100
+ }
101
+
102
+ function ensureDir(dirPath) {
103
+ if (!fs.existsSync(dirPath)) {
104
+ fs.mkdirSync(dirPath, { recursive: true })
105
+ }
106
+ }
107
+
108
+ function backupFileIfExists(filePath, suffix = '.bak') {
109
+ if (!fs.existsSync(filePath)) return null
110
+ const backupPath = `${filePath}${suffix}`
111
+ if (!fs.existsSync(backupPath)) {
112
+ fs.copyFileSync(filePath, backupPath)
113
+ }
114
+ return backupPath
115
+ }
116
+
117
+ function writeJson(filePath, data) {
118
+ ensureDir(path.dirname(filePath))
119
+ const backupPath = backupFileIfExists(filePath)
120
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8')
121
+ return { filePath, backupPath }
122
+ }
123
+
124
+ function writeText(filePath, content) {
125
+ ensureDir(path.dirname(filePath))
126
+ const backupPath = backupFileIfExists(filePath)
127
+ fs.writeFileSync(filePath, content, 'utf8')
128
+ return { filePath, backupPath }
129
+ }
130
+
131
+ function upsertDotenvFile(filePath, envVars) {
132
+ ensureDir(path.dirname(filePath))
133
+ const backupPath = backupFileIfExists(filePath)
134
+ const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
135
+ const lines = content.split(/\r?\n/)
136
+ const remaining = new Set(Object.keys(envVars))
137
+
138
+ const updatedLines = lines.map((line) => {
139
+ const trimmed = String(line || '').trim()
140
+ if (!trimmed || trimmed.startsWith('#')) return line
141
+
142
+ const eqIdx = String(line).indexOf('=')
143
+ if (eqIdx === -1) return line
144
+
145
+ const key = String(line)
146
+ .slice(0, eqIdx)
147
+ .trim()
148
+ if (!Object.prototype.hasOwnProperty.call(envVars, key)) return line
149
+
150
+ remaining.delete(key)
151
+ return `${key}=${envVars[key]}`
152
+ })
153
+
154
+ for (const key of remaining) {
155
+ updatedLines.push(`${key}=${envVars[key]}`)
156
+ }
157
+
158
+ fs.writeFileSync(filePath, updatedLines.join('\n'), 'utf8')
159
+ if (process.platform !== 'win32') {
160
+ try {
161
+ fs.chmodSync(filePath, 0o600)
162
+ } catch (_) {
163
+ // Ignore chmod failure.
164
+ }
165
+ }
166
+
167
+ return { filePath, backupPath }
168
+ }
169
+
170
+ function detectShellRcFiles() {
171
+ if (process.platform === 'win32') return []
172
+
173
+ const home = os.homedir()
174
+ const shell = String(process.env.SHELL || '').toLowerCase()
175
+ const priority = shell.includes('zsh')
176
+ ? ['.zshrc', '.zprofile']
177
+ : shell.includes('bash')
178
+ ? ['.bashrc', '.bash_profile']
179
+ : ['.zshrc', '.bashrc']
180
+
181
+ const defaults = ['.bashrc', '.bash_profile', '.profile', '.zshrc', '.zprofile']
182
+ const ordered = [...priority, ...defaults]
183
+ const seen = new Set()
184
+ const rcFiles = []
185
+
186
+ for (const fileName of ordered) {
187
+ if (seen.has(fileName)) continue
188
+ seen.add(fileName)
189
+ rcFiles.push(path.join(home, fileName))
190
+ }
191
+
192
+ return rcFiles
193
+ }
194
+
195
+ function updateRcFile(rcPath, envVars, marker) {
196
+ if (!rcPath || Object.keys(envVars).length === 0) return null
197
+
198
+ const startMarker = `# >>> ${marker}`
199
+ const endMarker = `# <<< ${marker}`
200
+ const content = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : ''
201
+ const lines = content.split(/\r?\n/)
202
+ const startIdx = lines.findIndex((line) => line.trim() === startMarker)
203
+ const endIdx = lines.findIndex((line) => line.trim() === endMarker)
204
+
205
+ if (startIdx !== -1 && endIdx !== -1 && endIdx >= startIdx) {
206
+ lines.splice(startIdx, endIdx - startIdx + 1)
207
+ }
208
+
209
+ const block = [startMarker]
210
+ for (const [key, value] of Object.entries(envVars)) {
211
+ block.push(`export ${key}="${value}"`)
212
+ }
213
+ block.push(endMarker, '')
214
+
215
+ const trimmed = lines.join('\n').trimEnd()
216
+ const finalContent = trimmed ? `${trimmed}\n\n${block.join('\n')}` : block.join('\n')
217
+ fs.writeFileSync(rcPath, finalContent, 'utf8')
218
+ return rcPath
219
+ }
220
+
221
+ function configureWindowsEnv(envVars) {
222
+ for (const [key, value] of Object.entries(envVars)) {
223
+ try {
224
+ spawnSync('setx', [key, String(value)], { stdio: 'ignore' })
225
+ } catch (_) {
226
+ // Best-effort only.
227
+ }
228
+ }
229
+ }
230
+
231
+ function askLine(question) {
232
+ return new Promise((resolve) => {
233
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
234
+ rl.question(question, (answer) => {
235
+ rl.close()
236
+ resolve(String(answer || '').trim())
237
+ })
238
+ })
239
+ }
240
+
241
+ async function askYesNo(question, initialYes = true) {
242
+ const yesHint = initialYes ? 'Y/n' : 'y/N'
243
+ while (true) {
244
+ const answer = (await askLine(`${question} [${yesHint}]: `)).toLowerCase()
245
+ if (!answer) return initialYes
246
+ if (['y', 'yes'].includes(answer)) return true
247
+ if (['n', 'no'].includes(answer)) return false
248
+ console.log('Please type y or n.')
249
+ }
250
+ }
251
+
252
+ async function promptApiKey(apiKeyRegex, example) {
253
+ while (true) {
254
+ const key = await askLine(`Input API key (${example}): `)
255
+ if (apiKeyRegex.test(key)) return key
256
+ console.log('Invalid API key format. Try again.')
257
+ }
258
+ }
259
+
260
+ async function promptTargets() {
261
+ while (true) {
262
+ console.log('\nSelect target:')
263
+ console.log('1) Codex')
264
+ console.log('2) Claude Code')
265
+ console.log('3) Codex + Claude Code')
266
+ console.log('q) Quit')
267
+
268
+ const answer = (await askLine('Choice: ')).toLowerCase()
269
+ if (answer === '1') return ['codex']
270
+ if (answer === '2') return ['claude']
271
+ if (answer === '3') return ['codex', 'claude']
272
+ if (['q', 'quit', 'exit'].includes(answer)) process.exit(0)
273
+
274
+ console.log('Invalid choice.')
275
+ }
276
+ }
277
+
278
+ function getNodeMajorVersion() {
279
+ const raw = (process.versions && process.versions.node) || '0.0.0'
280
+ const major = Number.parseInt(String(raw).split('.')[0], 10)
281
+ return Number.isFinite(major) ? major : 0
282
+ }
283
+
284
+ function ensureMinimumNodeVersion(minMajor) {
285
+ const required = Number.isFinite(Number(minMajor)) ? Number(minMajor) : 16
286
+ const current = getNodeMajorVersion()
287
+ if (current > 0 && current < required) {
288
+ console.log(`Node.js ${required}+ required. Current: ${process.versions.node}`)
289
+ return false
290
+ }
291
+ return true
292
+ }
293
+
294
+ function findExecutableInPath(binName) {
295
+ const rawPath = process.env.PATH || ''
296
+ if (!rawPath) return null
297
+
298
+ const dirs = rawPath.split(path.delimiter).filter(Boolean)
299
+ const exts =
300
+ process.platform === 'win32'
301
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
302
+ : ['']
303
+
304
+ for (const dir of dirs) {
305
+ for (const ext of exts) {
306
+ const fullPath =
307
+ process.platform === 'win32' ? path.join(dir, `${binName}${ext}`) : path.join(dir, binName)
308
+ try {
309
+ if (fs.existsSync(fullPath)) return fullPath
310
+ } catch (_) {
311
+ // Ignore path entries that cannot be read.
312
+ }
313
+ }
314
+ }
315
+
316
+ return null
317
+ }
318
+
319
+ function runCommandText(command, args) {
320
+ let result
321
+ try {
322
+ result = spawnSync(command, args, { encoding: 'utf8' })
323
+ } catch (error) {
324
+ return { ok: false, error, stdout: '', stderr: '' }
325
+ }
326
+
327
+ const stdout = String(result.stdout || '')
328
+ const stderr = String(result.stderr || '')
329
+ if (result.error) {
330
+ return { ok: false, error: result.error, stdout, stderr }
331
+ }
332
+
333
+ if (result.status === 0) {
334
+ return { ok: true, text: stdout.trim(), stdout, stderr }
335
+ }
336
+
337
+ return { ok: false, status: result.status, stdout, stderr }
338
+ }
339
+
340
+ function detectCli(binName, versionArgs, npmPackageName) {
341
+ const executablePath = findExecutableInPath(binName)
342
+ if (executablePath) {
343
+ const versionResult = runCommandText(binName, versionArgs)
344
+ if (versionResult.ok) {
345
+ const version = versionResult.text ? versionResult.text.split('\n')[0].trim() : null
346
+ return {
347
+ state: 'ready',
348
+ executablePath,
349
+ version
350
+ }
351
+ }
352
+
353
+ return {
354
+ state: 'exists_but_failed',
355
+ executablePath,
356
+ stdout: versionResult.stdout,
357
+ stderr: versionResult.stderr
358
+ }
359
+ }
360
+
361
+ if (findExecutableInPath('npm')) {
362
+ const listResult = runCommandText('npm', ['ls', '-g', '--depth=0', npmPackageName])
363
+ if (listResult.ok) {
364
+ return { state: 'installed_not_on_path' }
365
+ }
366
+ }
367
+
368
+ return { state: 'not_installed' }
369
+ }
370
+
371
+ function showMirrorHint(pkgName, registry) {
372
+ if (!pkgName || !registry) return
373
+ console.log(`Hint: if install is slow, try:`)
374
+ console.log(`npm install -g ${pkgName} --registry=${registry}`)
375
+ }
376
+
377
+ function showInstallTroubleshoot(result) {
378
+ const out = `${String(result?.stdout || '')}\n${String(result?.stderr || '')}`
379
+ if (/EACCES|permission denied/i.test(out)) {
380
+ console.log('Install failed: permission issue.')
381
+ return
382
+ }
383
+ if (/ETARGET|E404|404 Not Found/i.test(out)) {
384
+ console.log('Install failed: npm registry may be out of sync.')
385
+ return
386
+ }
387
+ if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|network/i.test(out)) {
388
+ console.log('Install failed: network problem detected.')
389
+ return
390
+ }
391
+ if (/unsupported engine|notsup|engine/i.test(out)) {
392
+ console.log('Install failed: Node.js version may be too low.')
393
+ return
394
+ }
395
+ console.log('Install failed. Check npm logs for details.')
396
+ }
397
+
398
+ function installGlobalPackage(pkgName) {
399
+ const result = spawnSync('npm', ['install', '-g', pkgName], {
400
+ stdio: ['ignore', 'pipe', 'pipe'],
401
+ encoding: 'utf8'
402
+ })
403
+
404
+ if (result.error) {
405
+ return {
406
+ ok: false,
407
+ error: result.error,
408
+ stdout: '',
409
+ stderr: ''
410
+ }
411
+ }
412
+
413
+ if (result.status === 0) {
414
+ return {
415
+ ok: true,
416
+ status: 0,
417
+ stdout: String(result.stdout || ''),
418
+ stderr: String(result.stderr || '')
419
+ }
420
+ }
421
+
422
+ return {
423
+ ok: false,
424
+ status: result.status,
425
+ stdout: String(result.stdout || ''),
426
+ stderr: String(result.stderr || '')
427
+ }
428
+ }
429
+
430
+ async function ensureCliInstalled(options) {
431
+ const {
432
+ label,
433
+ binName,
434
+ versionArgs,
435
+ npmPackageName,
436
+ mirrorRegistry,
437
+ minNodeMajorForInstall
438
+ } = options
439
+
440
+ const detected = detectCli(binName, versionArgs, npmPackageName)
441
+ if (detected.state === 'ready') {
442
+ const ver = detected.version ? ` (${detected.version})` : ''
443
+ console.log(`${label} already detected${ver}.`)
444
+ return true
445
+ }
446
+
447
+ if (
448
+ Number.isFinite(Number(minNodeMajorForInstall)) &&
449
+ getNodeMajorVersion() < Number(minNodeMajorForInstall)
450
+ ) {
451
+ console.log(
452
+ `${label} auto install skipped: Node.js ${minNodeMajorForInstall}+ recommended. Current: ${process.versions.node}`
453
+ )
454
+ return false
455
+ }
456
+
457
+ if (!findExecutableInPath('npm')) {
458
+ console.log('npm command not found. Install Node.js/npm first.')
459
+ return false
460
+ }
461
+
462
+ if (detected.state === 'installed_not_on_path') {
463
+ console.log(`${label} package is installed globally, but command is not on PATH.`)
464
+ return false
465
+ }
466
+
467
+ if (detected.state === 'exists_but_failed') {
468
+ console.log(`${label} command exists but failed to run.`)
469
+ const details = String(detected.stderr || detected.stdout || '').trim()
470
+ if (details) {
471
+ console.log(details.split('\n').slice(0, 6).join('\n'))
472
+ }
473
+ }
474
+
475
+ const shouldInstall = await askYesNo(`Run npm install -g ${npmPackageName} now?`, true)
476
+ if (!shouldInstall) {
477
+ showMirrorHint(npmPackageName, mirrorRegistry)
478
+ return false
479
+ }
480
+
481
+ showMirrorHint(npmPackageName, mirrorRegistry)
482
+ const installResult = installGlobalPackage(npmPackageName)
483
+ if (!installResult.ok) {
484
+ showInstallTroubleshoot(installResult)
485
+ return false
486
+ }
487
+
488
+ const after = detectCli(binName, versionArgs, npmPackageName)
489
+ if (after.state === 'ready') {
490
+ const ver = after.version ? ` (${after.version})` : ''
491
+ console.log(`${label} installed successfully${ver}.`)
492
+ return true
493
+ }
494
+
495
+ console.log(`${label} installed, but command still not found on PATH.`)
496
+ return false
497
+ }
498
+
499
+ function escapeRegExp(str) {
500
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
501
+ }
502
+
503
+ function parseExtensionSemver(name, extensionPrefix) {
504
+ const safePrefix = escapeRegExp(extensionPrefix)
505
+ const regex = new RegExp(`^${safePrefix}(\\d+)\\.(\\d+)\\.(\\d+)(?:[-.].*)?$`, 'i')
506
+ const match = String(name || '').trim().match(regex)
507
+ if (!match) return null
508
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
509
+ }
510
+
511
+ function compareSemverTupleDesc(left, right) {
512
+ const l = Array.isArray(left) ? left : []
513
+ const r = Array.isArray(right) ? right : []
514
+ const max = Math.max(l.length, r.length, 0)
515
+ for (let i = 0; i < max; i += 1) {
516
+ const lv = Number.isFinite(l[i]) ? l[i] : -1
517
+ const rv = Number.isFinite(r[i]) ? r[i] : -1
518
+ if (lv > rv) return -1
519
+ if (lv < rv) return 1
520
+ }
521
+ return 0
522
+ }
523
+
524
+ function listWindowsVscodeAssets(windowsPatchCfg) {
525
+ const home = os.homedir()
526
+ const extensionPrefix = String(windowsPatchCfg?.extensionPrefix || 'openai.chatgpt-')
527
+ const userDirs = Array.isArray(windowsPatchCfg?.userDirs)
528
+ ? windowsPatchCfg.userDirs
529
+ : ['.vscode', '.vscode-insiders']
530
+
531
+ const candidates = []
532
+
533
+ for (const userDir of userDirs) {
534
+ const extensionsRoot = path.join(home, String(userDir), 'extensions')
535
+ if (!fs.existsSync(extensionsRoot)) continue
536
+
537
+ let entries = []
538
+ try {
539
+ entries = fs.readdirSync(extensionsRoot, { withFileTypes: true })
540
+ } catch (_) {
541
+ continue
542
+ }
543
+
544
+ for (const entry of entries) {
545
+ if (!entry?.isDirectory?.()) continue
546
+ if (!String(entry.name).startsWith(extensionPrefix)) continue
547
+
548
+ const extensionDir = path.join(extensionsRoot, entry.name)
549
+ const assetsDir = path.join(extensionDir, 'webview', 'assets')
550
+ if (!fs.existsSync(assetsDir)) continue
551
+
552
+ let assetEntries = []
553
+ try {
554
+ assetEntries = fs.readdirSync(assetsDir, { withFileTypes: true })
555
+ } catch (_) {
556
+ continue
557
+ }
558
+
559
+ for (const assetEntry of assetEntries) {
560
+ if (!assetEntry?.isFile?.()) continue
561
+ if (!/^index-.*\.js$/i.test(String(assetEntry.name))) continue
562
+
563
+ const filePath = path.join(assetsDir, assetEntry.name)
564
+ let mtimeMs = 0
565
+ try {
566
+ mtimeMs = Number(fs.statSync(filePath)?.mtimeMs || 0)
567
+ } catch (_) {
568
+ mtimeMs = 0
569
+ }
570
+
571
+ candidates.push({
572
+ filePath,
573
+ extensionName: entry.name,
574
+ version: parseExtensionSemver(entry.name, extensionPrefix),
575
+ mtimeMs
576
+ })
577
+ }
578
+ }
579
+ }
580
+
581
+ candidates.sort((a, b) => {
582
+ const versionCmp = compareSemverTupleDesc(a.version, b.version)
583
+ if (versionCmp !== 0) return versionCmp
584
+ if (a.mtimeMs > b.mtimeMs) return -1
585
+ if (a.mtimeMs < b.mtimeMs) return 1
586
+ return 0
587
+ })
588
+
589
+ return candidates
590
+ }
591
+
592
+ function extractQuotedModels(expression) {
593
+ const models = []
594
+ const regex = /["']([^"']+)["']/g
595
+ let match = regex.exec(expression)
596
+ while (match) {
597
+ models.push(match[1])
598
+ match = regex.exec(expression)
599
+ }
600
+ return models
601
+ }
602
+
603
+ function patchDefaultModelOrder(content, targetModel) {
604
+ const match = /DEFAULT_MODEL_ORDER\s*=\s*\[([\s\S]*?)\]/.exec(content)
605
+ if (!match) return { status: 'marker_not_found' }
606
+
607
+ const originalModels = extractQuotedModels(match[1])
608
+ if (originalModels.length === 0) return { status: 'parse_failed' }
609
+
610
+ const nextModels = [targetModel, ...originalModels.filter((m) => m !== targetModel)]
611
+ const alreadyUpToDate =
612
+ originalModels.length === nextModels.length &&
613
+ originalModels.every((model, idx) => model === nextModels[idx])
614
+
615
+ if (alreadyUpToDate) {
616
+ return { status: 'already', models: originalModels }
617
+ }
618
+
619
+ const replacement = `DEFAULT_MODEL_ORDER=[${nextModels
620
+ .map((model) => JSON.stringify(model))
621
+ .join(',')}]`
622
+
623
+ const start = match.index
624
+ const end = start + match[0].length
625
+ const updatedContent = `${content.slice(0, start)}${replacement}${content.slice(end)}`
626
+ const verify = /DEFAULT_MODEL_ORDER\s*=\s*\[([\s\S]*?)\]/.exec(updatedContent)
627
+ if (!verify) return { status: 'verify_failed' }
628
+
629
+ const verifyModels = extractQuotedModels(verify[1])
630
+ if (verifyModels[0] !== targetModel) return { status: 'verify_failed' }
631
+
632
+ return { status: 'patched', content: updatedContent, models: verifyModels }
633
+ }
634
+
635
+ function patchWindowsVscodeDefaultModel(targetModel, windowsPatchCfg) {
636
+ if (process.platform !== 'win32') return { status: 'not_windows' }
637
+
638
+ const candidates = listWindowsVscodeAssets(windowsPatchCfg)
639
+ if (candidates.length === 0) return { status: 'assets_not_found' }
640
+
641
+ let parseFailed = 0
642
+ for (const candidate of candidates) {
643
+ let content = ''
644
+ try {
645
+ content = fs.readFileSync(candidate.filePath, 'utf8')
646
+ } catch (_) {
647
+ continue
648
+ }
649
+
650
+ const result = patchDefaultModelOrder(content, targetModel)
651
+ if (result.status === 'marker_not_found' || result.status === 'parse_failed') {
652
+ parseFailed += 1
653
+ continue
654
+ }
655
+
656
+ if (result.status === 'already') {
657
+ return {
658
+ status: 'already',
659
+ filePath: candidate.filePath,
660
+ extensionName: candidate.extensionName
661
+ }
662
+ }
663
+
664
+ if (result.status !== 'patched') {
665
+ continue
666
+ }
667
+
668
+ const backupPath = backupFileIfExists(candidate.filePath, '.backup')
669
+ fs.writeFileSync(candidate.filePath, result.content, 'utf8')
670
+
671
+ return {
672
+ status: 'patched',
673
+ filePath: candidate.filePath,
674
+ backupPath,
675
+ extensionName: candidate.extensionName
676
+ }
677
+ }
678
+
679
+ if (parseFailed > 0) return { status: 'pattern_not_supported' }
680
+ return { status: 'assets_not_found' }
681
+ }
682
+
683
+ function tomlString(value) {
684
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
685
+ }
686
+
687
+ function renderCodexToml(cfg) {
688
+ const codexBaseUrl = joinUrl(cfg.endpoints.baseOrigin, cfg.endpoints.codexPath)
689
+ const provider = cfg.codex.providerName
690
+ const migrations = Object.entries(cfg.codex.modelMigrations || {})
691
+ const migrationSection =
692
+ migrations.length === 0
693
+ ? ''
694
+ : `\n[notice.model_migrations]\n${migrations
695
+ .map(([from, to]) => `"${tomlString(from)}" = "${tomlString(to)}"`)
696
+ .join('\n')}\n`
697
+
698
+ const envInstructionLine = cfg?.codex?.envKeyInstructions
699
+ ? `\nenv_key_instructions = "${tomlString(cfg.codex.envKeyInstructions)}"`
700
+ : ''
701
+
702
+ return `model_provider = "${tomlString(provider)}"
703
+ model = "${tomlString(cfg.codex.defaultModel)}"
704
+ model_reasoning_effort = "${tomlString(cfg.codex.reasoningEffort)}"
705
+ disable_response_storage = true
706
+ preferred_auth_method = "apikey"
707
+
708
+ [model_providers.${provider}]
709
+ name = "${tomlString(provider)}"
710
+ base_url = "${tomlString(codexBaseUrl)}"
711
+ wire_api = "${tomlString(cfg.codex.wireApi)}"
712
+ requires_openai_auth = ${cfg.codex.requiresOpenAIAuth ? 'true' : 'false'}
713
+ env_key = "${tomlString(cfg.apiKey.envKey)}"${envInstructionLine}${migrationSection}`
714
+ }
715
+
716
+ function configureClaude(cfg, apiKey) {
717
+ const home = os.homedir()
718
+ const claudeConfigPath = path.join(home, cfg.claude.configPath)
719
+ const info = writeJson(claudeConfigPath, { primaryApiKey: cfg.claude.primaryApiKey })
720
+ const claudeBaseUrl = joinUrl(cfg.endpoints.baseOrigin, cfg.endpoints.claudePath)
721
+
722
+ return {
723
+ env: {
724
+ [cfg.claude.baseUrlEnvKey]: claudeBaseUrl,
725
+ [cfg.claude.authTokenEnvKey]: apiKey
726
+ },
727
+ files: [
728
+ {
729
+ label: 'Claude config',
730
+ path: info.filePath,
731
+ backupPath: info.backupPath
732
+ }
733
+ ]
734
+ }
735
+ }
736
+
737
+ function configureCodex(cfg, apiKey) {
738
+ const home = os.homedir()
739
+ const codexDir = path.join(home, cfg.codex.configDir)
740
+ const tomlPath = path.join(codexDir, 'config.toml')
741
+ const authPath = path.join(codexDir, 'auth.json')
742
+ const envPath = path.join(codexDir, '.env')
743
+
744
+ const tomlInfo = writeText(tomlPath, renderCodexToml(cfg))
745
+ const authInfo = writeJson(authPath, cfg.codex.authJson || { OPENAI_API_KEY: null })
746
+ const envInfo = upsertDotenvFile(envPath, { [cfg.apiKey.envKey]: apiKey })
747
+
748
+ return {
749
+ env: {
750
+ [cfg.apiKey.envKey]: apiKey
751
+ },
752
+ files: [
753
+ { label: 'Codex config.toml', path: tomlInfo.filePath, backupPath: tomlInfo.backupPath },
754
+ { label: 'Codex auth.json', path: authInfo.filePath, backupPath: authInfo.backupPath },
755
+ { label: 'Codex .env', path: envInfo.filePath, backupPath: envInfo.backupPath }
756
+ ]
757
+ }
758
+ }
759
+
760
+ function printSummary(fileSummaries) {
761
+ console.log('\nCompleted changes:')
762
+ for (const item of fileSummaries) {
763
+ console.log(`- ${item.label}: ${item.path}`)
764
+ console.log(` backup: ${item.backupPath || 'none (new file)'}`)
765
+ }
766
+ }
767
+
768
+ async function main() {
769
+ const { configPath } = parseArgs(process.argv.slice(2))
770
+ const cfg = loadConfig(configPath)
771
+
772
+ if (!ensureMinimumNodeVersion(cfg?.cli?.minNodeMajor)) {
773
+ process.exit(1)
774
+ }
775
+
776
+ console.log(`\n${cfg.brand.name}`)
777
+ console.log(`Config file: ${configPath}`)
778
+ console.log(`Base origin: ${cfg.endpoints.baseOrigin}`)
779
+
780
+ const apiKeyRegex = new RegExp(cfg.apiKey.regex)
781
+ const apiKey = await promptApiKey(apiKeyRegex, cfg.apiKey.example)
782
+ const targets = await promptTargets()
783
+
784
+ const npmMirrorRegistry = cfg?.cli?.npmMirrorRegistry || ''
785
+ const codexPackage = cfg.cli.packages.codex
786
+ const claudePackage = cfg.cli.packages.claude
787
+
788
+ if (targets.includes('codex')) {
789
+ await ensureCliInstalled({
790
+ label: 'Codex CLI',
791
+ binName: 'codex',
792
+ versionArgs: ['--version'],
793
+ npmPackageName: codexPackage,
794
+ mirrorRegistry: npmMirrorRegistry,
795
+ minNodeMajorForInstall: cfg?.cli?.codexMinNodeMajor
796
+ })
797
+ }
798
+
799
+ if (targets.includes('claude')) {
800
+ await ensureCliInstalled({
801
+ label: 'Claude CLI',
802
+ binName: 'claude',
803
+ versionArgs: ['--version'],
804
+ npmPackageName: claudePackage,
805
+ mirrorRegistry: npmMirrorRegistry,
806
+ minNodeMajorForInstall: cfg?.cli?.minNodeMajor
807
+ })
808
+ }
809
+
810
+ let envVars = {}
811
+ const fileSummaries = []
812
+
813
+ if (targets.includes('claude')) {
814
+ const result = configureClaude(cfg, apiKey)
815
+ envVars = { ...envVars, ...result.env }
816
+ fileSummaries.push(...result.files)
817
+ }
818
+
819
+ if (targets.includes('codex')) {
820
+ const result = configureCodex(cfg, apiKey)
821
+ envVars = { ...envVars, ...result.env }
822
+ fileSummaries.push(...result.files)
823
+
824
+ const windowsPatchCfg = cfg?.codex?.windowsVscodePatch
825
+ if (process.platform === 'win32' && windowsPatchCfg?.enabled) {
826
+ const shouldPatch = await askYesNo(
827
+ `Patch VSCode ChatGPT default model to ${cfg.codex.defaultModel}?`,
828
+ true
829
+ )
830
+
831
+ if (shouldPatch) {
832
+ const patchResult = patchWindowsVscodeDefaultModel(cfg.codex.defaultModel, windowsPatchCfg)
833
+ if (patchResult.status === 'patched') {
834
+ console.log(`VSCode model order patched: ${patchResult.filePath}`)
835
+ fileSummaries.push({
836
+ label: 'VSCode model order',
837
+ path: patchResult.filePath,
838
+ backupPath: patchResult.backupPath || null
839
+ })
840
+ } else if (patchResult.status === 'already') {
841
+ console.log(`VSCode model order already up to date: ${patchResult.filePath}`)
842
+ } else if (patchResult.status === 'assets_not_found') {
843
+ console.log('VSCode ChatGPT asset file not found, patch skipped.')
844
+ } else if (patchResult.status === 'pattern_not_supported') {
845
+ console.log('VSCode asset found but pattern not supported, patch skipped.')
846
+ }
847
+ }
848
+ }
849
+ }
850
+
851
+ if (process.platform === 'win32') {
852
+ configureWindowsEnv(envVars)
853
+ console.log('Environment variables were written with setx. Open a new terminal to apply.')
854
+ } else {
855
+ const updatedRcFiles = detectShellRcFiles()
856
+ .map((rcPath) => updateRcFile(rcPath, envVars, cfg.brand.shellMarker))
857
+ .filter(Boolean)
858
+
859
+ if (updatedRcFiles.length > 0) {
860
+ console.log('\nUpdated shell rc files:')
861
+ updatedRcFiles.forEach((filePath) => console.log(`- ${filePath}`))
862
+ }
863
+ }
864
+
865
+ printSummary(fileSummaries)
866
+ }
867
+
868
+ main().catch((error) => {
869
+ console.error(`Failed: ${error.message}`)
870
+ process.exit(1)
871
+ })
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "lingyao-ai",
3
+ "version": "0.2.0",
4
+ "description": "Lingyao setup CLI for Codex and Claude Code.",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "lingyao-ai": "cli.js",
8
+ "lingyao": "cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node cli.js",
12
+ "lint": "node --check cli.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "license": "MIT"
18
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "brand": {
3
+ "name": "Lingyao AI",
4
+ "shellMarker": "lingyao-ai"
5
+ },
6
+ "apiKey": {
7
+ "regex": "^cr_[0-9a-f]{64}$",
8
+ "example": "cr_<64_hex>",
9
+ "envKey": "CRS_OAI_KEY"
10
+ },
11
+ "endpoints": {
12
+ "baseOrigin": "https://claude.chiddns.com",
13
+ "claudePath": "/",
14
+ "codexPath": "/v1"
15
+ },
16
+ "cli": {
17
+ "minNodeMajor": 16,
18
+ "codexMinNodeMajor": 18,
19
+ "npmMirrorRegistry": "https://registry.npmmirror.com",
20
+ "packages": {
21
+ "codex": "@openai/codex",
22
+ "claude": "@anthropic-ai/claude-code"
23
+ }
24
+ },
25
+ "claude": {
26
+ "configPath": ".claude/config.json",
27
+ "primaryApiKey": "custom-provider",
28
+ "baseUrlEnvKey": "ANTHROPIC_BASE_URL",
29
+ "authTokenEnvKey": "ANTHROPIC_AUTH_TOKEN"
30
+ },
31
+ "codex": {
32
+ "configDir": ".codex",
33
+ "providerName": "custom-provider",
34
+ "defaultModel": "gpt-5.4",
35
+ "reasoningEffort": "xhigh",
36
+ "wireApi": "responses",
37
+ "requiresOpenAIAuth": true,
38
+ "authJson": {
39
+ "OPENAI_API_KEY": null
40
+ },
41
+ "modelMigrations": {
42
+ "gpt-5.4": "gpt-5.4"
43
+ },
44
+ "windowsVscodePatch": {
45
+ "enabled": true,
46
+ "extensionPrefix": "openai.chatgpt-",
47
+ "userDirs": [
48
+ ".vscode",
49
+ ".vscode-insiders"
50
+ ]
51
+ }
52
+ }
53
+ }