@skillshub-labs/cli 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1223 @@
1
+ import fs from 'fs'
2
+ import fsp from 'fs/promises'
3
+ import path from 'path'
4
+ import os from 'os'
5
+ import crypto from 'crypto'
6
+ import Database from 'better-sqlite3'
7
+ import TOML from '@iarna/toml'
8
+
9
+ const APP_TYPES = ['claude', 'codex', 'gemini']
10
+ const DB_DIR = path.join(os.homedir(), '.skills-hub')
11
+ const DB_PATH = path.join(DB_DIR, 'skills-hub.db')
12
+
13
+ let dbInstance = null
14
+
15
+ function ensureDb() {
16
+ if (dbInstance) return dbInstance
17
+
18
+ fs.mkdirSync(DB_DIR, { recursive: true })
19
+ dbInstance = new Database(DB_PATH)
20
+ dbInstance.pragma('journal_mode = WAL')
21
+ dbInstance.exec(`
22
+ CREATE TABLE IF NOT EXISTS providers (
23
+ id TEXT PRIMARY KEY,
24
+ app_type TEXT NOT NULL,
25
+ name TEXT NOT NULL,
26
+ config_json TEXT NOT NULL,
27
+ is_current INTEGER NOT NULL DEFAULT 0,
28
+ created_at INTEGER NOT NULL,
29
+ updated_at INTEGER NOT NULL
30
+ );
31
+
32
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_current_app
33
+ ON providers(app_type)
34
+ WHERE is_current = 1;
35
+
36
+ CREATE TABLE IF NOT EXISTS live_backups (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ app_type TEXT NOT NULL,
39
+ backup_json TEXT NOT NULL,
40
+ created_at INTEGER NOT NULL
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS universal_providers (
44
+ id TEXT PRIMARY KEY,
45
+ name TEXT NOT NULL,
46
+ base_url TEXT NOT NULL,
47
+ api_key TEXT NOT NULL,
48
+ website_url TEXT,
49
+ notes TEXT,
50
+ apps_json TEXT NOT NULL,
51
+ models_json TEXT NOT NULL,
52
+ created_at INTEGER NOT NULL,
53
+ updated_at INTEGER NOT NULL
54
+ );
55
+ `)
56
+
57
+ return dbInstance
58
+ }
59
+
60
+ function nowTs() {
61
+ return Date.now()
62
+ }
63
+
64
+ function assertAppType(appType) {
65
+ if (!APP_TYPES.includes(appType)) {
66
+ throw new Error(`Unsupported app type: ${appType}`)
67
+ }
68
+ }
69
+
70
+ function parseJsonSafe(raw, fallback) {
71
+ try {
72
+ return JSON.parse(raw)
73
+ } catch {
74
+ return fallback
75
+ }
76
+ }
77
+
78
+ function parseProviderRow(row) {
79
+ if (!row) return null
80
+ return {
81
+ id: row.id,
82
+ appType: row.app_type,
83
+ name: row.name,
84
+ config: parseJsonSafe(row.config_json, {}),
85
+ isCurrent: row.is_current === 1,
86
+ createdAt: row.created_at,
87
+ updatedAt: row.updated_at,
88
+ }
89
+ }
90
+
91
+ function normalizeUniversalApps(appsInput) {
92
+ const apps = appsInput && typeof appsInput === 'object' ? appsInput : {}
93
+ return {
94
+ claude: apps.claude !== false,
95
+ codex: apps.codex !== false,
96
+ gemini: apps.gemini !== false,
97
+ }
98
+ }
99
+
100
+ function normalizeUniversalModels(modelsInput) {
101
+ const models = modelsInput && typeof modelsInput === 'object' ? modelsInput : {}
102
+ const toModelObj = (value) => {
103
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
104
+ const model = typeof value.model === 'string' ? value.model : undefined
105
+ return model ? { model } : {}
106
+ }
107
+
108
+ return {
109
+ claude: toModelObj(models.claude),
110
+ codex: toModelObj(models.codex),
111
+ gemini: toModelObj(models.gemini),
112
+ }
113
+ }
114
+
115
+ function parseUniversalProviderRow(row) {
116
+ if (!row) return null
117
+ return {
118
+ id: row.id,
119
+ name: row.name,
120
+ baseUrl: row.base_url,
121
+ apiKey: row.api_key,
122
+ websiteUrl: row.website_url || undefined,
123
+ notes: row.notes || undefined,
124
+ apps: normalizeUniversalApps(parseJsonSafe(row.apps_json, {})),
125
+ models: normalizeUniversalModels(parseJsonSafe(row.models_json, {})),
126
+ createdAt: row.created_at,
127
+ updatedAt: row.updated_at,
128
+ }
129
+ }
130
+
131
+ function ensureObject(value, fieldName) {
132
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
133
+ throw new Error(`${fieldName} must be an object`)
134
+ }
135
+ }
136
+
137
+ async function readJsonFile(filePath) {
138
+ try {
139
+ const raw = await fsp.readFile(filePath, 'utf-8')
140
+ return JSON.parse(raw)
141
+ } catch (error) {
142
+ if (error && error.code === 'ENOENT') return {}
143
+ throw new Error(`Failed to read JSON file: ${filePath}`)
144
+ }
145
+ }
146
+
147
+ async function atomicWriteFile(filePath, content) {
148
+ const dir = path.dirname(filePath)
149
+ await fsp.mkdir(dir, { recursive: true })
150
+
151
+ const tempFile = path.join(
152
+ dir,
153
+ `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
154
+ )
155
+
156
+ await fsp.writeFile(tempFile, content, 'utf-8')
157
+ await fsp.rename(tempFile, filePath)
158
+ }
159
+
160
+ async function writeJsonAtomic(filePath, value) {
161
+ await atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
162
+ }
163
+
164
+ function atomicWriteFileSync(filePath, content) {
165
+ const dir = path.dirname(filePath)
166
+ fs.mkdirSync(dir, { recursive: true })
167
+
168
+ const tempFile = path.join(
169
+ dir,
170
+ `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
171
+ )
172
+ fs.writeFileSync(tempFile, content, 'utf-8')
173
+ fs.renameSync(tempFile, filePath)
174
+ }
175
+
176
+ function writeJsonAtomicSync(filePath, value) {
177
+ atomicWriteFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
178
+ }
179
+
180
+ function getCodexProviderAuthSnapshotPath(providerId) {
181
+ return path.join(DB_DIR, 'provider-auth', 'codex', providerId, 'auth.json')
182
+ }
183
+
184
+ function writeCodexProviderAuthSnapshot(providerId, auth) {
185
+ if (!providerId) return
186
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return
187
+ writeJsonAtomicSync(getCodexProviderAuthSnapshotPath(providerId), auth)
188
+ }
189
+
190
+ async function readCodexProviderAuthSnapshot(providerId) {
191
+ if (!providerId) return null
192
+ const snapshotPath = getCodexProviderAuthSnapshotPath(providerId)
193
+ try {
194
+ const raw = await fsp.readFile(snapshotPath, 'utf-8')
195
+ const parsed = JSON.parse(raw)
196
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
197
+ return parsed
198
+ } catch (error) {
199
+ if (error && error.code === 'ENOENT') return null
200
+ throw new Error(`Failed to read Codex auth snapshot: ${snapshotPath}`)
201
+ }
202
+ }
203
+
204
+ function parseEnv(raw) {
205
+ const result = {}
206
+ const lines = raw.split(/\r?\n/)
207
+ for (const line of lines) {
208
+ const trimmed = line.trim()
209
+ if (!trimmed || trimmed.startsWith('#')) continue
210
+ const eqIndex = trimmed.indexOf('=')
211
+ if (eqIndex <= 0) continue
212
+ const key = trimmed.slice(0, eqIndex).trim()
213
+ const value = trimmed.slice(eqIndex + 1).trim().replace(/^"|"$/g, '')
214
+ result[key] = value
215
+ }
216
+ return result
217
+ }
218
+
219
+ function stringifyEnv(envObj) {
220
+ return (
221
+ Object.entries(envObj)
222
+ .sort(([a], [b]) => a.localeCompare(b))
223
+ .map(([key, value]) => `${key}=${String(value)}`)
224
+ .join('\n') + '\n'
225
+ )
226
+ }
227
+
228
+ function deepMergeObject(base, next) {
229
+ if (!base || typeof base !== 'object' || Array.isArray(base)) return next
230
+ if (!next || typeof next !== 'object' || Array.isArray(next)) return next
231
+
232
+ const merged = { ...base }
233
+ for (const [key, value] of Object.entries(next)) {
234
+ if (
235
+ value &&
236
+ typeof value === 'object' &&
237
+ !Array.isArray(value) &&
238
+ merged[key] &&
239
+ typeof merged[key] === 'object' &&
240
+ !Array.isArray(merged[key])
241
+ ) {
242
+ merged[key] = deepMergeObject(merged[key], value)
243
+ } else {
244
+ merged[key] = value
245
+ }
246
+ }
247
+ return merged
248
+ }
249
+
250
+ function sanitizeProviderConfigForLive(providerConfig) {
251
+ if (!providerConfig || typeof providerConfig !== 'object' || Array.isArray(providerConfig)) {
252
+ return providerConfig
253
+ }
254
+
255
+ const sanitized = { ...providerConfig }
256
+ if (Object.prototype.hasOwnProperty.call(sanitized, '_profile')) {
257
+ delete sanitized._profile
258
+ }
259
+ return sanitized
260
+ }
261
+
262
+ function preserveProviderProfile(nextConfig, previousConfig) {
263
+ if (!nextConfig || typeof nextConfig !== 'object' || Array.isArray(nextConfig)) {
264
+ return nextConfig
265
+ }
266
+ if (!previousConfig || typeof previousConfig !== 'object' || Array.isArray(previousConfig)) {
267
+ return nextConfig
268
+ }
269
+
270
+ const profile = previousConfig._profile
271
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
272
+ return nextConfig
273
+ }
274
+
275
+ return {
276
+ ...nextConfig,
277
+ _profile: profile,
278
+ }
279
+ }
280
+
281
+ function sanitizeOfficialProviderConfig(appType, liveConfig) {
282
+ if (!liveConfig || typeof liveConfig !== 'object' || Array.isArray(liveConfig)) {
283
+ return liveConfig
284
+ }
285
+
286
+ // Official Codex providers should never carry API-key credentials from API-mode snapshots.
287
+ if (appType === 'codex') {
288
+ const next = { ...liveConfig }
289
+ const auth =
290
+ next.auth && typeof next.auth === 'object' && !Array.isArray(next.auth) ? { ...next.auth } : {}
291
+ auth.OPENAI_API_KEY = null
292
+ if (Object.prototype.hasOwnProperty.call(auth, 'api_key')) {
293
+ delete auth.api_key
294
+ }
295
+ next.auth = auth
296
+ return next
297
+ }
298
+
299
+ return liveConfig
300
+ }
301
+
302
+ function sanitizeOfficialConfigIfNeeded(appType, config) {
303
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
304
+ return config
305
+ }
306
+ const profile = config._profile
307
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
308
+ return config
309
+ }
310
+ if (profile.kind !== 'official') {
311
+ return config
312
+ }
313
+ return sanitizeOfficialProviderConfig(appType, config)
314
+ }
315
+
316
+ function normalizeCodexAuthConfig(authConfig) {
317
+ if (!authConfig || typeof authConfig !== 'object' || Array.isArray(authConfig)) {
318
+ return {}
319
+ }
320
+
321
+ const normalized = { ...authConfig }
322
+ if (
323
+ typeof normalized.OPENAI_API_KEY !== 'string' &&
324
+ typeof normalized.api_key === 'string'
325
+ ) {
326
+ normalized.OPENAI_API_KEY = normalized.api_key
327
+ }
328
+
329
+ return normalized
330
+ }
331
+
332
+ function normalizeCodexProviderConfig(providerConfig) {
333
+ if (!providerConfig || typeof providerConfig !== 'object' || Array.isArray(providerConfig)) {
334
+ return {}
335
+ }
336
+
337
+ const normalized = {}
338
+ if (providerConfig.auth && typeof providerConfig.auth === 'object' && !Array.isArray(providerConfig.auth)) {
339
+ normalized.auth = normalizeCodexAuthConfig(providerConfig.auth)
340
+ }
341
+
342
+ if (typeof providerConfig.config === 'string') {
343
+ normalized.config = providerConfig.config
344
+ } else if (
345
+ providerConfig.config &&
346
+ typeof providerConfig.config === 'object' &&
347
+ !Array.isArray(providerConfig.config)
348
+ ) {
349
+ normalized.config = TOML.stringify(providerConfig.config)
350
+ } else if (
351
+ providerConfig.configToml &&
352
+ typeof providerConfig.configToml === 'object' &&
353
+ !Array.isArray(providerConfig.configToml)
354
+ ) {
355
+ // Backward compatibility for old stored shape.
356
+ normalized.config = TOML.stringify(providerConfig.configToml)
357
+ }
358
+
359
+ return normalized
360
+ }
361
+
362
+ function sanitizeCodexProviderName(rawName) {
363
+ const normalized = String(rawName || '')
364
+ .trim()
365
+ .toLowerCase()
366
+ .replace(/[^a-z0-9_]/g, '_')
367
+ .replace(/^_+|_+$/g, '')
368
+ return normalized || 'custom'
369
+ }
370
+
371
+ function buildCodexConfigToml(providerName, endpoint, modelName = 'gpt-5.2') {
372
+ const providerKey = sanitizeCodexProviderName(providerName)
373
+ return `model_provider = "${providerKey}"
374
+ model = "${modelName || 'gpt-5.2'}"
375
+ model_reasoning_effort = "high"
376
+ disable_response_storage = true
377
+
378
+ [model_providers.${providerKey}]
379
+ name = "${providerKey}"
380
+ base_url = "${endpoint}"
381
+ wire_api = "responses"
382
+ requires_openai_auth = true
383
+ `
384
+ }
385
+
386
+ function mergeLiveConfig(appType, liveConfig, providerConfig) {
387
+ const sanitizedProviderConfig = sanitizeProviderConfigForLive(providerConfig || {})
388
+
389
+ if (appType === 'claude') {
390
+ return deepMergeObject(liveConfig || {}, sanitizedProviderConfig || {})
391
+ }
392
+
393
+ if (appType === 'codex') {
394
+ // Align with cc-switch: switch writes provider snapshot directly.
395
+ // If legacy provider misses auth/config, fallback to current live values.
396
+ const liveCodex = normalizeCodexProviderConfig(liveConfig || {})
397
+ const nextCodex = normalizeCodexProviderConfig(sanitizedProviderConfig || {})
398
+ return {
399
+ ...(nextCodex.auth || liveCodex.auth ? { auth: nextCodex.auth || liveCodex.auth } : {}),
400
+ ...(typeof nextCodex.config === 'string' || typeof liveCodex.config === 'string'
401
+ ? { config: typeof nextCodex.config === 'string' ? nextCodex.config : liveCodex.config }
402
+ : {}),
403
+ }
404
+ }
405
+
406
+ if (appType === 'gemini') {
407
+ return {
408
+ env: { ...(liveConfig?.env || {}), ...(sanitizedProviderConfig?.env || {}) },
409
+ settings: deepMergeObject(liveConfig?.settings || {}, sanitizedProviderConfig?.settings || {}),
410
+ }
411
+ }
412
+
413
+ return sanitizedProviderConfig
414
+ }
415
+
416
+ function getAdapter(appType) {
417
+ assertAppType(appType)
418
+
419
+ if (appType === 'claude') {
420
+ const livePath = path.join(os.homedir(), '.claude', 'settings.json')
421
+ return {
422
+ async readLive() {
423
+ return await readJsonFile(livePath)
424
+ },
425
+ async validateProviderConfig(providerConfig) {
426
+ ensureObject(providerConfig, 'Claude provider config')
427
+ },
428
+ async writeLive(providerConfig) {
429
+ ensureObject(providerConfig, 'Claude provider config')
430
+ await writeJsonAtomic(livePath, providerConfig)
431
+ },
432
+ }
433
+ }
434
+
435
+ if (appType === 'codex') {
436
+ const authPath = path.join(os.homedir(), '.codex', 'auth.json')
437
+ const configTomlPath = path.join(os.homedir(), '.codex', 'config.toml')
438
+
439
+ return {
440
+ async readLive() {
441
+ let config = ''
442
+ try {
443
+ config = await fsp.readFile(configTomlPath, 'utf-8')
444
+ } catch (error) {
445
+ if (!error || error.code !== 'ENOENT') {
446
+ throw new Error(`Failed to read Codex config.toml: ${configTomlPath}`)
447
+ }
448
+ }
449
+
450
+ return {
451
+ auth: await readJsonFile(authPath),
452
+ config,
453
+ }
454
+ },
455
+ async validateProviderConfig(providerConfig) {
456
+ ensureObject(providerConfig, 'Codex provider config')
457
+ const normalized = normalizeCodexProviderConfig(providerConfig)
458
+ const hasAuth = normalized.auth && typeof normalized.auth === 'object'
459
+ const hasConfig = typeof normalized.config === 'string'
460
+ if (!hasAuth && !hasConfig) {
461
+ throw new Error('Codex provider config must include auth and/or config')
462
+ }
463
+ },
464
+ async writeLive(providerConfig) {
465
+ ensureObject(providerConfig, 'Codex provider config')
466
+ const normalized = normalizeCodexProviderConfig(providerConfig)
467
+
468
+ if (normalized.auth && typeof normalized.auth === 'object') {
469
+ await writeJsonAtomic(authPath, normalized.auth)
470
+ }
471
+
472
+ if (typeof normalized.config === 'string') {
473
+ await atomicWriteFile(configTomlPath, normalized.config)
474
+ }
475
+ },
476
+ }
477
+ }
478
+
479
+ if (appType === 'gemini') {
480
+ const envPath = path.join(os.homedir(), '.gemini', '.env')
481
+ const settingsPath = path.join(os.homedir(), '.gemini', 'settings.json')
482
+
483
+ return {
484
+ async readLive() {
485
+ let env = {}
486
+ try {
487
+ const rawEnv = await fsp.readFile(envPath, 'utf-8')
488
+ env = parseEnv(rawEnv)
489
+ } catch (error) {
490
+ if (!error || error.code !== 'ENOENT') {
491
+ throw new Error(`Failed to read Gemini .env: ${envPath}`)
492
+ }
493
+ }
494
+
495
+ return {
496
+ env,
497
+ settings: await readJsonFile(settingsPath),
498
+ }
499
+ },
500
+ async validateProviderConfig(providerConfig) {
501
+ ensureObject(providerConfig, 'Gemini provider config')
502
+ const hasEnv = providerConfig.env && typeof providerConfig.env === 'object'
503
+ const hasSettings = providerConfig.settings && typeof providerConfig.settings === 'object'
504
+ if (!hasEnv && !hasSettings) {
505
+ throw new Error('Gemini provider config must include env and/or settings object')
506
+ }
507
+ },
508
+ async writeLive(providerConfig) {
509
+ ensureObject(providerConfig, 'Gemini provider config')
510
+
511
+ if (providerConfig.env && typeof providerConfig.env === 'object') {
512
+ await atomicWriteFile(envPath, stringifyEnv(providerConfig.env))
513
+ }
514
+
515
+ if (providerConfig.settings && typeof providerConfig.settings === 'object') {
516
+ await writeJsonAtomic(settingsPath, providerConfig.settings)
517
+ }
518
+ },
519
+ }
520
+ }
521
+
522
+ throw new Error(`No adapter for app type: ${appType}`)
523
+ }
524
+
525
+ function addLiveBackup(appType, backup) {
526
+ const db = ensureDb()
527
+ const stmt = db.prepare(`
528
+ INSERT INTO live_backups (app_type, backup_json, created_at)
529
+ VALUES (@appType, @backupJson, @createdAt)
530
+ `)
531
+
532
+ const result = stmt.run({
533
+ appType,
534
+ backupJson: JSON.stringify(backup),
535
+ createdAt: nowTs(),
536
+ })
537
+
538
+ return Number(result.lastInsertRowid)
539
+ }
540
+
541
+ function getBackupById(backupId) {
542
+ const db = ensureDb()
543
+ const row = db
544
+ .prepare(`SELECT id, app_type, backup_json, created_at FROM live_backups WHERE id = ?`)
545
+ .get(backupId)
546
+
547
+ if (!row) return null
548
+
549
+ return {
550
+ id: row.id,
551
+ appType: row.app_type,
552
+ backup: parseJsonSafe(row.backup_json, {}),
553
+ createdAt: row.created_at,
554
+ }
555
+ }
556
+
557
+ function getLatestBackup(appType) {
558
+ assertAppType(appType)
559
+ const db = ensureDb()
560
+ const row = db
561
+ .prepare(
562
+ `SELECT id, app_type, backup_json, created_at
563
+ FROM live_backups
564
+ WHERE app_type = ?
565
+ ORDER BY id DESC
566
+ LIMIT 1`
567
+ )
568
+ .get(appType)
569
+
570
+ if (!row) return null
571
+
572
+ return {
573
+ id: row.id,
574
+ appType: row.app_type,
575
+ backup: parseJsonSafe(row.backup_json, {}),
576
+ createdAt: row.created_at,
577
+ }
578
+ }
579
+
580
+ function listProviders(appType) {
581
+ const db = ensureDb()
582
+
583
+ if (appType) assertAppType(appType)
584
+
585
+ const rows = appType
586
+ ? db
587
+ .prepare(
588
+ `SELECT id, app_type, name, config_json, is_current, created_at, updated_at
589
+ FROM providers
590
+ WHERE app_type = ?
591
+ ORDER BY updated_at DESC, name ASC`
592
+ )
593
+ .all(appType)
594
+ : db
595
+ .prepare(
596
+ `SELECT id, app_type, name, config_json, is_current, created_at, updated_at
597
+ FROM providers
598
+ ORDER BY app_type ASC, updated_at DESC, name ASC`
599
+ )
600
+ .all()
601
+
602
+ return rows.map(parseProviderRow)
603
+ }
604
+
605
+ function getProviderById(id) {
606
+ const db = ensureDb()
607
+ const row = db
608
+ .prepare(
609
+ `SELECT id, app_type, name, config_json, is_current, created_at, updated_at
610
+ FROM providers
611
+ WHERE id = ?`
612
+ )
613
+ .get(id)
614
+
615
+ return parseProviderRow(row)
616
+ }
617
+
618
+ function getCurrentProvider(appType) {
619
+ assertAppType(appType)
620
+ const db = ensureDb()
621
+ const row = db
622
+ .prepare(
623
+ `SELECT id, app_type, name, config_json, is_current, created_at, updated_at
624
+ FROM providers
625
+ WHERE app_type = ? AND is_current = 1
626
+ LIMIT 1`
627
+ )
628
+ .get(appType)
629
+
630
+ return parseProviderRow(row)
631
+ }
632
+
633
+ function normalizeProviderProfile(profileInput) {
634
+ const profile = profileInput && typeof profileInput === 'object' ? profileInput : {}
635
+ return {
636
+ kind: profile.kind === 'official' ? 'official' : 'api',
637
+ vendorKey: typeof profile.vendorKey === 'string' ? profile.vendorKey : undefined,
638
+ universalId: typeof profile.universalId === 'string' ? profile.universalId : undefined,
639
+ accountName: typeof profile.accountName === 'string' ? profile.accountName : undefined,
640
+ endpoint: typeof profile.endpoint === 'string' ? profile.endpoint : undefined,
641
+ website: typeof profile.website === 'string' ? profile.website : undefined,
642
+ model: typeof profile.model === 'string' ? profile.model : undefined,
643
+ accountId: typeof profile.accountId === 'string' ? profile.accountId : undefined,
644
+ note: typeof profile.note === 'string' ? profile.note : undefined,
645
+ }
646
+ }
647
+
648
+ function attachProfile(config, profileInput) {
649
+ const profile = normalizeProviderProfile(profileInput)
650
+ return {
651
+ ...(config || {}),
652
+ _profile: profile,
653
+ }
654
+ }
655
+
656
+ function addProvider({ appType, name, config }) {
657
+ assertAppType(appType)
658
+ ensureObject(config, 'Provider config')
659
+ const normalizedConfig = sanitizeOfficialConfigIfNeeded(appType, config)
660
+
661
+ const db = ensureDb()
662
+ const ts = nowTs()
663
+ const id = crypto.randomUUID()
664
+
665
+ db.prepare(
666
+ `INSERT INTO providers (id, app_type, name, config_json, is_current, created_at, updated_at)
667
+ VALUES (@id, @appType, @name, @configJson, 0, @createdAt, @updatedAt)`
668
+ ).run({
669
+ id,
670
+ appType,
671
+ name: name?.trim() || `${appType}-${id.slice(0, 8)}`,
672
+ configJson: JSON.stringify(normalizedConfig),
673
+ createdAt: ts,
674
+ updatedAt: ts,
675
+ })
676
+
677
+ if (
678
+ appType === 'codex' &&
679
+ normalizedConfig.auth &&
680
+ typeof normalizedConfig.auth === 'object' &&
681
+ !Array.isArray(normalizedConfig.auth)
682
+ ) {
683
+ writeCodexProviderAuthSnapshot(id, normalizedConfig.auth)
684
+ }
685
+
686
+ return getProviderById(id)
687
+ }
688
+
689
+ async function captureProviderFromLive({ appType, name, profile }) {
690
+ assertAppType(appType)
691
+ const adapter = getAdapter(appType)
692
+ const liveConfig = await adapter.readLive()
693
+ ensureObject(liveConfig, 'Live config')
694
+ const capturedConfig = sanitizeOfficialProviderConfig(appType, liveConfig)
695
+ const normalizedProfileInput = profile && typeof profile === 'object' ? { ...profile } : {}
696
+
697
+ if (appType === 'codex') {
698
+ const capturedAccountId = getCodexAccountIdFromConfig(capturedConfig)
699
+ if (capturedAccountId) {
700
+ normalizedProfileInput.accountId = capturedAccountId
701
+
702
+ const existingOfficial = listProviders('codex').find((provider) => {
703
+ const providerProfile = getProviderProfile(provider)
704
+ if (providerProfile?.kind !== 'official') return false
705
+
706
+ const providerAccountId =
707
+ (typeof providerProfile.accountId === 'string' && providerProfile.accountId) ||
708
+ getCodexAccountIdFromConfig(provider.config)
709
+
710
+ return providerAccountId === capturedAccountId
711
+ })
712
+
713
+ if (existingOfficial) {
714
+ delete normalizedProfileInput.accountId
715
+ const emptyOfficialAuth = {
716
+ OPENAI_API_KEY: null,
717
+ auth_mode: 'chatgpt',
718
+ }
719
+ capturedConfig.auth = emptyOfficialAuth
720
+ }
721
+ }
722
+ }
723
+
724
+ const provider = addProvider({
725
+ appType,
726
+ name,
727
+ config: attachProfile(capturedConfig, {
728
+ ...normalizedProfileInput,
729
+ kind: 'official',
730
+ }),
731
+ })
732
+
733
+ return provider
734
+ }
735
+
736
+ function updateProvider({ id, name, config }) {
737
+ const db = ensureDb()
738
+ const existing = getProviderById(id)
739
+ if (!existing) {
740
+ throw new Error(`Provider not found: ${id}`)
741
+ }
742
+
743
+ if (config !== undefined) ensureObject(config, 'Provider config')
744
+ const nextConfig =
745
+ config === undefined ? existing.config : sanitizeOfficialConfigIfNeeded(existing.appType, config)
746
+
747
+ db.prepare(
748
+ `UPDATE providers
749
+ SET name = @name,
750
+ config_json = @configJson,
751
+ updated_at = @updatedAt
752
+ WHERE id = @id`
753
+ ).run({
754
+ id,
755
+ name: name?.trim() || existing.name,
756
+ configJson: JSON.stringify(nextConfig),
757
+ updatedAt: nowTs(),
758
+ })
759
+
760
+ if (
761
+ existing.appType === 'codex' &&
762
+ nextConfig &&
763
+ typeof nextConfig === 'object' &&
764
+ !Array.isArray(nextConfig) &&
765
+ nextConfig.auth &&
766
+ typeof nextConfig.auth === 'object' &&
767
+ !Array.isArray(nextConfig.auth)
768
+ ) {
769
+ writeCodexProviderAuthSnapshot(existing.id, nextConfig.auth)
770
+ }
771
+
772
+ return getProviderById(id)
773
+ }
774
+
775
+ function deleteProvider(id) {
776
+ const db = ensureDb()
777
+ const existing = getProviderById(id)
778
+ if (!existing) return false
779
+
780
+ db.prepare(`DELETE FROM providers WHERE id = ?`).run(id)
781
+
782
+ if (existing.appType === 'codex') {
783
+ const snapshotDir = path.dirname(getCodexProviderAuthSnapshotPath(id))
784
+ try {
785
+ fs.rmSync(snapshotDir, { recursive: true, force: true })
786
+ } catch {
787
+ // Ignore cleanup failures.
788
+ }
789
+ }
790
+
791
+ return true
792
+ }
793
+
794
+ function listUniversalProviders() {
795
+ const db = ensureDb()
796
+ const rows = db
797
+ .prepare(
798
+ `SELECT id, name, base_url, api_key, website_url, notes, apps_json, models_json, created_at, updated_at
799
+ FROM universal_providers
800
+ ORDER BY updated_at DESC, name ASC`
801
+ )
802
+ .all()
803
+
804
+ return rows.map(parseUniversalProviderRow)
805
+ }
806
+
807
+ function getUniversalProviderById(id) {
808
+ const db = ensureDb()
809
+ const row = db
810
+ .prepare(
811
+ `SELECT id, name, base_url, api_key, website_url, notes, apps_json, models_json, created_at, updated_at
812
+ FROM universal_providers
813
+ WHERE id = ?`
814
+ )
815
+ .get(id)
816
+
817
+ return parseUniversalProviderRow(row)
818
+ }
819
+
820
+ function addUniversalProvider(input) {
821
+ const db = ensureDb()
822
+ const ts = nowTs()
823
+ const id = crypto.randomUUID()
824
+
825
+ const name = input?.name?.trim()
826
+ const baseUrl = input?.baseUrl?.trim()
827
+ const apiKey = input?.apiKey?.trim()
828
+ if (!name) throw new Error('Universal provider name is required')
829
+ if (!baseUrl) throw new Error('Universal provider baseUrl is required')
830
+ if (!apiKey) throw new Error('Universal provider apiKey is required')
831
+
832
+ db.prepare(
833
+ `INSERT INTO universal_providers
834
+ (id, name, base_url, api_key, website_url, notes, apps_json, models_json, created_at, updated_at)
835
+ VALUES (@id, @name, @baseUrl, @apiKey, @websiteUrl, @notes, @appsJson, @modelsJson, @createdAt, @updatedAt)`
836
+ ).run({
837
+ id,
838
+ name,
839
+ baseUrl,
840
+ apiKey,
841
+ websiteUrl: input.websiteUrl?.trim() || null,
842
+ notes: input.notes?.trim() || null,
843
+ appsJson: JSON.stringify(normalizeUniversalApps(input.apps || {})),
844
+ modelsJson: JSON.stringify(normalizeUniversalModels(input.models || {})),
845
+ createdAt: ts,
846
+ updatedAt: ts,
847
+ })
848
+
849
+ return getUniversalProviderById(id)
850
+ }
851
+
852
+ function updateUniversalProvider(input) {
853
+ const existing = getUniversalProviderById(input?.id)
854
+ if (!existing) {
855
+ throw new Error(`Universal provider not found: ${input?.id}`)
856
+ }
857
+
858
+ const db = ensureDb()
859
+ const next = {
860
+ name: input.name?.trim() || existing.name,
861
+ baseUrl: input.baseUrl?.trim() || existing.baseUrl,
862
+ apiKey: input.apiKey?.trim() || existing.apiKey,
863
+ websiteUrl:
864
+ input.websiteUrl === undefined ? existing.websiteUrl : input.websiteUrl?.trim() || undefined,
865
+ notes: input.notes === undefined ? existing.notes : input.notes?.trim() || undefined,
866
+ apps: normalizeUniversalApps(input.apps ? { ...existing.apps, ...input.apps } : existing.apps),
867
+ models: input.models ? normalizeUniversalModels(input.models) : existing.models,
868
+ }
869
+
870
+ db.prepare(
871
+ `UPDATE universal_providers
872
+ SET name = @name,
873
+ base_url = @baseUrl,
874
+ api_key = @apiKey,
875
+ website_url = @websiteUrl,
876
+ notes = @notes,
877
+ apps_json = @appsJson,
878
+ models_json = @modelsJson,
879
+ updated_at = @updatedAt
880
+ WHERE id = @id`
881
+ ).run({
882
+ id: existing.id,
883
+ name: next.name,
884
+ baseUrl: next.baseUrl,
885
+ apiKey: next.apiKey,
886
+ websiteUrl: next.websiteUrl || null,
887
+ notes: next.notes || null,
888
+ appsJson: JSON.stringify(next.apps),
889
+ modelsJson: JSON.stringify(next.models),
890
+ updatedAt: nowTs(),
891
+ })
892
+
893
+ return getUniversalProviderById(existing.id)
894
+ }
895
+
896
+ function deleteUniversalProvider(id) {
897
+ const db = ensureDb()
898
+ const existing = getUniversalProviderById(id)
899
+ if (!existing) return false
900
+ db.prepare(`DELETE FROM universal_providers WHERE id = ?`).run(id)
901
+ return true
902
+ }
903
+
904
+ function getProviderProfile(provider) {
905
+ if (!provider?.config || typeof provider.config !== 'object') return null
906
+ const profile = provider.config._profile
907
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return null
908
+ return profile
909
+ }
910
+
911
+ function getCodexAccountIdFromConfig(config) {
912
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return undefined
913
+ const auth = config.auth
914
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return undefined
915
+ const tokens = auth.tokens
916
+ if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) return undefined
917
+ return typeof tokens.account_id === 'string' ? tokens.account_id : undefined
918
+ }
919
+
920
+ function buildProviderConfigFromUniversal(universalProvider, appType) {
921
+ const model = universalProvider?.models?.[appType]?.model
922
+ const endpoint = universalProvider.baseUrl
923
+ const profile = {
924
+ kind: 'api',
925
+ vendorKey: 'universal',
926
+ universalId: universalProvider.id,
927
+ endpoint,
928
+ website: universalProvider.websiteUrl || undefined,
929
+ model: model || undefined,
930
+ note: universalProvider.notes || undefined,
931
+ }
932
+
933
+ if (appType === 'claude') {
934
+ return attachProfile(
935
+ {
936
+ api_key: universalProvider.apiKey,
937
+ model: model || 'claude-sonnet-4',
938
+ api_base_url: endpoint,
939
+ },
940
+ profile
941
+ )
942
+ }
943
+
944
+ if (appType === 'codex') {
945
+ return attachProfile(
946
+ {
947
+ auth: {
948
+ OPENAI_API_KEY: universalProvider.apiKey,
949
+ },
950
+ config: buildCodexConfigToml(universalProvider.name, endpoint, model || 'gpt-5.2'),
951
+ },
952
+ profile
953
+ )
954
+ }
955
+
956
+ if (appType === 'gemini') {
957
+ return attachProfile(
958
+ {
959
+ env: {
960
+ GEMINI_API_KEY: universalProvider.apiKey,
961
+ },
962
+ settings: {
963
+ model: model || 'gemini-2.5-pro',
964
+ api_base_url: endpoint,
965
+ },
966
+ },
967
+ profile
968
+ )
969
+ }
970
+
971
+ throw new Error(`Unsupported app type: ${appType}`)
972
+ }
973
+
974
+ function applyUniversalProvider({ id }) {
975
+ const universal = getUniversalProviderById(id)
976
+ if (!universal) {
977
+ throw new Error(`Universal provider not found: ${id}`)
978
+ }
979
+
980
+ const applied = []
981
+ for (const appType of APP_TYPES) {
982
+ if (!universal.apps[appType]) continue
983
+
984
+ const existing = listProviders(appType).find((provider) => {
985
+ const profile = getProviderProfile(provider)
986
+ return profile?.universalId === universal.id
987
+ })
988
+
989
+ const config = buildProviderConfigFromUniversal(universal, appType)
990
+ if (existing) {
991
+ applied.push(
992
+ updateProvider({
993
+ id: existing.id,
994
+ name: universal.name,
995
+ config,
996
+ })
997
+ )
998
+ } else {
999
+ applied.push(
1000
+ addProvider({
1001
+ appType,
1002
+ name: universal.name,
1003
+ config,
1004
+ })
1005
+ )
1006
+ }
1007
+ }
1008
+
1009
+ return applied
1010
+ }
1011
+
1012
+ function setCurrentProvider(appType, providerId) {
1013
+ assertAppType(appType)
1014
+ const db = ensureDb()
1015
+
1016
+ const tx = db.transaction(() => {
1017
+ db.prepare(`UPDATE providers SET is_current = 0, updated_at = ? WHERE app_type = ?`).run(
1018
+ nowTs(),
1019
+ appType
1020
+ )
1021
+
1022
+ db.prepare(`UPDATE providers SET is_current = 1, updated_at = ? WHERE id = ?`).run(nowTs(), providerId)
1023
+ })
1024
+
1025
+ tx()
1026
+ }
1027
+
1028
+ function updateProviderConfig(providerId, config) {
1029
+ ensureObject(config, 'Provider config')
1030
+ const db = ensureDb()
1031
+ db.prepare(`UPDATE providers SET config_json = ?, updated_at = ? WHERE id = ?`).run(
1032
+ JSON.stringify(config),
1033
+ nowTs(),
1034
+ providerId
1035
+ )
1036
+ }
1037
+
1038
+ function formatSwitchError(step, err, rollbackErr, appType, backupId) {
1039
+ const detail = err instanceof Error ? err.message : String(err)
1040
+ const rollbackDetail = rollbackErr
1041
+ ? ` Rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}.`
1042
+ : ''
1043
+
1044
+ return new Error(
1045
+ `Provider switch failed at ${step} for ${appType}. Backup id: ${backupId}.${rollbackDetail} Root cause: ${detail}`
1046
+ )
1047
+ }
1048
+
1049
+ async function restoreBackup(appType, backupId) {
1050
+ const adapter = getAdapter(appType)
1051
+ const byId = backupId ? getBackupById(backupId) : null
1052
+ const latest = byId || getLatestBackup(appType)
1053
+
1054
+ if (!latest) {
1055
+ throw new Error(`No backup found for app ${appType}`)
1056
+ }
1057
+
1058
+ await adapter.writeLive(latest.backup)
1059
+ return latest
1060
+ }
1061
+
1062
+ async function switchProvider({ appType, providerId }) {
1063
+ assertAppType(appType)
1064
+
1065
+ const target = getProviderById(providerId)
1066
+ if (!target) {
1067
+ throw new Error(`Target provider not found: ${providerId}`)
1068
+ }
1069
+ if (target.appType !== appType) {
1070
+ throw new Error(`Provider ${providerId} does not belong to app ${appType}`)
1071
+ }
1072
+
1073
+ const adapter = getAdapter(appType)
1074
+ const current = getCurrentProvider(appType)
1075
+
1076
+ const liveBefore = await adapter.readLive()
1077
+ const backupId = addLiveBackup(appType, liveBefore)
1078
+
1079
+ if (current && current.id !== target.id) {
1080
+ if (appType === 'codex') {
1081
+ const liveAuth =
1082
+ liveBefore &&
1083
+ typeof liveBefore === 'object' &&
1084
+ !Array.isArray(liveBefore) &&
1085
+ liveBefore.auth &&
1086
+ typeof liveBefore.auth === 'object' &&
1087
+ !Array.isArray(liveBefore.auth)
1088
+ ? liveBefore.auth
1089
+ : null
1090
+ if (liveAuth) {
1091
+ writeCodexProviderAuthSnapshot(current.id, liveAuth)
1092
+ }
1093
+ }
1094
+ updateProviderConfig(current.id, preserveProviderProfile(liveBefore, current.config))
1095
+ }
1096
+
1097
+ let targetConfigForSwitch = target.config
1098
+ if (appType === 'codex') {
1099
+ const snapshotAuth = await readCodexProviderAuthSnapshot(target.id)
1100
+ if (snapshotAuth) {
1101
+ targetConfigForSwitch = {
1102
+ ...(target.config || {}),
1103
+ auth: snapshotAuth,
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ const nextConfig = mergeLiveConfig(appType, liveBefore, targetConfigForSwitch)
1109
+
1110
+ try {
1111
+ await adapter.validateProviderConfig(nextConfig)
1112
+ } catch (error) {
1113
+ throw formatSwitchError('validate', error, null, appType, backupId)
1114
+ }
1115
+
1116
+ try {
1117
+ await adapter.writeLive(nextConfig)
1118
+ } catch (error) {
1119
+ let rollbackErr = null
1120
+ try {
1121
+ await restoreBackup(appType, backupId)
1122
+ } catch (restoreError) {
1123
+ rollbackErr = restoreError
1124
+ }
1125
+
1126
+ throw formatSwitchError('writeLive', error, rollbackErr, appType, backupId)
1127
+ }
1128
+
1129
+ try {
1130
+ setCurrentProvider(appType, target.id)
1131
+ } catch (error) {
1132
+ let rollbackErr = null
1133
+ try {
1134
+ await restoreBackup(appType, backupId)
1135
+ } catch (restoreError) {
1136
+ rollbackErr = restoreError
1137
+ }
1138
+
1139
+ throw formatSwitchError('setCurrent', error, rollbackErr, appType, backupId)
1140
+ }
1141
+
1142
+ return {
1143
+ appType,
1144
+ currentProviderId: target.id,
1145
+ backupId,
1146
+ switchedFrom: current?.id || null,
1147
+ switchedTo: target.id,
1148
+ }
1149
+ }
1150
+
1151
+ function getDbPath() {
1152
+ return DB_PATH
1153
+ }
1154
+
1155
+ function maskProviderConfig(value) {
1156
+ if (Array.isArray(value)) {
1157
+ return value.map((entry) => maskProviderConfig(entry))
1158
+ }
1159
+
1160
+ if (!value || typeof value !== 'object') return value
1161
+
1162
+ const masked = {}
1163
+ for (const [key, val] of Object.entries(value)) {
1164
+ const lowerKey = key.toLowerCase()
1165
+ const shouldMask =
1166
+ lowerKey.includes('key') ||
1167
+ lowerKey.includes('token') ||
1168
+ lowerKey.includes('secret') ||
1169
+ lowerKey.includes('password')
1170
+
1171
+ if (shouldMask && typeof val === 'string') {
1172
+ const raw = val.trim()
1173
+ if (raw.length <= 8) {
1174
+ masked[key] = '****'
1175
+ } else {
1176
+ masked[key] = `${raw.slice(0, 4)}****${raw.slice(-2)}`
1177
+ }
1178
+ continue
1179
+ }
1180
+
1181
+ masked[key] = maskProviderConfig(val)
1182
+ }
1183
+
1184
+ return masked
1185
+ }
1186
+
1187
+ function maskProvider(provider) {
1188
+ if (!provider) return provider
1189
+ return {
1190
+ ...provider,
1191
+ config: maskProviderConfig(provider.config),
1192
+ }
1193
+ }
1194
+
1195
+ function maskProviders(providers) {
1196
+ return providers.map(maskProvider)
1197
+ }
1198
+
1199
+ export {
1200
+ APP_TYPES,
1201
+ addProvider,
1202
+ addUniversalProvider,
1203
+ applyUniversalProvider,
1204
+ captureProviderFromLive,
1205
+ deleteProvider,
1206
+ deleteUniversalProvider,
1207
+ ensureDb,
1208
+ getAdapter,
1209
+ getBackupById,
1210
+ getCurrentProvider,
1211
+ getDbPath,
1212
+ getLatestBackup,
1213
+ getProviderById,
1214
+ getUniversalProviderById,
1215
+ listProviders,
1216
+ listUniversalProviders,
1217
+ maskProvider,
1218
+ maskProviders,
1219
+ restoreBackup,
1220
+ switchProvider,
1221
+ updateProvider,
1222
+ updateUniversalProvider,
1223
+ }