@goodandready/dsh-clinebot 0.5.28 → 0.5.29

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/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.29] - 2026-10-17
9
+
10
+ ### Added
11
+ - **Composer Session Cost Widget (`conversation.composer.dock` slot) (Issue #231)**: Added live token & cost indicator pill inside the chat composer dock. Displays real-time estimated dollar cost (`≈ $0.00X`) and token volume (prompt, completion, reasoning) with an interactive popover showing full pricing breakdown.
12
+ - **Local Key Auto-Discovery (`lib/key-discovery.js`) (Issue #232)**: Automatically scans local Cline CLI configuration (`~/.cline/`, `~/.config/cline/`, `~/.cline.json`), VS Code and Cursor extension storage (`saoudrizwan.claude-dev`), and environment variables (`CLINE_API_KEY`, `CLINEPASS_API_KEY`) to discover existing keys.
13
+ - **Discovered Keys API & Import UI (Issue #232)**: Added endpoint `GET /dsh-clinebot/keys/discovered` with secure secret masking and one-click import banner in `AccountsSection` to seamlessly add discovered keys into the DSH credentials and accounts pool.
14
+
8
15
  ## [0.5.28] - 2026-10-17
9
16
 
10
17
  ### Added
package/lib/client.js CHANGED
@@ -241,6 +241,16 @@ const en = {
241
241
  'card.save_key': 'Save Key',
242
242
  'card.key_saved': 'Key saved',
243
243
  'card.models_active': '{count} active models',
244
+ 'composer.cost_title': 'Estimated Session Cost',
245
+ 'composer.cost_breakdown': 'Token & Cost Breakdown',
246
+ 'composer.prompt_tok': 'Prompt Tokens',
247
+ 'composer.completion_tok': 'Completion Tokens',
248
+ 'composer.reasoning_tok': 'Reasoning Tokens',
249
+ 'composer.est_cost': 'Estimated Cost',
250
+ 'discovery.title': 'Discovered Local Keys',
251
+ 'discovery.import_btn': 'Import from {source}',
252
+ 'discovery.imported': 'Imported to accounts pool',
253
+ 'discovery.scanning': 'Scanning for local Cline keys…',
244
254
  }
245
255
 
246
256
  const zh = {
@@ -446,6 +456,16 @@ const en = {
446
456
  'card.save_key': '保存密钥',
447
457
  'card.key_saved': '密钥已保存',
448
458
  'card.models_active': '{count} 个可用模型',
459
+ 'composer.cost_title': '预估会话成本',
460
+ 'composer.cost_breakdown': 'Token 与费用明细',
461
+ 'composer.prompt_tok': '提示词 Token',
462
+ 'composer.completion_tok': '补全 Token',
463
+ 'composer.reasoning_tok': '思考推理 Token',
464
+ 'composer.est_cost': '预估花费',
465
+ 'discovery.title': '发现本地密钥',
466
+ 'discovery.import_btn': '从 {source} 导入',
467
+ 'discovery.imported': '已成功导入账户池',
468
+ 'discovery.scanning': '正在扫描本地 Cline 密钥…',
449
469
  }
450
470
 
451
471
  function makeT(dict, fallback) {
@@ -950,6 +970,40 @@ function AccountsSection({
950
970
  const [deleteSecret, setDeleteSecret] = React.useState(true)
951
971
  const [routeModel, setRouteModel] = React.useState('')
952
972
  const [routeAccount, setRouteAccount] = React.useState('')
973
+ const [discoveredKeys, setDiscoveredKeys] = React.useState([])
974
+
975
+ React.useEffect(() => {
976
+ let active = true
977
+ fetchWithTimeout(`${ROUTE_PREFIX}/keys/discovered`, { cache: 'no-store' })
978
+ .then((r) => r.json())
979
+ .then((data) => {
980
+ if (active && data?.ok && Array.isArray(data.discovered)) {
981
+ setDiscoveredKeys(data.discovered)
982
+ }
983
+ })
984
+ .catch(() => {})
985
+ return () => { active = false }
986
+ }, [])
987
+
988
+ async function handleImportDiscovered(item) {
989
+ let max = 1
990
+ for (const a of (status.accounts || [])) {
991
+ const m = String(a.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
992
+ if (m) {
993
+ const n = parseInt(m[1], 10)
994
+ if (n >= max) max = n + 1
995
+ }
996
+ }
997
+ const targetEnv = `CLINEBOT_API_KEY_${max}`
998
+ if (typeof handleAddAccount === 'function') {
999
+ await handleAddAccount({
1000
+ label: item.source || targetEnv,
1001
+ apiKeyEnv: targetEnv,
1002
+ apiKey: item.key,
1003
+ })
1004
+ setDiscoveredKeys((prev) => prev.filter((k) => k.key !== item.key))
1005
+ }
1006
+ }
953
1007
 
954
1008
  function handleOpenAdd() {
955
1009
  let max = 1
@@ -1011,6 +1065,31 @@ function AccountsSection({
1011
1065
  : null
1012
1066
  ),
1013
1067
  React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
1068
+ discoveredKeys.length > 0
1069
+ ? React.createElement(
1070
+ 'div',
1071
+ { className: 'cb-alert-ok', style: { display: 'flex', flexDirection: 'column', gap: '6px', margin: '6px 0' } },
1072
+ React.createElement('div', { style: { fontWeight: 600 } }, `🔍 ${t('discovery.title')}`),
1073
+ discoveredKeys.map((item, idx) =>
1074
+ React.createElement(
1075
+ 'div',
1076
+ { key: idx, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1077
+ React.createElement('span', null, `${item.maskedKey} (${item.source})`),
1078
+ React.createElement(
1079
+ 'button',
1080
+ {
1081
+ type: 'button',
1082
+ className: 'cb-btn',
1083
+ style: { fontSize: '11px', padding: '2px 8px' },
1084
+ disabled: !!busy,
1085
+ onClick: () => handleImportDiscovered(item),
1086
+ },
1087
+ t('discovery.import_btn', { source: item.source })
1088
+ )
1089
+ )
1090
+ )
1091
+ )
1092
+ : null,
1014
1093
  React.createElement(
1015
1094
  'div',
1016
1095
  {
@@ -3137,6 +3216,165 @@ function ModelsProviderCard(props) {
3137
3216
  )
3138
3217
  }
3139
3218
 
3219
+ // --- src/client/components/composer-cost.js ---
3220
+ function formatTokens(count) {
3221
+ const n = Number(count) || 0
3222
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
3223
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
3224
+ return String(n)
3225
+ }
3226
+
3227
+ function calculateCost(tokens, modelId = '') {
3228
+ const isPremium = String(modelId).includes('claude') || String(modelId).includes('pro')
3229
+ // Rates per 1M tokens
3230
+ const promptRate = isPremium ? 3.0 : 0.15
3231
+ const completionRate = isPremium ? 15.0 : 0.60
3232
+ const reasoningRate = isPremium ? 15.0 : 0.60
3233
+
3234
+ const pTok = Number(tokens?.promptTokens || tokens?.inputTokens || 0)
3235
+ const cTok = Number(tokens?.completionTokens || tokens?.outputTokens || 0)
3236
+ const rTok = Number(tokens?.reasoningTokens || 0)
3237
+
3238
+ const cost = (pTok * promptRate + cTok * completionRate + rTok * reasoningRate) / 1000000
3239
+ return { cost, pTok, cTok, rTok, total: pTok + cTok + rTok }
3240
+ }
3241
+
3242
+ function ComposerCostDock(props) {
3243
+ const t = props?.t || makeT(en, en)
3244
+ const [stats, setStats] = React.useState(null)
3245
+ const [showPopover, setShowPopover] = React.useState(false)
3246
+
3247
+ // Use projection hook if passed by DSH composer dock, or poll session stats
3248
+ const projectedTokens = typeof props?.useProjection === 'function' ? props.useProjection('tokenUsage') : null
3249
+ const projectedModel = typeof props?.useProjection === 'function' ? props.useProjection('modelSelection') : null
3250
+
3251
+ React.useEffect(() => {
3252
+ if (projectedTokens) return
3253
+ let active = true
3254
+ const loadStats = async () => {
3255
+ try {
3256
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' })
3257
+ if (!res.ok) return
3258
+ const data = await res.json().catch(() => null)
3259
+ if (active && data?.stats?.summary) {
3260
+ setStats(data.stats.summary)
3261
+ }
3262
+ } catch {
3263
+ // quiet
3264
+ }
3265
+ }
3266
+ loadStats()
3267
+ const timer = setTimeout(loadStats, 30000)
3268
+ return () => { active = false; clearTimeout(timer) }
3269
+ }, [projectedTokens])
3270
+
3271
+ const tokens = projectedTokens || {
3272
+ promptTokens: stats?.promptTokens || 0,
3273
+ completionTokens: stats?.completionTokens || 0,
3274
+ reasoningTokens: stats?.reasoningTokens || 0,
3275
+ }
3276
+
3277
+ const modelId = projectedModel?.id || ''
3278
+ const { cost, pTok, cTok, rTok, total } = calculateCost(tokens, modelId)
3279
+
3280
+ if (total === 0 && !stats) {
3281
+ return null
3282
+ }
3283
+
3284
+ const costDisplay = cost < 0.001 && cost > 0 ? '< $0.001' : `$${cost.toFixed(3)}`
3285
+
3286
+ return React.createElement(
3287
+ 'div',
3288
+ {
3289
+ className: 'cb-composer-cost-dock',
3290
+ 'data-composer-session-cost': 'true',
3291
+ style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3292
+ },
3293
+ React.createElement(
3294
+ 'button',
3295
+ {
3296
+ type: 'button',
3297
+ className: 'cb-btn',
3298
+ style: {
3299
+ padding: '2px 8px',
3300
+ fontSize: '11px',
3301
+ height: '24px',
3302
+ borderRadius: '999px',
3303
+ display: 'inline-flex',
3304
+ alignItems: 'center',
3305
+ gap: '4px',
3306
+ color: 'var(--dsw-alias-label-secondary)',
3307
+ background: 'var(--dsw-alias-bg-layer-2)',
3308
+ border: '1px solid var(--dsw-alias-border-l2)',
3309
+ cursor: 'pointer',
3310
+ },
3311
+ title: `${t('composer.cost_title')}: ${costDisplay} (${formatTokens(total)} tokens)`,
3312
+ onClick: () => setShowPopover(!showPopover),
3313
+ },
3314
+ React.createElement('span', { style: { fontSize: '12px' } }, '🪙'),
3315
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, `≈ ${costDisplay}`),
3316
+ React.createElement('span', { style: { opacity: 0.7 } }, `· ${formatTokens(total)} tok`)
3317
+ ),
3318
+ showPopover
3319
+ ? React.createElement(
3320
+ 'div',
3321
+ {
3322
+ className: 'cb-popover',
3323
+ style: {
3324
+ position: 'absolute',
3325
+ bottom: '100%',
3326
+ left: 0,
3327
+ marginBottom: '6px',
3328
+ minWidth: '200px',
3329
+ padding: '10px 12px',
3330
+ background: 'var(--dsw-alias-bg-layer-3)',
3331
+ border: '1px solid var(--dsw-alias-border-l2)',
3332
+ borderRadius: '8px',
3333
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
3334
+ zIndex: 100,
3335
+ fontSize: '12px',
3336
+ color: 'var(--dsw-alias-label-primary)',
3337
+ display: 'flex',
3338
+ flexDirection: 'column',
3339
+ gap: '6px',
3340
+ },
3341
+ },
3342
+ React.createElement(
3343
+ 'div',
3344
+ { style: { fontWeight: 600, borderBottom: '1px solid var(--dsw-alias-border-l2)', paddingBottom: '4px' } },
3345
+ t('composer.cost_breakdown')
3346
+ ),
3347
+ React.createElement(
3348
+ 'div',
3349
+ { style: { display: 'flex', justifyContent: 'space-between' } },
3350
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.prompt_tok')),
3351
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(pTok))
3352
+ ),
3353
+ React.createElement(
3354
+ 'div',
3355
+ { style: { display: 'flex', justifyContent: 'space-between' } },
3356
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.completion_tok')),
3357
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(cTok))
3358
+ ),
3359
+ rTok > 0
3360
+ ? React.createElement(
3361
+ 'div',
3362
+ { style: { display: 'flex', justifyContent: 'space-between' } },
3363
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.reasoning_tok')),
3364
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(rTok))
3365
+ )
3366
+ : null,
3367
+ React.createElement(
3368
+ 'div',
3369
+ { style: { display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '4px', fontWeight: 600 } },
3370
+ React.createElement('span', null, t('composer.est_cost')),
3371
+ React.createElement('span', { style: { color: 'var(--dsw-alias-brand-primary)' } }, `≈ ${costDisplay}`)
3372
+ )
3373
+ )
3374
+ : null
3375
+ )
3376
+ }
3377
+
3140
3378
  // --- src/client/settings-page.js ---
3141
3379
  function SettingsPage(props) {
3142
3380
  const ctx = props?.ctx
@@ -3843,6 +4081,20 @@ function apply(ctx) {
3843
4081
  )
3844
4082
  )
3845
4083
  }
4084
+
4085
+ // Composer Session Cost Dock (conversation.composer.dock slot)
4086
+ registerSlotWhenReady('conversation.composer.dock', () =>
4087
+ ctx.slots.register(
4088
+ {
4089
+ name: 'conversation.composer.dock',
4090
+ id: 'clinebot-cost',
4091
+ order: 10,
4092
+ locale: NS,
4093
+ inject: () => ({ ctx }),
4094
+ },
4095
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(ComposerCostDock, { ...props, ctx: (props && props.ctx) || ctx }))
4096
+ )
4097
+ )
3846
4098
  }
3847
4099
 
3848
4100
  module.exports = { apply, inject: ['slots', 'locale', 'configForms'] }
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ const CLINE_KEY_REGEX = /cline_[A-Za-z0-9_\-]{16,}/g
6
+
7
+ function maskApiKey(key) {
8
+ if (!key || typeof key !== 'string') return ''
9
+ if (key.length <= 12) return '••••••••'
10
+ return key.slice(0, 8) + '••••' + key.slice(-4)
11
+ }
12
+
13
+ function scanFileForKeys(filePath, sourceName) {
14
+ const results = []
15
+ try {
16
+ if (!fs.existsSync(filePath)) return results
17
+ const stat = fs.statSync(filePath)
18
+ if (!stat.isFile() || stat.size > 5 * 1024 * 1024) return results
19
+ const content = fs.readFileSync(filePath, 'utf8')
20
+ const matches = content.match(CLINE_KEY_REGEX)
21
+ if (matches) {
22
+ for (const m of matches) {
23
+ results.push({
24
+ source: sourceName,
25
+ key: m,
26
+ maskedKey: maskApiKey(m),
27
+ path: filePath,
28
+ })
29
+ }
30
+ }
31
+ } catch {
32
+ // quiet on unreadable files
33
+ }
34
+ return results
35
+ }
36
+
37
+ export function discoverLocalClineKeys() {
38
+ const discovered = []
39
+ const seenKeys = new Set()
40
+
41
+ const addKey = (item) => {
42
+ if (!item?.key || seenKeys.has(item.key)) return
43
+ seenKeys.add(item.key)
44
+ discovered.push(item)
45
+ }
46
+
47
+ // 1. Check environment variables
48
+ for (const envName of ['CLINE_API_KEY', 'CLINEPASS_API_KEY']) {
49
+ const val = process.env[envName]
50
+ if (val && typeof val === 'string' && val.startsWith('cline_')) {
51
+ addKey({
52
+ source: `Environment (${envName})`,
53
+ key: val,
54
+ maskedKey: maskApiKey(val),
55
+ path: `$${envName}`,
56
+ })
57
+ }
58
+ }
59
+
60
+ // 2. Candidate directories & file paths across OSes
61
+ const home = os.homedir()
62
+ const candidateFiles = [
63
+ // CLI configs
64
+ { path: path.join(home, '.cline', 'config.json'), source: 'Cline CLI' },
65
+ { path: path.join(home, '.cline', 'data', 'settings.json'), source: 'Cline CLI Data' },
66
+ { path: path.join(home, '.cline', 'settings.json'), source: 'Cline CLI Settings' },
67
+ { path: path.join(home, '.config', 'cline', 'config.json'), source: 'Cline Config' },
68
+ { path: path.join(home, '.cline.json'), source: 'Cline Dotfile' },
69
+
70
+ // VS Code Extension Global Storage (Linux, macOS, Windows)
71
+ { path: path.join(home, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'VS Code Extension' },
72
+ { path: path.join(home, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'state.vscdb'), source: 'VS Code Extension Storage' },
73
+ { path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'VS Code Extension (macOS)' },
74
+ { path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'VS Code Extension (Windows)' },
75
+
76
+ // Cursor Extension Global Storage
77
+ { path: path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'Cursor Extension' },
78
+ { path: path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'state.vscdb'), source: 'Cursor Extension Storage' },
79
+ { path: path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'Cursor Extension (macOS)' },
80
+ { path: path.join(home, 'AppData', 'Roaming', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'Cursor Extension (Windows)' },
81
+ ]
82
+
83
+ for (const candidate of candidateFiles) {
84
+ const hits = scanFileForKeys(candidate.path, candidate.source)
85
+ for (const h of hits) {
86
+ addKey(h)
87
+ }
88
+ }
89
+
90
+ // Also check if any file in ~/.cline/ contains cline_ keys
91
+ const clineDir = path.join(home, '.cline')
92
+ try {
93
+ if (fs.existsSync(clineDir)) {
94
+ const entries = fs.readdirSync(clineDir)
95
+ for (const entry of entries) {
96
+ const full = path.join(clineDir, entry)
97
+ try {
98
+ const st = fs.statSync(full)
99
+ if (st.isFile()) {
100
+ const hits = scanFileForKeys(full, 'Cline CLI (~/.cline)')
101
+ for (const h of hits) addKey(h)
102
+ }
103
+ } catch {}
104
+ }
105
+ }
106
+ } catch {}
107
+
108
+ return discovered
109
+ }
@@ -14,9 +14,9 @@ export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
14
14
  let cachedToken = null
15
15
 
16
16
  const require = createRequire(import.meta.url)
17
- let packageVersion = '0.5.28'
17
+ let packageVersion = '0.5.29'
18
18
  try {
19
- packageVersion = require('../package.json').version || '0.5.28'
19
+ packageVersion = require('../package.json').version || '0.5.29'
20
20
  } catch (err) {
21
21
  /* fallback to default version if manifest is unreadable */
22
22
  }
@@ -1,4 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
+ import { discoverLocalClineKeys } from '../key-discovery.js'
2
3
  import { assertTrustedSettingsRequest } from '../access.js'
3
4
  import { publicConfig, plainConfig, Config } from '../config.js'
4
5
  import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
@@ -336,4 +337,20 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
336
337
  }
337
338
  },
338
339
  }), 'dsh-clinebot: /unregister')
340
+
341
+ // GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232)
342
+ ctx.effect(() => ctx.webServer.register({
343
+ kind: 'exact',
344
+ path: '/dsh-clinebot/keys/discovered',
345
+ handler: async (req, res) => {
346
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
347
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
348
+ try {
349
+ const discovered = discoverLocalClineKeys()
350
+ writeJson(res, 200, { ok: true, discovered })
351
+ } catch (err) {
352
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
353
+ }
354
+ },
355
+ }), 'dsh-clinebot: /keys/discovered')
339
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.28",
3
+ "version": "0.5.29",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",