@goodandready/dsh-clinebot 0.5.27 → 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,21 @@ 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
+
15
+ ## [0.5.28] - 2026-10-17
16
+
17
+ ### Added
18
+ - **Sidebar Quota Meter (`sidebar.footer.action` slot) (Issue #229)**: Added live subscription quota indicator in DSH sidebar footer right above Settings. Supports collapsed rail mode with 20px dynamic circular ring (`MiniRing`) and wide mode with plan name (`ClinePass Pro`), rolling 5h quota progress bar, and percentage used.
19
+ - **Center-Column Dashboard Overlay (`main` slot) (Issue #229)**: Registered full overlay dashboard under key `clinebot-dashboard`, providing detailed view of 5-hour and weekly quota rolling windows, reset countdown timers, active account pool routing mode, and a close button (`×`) that restores the current chat view without page reload.
20
+ - **Native Models Provider Card (`settings.models.provider-card` slot) (Issue #230)**: Added native provider card registered under `clinebot` and `llm-clinebot` on DSH Settings -> Models page, featuring connection status badges, quick API key input and save into DSH credentials, active model count, and direct navigation to full ClineBot settings.
21
+ - **Sidebar Quota Toggle (`showSidebarQuota`) (Issue #229)**: Added volatile config field and settings toggle (default `true`) allowing users to easily enable or disable the sidebar quota meter.
22
+
8
23
  ## [0.5.27] - 2026-10-17
9
24
 
10
25
  ### Added
package/lib/client.js CHANGED
@@ -219,6 +219,38 @@ const en = {
219
219
  'update.failed': 'Update failed: {error}',
220
220
  'update.checking': 'Checking for updates…',
221
221
  'update.up_to_date': 'Up to date',
222
+ 'sidebar.quota_title': 'Cline Quota',
223
+ 'sidebar.show_quota': 'Show quota meter in sidebar',
224
+ 'sidebar.show_quota_hint': 'Display compact quota meter in DSH sidebar footer.',
225
+ 'dashboard.title': 'Cline Quota & Status',
226
+ 'dashboard.subtitle': 'Real-time subscription quota, rolling limits, and account pool status.',
227
+ 'dashboard.close': 'Close dashboard',
228
+ 'dashboard.refresh': 'Refresh',
229
+ 'dashboard.refreshing': 'Refreshing…',
230
+ 'dashboard.rolling_window': '5-Hour Window',
231
+ 'dashboard.weekly_window': 'Weekly Window',
232
+ 'dashboard.remaining': 'remaining',
233
+ 'dashboard.used': 'used',
234
+ 'dashboard.account_pool': 'Active Routing Account',
235
+ 'dashboard.credits': 'Credit Balance',
236
+ 'dashboard.updated_at': 'Updated at',
237
+ 'card.title': 'ClineBot Provider',
238
+ 'card.status_connected': 'Connected',
239
+ 'card.status_no_key': 'Key missing',
240
+ 'card.open_settings': 'Settings & Accounts',
241
+ 'card.save_key': 'Save Key',
242
+ 'card.key_saved': 'Key saved',
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…',
222
254
  }
223
255
 
224
256
  const zh = {
@@ -402,6 +434,38 @@ const en = {
402
434
  'update.failed': '更新失败:{error}',
403
435
  'update.checking': '正在检查更新…',
404
436
  'update.up_to_date': '已是最新版本',
437
+ 'sidebar.quota_title': 'Cline 额度',
438
+ 'sidebar.show_quota': '在侧边栏显示额度条',
439
+ 'sidebar.show_quota_hint': '在 DSH 侧边栏底部常驻显示额度 mini 进度指示器。',
440
+ 'dashboard.title': 'Cline 额度与状态',
441
+ 'dashboard.subtitle': '实时订阅额度、滑动窗口限制与账户池状态。',
442
+ 'dashboard.close': '关闭面板',
443
+ 'dashboard.refresh': '刷新',
444
+ 'dashboard.refreshing': '正在刷新…',
445
+ 'dashboard.rolling_window': '5小时滑动窗口',
446
+ 'dashboard.weekly_window': '每周配额窗口',
447
+ 'dashboard.remaining': '剩余',
448
+ 'dashboard.used': '已用',
449
+ 'dashboard.account_pool': '当前承载账户',
450
+ 'dashboard.credits': '点数余额',
451
+ 'dashboard.updated_at': '更新于',
452
+ 'card.title': 'ClineBot 模型服务',
453
+ 'card.status_connected': '已连接',
454
+ 'card.status_no_key': '未配置密钥',
455
+ 'card.open_settings': '完整设置与多账户',
456
+ 'card.save_key': '保存密钥',
457
+ 'card.key_saved': '密钥已保存',
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 密钥…',
405
469
  }
406
470
 
407
471
  function makeT(dict, fallback) {
@@ -504,6 +568,34 @@ function ensureCss() {
504
568
  .cb-stat-val{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary)}
505
569
  .cb-stat-lbl{font-size:12px;color:var(--dsw-alias-label-secondary)}
506
570
  .cb-preview{padding:12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-family:monospace;font-size:12px;white-space:pre-wrap;word-break:break-all;border:1px solid var(--dsw-alias-border-l2)}
571
+
572
+ [class*="_footArea"] [class*="_footerActions"]{flex-direction:column}
573
+ .cb-foot{box-sizing:border-box;flex:0 0 auto;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:transparent;border:1px solid transparent;border-radius:10px;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;display:flex}
574
+ .cb-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}
575
+ .cb-foot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
576
+ .cb-footTop{align-items:center;gap:8px;min-width:0;display:flex}
577
+ .cb-footName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;font-size:13px;font-weight:500;line-height:20px}
578
+ .cb-footRow{flex-direction:column;gap:4px;min-width:0;display:flex}
579
+ .cb-footHead{align-items:baseline;gap:8px;min-width:0;display:flex}
580
+ .cb-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
581
+ .cb-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap}
582
+ .cb-footBar{display:block;background:var(--dsw-alias-bg-layer-2);border-radius:999px;height:5px;overflow:hidden}
583
+ .cb-footFill{display:block;background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease}
584
+ .cb-footFillWarn{background:var(--dsw-alias-state-error-primary)}
585
+ .cb-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums}
586
+ .cb-railButton{box-sizing:border-box;width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:transparent;border:1px solid transparent;border-radius:8px;flex:none;justify-content:center;align-items:center;margin:0 0 4px;padding:0;display:inline-flex}
587
+ .cb-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
588
+ .cb-railButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
589
+ .cb-glyph{flex:none;justify-content:center;align-items:center;display:inline-flex;color:var(--dsw-alias-brand-primary)}
590
+
591
+ .cb-mainOverlay{background:var(--dsw-alias-bg-layer-1);width:100%;height:100%;overflow:auto;display:block}
592
+ .cb-mainInner{max-width:760px;margin:0 auto;padding:24px 20px 40px;flex-direction:column;gap:14px;display:flex;color:var(--dsw-alias-label-primary)}
593
+ .cb-mainHeader{align-items:center;gap:10px;display:flex;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:14px}
594
+ .cb-mainTitle{margin:0;font-size:18px;font-weight:600;line-height:1.4}
595
+ .cb-mainSub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}
596
+ .cb-mainCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:14px}
597
+
598
+ .cb-providerCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px;margin:8px 0}
507
599
  `
508
600
  document.head.appendChild(style)
509
601
  }
@@ -878,6 +970,40 @@ function AccountsSection({
878
970
  const [deleteSecret, setDeleteSecret] = React.useState(true)
879
971
  const [routeModel, setRouteModel] = React.useState('')
880
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
+ }
881
1007
 
882
1008
  function handleOpenAdd() {
883
1009
  let max = 1
@@ -939,6 +1065,31 @@ function AccountsSection({
939
1065
  : null
940
1066
  ),
941
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,
942
1093
  React.createElement(
943
1094
  'div',
944
1095
  {
@@ -1289,7 +1440,7 @@ function AccountsSection({
1289
1440
  }
1290
1441
 
1291
1442
  // --- src/client/components/quota-section.js ---
1292
- function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }) {
1443
+ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, showSidebarQuota, onToggleSidebarQuota, t }) {
1293
1444
  const isLowQuota = Boolean(
1294
1445
  (usage?.windows?.weekly && typeof usage.windows.weekly.remainingPercent === 'number' && usage.windows.weekly.remainingPercent <= 10) ||
1295
1446
  (usage?.windows?.monthly && typeof usage.windows.monthly.remainingPercent === 'number' && usage.windows.monthly.remainingPercent <= 10) ||
@@ -1379,6 +1530,22 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
1379
1530
  t,
1380
1531
  })
1381
1532
  : null
1533
+ ),
1534
+ React.createElement(
1535
+ 'div',
1536
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '4px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1537
+ React.createElement(
1538
+ 'div',
1539
+ { style: { display: 'flex', flexDirection: 'column' } },
1540
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, t('sidebar.show_quota')),
1541
+ React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, t('sidebar.show_quota_hint'))
1542
+ ),
1543
+ React.createElement('input', {
1544
+ type: 'checkbox',
1545
+ style: { cursor: 'pointer', width: '16px', height: '16px' },
1546
+ checked: showSidebarQuota !== false,
1547
+ onChange: (e) => onToggleSidebarQuota && onToggleSidebarQuota(e.target.checked),
1548
+ })
1382
1549
  )
1383
1550
  )
1384
1551
  : null
@@ -2573,6 +2740,641 @@ function DiagSection({
2573
2740
  )
2574
2741
  }
2575
2742
 
2743
+ // --- src/client/components/sidebar-quota.js ---
2744
+ function MiniRing({ percent, size = 18, warn = false }) {
2745
+ const r = 7
2746
+ const c = 2 * Math.PI * r
2747
+ const pct = Math.max(0, Math.min(100, Number(percent) || 0))
2748
+ const offset = c - (pct / 100) * c
2749
+ const strokeColor = warn ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-brand-primary)'
2750
+
2751
+ return React.createElement(
2752
+ 'svg',
2753
+ {
2754
+ className: 'cb-glyph',
2755
+ width: size,
2756
+ height: size,
2757
+ viewBox: '0 0 20 20',
2758
+ fill: 'none',
2759
+ },
2760
+ React.createElement('circle', {
2761
+ cx: 10,
2762
+ cy: 10,
2763
+ r,
2764
+ stroke: 'var(--dsw-alias-bg-layer-2)',
2765
+ strokeWidth: 2.5,
2766
+ }),
2767
+ React.createElement('circle', {
2768
+ cx: 10,
2769
+ cy: 10,
2770
+ r,
2771
+ stroke: strokeColor,
2772
+ strokeWidth: 2.5,
2773
+ strokeDasharray: c,
2774
+ strokeDashoffset: offset,
2775
+ strokeLinecap: 'round',
2776
+ transform: 'rotate(-90 10 10)',
2777
+ style: { transition: 'stroke-dashoffset 0.3s ease' },
2778
+ })
2779
+ )
2780
+ }
2781
+
2782
+ function SidebarQuota({ ctx, wide, open, t: propT }) {
2783
+ const t = propT || makeT(en, en)
2784
+ const [data, setData] = React.useState(null)
2785
+ const [visible, setVisible] = React.useState(true)
2786
+
2787
+ const loadQuota = React.useCallback(async () => {
2788
+ try {
2789
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/usage`, { cache: 'no-store' })
2790
+ if (!res.ok) return
2791
+ const json = await res.json().catch(() => null)
2792
+ if (json && json.ok) setData(json)
2793
+ } catch {
2794
+ // quiet on network fail
2795
+ }
2796
+ }, [])
2797
+
2798
+ React.useEffect(() => {
2799
+ let timer = null
2800
+ const scheduleNext = () => {
2801
+ timer = setTimeout(async () => {
2802
+ await loadQuota()
2803
+ scheduleNext()
2804
+ }, 60000)
2805
+ }
2806
+ loadQuota()
2807
+ scheduleNext()
2808
+ return () => { if (timer) clearTimeout(timer) }
2809
+ }, [loadQuota])
2810
+
2811
+ // Check configForms snapshot for showSidebarQuota toggle
2812
+ const config = useConfigFormsSnapshot(ctx)
2813
+ const show = config?.showSidebarQuota !== false
2814
+ if (!show || !visible) return null
2815
+
2816
+ const fiveHour = data?.windows?.fiveHour
2817
+ const weekly = data?.windows?.weekly
2818
+ const activeWindow = fiveHour || weekly
2819
+ const pct = Math.round(activeWindow?.percentUsed || 0)
2820
+ const isWarn = pct >= 85
2821
+ const planName = data?.plan || t('title')
2822
+
2823
+ const handleOpen = () => {
2824
+ if (typeof open === 'function') {
2825
+ open()
2826
+ } else {
2827
+ ctx?.get?.('layout')?.selectPanel?.('clinebot-dashboard')
2828
+ }
2829
+ }
2830
+
2831
+ const tooltip = `${t('sidebar.quota_title')}: ${pct}% ${t('dashboard.used')} (${planName})`
2832
+
2833
+ if (!wide) {
2834
+ return React.createElement(
2835
+ 'button',
2836
+ {
2837
+ type: 'button',
2838
+ className: 'cb-railButton',
2839
+ 'aria-label': tooltip,
2840
+ title: tooltip,
2841
+ onClick: handleOpen,
2842
+ },
2843
+ React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 20 })
2844
+ )
2845
+ }
2846
+
2847
+ return React.createElement(
2848
+ 'button',
2849
+ {
2850
+ type: 'button',
2851
+ className: 'cb-foot',
2852
+ 'aria-label': tooltip,
2853
+ title: tooltip,
2854
+ onClick: handleOpen,
2855
+ },
2856
+ React.createElement(
2857
+ 'div',
2858
+ { className: 'cb-footTop' },
2859
+ React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 16 }),
2860
+ React.createElement('span', { className: 'cb-footName' }, planName),
2861
+ React.createElement('span', { className: 'cb-footPct', style: { marginLeft: 'auto', fontWeight: 600, color: isWarn ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)' } }, `${pct}%`)
2862
+ ),
2863
+ React.createElement(
2864
+ 'div',
2865
+ { className: 'cb-footRow' },
2866
+ React.createElement(
2867
+ 'div',
2868
+ { className: 'cb-footHead' },
2869
+ React.createElement('span', { className: 'cb-footLabel' }, t('dashboard.rolling_window')),
2870
+ React.createElement('span', { className: 'cb-footAmount' }, `${100 - pct}% ${t('dashboard.remaining')}`)
2871
+ ),
2872
+ React.createElement(
2873
+ 'span',
2874
+ { className: 'cb-footBar' },
2875
+ React.createElement('span', {
2876
+ className: isWarn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
2877
+ style: { width: `${Math.min(100, Math.max(2, pct))}%` },
2878
+ })
2879
+ )
2880
+ )
2881
+ )
2882
+ }
2883
+
2884
+ // --- src/client/components/dashboard-overlay.js ---
2885
+ function DashboardOverlay(props) {
2886
+ const ctx = props?.ctx
2887
+ const t = props?.t || makeT(en, en)
2888
+ const [data, setData] = React.useState(null)
2889
+ const [status, setStatus] = React.useState(null)
2890
+ const [loading, setLoading] = React.useState(false)
2891
+ const [updatedAt, setUpdatedAt] = React.useState('')
2892
+ const [err, setErr] = React.useState('')
2893
+
2894
+ const handleClose = () => {
2895
+ if (typeof props?.close === 'function') {
2896
+ props.close()
2897
+ } else {
2898
+ ctx?.get?.('layout')?.selectPanel?.(null)
2899
+ }
2900
+ }
2901
+
2902
+ const loadData = React.useCallback(async () => {
2903
+ setLoading(true)
2904
+ setErr('')
2905
+ try {
2906
+ const [uRes, sRes] = await Promise.all([
2907
+ fetchWithTimeout(`${ROUTE_PREFIX}/usage`, { cache: 'no-store' }),
2908
+ fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
2909
+ ])
2910
+ const uJson = await uRes.json().catch(() => ({}))
2911
+ const sJson = await sRes.json().catch(() => ({}))
2912
+ if (uJson?.ok) setData(uJson)
2913
+ if (sJson?.ok) setStatus(sJson)
2914
+ setUpdatedAt(new Date().toLocaleTimeString())
2915
+ } catch (e) {
2916
+ setErr(String(e?.message || e))
2917
+ } finally {
2918
+ setLoading(false)
2919
+ }
2920
+ }, [])
2921
+
2922
+ React.useEffect(() => {
2923
+ loadData()
2924
+ }, [loadData])
2925
+
2926
+ const plan = data?.plan || status?.config?.defaultModel || 'ClinePass'
2927
+ const email = data?.user?.email || ''
2928
+ const activeAcc = status?.config?.activeAccount || 'Auto (Least-used)'
2929
+ const enabledCount = status?.config?.enabledModels?.length || 0
2930
+
2931
+ return React.createElement(
2932
+ 'div',
2933
+ { className: 'cb-mainOverlay', role: 'region', 'aria-label': t('dashboard.title') },
2934
+ React.createElement(
2935
+ 'div',
2936
+ { className: 'cb-mainInner' },
2937
+ // Header
2938
+ React.createElement(
2939
+ 'header',
2940
+ { className: 'cb-mainHeader' },
2941
+ React.createElement(
2942
+ 'div',
2943
+ { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 } },
2944
+ React.createElement('h2', { className: 'cb-mainTitle' }, t('dashboard.title')),
2945
+ React.createElement('p', { className: 'cb-mainSub' }, t('dashboard.subtitle'))
2946
+ ),
2947
+ React.createElement('span', { style: { flex: 1 } }),
2948
+ updatedAt
2949
+ ? React.createElement(
2950
+ 'span',
2951
+ { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', fontVariantNumeric: 'tabular-nums' } },
2952
+ `${t('dashboard.updated_at')} ${updatedAt}`
2953
+ )
2954
+ : null,
2955
+ React.createElement(
2956
+ 'button',
2957
+ {
2958
+ type: 'button',
2959
+ className: 'cb-btn',
2960
+ disabled: loading,
2961
+ onClick: loadData,
2962
+ },
2963
+ loading ? t('dashboard.refreshing') : t('dashboard.refresh')
2964
+ ),
2965
+ React.createElement(
2966
+ 'button',
2967
+ {
2968
+ type: 'button',
2969
+ className: 'cb-btn',
2970
+ style: { minWidth: '32px', padding: '0 8px', fontSize: '16px' },
2971
+ 'aria-label': t('dashboard.close'),
2972
+ title: t('dashboard.close'),
2973
+ onClick: handleClose,
2974
+ },
2975
+ '✕'
2976
+ )
2977
+ ),
2978
+
2979
+ err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
2980
+
2981
+ // Card 1: Account & Plan Status
2982
+ React.createElement(
2983
+ 'div',
2984
+ { className: 'cb-mainCard' },
2985
+ React.createElement(
2986
+ 'div',
2987
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
2988
+ React.createElement(
2989
+ 'div',
2990
+ { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
2991
+ React.createElement('span', { style: { fontSize: '20px' } }, '💳'),
2992
+ React.createElement(
2993
+ 'div',
2994
+ null,
2995
+ React.createElement('div', { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, plan),
2996
+ email ? React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, email) : null
2997
+ )
2998
+ ),
2999
+ React.createElement(
3000
+ 'span',
3001
+ { className: 'cb-badge cb-badge-ok' },
3002
+ t('badge.registered', { count: enabledCount })
3003
+ )
3004
+ ),
3005
+ React.createElement(
3006
+ 'div',
3007
+ { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px', marginTop: '6px' } },
3008
+ React.createElement(
3009
+ 'div',
3010
+ { className: 'cb-stat-box' },
3011
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
3012
+ React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px', wordBreak: 'break-all' } }, activeAcc)
3013
+ ),
3014
+ React.createElement(
3015
+ 'div',
3016
+ { className: 'cb-stat-box' },
3017
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
3018
+ React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px' } }, status?.config?.accountMode === 'round_robin' ? t('accounts.mode_round_robin') : t('accounts.mode_least_used'))
3019
+ )
3020
+ )
3021
+ ),
3022
+
3023
+ // Card 2: Quota Windows
3024
+ React.createElement(
3025
+ 'div',
3026
+ { className: 'cb-mainCard' },
3027
+ React.createElement(
3028
+ 'div',
3029
+ { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
3030
+ t('quota.title')
3031
+ ),
3032
+ React.createElement(
3033
+ 'div',
3034
+ { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
3035
+ data?.windows?.fiveHour
3036
+ ? React.createElement(ProgressBar, {
3037
+ label: t('quota.window_5h'),
3038
+ percentUsed: data.windows.fiveHour.percentUsed || 0,
3039
+ remainingPercent: data.windows.fiveHour.remainingPercent || 100,
3040
+ resetsAt: data.windows.fiveHour.resetsAt,
3041
+ t,
3042
+ })
3043
+ : null,
3044
+ React.createElement(ProgressBar, {
3045
+ label: t('quota.window_weekly'),
3046
+ percentUsed: data?.windows?.weekly?.percentUsed || 0,
3047
+ remainingPercent: data?.windows?.weekly?.remainingPercent || 100,
3048
+ resetsAt: data?.windows?.weekly?.resetsAt,
3049
+ t,
3050
+ }),
3051
+ data?.windows?.monthly
3052
+ ? React.createElement(ProgressBar, {
3053
+ label: t('quota.window_monthly'),
3054
+ percentUsed: data.windows.monthly.percentUsed || 0,
3055
+ remainingPercent: data.windows.monthly.remainingPercent || 100,
3056
+ resetsAt: data.windows.monthly.resetsAt,
3057
+ t,
3058
+ })
3059
+ : null
3060
+ )
3061
+ )
3062
+ )
3063
+ )
3064
+ }
3065
+
3066
+ // --- src/client/components/models-provider-card.js ---
3067
+ function ModelsProviderCard(props) {
3068
+ const ctx = props?.ctx
3069
+ const t = props?.t || makeT(en, en)
3070
+ const [status, setStatus] = React.useState(null)
3071
+ const [apiKey, setApiKey] = React.useState('')
3072
+ const [showKey, setShowKey] = React.useState(false)
3073
+ const [saving, setSaving] = React.useState(false)
3074
+ const [msg, setMsg] = React.useState('')
3075
+ const [err, setErr] = React.useState('')
3076
+
3077
+ const loadStatus = React.useCallback(async () => {
3078
+ try {
3079
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
3080
+ if (!res.ok) return
3081
+ const json = await res.json().catch(() => null)
3082
+ if (json && json.ok) setStatus(json)
3083
+ } catch {
3084
+ // quiet on network fail
3085
+ }
3086
+ }, [])
3087
+
3088
+ React.useEffect(() => {
3089
+ loadStatus()
3090
+ }, [loadStatus])
3091
+
3092
+ const keyConfigured = Boolean(status?.keyPresent || status?.config?.hasKey)
3093
+ const modelsCount = status?.config?.enabledModels?.length || status?.availableModels?.length || 0
3094
+
3095
+ const handleSaveKey = async () => {
3096
+ const keyVal = String(apiKey || '').trim()
3097
+ if (!keyVal) {
3098
+ setErr(t('key.empty_err'))
3099
+ return
3100
+ }
3101
+ setSaving(true)
3102
+ setErr('')
3103
+ setMsg('')
3104
+ try {
3105
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/save-key`, {
3106
+ method: 'POST',
3107
+ headers: { 'Content-Type': 'application/json' },
3108
+ body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: status?.config?.apiKeyEnv }),
3109
+ })
3110
+ const data = await res.json().catch(() => ({}))
3111
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3112
+ setApiKey('')
3113
+ setMsg(t('card.key_saved'))
3114
+ await loadStatus()
3115
+ } catch (e) {
3116
+ setErr(String(e?.message || e))
3117
+ } finally {
3118
+ setSaving(false)
3119
+ }
3120
+ }
3121
+
3122
+ const handleOpenSettings = () => {
3123
+ const layout = ctx?.get?.('layout')
3124
+ if (typeof layout?.selectPanel === 'function') {
3125
+ layout.selectPanel('clinebot-dashboard')
3126
+ } else if (typeof props?.openSettings === 'function') {
3127
+ props.openSettings()
3128
+ }
3129
+ }
3130
+
3131
+ return React.createElement(
3132
+ 'div',
3133
+ { className: 'cb-providerCard', 'data-clinebot-provider-card': 'true' },
3134
+ // Header
3135
+ React.createElement(
3136
+ 'div',
3137
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3138
+ React.createElement(
3139
+ 'div',
3140
+ { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
3141
+ React.createElement('span', { style: { fontSize: '18px' } }, '🤖'),
3142
+ React.createElement('span', { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t('card.title'))
3143
+ ),
3144
+ React.createElement(
3145
+ 'div',
3146
+ { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
3147
+ React.createElement(
3148
+ 'span',
3149
+ { className: keyConfigured ? 'cb-badge cb-badge-ok' : 'cb-badge cb-badge-warn' },
3150
+ keyConfigured ? `✓ ${t('card.status_connected')}` : `⚠️ ${t('card.status_no_key')}`
3151
+ ),
3152
+ modelsCount > 0
3153
+ ? React.createElement(
3154
+ 'span',
3155
+ { className: 'cb-badge cb-badge-ok' },
3156
+ t('card.models_active', { count: modelsCount })
3157
+ )
3158
+ : null
3159
+ )
3160
+ ),
3161
+
3162
+ // Quick Key Input Form
3163
+ React.createElement(
3164
+ 'div',
3165
+ { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
3166
+ React.createElement(
3167
+ 'div',
3168
+ { className: 'cb-input-group' },
3169
+ React.createElement('input', {
3170
+ type: showKey ? 'text' : 'password',
3171
+ className: 'cb-input',
3172
+ style: { flex: 1 },
3173
+ value: apiKey,
3174
+ placeholder: keyConfigured ? t('key.placeholder_has') : t('key.placeholder_empty'),
3175
+ onChange: (e) => setApiKey(e.target.value),
3176
+ onKeyDown: (e) => { if (e.key === 'Enter') handleSaveKey() },
3177
+ }),
3178
+ React.createElement(
3179
+ 'button',
3180
+ {
3181
+ type: 'button',
3182
+ className: 'cb-btn',
3183
+ onClick: () => setShowKey(!showKey),
3184
+ },
3185
+ showKey ? t('key.hide') : t('key.show')
3186
+ ),
3187
+ React.createElement(
3188
+ 'button',
3189
+ {
3190
+ type: 'button',
3191
+ className: 'cb-btn cb-btn-primary',
3192
+ disabled: saving || !apiKey.trim(),
3193
+ onClick: handleSaveKey,
3194
+ },
3195
+ saving ? t('key.saving') : t('card.save_key')
3196
+ )
3197
+ ),
3198
+ msg ? React.createElement('div', { className: 'cb-alert-ok' }, msg) : null,
3199
+ err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null
3200
+ ),
3201
+
3202
+ // Footer with navigation to full settings
3203
+ React.createElement(
3204
+ 'div',
3205
+ { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
3206
+ React.createElement(
3207
+ 'button',
3208
+ {
3209
+ type: 'button',
3210
+ className: 'cb-btn',
3211
+ onClick: handleOpenSettings,
3212
+ },
3213
+ `⚙️ ${t('card.open_settings')}`
3214
+ )
3215
+ )
3216
+ )
3217
+ }
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
+
2576
3378
  // --- src/client/settings-page.js ---
2577
3379
  function SettingsPage(props) {
2578
3380
  const ctx = props?.ctx
@@ -3020,7 +3822,7 @@ function SettingsPage(props) {
3020
3822
  }),
3021
3823
 
3022
3824
  // Quota Warning & Dashboard Card
3023
- React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, t }),
3825
+ React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, showSidebarQuota: draft?.showSidebarQuota !== false, onToggleSidebarQuota: (v) => handleConfigPatch({ showSidebarQuota: v }), t }),
3024
3826
 
3025
3827
  // Card 3: Model Picker Management
3026
3828
  React.createElement(ModelsSection, {
@@ -3237,6 +4039,62 @@ function apply(ctx) {
3237
4039
  (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
3238
4040
  )
3239
4041
  )
4042
+
4043
+ // Sidebar Quota Meter (sidebar.footer.action slot)
4044
+ registerSlotWhenReady('sidebar.footer.action', () =>
4045
+ ctx.slots.register(
4046
+ {
4047
+ name: 'sidebar.footer.action',
4048
+ id: 'clinebot-quota',
4049
+ order: 20,
4050
+ locale: NS,
4051
+ inject: () => ({ ctx }),
4052
+ },
4053
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(SidebarQuota, { ...props, ctx: (props && props.ctx) || ctx }))
4054
+ )
4055
+ )
4056
+
4057
+ // Central Overlay Dashboard (main slot)
4058
+ registerSlotWhenReady('main', () =>
4059
+ ctx.slots.register(
4060
+ {
4061
+ name: 'main',
4062
+ key: 'clinebot-dashboard',
4063
+ locale: NS,
4064
+ inject: () => ({ ctx }),
4065
+ },
4066
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(DashboardOverlay, { ...props, ctx: (props && props.ctx) || ctx }))
4067
+ )
4068
+ )
4069
+
4070
+ // Settings Models Provider Card (settings.models.provider-card slot)
4071
+ for (const pKey of ['clinebot', 'llm-clinebot']) {
4072
+ registerSlotWhenReady('settings.models.provider-card', () =>
4073
+ ctx.slots.register(
4074
+ {
4075
+ name: 'settings.models.provider-card',
4076
+ key: pKey,
4077
+ locale: NS,
4078
+ inject: () => ({ ctx }),
4079
+ },
4080
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(ModelsProviderCard, { ...props, ctx: (props && props.ctx) || ctx }))
4081
+ )
4082
+ )
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
+ )
3240
4098
  }
3241
4099
 
3242
4100
  module.exports = { apply, inject: ['slots', 'locale', 'configForms'] }
package/lib/config.js CHANGED
@@ -41,6 +41,8 @@ export const Config = z.object({
41
41
  .description('Automatically hide models not included in active subscription plan from chat picker.').volatile(),
42
42
  visibleModels: z.array(z.string()).default([])
43
43
  .description('User-selected visible models in chat model picker (empty means all supported models).').volatile(),
44
+ showSidebarQuota: z.boolean().default(true)
45
+ .description('Display compact quota meter in DSH sidebar footer.').volatile(),
44
46
  enabledModels: z.array(z.string()).default([])
45
47
  .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
46
48
  dynamicModels: z.array(z.object({
@@ -209,6 +211,7 @@ export function publicConfig(cfg) {
209
211
  disabledModels: disabledList,
210
212
  filterModelsByPlan,
211
213
  visibleModels,
214
+ showSidebarQuota: cfg?.showSidebarQuota !== false,
212
215
  enabledModels: activeIds,
213
216
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
214
217
  smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
@@ -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.27'
17
+ let packageVersion = '0.5.29'
18
18
  try {
19
- packageVersion = require('../package.json').version || '0.5.27'
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.27",
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",