@goodandready/dsh-clinebot 0.5.32 → 0.6.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.
@@ -1,404 +1,524 @@
1
- import { writeJson, readBody } from '../http.js'
2
- import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
3
- import { assertTrustedSettingsRequest } from '../access.js'
4
- import { publicConfig, plainConfig, Config } from '../config.js'
5
- import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
6
- import {
7
- clearUsageCache,
8
- clearProbeCache,
9
- saveCredentialKey,
10
- deleteCredentialKey,
11
- smokeChat,
12
- DEFAULT_API_KEY_ENV,
13
- } from '../cline-client.js'
14
-
15
- export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettingsApi }) {
16
- const pendingSecretCleanupAccounts = new Set()
17
-
18
- async function addAccountInternal({ apiKey, label, apiKeyEnv }) {
19
- const curr = plainConfig(live())
20
- const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
21
-
22
- let targetEnv = String(apiKeyEnv || '').trim()
23
- if (!targetEnv) {
24
- let maxN = 1
25
- for (const acc of existingAccounts) {
26
- const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
27
- if (m) {
28
- const num = parseInt(m[1], 10)
29
- if (num >= maxN) maxN = num + 1
30
- }
31
- }
32
- if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
33
- const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
34
- if (m) {
35
- const num = parseInt(m[1], 10)
36
- if (num >= maxN) maxN = num + 1
37
- }
38
- }
39
- targetEnv = `CLINEBOT_API_KEY_${maxN}`
40
- }
41
-
42
- if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
43
- const err = new Error(`Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`)
44
- err.status = 400
45
- throw err
46
- }
47
-
48
- const accountLabel = String(label || '').trim() || targetEnv
49
-
50
- await saveCredentialKey(ctx, targetEnv, apiKey)
51
-
52
- const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
53
- const accountEntry = { label: accountLabel, apiKeyEnv: targetEnv }
54
- if (idx >= 0) {
55
- existingAccounts[idx] = accountEntry
56
- } else {
57
- existingAccounts.push(accountEntry)
58
- }
59
-
60
- const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
61
- const settingsApi = getSettingsApi()
62
- await settingsApi.replace(next)
63
- await syncProviderState(next)
64
-
65
- let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
66
- try {
67
- const parsed = new URL(curr.baseUrl)
68
- if (parsed.protocol === 'https:') {
69
- validation = await smokeChat(curr.baseUrl, apiKey, {
70
- model: curr.defaultModel,
71
- timeoutMs: 15000,
72
- })
73
- }
74
- } catch {
75
- validation = { ok: false, error: 'Invalid baseUrl' }
76
- }
77
-
78
- clearUsageCache()
79
- clearProbeCache()
80
-
81
- return {
82
- account: accountEntry,
83
- validated: validation.ok,
84
- latencyMs: validation.latencyMs,
85
- validationError: validation.ok ? null : validation.error,
86
- }
87
- }
88
-
89
- // POST /dsh-clinebot/accounts — add account endpoint (supports DELETE for backward compat)
90
- ctx.effect(() => ctx.webServer.register({
91
- kind: 'exact',
92
- path: '/dsh-clinebot/accounts',
93
- handler: async (req, res) => {
94
- if (req.method === 'DELETE') {
95
- return handleDeleteAccount(req, res)
96
- }
97
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
98
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
99
- const settingsApi = getSettingsApi()
100
- if (!settingsApi || typeof settingsApi.replace !== 'function') {
101
- return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
102
- }
103
- try {
104
- const bodyBuf = await readBody(req)
105
- let body = {}
106
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
107
-
108
- const apiKey = String(body.apiKey || '').trim()
109
- if (!apiKey) {
110
- return writeJson(res, 400, { ok: false, error: 'API key is required' })
111
- }
112
-
113
- const result = await addAccountInternal({
114
- apiKey,
115
- label: body.label,
116
- apiKeyEnv: body.apiKeyEnv,
117
- })
118
- writeJson(res, 200, { ok: true, ...result })
119
- } catch (err) {
120
- const status = typeof err?.status === 'number' ? err.status : 500
121
- writeJson(res, status, { ok: false, error: String(err?.message || err) })
122
- }
123
- },
124
- }), 'dsh-clinebot: /accounts')
125
-
126
- async function handleDeleteAccount(req, res, targetEnvFromUrl = '') {
127
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
128
- const settingsApi = getSettingsApi()
129
- if (!settingsApi || typeof settingsApi.replace !== 'function') {
130
- return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
131
- }
132
- try {
133
- let body = {}
134
- if (req.method === 'POST' || req.method === 'DELETE') {
135
- try {
136
- const bodyBuf = await readBody(req)
137
- body = JSON.parse(bodyBuf.toString('utf8'))
138
- } catch {
139
- body = {}
140
- }
141
- }
142
- let queryApiKeyEnv = ''
143
- try {
144
- const u = new URL(req.url, 'http://127.0.0.1')
145
- queryApiKeyEnv = u.searchParams.get('apiKeyEnv') || ''
146
- } catch {
147
- queryApiKeyEnv = ''
148
- }
149
- const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || queryApiKeyEnv || '').trim()
150
- if (!targetEnv) {
151
- return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
152
- }
153
-
154
- const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)
155
- if (!isDefaultPattern) {
156
- return writeJson(res, 400, {
157
- ok: false,
158
- error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
159
- })
160
- }
161
-
162
- const curr = plainConfig(live())
163
- if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
164
- return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
165
- }
166
-
167
- const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
168
- const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
169
- if (!accountToDelete) {
170
- if (body.deleteSecret && pendingSecretCleanupAccounts.has(targetEnv) && ctx?.get?.('credentials')) {
171
- let retryDelRes = null
172
- try {
173
- retryDelRes = await deleteCredentialKey(ctx, targetEnv)
174
- } catch {
175
- retryDelRes = null
176
- }
177
- if (retryDelRes && retryDelRes.ok) {
178
- pendingSecretCleanupAccounts.delete(targetEnv)
179
- clearUsageCache()
180
- clearProbeCache()
181
- return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
182
- }
183
- return writeJson(res, 500, {
184
- ok: false,
185
- partial: true,
186
- accountRemoved: true,
187
- secretDeleted: false,
188
- error: `Failed to delete secret for account "${targetEnv}" from credentials store on retry`,
189
- })
190
- }
191
- return writeJson(res, 404, {
192
- ok: false,
193
- error: `Account "${targetEnv}" not found in accounts pool`,
194
- })
195
- }
196
-
197
- const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
198
- let activeAccount = curr.activeAccount
199
- if (activeAccount === targetEnv) {
200
- activeAccount = ''
201
- }
202
-
203
- // Persist settings changes first before unsetting credentials (#184)
204
- const next = plainConfig(Config({ ...curr, accounts: nextAccounts, activeAccount }))
205
- await settingsApi.replace(next)
206
- await syncProviderState(next)
207
-
208
- let delRes = null
209
- if (body.deleteSecret) {
210
- try {
211
- delRes = await deleteCredentialKey(ctx, targetEnv)
212
- } catch {
213
- delRes = null
214
- }
215
- if (!delRes || !delRes.ok) {
216
- let rollbackSuccess = false
217
- try {
218
- const fresh = plainConfig(live())
219
- const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
220
- if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
221
- freshAccounts.push(accountToDelete)
222
- }
223
- const restoredConfig = plainConfig(Config({
224
- ...fresh,
225
- accounts: freshAccounts,
226
- activeAccount: fresh.activeAccount,
227
- }))
228
- await settingsApi.replace(restoredConfig)
229
- await syncProviderState(restoredConfig)
230
- rollbackSuccess = true
231
- } catch {
232
- rollbackSuccess = false
233
- }
234
-
235
- if (rollbackSuccess) {
236
- return writeJson(res, 500, {
237
- ok: false,
238
- partial: false,
239
- accountRemoved: false,
240
- secretDeleted: false,
241
- error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
242
- })
243
- }
244
-
245
- pendingSecretCleanupAccounts.add(targetEnv)
246
- return writeJson(res, 500, {
247
- ok: false,
248
- partial: true,
249
- accountRemoved: true,
250
- secretDeleted: false,
251
- error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
252
- })
253
- }
254
- }
255
-
256
- pendingSecretCleanupAccounts.delete(targetEnv)
257
- clearUsageCache()
258
- clearProbeCache()
259
-
260
- writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: Boolean(body.deleteSecret && delRes?.ok) })
261
- } catch (err) {
262
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
263
- }
264
- }
265
-
266
- // POST /dsh-clinebot/accounts/delete — delete account endpoint
267
- ctx.effect(() => ctx.webServer.register({
268
- kind: 'exact',
269
- path: '/dsh-clinebot/accounts/delete',
270
- handler: async (req, res) => {
271
- if (req.method !== 'POST' && req.method !== 'DELETE') {
272
- return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
273
- }
274
- return handleDeleteAccount(req, res)
275
- },
276
- }), 'dsh-clinebot: /accounts/delete')
277
-
278
- // POST /dsh-clinebot/accounts/active — switch or pin active account
279
- ctx.effect(() => ctx.webServer.register({
280
- kind: 'exact',
281
- path: '/dsh-clinebot/accounts/active',
282
- handler: async (req, res) => {
283
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
284
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
285
- try {
286
- const bodyBuf = await readBody(req)
287
- let body = {}
288
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
289
- const account = String(body.account || '').trim()
290
-
291
- clearUsageCache()
292
- clearProbeCache()
293
- const settingsApi = getSettingsApi()
294
- if (!settingsApi || typeof settingsApi.replace !== 'function') {
295
- return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
296
- }
297
-
298
- const currentLive = plainConfig(live())
299
- const accountChanged = currentLive.activeAccount !== account
300
- const next = plainConfig(Config({
301
- ...currentLive,
302
- activeAccount: account,
303
- ...(accountChanged ? { dynamicModels: [], planSyncedAt: 0 } : {}),
304
- }))
305
- await settingsApi.replace(next)
306
- await syncProviderState(next)
307
- writeJson(res, 200, { ok: true, activeAccount: account })
308
- } catch (err) {
309
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
310
- }
311
- },
312
- }), 'dsh-clinebot: /accounts/active')
313
-
314
- // POST /dsh-clinebot/register — upsert into DSH llm-pi-ai
315
- ctx.effect(() => ctx.webServer.register({
316
- kind: 'exact',
317
- path: '/dsh-clinebot/register',
318
- handler: async (req, res) => {
319
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
320
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
321
- try {
322
- const bodyBuf = await readBody(req)
323
- let body = {}
324
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
325
- const activeModels = body.models || publicConfig(live()).enabledModels
326
- const result = await upsertPiAiProvider(ctx, live(), activeModels)
327
- writeJson(res, 200, { ok: true, provider: result })
328
- } catch (err) {
329
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
330
- }
331
- },
332
- }), 'dsh-clinebot: /register')
333
-
334
- // POST /dsh-clinebot/unregister — remove from DSH
335
- ctx.effect(() => ctx.webServer.register({
336
- kind: 'exact',
337
- path: '/dsh-clinebot/unregister',
338
- handler: async (req, res) => {
339
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
340
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
341
- try {
342
- await removePiAiProvider(ctx)
343
- writeJson(res, 200, { ok: true })
344
- } catch (err) {
345
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
346
- }
347
- },
348
- }), 'dsh-clinebot: /unregister')
349
-
350
- // GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232, #246)
351
- ctx.effect(() => ctx.webServer.register({
352
- kind: 'exact',
353
- path: '/dsh-clinebot/keys/discovered',
354
- handler: async (req, res) => {
355
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
356
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
357
- try {
358
- const discovered = discoverLocalClineKeys()
359
- writeJson(res, 200, { ok: true, discovered })
360
- } catch (err) {
361
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
362
- }
363
- },
364
- }), 'dsh-clinebot: /keys/discovered')
365
-
366
- // POST /dsh-clinebot/keys/discovered/import — safely import discovered key without exposing secret (#246)
367
- ctx.effect(() => ctx.webServer.register({
368
- kind: 'exact',
369
- path: '/dsh-clinebot/keys/discovered/import',
370
- handler: async (req, res) => {
371
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
372
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
373
- const settingsApi = getSettingsApi()
374
- if (!settingsApi || typeof settingsApi.replace !== 'function') {
375
- return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
376
- }
377
- try {
378
- const bodyBuf = await readBody(req)
379
- let body = {}
380
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
381
-
382
- const id = String(body.id || '').trim()
383
- if (!id) {
384
- return writeJson(res, 400, { ok: false, error: 'Discovered key id is required' })
385
- }
386
-
387
- const apiKey = consumeDiscoveredKey(id)
388
- if (!apiKey) {
389
- return writeJson(res, 404, { ok: false, error: 'Discovered key not found or expired' })
390
- }
391
-
392
- const result = await addAccountInternal({
393
- apiKey,
394
- label: body.label,
395
- apiKeyEnv: body.apiKeyEnv,
396
- })
397
- writeJson(res, 200, { ok: true, ...result })
398
- } catch (err) {
399
- const status = typeof err?.status === 'number' ? err.status : 500
400
- writeJson(res, status, { ok: false, error: String(err?.message || err) })
401
- }
402
- },
403
- }), 'dsh-clinebot: /keys/discovered/import')
1
+ import { writeJson, readBody, publicUsage } from '../http.js'
2
+ import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
3
+ import { assertTrustedSettingsRequest } from '../access.js'
4
+ import { publicConfig, plainConfig, Config } from '../config.js'
5
+ import { upsertPiAiProvider, removePiAiProvider, resolveActiveAccountKey } from '../provider-sync.js'
6
+ import { resolveAccountPool } from '../account-pool.js'
7
+ import {
8
+ clearUsageCache,
9
+ clearProbeCache,
10
+ saveCredentialKey,
11
+ deleteCredentialKey,
12
+ fetchUsageLimits,
13
+ smokeChat,
14
+ DEFAULT_API_KEY_ENV,
15
+ } from '../cline-client.js'
16
+
17
+ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettingsApi }) {
18
+ const pendingSecretCleanupAccounts = new Set()
19
+
20
+ async function addAccountInternal({ apiKey, label, apiKeyEnv }) {
21
+ const curr = plainConfig(live())
22
+ const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
23
+
24
+ let targetEnv = String(apiKeyEnv || '').trim()
25
+ if (!targetEnv) {
26
+ let maxN = 1
27
+ for (const acc of existingAccounts) {
28
+ const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
29
+ if (m) {
30
+ const num = parseInt(m[1], 10)
31
+ if (num >= maxN) maxN = num + 1
32
+ }
33
+ }
34
+ if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
35
+ const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
36
+ if (m) {
37
+ const num = parseInt(m[1], 10)
38
+ if (num >= maxN) maxN = num + 1
39
+ }
40
+ }
41
+ targetEnv = `CLINEBOT_API_KEY_${maxN}`
42
+ }
43
+
44
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
45
+ const err = new Error(`Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`)
46
+ err.status = 400
47
+ throw err
48
+ }
49
+
50
+ const accountLabel = String(label || '').trim() || targetEnv
51
+
52
+ await saveCredentialKey(ctx, targetEnv, apiKey)
53
+
54
+ const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
55
+ const accountEntry = { label: accountLabel, apiKeyEnv: targetEnv }
56
+ if (idx >= 0) {
57
+ existingAccounts[idx] = accountEntry
58
+ } else {
59
+ existingAccounts.push(accountEntry)
60
+ }
61
+
62
+ const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
63
+ const settingsApi = getSettingsApi()
64
+ await settingsApi.replace(next)
65
+ await syncProviderState(next)
66
+
67
+ let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
68
+ try {
69
+ const parsed = new URL(curr.baseUrl)
70
+ if (parsed.protocol === 'https:') {
71
+ validation = await smokeChat(curr.baseUrl, apiKey, {
72
+ model: curr.defaultModel,
73
+ timeoutMs: 15000,
74
+ })
75
+ }
76
+ } catch {
77
+ validation = { ok: false, error: 'Invalid baseUrl' }
78
+ }
79
+
80
+ clearUsageCache()
81
+ clearProbeCache()
82
+
83
+ return {
84
+ account: accountEntry,
85
+ validated: validation.ok,
86
+ latencyMs: validation.latencyMs,
87
+ validationError: validation.ok ? null : validation.error,
88
+ }
89
+ }
90
+
91
+ // POST /dsh-clinebot/accounts — add account endpoint (supports DELETE for backward compat)
92
+ ctx.effect(() => ctx.webServer.register({
93
+ kind: 'exact',
94
+ path: '/dsh-clinebot/accounts',
95
+ handler: async (req, res) => {
96
+ if (req.method === 'DELETE') {
97
+ return handleDeleteAccount(req, res)
98
+ }
99
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
100
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
101
+ const settingsApi = getSettingsApi()
102
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
103
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
104
+ }
105
+ try {
106
+ const bodyBuf = await readBody(req)
107
+ let body = {}
108
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
109
+
110
+ const apiKey = String(body.apiKey || '').trim()
111
+ if (!apiKey) {
112
+ return writeJson(res, 400, { ok: false, error: 'API key is required' })
113
+ }
114
+
115
+ const result = await addAccountInternal({
116
+ apiKey,
117
+ label: body.label,
118
+ apiKeyEnv: body.apiKeyEnv,
119
+ })
120
+ writeJson(res, 200, { ok: true, ...result })
121
+ } catch (err) {
122
+ const status = typeof err?.status === 'number' ? err.status : 500
123
+ writeJson(res, status, { ok: false, error: String(err?.message || err) })
124
+ }
125
+ },
126
+ }), 'dsh-clinebot: /accounts')
127
+
128
+ async function handleDeleteAccount(req, res, targetEnvFromUrl = '') {
129
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
130
+ const settingsApi = getSettingsApi()
131
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
132
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
133
+ }
134
+ try {
135
+ let body = {}
136
+ if (req.method === 'POST' || req.method === 'DELETE') {
137
+ try {
138
+ const bodyBuf = await readBody(req)
139
+ body = JSON.parse(bodyBuf.toString('utf8'))
140
+ } catch {
141
+ body = {}
142
+ }
143
+ }
144
+ let queryApiKeyEnv = ''
145
+ try {
146
+ const u = new URL(req.url, 'http://127.0.0.1')
147
+ queryApiKeyEnv = u.searchParams.get('apiKeyEnv') || ''
148
+ } catch {
149
+ queryApiKeyEnv = ''
150
+ }
151
+ const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || queryApiKeyEnv || '').trim()
152
+ if (!targetEnv) {
153
+ return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
154
+ }
155
+
156
+ const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)
157
+ if (!isDefaultPattern) {
158
+ return writeJson(res, 400, {
159
+ ok: false,
160
+ error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
161
+ })
162
+ }
163
+
164
+ const curr = plainConfig(live())
165
+ if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
166
+ return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
167
+ }
168
+
169
+ const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
170
+ const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
171
+ if (!accountToDelete) {
172
+ if (body.deleteSecret && pendingSecretCleanupAccounts.has(targetEnv) && ctx?.get?.('credentials')) {
173
+ let retryDelRes = null
174
+ try {
175
+ retryDelRes = await deleteCredentialKey(ctx, targetEnv)
176
+ } catch {
177
+ retryDelRes = null
178
+ }
179
+ if (retryDelRes && retryDelRes.ok) {
180
+ pendingSecretCleanupAccounts.delete(targetEnv)
181
+ clearUsageCache()
182
+ clearProbeCache()
183
+ return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
184
+ }
185
+ return writeJson(res, 500, {
186
+ ok: false,
187
+ partial: true,
188
+ accountRemoved: true,
189
+ secretDeleted: false,
190
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store on retry`,
191
+ })
192
+ }
193
+ return writeJson(res, 404, {
194
+ ok: false,
195
+ error: `Account "${targetEnv}" not found in accounts pool`,
196
+ })
197
+ }
198
+
199
+ const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
200
+ let activeAccount = curr.activeAccount
201
+ if (activeAccount === targetEnv) {
202
+ activeAccount = ''
203
+ }
204
+
205
+ // Persist settings changes first before unsetting credentials (#184)
206
+ const next = plainConfig(Config({ ...curr, accounts: nextAccounts, activeAccount }))
207
+ await settingsApi.replace(next)
208
+ await syncProviderState(next)
209
+
210
+ let delRes = null
211
+ if (body.deleteSecret) {
212
+ try {
213
+ delRes = await deleteCredentialKey(ctx, targetEnv)
214
+ } catch {
215
+ delRes = null
216
+ }
217
+ if (!delRes || !delRes.ok) {
218
+ let rollbackSuccess = false
219
+ try {
220
+ const fresh = plainConfig(live())
221
+ const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
222
+ if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
223
+ freshAccounts.push(accountToDelete)
224
+ }
225
+ const restoredConfig = plainConfig(Config({
226
+ ...fresh,
227
+ accounts: freshAccounts,
228
+ activeAccount: fresh.activeAccount,
229
+ }))
230
+ await settingsApi.replace(restoredConfig)
231
+ await syncProviderState(restoredConfig)
232
+ rollbackSuccess = true
233
+ } catch {
234
+ rollbackSuccess = false
235
+ }
236
+
237
+ if (rollbackSuccess) {
238
+ return writeJson(res, 500, {
239
+ ok: false,
240
+ partial: false,
241
+ accountRemoved: false,
242
+ secretDeleted: false,
243
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
244
+ })
245
+ }
246
+
247
+ pendingSecretCleanupAccounts.add(targetEnv)
248
+ return writeJson(res, 500, {
249
+ ok: false,
250
+ partial: true,
251
+ accountRemoved: true,
252
+ secretDeleted: false,
253
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
254
+ })
255
+ }
256
+ }
257
+
258
+ pendingSecretCleanupAccounts.delete(targetEnv)
259
+ clearUsageCache()
260
+ clearProbeCache()
261
+
262
+ writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: Boolean(body.deleteSecret && delRes?.ok) })
263
+ } catch (err) {
264
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
265
+ }
266
+ }
267
+
268
+ // POST /dsh-clinebot/accounts/delete — delete account endpoint
269
+ ctx.effect(() => ctx.webServer.register({
270
+ kind: 'exact',
271
+ path: '/dsh-clinebot/accounts/delete',
272
+ handler: async (req, res) => {
273
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
274
+ return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
275
+ }
276
+ return handleDeleteAccount(req, res)
277
+ },
278
+ }), 'dsh-clinebot: /accounts/delete')
279
+
280
+ // POST /dsh-clinebot/accounts/active — switch or pin active account
281
+ ctx.effect(() => ctx.webServer.register({
282
+ kind: 'exact',
283
+ path: '/dsh-clinebot/accounts/active',
284
+ handler: async (req, res) => {
285
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
286
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
287
+ try {
288
+ const bodyBuf = await readBody(req)
289
+ let body = {}
290
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
291
+ const account = String(body.account || '').trim()
292
+
293
+ clearUsageCache()
294
+ clearProbeCache()
295
+ const settingsApi = getSettingsApi()
296
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
297
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
298
+ }
299
+
300
+ const currentLive = plainConfig(live())
301
+ const accountChanged = currentLive.activeAccount !== account
302
+ const next = plainConfig(Config({
303
+ ...currentLive,
304
+ activeAccount: account,
305
+ ...(accountChanged ? { dynamicModels: [], planSyncedAt: 0 } : {}),
306
+ }))
307
+ await settingsApi.replace(next)
308
+ await syncProviderState(next)
309
+ writeJson(res, 200, { ok: true, activeAccount: account })
310
+ } catch (err) {
311
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
312
+ }
313
+ },
314
+ }), 'dsh-clinebot: /accounts/active')
315
+
316
+ // POST /dsh-clinebot/register — upsert into DSH llm-pi-ai
317
+ ctx.effect(() => ctx.webServer.register({
318
+ kind: 'exact',
319
+ path: '/dsh-clinebot/register',
320
+ handler: async (req, res) => {
321
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
322
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
323
+ try {
324
+ const bodyBuf = await readBody(req)
325
+ let body = {}
326
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
327
+ const activeModels = body.models || publicConfig(live()).enabledModels
328
+ const result = await upsertPiAiProvider(ctx, live(), activeModels)
329
+ writeJson(res, 200, { ok: true, provider: result })
330
+ } catch (err) {
331
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
332
+ }
333
+ },
334
+ }), 'dsh-clinebot: /register')
335
+
336
+ // POST /dsh-clinebot/unregister — remove from DSH
337
+ ctx.effect(() => ctx.webServer.register({
338
+ kind: 'exact',
339
+ path: '/dsh-clinebot/unregister',
340
+ handler: async (req, res) => {
341
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
342
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
343
+ try {
344
+ await removePiAiProvider(ctx)
345
+ writeJson(res, 200, { ok: true })
346
+ } catch (err) {
347
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
348
+ }
349
+ },
350
+ }), 'dsh-clinebot: /unregister')
351
+
352
+ // GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232, #246)
353
+ ctx.effect(() => ctx.webServer.register({
354
+ kind: 'exact',
355
+ path: '/dsh-clinebot/keys/discovered',
356
+ handler: async (req, res) => {
357
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
358
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
359
+ try {
360
+ const discovered = discoverLocalClineKeys()
361
+ writeJson(res, 200, { ok: true, discovered })
362
+ } catch (err) {
363
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
364
+ }
365
+ },
366
+ }), 'dsh-clinebot: /keys/discovered')
367
+
368
+ // POST /dsh-clinebot/keys/discovered/import — safely import discovered key without exposing secret (#246)
369
+ ctx.effect(() => ctx.webServer.register({
370
+ kind: 'exact',
371
+ path: '/dsh-clinebot/keys/discovered/import',
372
+ handler: async (req, res) => {
373
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
374
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
375
+ const settingsApi = getSettingsApi()
376
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
377
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
378
+ }
379
+ try {
380
+ const bodyBuf = await readBody(req)
381
+ let body = {}
382
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
383
+
384
+ const id = String(body.id || '').trim()
385
+ if (!id) {
386
+ return writeJson(res, 400, { ok: false, error: 'Discovered key id is required' })
387
+ }
388
+
389
+ const apiKey = consumeDiscoveredKey(id)
390
+ if (!apiKey) {
391
+ return writeJson(res, 404, { ok: false, error: 'Discovered key not found or expired' })
392
+ }
393
+
394
+ const result = await addAccountInternal({
395
+ apiKey,
396
+ label: body.label,
397
+ apiKeyEnv: body.apiKeyEnv,
398
+ })
399
+ writeJson(res, 200, { ok: true, ...result })
400
+ } catch (err) {
401
+ const status = typeof err?.status === 'number' ? err.status : 500
402
+ writeJson(res, status, { ok: false, error: String(err?.message || err) })
403
+ }
404
+ },
405
+ }), 'dsh-clinebot: /keys/discovered/import')
406
+
407
+ // GET /dsh-clinebot/accounts/usage — per-account live quota report
408
+ ctx.effect(() => ctx.webServer.register({
409
+ kind: 'exact',
410
+ path: '/dsh-clinebot/accounts/usage',
411
+ handler: async (req, res) => {
412
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
413
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
414
+ try {
415
+ const cfg = live()
416
+ const pub = publicConfig(cfg)
417
+ const pool = await resolveAccountPool(ctx, cfg)
418
+ const active = await resolveActiveAccountKey(ctx, cfg, pool)
419
+ const timeoutMs = Math.min(2500, Number(pub.timeoutMs) || 2500)
420
+ const accounts = await Promise.all(pool.map(async (account) => {
421
+ let usage = null
422
+ let error = null
423
+ if (account.present && account.value) {
424
+ usage = await fetchUsageLimits(pub.baseUrl, account.value, { timeoutMs }).catch((err) => {
425
+ error = String(err?.message || err)
426
+ return null
427
+ })
428
+ if (usage && usage.ok === false) error = usage.error || 'usage unavailable'
429
+ }
430
+ return {
431
+ id: account.id,
432
+ label: account.label,
433
+ apiKeyEnv: account.apiKeyEnv,
434
+ configured: Boolean(account.present && account.value),
435
+ source: account.source,
436
+ active: account.apiKeyEnv === active.apiKeyEnv,
437
+ isPinned: Boolean(account.isPinned),
438
+ usage: usage ? publicUsage(usage) : null,
439
+ error,
440
+ }
441
+ }))
442
+ return writeJson(res, 200, {
443
+ ok: true,
444
+ activeAccount: active.apiKeyEnv,
445
+ accountMode: pub.accountMode,
446
+ checkedAt: Date.now(),
447
+ accounts,
448
+ })
449
+ } catch (err) {
450
+ return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
451
+ }
452
+ },
453
+ }), 'dsh-clinebot: /accounts/usage')
454
+
455
+ // POST /dsh-clinebot/accounts/rename — rename a pooled account
456
+ ctx.effect(() => ctx.webServer.register({
457
+ kind: 'exact',
458
+ path: '/dsh-clinebot/accounts/rename',
459
+ handler: async (req, res) => {
460
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
461
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
462
+ const settingsApi = getSettingsApi()
463
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
464
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
465
+ }
466
+ try {
467
+ const bodyBuf = await readBody(req)
468
+ let body = {}
469
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
470
+ const apiKeyEnv = String(body.apiKeyEnv || '').trim()
471
+ const label = String(body.label || '').trim()
472
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(apiKeyEnv)) {
473
+ return writeJson(res, 400, { ok: false, error: `Disallowed apiKeyEnv: "${apiKeyEnv}"` })
474
+ }
475
+ const curr = plainConfig(live())
476
+ if (apiKeyEnv === curr.apiKeyEnv || apiKeyEnv === DEFAULT_API_KEY_ENV) {
477
+ return writeJson(res, 400, { ok: false, error: 'Cannot rename the primary account' })
478
+ }
479
+ const accounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
480
+ const index = accounts.findIndex((account) => account && account.apiKeyEnv === apiKeyEnv)
481
+ if (index < 0) return writeJson(res, 404, { ok: false, error: `Account "${apiKeyEnv}" not found` })
482
+ accounts[index] = { ...accounts[index], label: label || apiKeyEnv }
483
+ const next = plainConfig(Config({ ...curr, accounts }))
484
+ await settingsApi.replace(next)
485
+ await syncProviderState(next)
486
+ return writeJson(res, 200, { ok: true, accounts: next.accounts })
487
+ } catch (err) {
488
+ return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
489
+ }
490
+ },
491
+ }), 'dsh-clinebot: /accounts/rename')
492
+
493
+ // POST /dsh-clinebot/accounts/key/clear — remove a stored key but keep the account row
494
+ ctx.effect(() => ctx.webServer.register({
495
+ kind: 'exact',
496
+ path: '/dsh-clinebot/accounts/key/clear',
497
+ handler: async (req, res) => {
498
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
499
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
500
+ try {
501
+ const bodyBuf = await readBody(req)
502
+ let body = {}
503
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
504
+ const apiKeyEnv = String(body.apiKeyEnv || '').trim()
505
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(apiKeyEnv)) {
506
+ return writeJson(res, 400, { ok: false, error: `Disallowed apiKeyEnv: "${apiKeyEnv}"` })
507
+ }
508
+ const curr = plainConfig(live())
509
+ const isPrimary = apiKeyEnv === curr.apiKeyEnv || apiKeyEnv === DEFAULT_API_KEY_ENV
510
+ const inPool = Array.isArray(curr.accounts) && curr.accounts.some((account) => account && account.apiKeyEnv === apiKeyEnv)
511
+ if (!isPrimary && !inPool) {
512
+ return writeJson(res, 404, { ok: false, error: `Account "${apiKeyEnv}" not found` })
513
+ }
514
+ const deleted = await deleteCredentialKey(ctx, apiKeyEnv).catch(() => ({ ok: false }))
515
+ clearUsageCache()
516
+ clearProbeCache()
517
+ await syncProviderState(live())
518
+ return writeJson(res, 200, { ok: true, apiKeyEnv, cleared: Boolean(deleted?.ok) })
519
+ } catch (err) {
520
+ return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
521
+ }
522
+ },
523
+ }), 'dsh-clinebot: /accounts/key/clear')
404
524
  }