@goodandready/dsh-clinebot 0.4.0 → 0.4.2

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.
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import {
9
- CLINE_MODELS,
10
9
  DEFAULT_MODEL_ID,
11
10
  PROVIDER_ID,
12
11
  PROVIDER_DISPLAY_NAME,
@@ -90,6 +89,26 @@ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
90
89
  return { ok: true, envName: name }
91
90
  }
92
91
 
92
+ /**
93
+ * Delete API key from DSH credentials service.
94
+ */
95
+ export async function deleteCredentialKey(ctx, apiKeyEnv) {
96
+ const name = String(apiKeyEnv || '').trim()
97
+ if (!name) return { ok: false }
98
+ const credentials = ctx?.credentials || ctx?.get?.('credentials')
99
+ if (!credentials) return { ok: false }
100
+ try {
101
+ const ref = await toCredentialRef(name)
102
+ if (typeof credentials.unset === 'function') {
103
+ await credentials.unset(ref)
104
+ return { ok: true, envName: name }
105
+ }
106
+ } catch (err) {
107
+ console.warn('[dsh-clinebot] Failed unsetting credential:', err)
108
+ }
109
+ return { ok: false }
110
+ }
111
+
93
112
  function abortAfter(ms) {
94
113
  const ac = new AbortController()
95
114
  const timer = setTimeout(() => ac.abort(), Math.max(1, Number(ms) || DEFAULT_TIMEOUT_MS))
package/lib/index.js CHANGED
@@ -40,9 +40,7 @@ export function apply(ctx, config) {
40
40
  try {
41
41
  const pub = publicConfig(cfg)
42
42
  if (!pub.enabled) {
43
- if (await checkRegisteredInPiAi(ctx)) {
44
- await removePiAiProvider(ctx)
45
- }
43
+ await removePiAiProvider(ctx)
46
44
  return
47
45
  }
48
46
  const activeKey = await resolveActiveAccountKey(ctx, cfg)
package/lib/models.js CHANGED
@@ -168,12 +168,12 @@ export function parsePlanIncludedModels(includedInput) {
168
168
  const clean = includedText
169
169
  .replace(/^includes\s+/i, '')
170
170
  .replace(/\band\b/gi, ',')
171
- .replace(/\./g, '')
171
+ .replace(/[.;]+$/g, '')
172
172
  .trim()
173
173
 
174
174
  const parts = clean
175
175
  .split(',')
176
- .map((p) => p.trim())
176
+ .map((p) => p.trim().replace(/[.;]+$/g, '').trim())
177
177
  .filter(Boolean)
178
178
 
179
179
  const normalize = (s) =>
@@ -193,7 +193,14 @@ export function parsePlanIncludedModels(includedInput) {
193
193
  matched.push(found)
194
194
  } else {
195
195
  // Dynamic fallback for newly introduced models mentioned in plan
196
- const idPart = name.toLowerCase().replace(/[\s_]+/g, '-')
196
+ const idPart = name
197
+ .toLowerCase()
198
+ .trim()
199
+ .replace(/^cline-pass\//, '')
200
+ .replace(/qwen\s+([0-9])/i, 'qwen$1')
201
+ .replace(/glm\s+([0-9])/i, 'glm-$1')
202
+ .replace(/[\s_]+/g, '-')
203
+ .replace(/-+/g, '-')
197
204
  const isVision = /(vision|vl|multimodal|omni|image)/i.test(name)
198
205
  const hasReasoning = /(reason|think|r1|pro|flash|max|plus|k3|m3|glm|qwen|mimo)/i.test(name)
199
206
  matched.push({
@@ -17,6 +17,8 @@ import {
17
17
  buildPiAiProvider,
18
18
  sessionStats,
19
19
  getLastRotation,
20
+ usageCache,
21
+ DEFAULT_API_KEY_ENV,
20
22
  } from './cline-client.js'
21
23
 
22
24
  export function resolvePathWithHome(p) {
@@ -30,27 +32,48 @@ export function resolvePathWithHome(p) {
30
32
 
31
33
  export async function checkRegisteredInPiAi(ctx) {
32
34
  const settings = ctx?.get?.('settings')
33
- if (!settings?.get) return false
34
35
  try {
35
- const piAi = settings.get(LLM_PI_AI_NS)
36
- return !!piAi?.providers?.[PROVIDER_ID]
36
+ if (typeof settings?.describe === 'function') {
37
+ const descriptors = settings.describe()
38
+ const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
39
+ if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
40
+ }
41
+ if (typeof settings?.get === 'function') {
42
+ const piAi = settings.get(LLM_PI_AI_NS)
43
+ if (piAi?.providers?.[PROVIDER_ID]) return true
44
+ }
45
+ const llm = ctx?.get?.('llm')
46
+ if (typeof llm?.listProviders === 'function') {
47
+ const list = await llm.listProviders()
48
+ if (Array.isArray(list) && list.some((p) => (p?.id || p) === PROVIDER_ID)) {
49
+ return true
50
+ }
51
+ }
37
52
  } catch {
38
53
  return false
39
54
  }
55
+ return false
40
56
  }
41
57
 
42
58
  export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
43
59
  const accountPool = pool || await resolveAccountPool(ctx, cfg)
44
60
  const configured = accountPool.filter((acc) => acc.present && acc.value)
45
61
  if (!configured.length) {
46
- return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
62
+ const fallbackEnv = publicConfig(cfg).apiKeyEnv || DEFAULT_API_KEY_ENV
63
+ return { envName: fallbackEnv, apiKeyEnv: fallbackEnv, value: '', source: 'none', id: 'default' }
47
64
  }
48
65
  const pub = publicConfig(cfg)
66
+ let chosen = configured[0]
49
67
  if (pub.activeAccount) {
50
68
  const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
51
- if (pinned) return pinned
69
+ if (pinned) chosen = pinned
70
+ }
71
+ const keyEnv = chosen.apiKeyEnv || chosen.envName || DEFAULT_API_KEY_ENV
72
+ return {
73
+ ...chosen,
74
+ envName: keyEnv,
75
+ apiKeyEnv: keyEnv,
52
76
  }
53
- return configured[0]
54
77
  }
55
78
 
56
79
  export async function buildStatus(ctx, cfg) {
@@ -101,14 +124,20 @@ export async function buildStatus(ctx, cfg) {
101
124
  present: !!(activeAcc.value || key.value),
102
125
  source: activeAcc.source || key.source,
103
126
  },
104
- accounts: pool.map((acc) => ({
105
- id: acc.id,
106
- label: acc.label,
107
- apiKeyEnv: acc.apiKeyEnv,
108
- present: acc.present,
109
- source: acc.source,
110
- isPinned: acc.isPinned,
111
- })),
127
+ accounts: pool.map((acc) => {
128
+ const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
129
+ const cached = usageCache.get(cacheKey)?.data
130
+ const pct = cached?.windows?.fiveHour?.percentUsed
131
+ return {
132
+ id: acc.id,
133
+ label: acc.label,
134
+ apiKeyEnv: acc.apiKeyEnv,
135
+ present: acc.present,
136
+ source: acc.source,
137
+ isPinned: acc.isPinned,
138
+ percentUsed: typeof pct === 'number' ? pct : null,
139
+ }
140
+ }),
112
141
  activeAccount: activeAcc.apiKeyEnv,
113
142
  health,
114
143
  usage: publicUsage(usage),
@@ -158,12 +187,19 @@ export async function removePiAiProvider(ctx) {
158
187
  throw new Error('DSH settings service unavailable')
159
188
  }
160
189
 
161
- await settings.mutate(LLM_PI_AI_NS, [
162
- {
163
- op: 'remove',
164
- path: ['providers', PROVIDER_ID],
165
- },
166
- ])
190
+ try {
191
+ await settings.mutate(LLM_PI_AI_NS, [
192
+ {
193
+ op: 'remove',
194
+ path: ['providers', PROVIDER_ID],
195
+ },
196
+ ])
197
+ } catch (err) {
198
+ const msg = String(err?.message || err).toLowerCase()
199
+ if (!msg.includes('not found') && !msg.includes('absent') && !msg.includes('enoent') && !msg.includes('does not exist')) {
200
+ throw err
201
+ }
202
+ }
167
203
  return { ok: true }
168
204
  }
169
205
 
@@ -1,10 +1,178 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
2
+ import { assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
5
- import { clearUsageCache, clearProbeCache } from '../cline-client.js'
5
+ import {
6
+ clearUsageCache,
7
+ clearProbeCache,
8
+ saveCredentialKey,
9
+ deleteCredentialKey,
10
+ smokeChat,
11
+ DEFAULT_API_KEY_ENV,
12
+ } from '../cline-client.js'
6
13
 
7
14
  export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
15
+ // POST /dsh-clinebot/accounts — add or update account in pool
16
+ ctx.effect(() => ctx.webServer.register({
17
+ kind: 'exact',
18
+ path: '/dsh-clinebot/accounts',
19
+ handler: async (req, res) => {
20
+ if (req.method === 'DELETE') {
21
+ return handleDeleteAccount(req, res)
22
+ }
23
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
24
+ if (!assertTrustedSettingsRequest(req, res)) return
25
+ const settingsApi = getSettingsApi()
26
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
27
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
28
+ }
29
+ try {
30
+ const bodyBuf = await readBody(req)
31
+ let body = {}
32
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
33
+
34
+ const apiKey = String(body.apiKey || '').trim()
35
+ if (!apiKey) {
36
+ return writeJson(res, 400, { ok: false, error: 'API key is required' })
37
+ }
38
+
39
+ const curr = plainConfig(live())
40
+ const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
41
+
42
+ let targetEnv = String(body.apiKeyEnv || '').trim()
43
+ if (!targetEnv) {
44
+ let maxN = 1
45
+ for (const acc of existingAccounts) {
46
+ const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
47
+ if (m) {
48
+ const num = parseInt(m[1], 10)
49
+ if (num >= maxN) maxN = num + 1
50
+ }
51
+ }
52
+ if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
53
+ const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
54
+ if (m) {
55
+ const num = parseInt(m[1], 10)
56
+ if (num >= maxN) maxN = num + 1
57
+ }
58
+ }
59
+ targetEnv = `CLINEBOT_API_KEY_${maxN}`
60
+ }
61
+
62
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
63
+ return writeJson(res, 400, {
64
+ ok: false,
65
+ error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`,
66
+ })
67
+ }
68
+
69
+ const label = String(body.label || '').trim() || targetEnv
70
+
71
+ await saveCredentialKey(ctx, targetEnv, apiKey)
72
+
73
+ const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
74
+ const accountEntry = { label, apiKeyEnv: targetEnv }
75
+ if (idx >= 0) {
76
+ existingAccounts[idx] = accountEntry
77
+ } else {
78
+ existingAccounts.push(accountEntry)
79
+ }
80
+
81
+ const next = Config({ ...curr, accounts: existingAccounts })
82
+ await settingsApi.replace(next)
83
+ await syncProviderState(next)
84
+
85
+ let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
86
+ try {
87
+ const parsed = new URL(curr.baseUrl)
88
+ if (parsed.protocol === 'https:') {
89
+ validation = await smokeChat(curr.baseUrl, apiKey, {
90
+ model: curr.defaultModel,
91
+ timeoutMs: 15000,
92
+ })
93
+ }
94
+ } catch {
95
+ validation = { ok: false, error: 'Invalid baseUrl' }
96
+ }
97
+
98
+ clearUsageCache()
99
+ clearProbeCache()
100
+
101
+ writeJson(res, 200, {
102
+ ok: true,
103
+ account: accountEntry,
104
+ validated: validation.ok,
105
+ latencyMs: validation.latencyMs,
106
+ validationError: validation.ok ? null : validation.error,
107
+ })
108
+ } catch (err) {
109
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
110
+ }
111
+ },
112
+ }), 'dsh-clinebot: /accounts')
113
+
114
+ async function handleDeleteAccount(req, res, targetEnvFromUrl = '') {
115
+ if (!assertTrustedSettingsRequest(req, res)) return
116
+ const settingsApi = getSettingsApi()
117
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
118
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
119
+ }
120
+ try {
121
+ let body = {}
122
+ if (req.method === 'POST' || req.method === 'DELETE') {
123
+ try {
124
+ const bodyBuf = await readBody(req)
125
+ body = JSON.parse(bodyBuf.toString('utf8'))
126
+ } catch {
127
+ body = {}
128
+ }
129
+ }
130
+ const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || '').trim()
131
+ if (!targetEnv) {
132
+ return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
133
+ }
134
+
135
+ const curr = plainConfig(live())
136
+ if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
137
+ return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
138
+ }
139
+
140
+ const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
141
+ const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
142
+ let activeAccount = curr.activeAccount
143
+ if (activeAccount === targetEnv) {
144
+ activeAccount = ''
145
+ }
146
+
147
+ const next = Config({ ...curr, accounts: nextAccounts, activeAccount })
148
+ await settingsApi.replace(next)
149
+ await syncProviderState(next)
150
+
151
+ if (body.deleteSecret) {
152
+ await deleteCredentialKey(ctx, targetEnv)
153
+ }
154
+
155
+ clearUsageCache()
156
+ clearProbeCache()
157
+
158
+ writeJson(res, 200, { ok: true, removed: targetEnv })
159
+ } catch (err) {
160
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
161
+ }
162
+ }
163
+
164
+ // POST /dsh-clinebot/accounts/delete — delete account endpoint
165
+ ctx.effect(() => ctx.webServer.register({
166
+ kind: 'exact',
167
+ path: '/dsh-clinebot/accounts/delete',
168
+ handler: async (req, res) => {
169
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
170
+ return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
171
+ }
172
+ return handleDeleteAccount(req, res)
173
+ },
174
+ }), 'dsh-clinebot: /accounts/delete')
175
+
8
176
  // POST /dsh-clinebot/accounts/active — switch or pin active account
9
177
  ctx.effect(() => ctx.webServer.register({
10
178
  kind: 'exact',
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
2
+ import { assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig } from '../config.js'
4
4
  import { resolveActiveAccountKey } from '../provider-sync.js'
5
5
  import {
@@ -29,6 +29,20 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
29
29
  const pub = publicConfig(live())
30
30
  const base = normalizeBaseUrl(pub.baseUrl)
31
31
 
32
+ let parsedBase
33
+ try {
34
+ parsedBase = new URL(base)
35
+ } catch {
36
+ return writeJson(res, 400, { ok: false, valid: false, error: 'Invalid baseUrl' })
37
+ }
38
+ if (parsedBase.protocol !== 'https:' && parsedBase.hostname !== 'localhost' && parsedBase.hostname !== '127.0.0.1') {
39
+ return writeJson(res, 400, {
40
+ ok: false,
41
+ valid: false,
42
+ error: 'Insecure baseUrl: key verification requires https://',
43
+ })
44
+ }
45
+
32
46
  const ac = new AbortController()
33
47
  const timer = setTimeout(() => ac.abort(), 6000)
34
48
  try {
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
2
+ import { assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
5
5
  import { getAllModels, saveModelsDiskCache } from '../models.js'
@@ -64,6 +64,30 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
64
64
  return writeJson(res, 400, { ok: false, error: `unknown config field: ${k}` })
65
65
  }
66
66
  }
67
+ if (rawPayload.apiKeyEnv && !/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(String(rawPayload.apiKeyEnv))) {
68
+ return writeJson(res, 400, { ok: false, error: `Disallowed apiKeyEnv: "${rawPayload.apiKeyEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$` })
69
+ }
70
+ if (Array.isArray(rawPayload.accounts)) {
71
+ for (const acc of rawPayload.accounts) {
72
+ if (!acc || typeof acc !== 'object') {
73
+ return writeJson(res, 400, { ok: false, error: 'Each account must be an object' })
74
+ }
75
+ const env = String(acc.apiKeyEnv || '').trim()
76
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(env)) {
77
+ return writeJson(res, 400, { ok: false, error: `Disallowed account apiKeyEnv: "${env}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$` })
78
+ }
79
+ }
80
+ }
81
+ if (rawPayload.baseUrl) {
82
+ try {
83
+ const parsed = new URL(rawPayload.baseUrl)
84
+ if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
85
+ return writeJson(res, 400, { ok: false, error: 'Insecure baseUrl: remote endpoint must use https://' })
86
+ }
87
+ } catch {
88
+ return writeJson(res, 400, { ok: false, error: 'Invalid baseUrl' })
89
+ }
90
+ }
67
91
  try {
68
92
  const base = plainConfig(live()) || {}
69
93
  delete base.enabledModels
@@ -99,13 +123,21 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
99
123
  const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
100
124
 
101
125
  const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnvName)
102
- const isConfiguredEnv = targetEnvName === pub.apiKeyEnv ||
126
+ if (!isDefaultPattern) {
127
+ return writeJson(res, 400, {
128
+ ok: false,
129
+ error: `Disallowed apiKeyEnv: "${targetEnvName}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
130
+ })
131
+ }
132
+
133
+ const isConfiguredEnv = targetEnvName === DEFAULT_API_KEY_ENV ||
134
+ targetEnvName === pub.apiKeyEnv ||
103
135
  (Array.isArray(pub.accounts) && pub.accounts.some(acc => acc && acc.apiKeyEnv === targetEnvName))
104
136
 
105
- if (!isDefaultPattern && !isConfiguredEnv) {
137
+ if (!isConfiguredEnv) {
106
138
  return writeJson(res, 400, {
107
139
  ok: false,
108
- error: `Disallowed apiKeyEnv: "${targetEnvName}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$ or be configured in settings.`,
140
+ error: `Disallowed apiKeyEnv: "${targetEnvName}". Must be configured in settings accounts pool.`,
109
141
  })
110
142
  }
111
143