@goodandready/dsh-clinebot 0.3.5 → 0.3.7
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 +16 -0
- package/docs/design/DESIGN.md +7 -5
- package/lib/client.js +120 -45
- package/lib/index.js +34 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.3.7] - 2026-09-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Canonical Settings Namespace Registration** (Issue #16): Moved settings declaration in `lib/index.js` to `ctx.inject(['settings'], (sctx) => { sctx.settings.register(NS, Config, { base: config }) })`, guaranteeing synchronous/asynchronous namespace availability and reactive config watching via `live()`.
|
|
12
|
+
- **Top-Level Section Removal**: Removed the unauthorized fallback to `settings.section` in `lib/client.js`, strictly confining the plugin UI to the standard `settings.plugin.item` slot under Settings → Plugins → Plugin Settings.
|
|
13
|
+
- **Safe Service Resolution**: Replaced direct property access `ctx.credentials` with safe proxy lookup `(ctx?.get && ctx.get('credentials')) || ctx?.credentials`.
|
|
14
|
+
- **Client Mirror Invalidation & Scope Sync**: Added `refreshMirrorUntilVisible(ctx)` in `lib/client.js` to trigger settings mirror re-reads until the namespace is visible in the web client, and synchronized multi-account changes directly through `scope.set('activeAccount', accountEnv)`.
|
|
15
|
+
- **Duplicate-Safe Locales**: Wrapped client dictionary registration with duplicate-safe guards (`ctx.locale.register()`) preventing registration collision errors.
|
|
16
|
+
|
|
17
|
+
## [0.3.6] - 2026-09-09
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Restored `handleSmoke` diagnostics handler**: Re-introduced missing `handleSmoke` click callback in `SettingsPage`, fixing `handleSmoke is not defined` runtime error during Diagnostics card interaction.
|
|
21
|
+
- **Defensive ErrorBoundary child rendering**: Hardened `ErrorBoundary.render()` with `this.props?.children || null` to prevent unhandled exceptions if props are omitted.
|
|
22
|
+
- **Comprehensive UI render integrity test**: Added recursive VDOM component tree evaluation test in `test/client-modules.test.js` covering all 190+ elements and verifying definition of all 18 event handlers.
|
|
23
|
+
|
|
8
24
|
## [0.3.5] - 2026-09-09
|
|
9
25
|
|
|
10
26
|
### Fixed
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
The plugin consists of two runtime boundaries conforming to DSH authoring standards:
|
|
8
8
|
|
|
9
9
|
### 2.1 Host Runtime (`lib/index.js`, `lib/cline-client.js`, `lib/models.js`, `lib/http.js`)
|
|
10
|
-
* **Cordis Service Registration**:
|
|
11
|
-
* **
|
|
10
|
+
* **Cordis Service Registration**: Declares `inject = ['settings', 'webServer', 'credentials']` and registers the settings namespace dynamically via `ctx.inject(['settings'], (sctx) => { sctx.settings.register(NS, Config, { base: config }) })`. This guarantees that the configuration schema, default values, and reactive watchers are declared and available to the host and client settingsScope without timing issues.
|
|
11
|
+
* **Safe Service Resolution**: Service lookups utilize defensive proxy resolution `(ctx?.get && ctx.get('credentials')) || ctx?.credentials` to prevent `undefined` properties on Cordis proxies.
|
|
12
|
+
* **Credential Isolation**: The plugin NEVER stores plain API keys in its configuration. The setting `apiKeyEnv` holds the credential identifier (default: `CLINEBOT_API_KEY`), resolved via `ctx.get('credentials').resolve()` or `process.env`.
|
|
12
13
|
* **State Synchronization & Auto-Registration**: Mutates the core `llm-pi-ai` settings space (`op: 'set', path: ['providers', 'clinebot']`) declaratively and automatically when enabled or key is saved.
|
|
13
14
|
* **Auto-Discovery & `disabledModels`**: Features automatic background polling of subscription plan models (`GET /api/v1/users/me/plan`). User preferences are tracked via `disabledModels: []`, ensuring newly added plan models appear enabled by default in the DSH chat picker without manual re-synchronization.
|
|
14
15
|
|
|
15
16
|
### 2.2 Client Runtime (`lib/client.js`)
|
|
16
17
|
* Self-registering module via `window.__ModuleLoader__.load({ id: '@goodandready/dsh-clinebot', factory })`.
|
|
17
18
|
* Injects `['slots', 'locale', 'settingsScope']`.
|
|
18
|
-
* Slots into `settings.plugin.item` (
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Slots strictly and exclusively into `settings.plugin.item` (`key: NS`, `locale: NS`). Standalone top-level `settings.section` registration is omitted to maintain clean primary navigation in DSH and prevent side-list pollution.
|
|
20
|
+
* Uses `refreshMirrorUntilVisible(ctx)` to invalidate and re-read the client settings mirror until the namespace is reported ready by the host.
|
|
21
|
+
* Registers localized `en` and `ru` dictionaries with duplicate-safe guards (`ctx.locale.register()`).
|
|
22
|
+
* Reactive binding via `((ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope).bind({ namespace: NS })` with `useSyncExternalStore` guarding against `unavailable` / `loading` snapshot states. Form mutations write directly to `scope.set()`.
|
|
21
23
|
* Uses native design tokens (`--dsw-alias-...`) with full dark/light theme support.
|
|
22
24
|
* Injects isolated style tag tagged with `data-dsh-plugin="dsh-clinebot"`.
|
|
23
25
|
|
package/lib/client.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// & Plugin Card (settings.plugin.item).
|
|
1
|
+
// Plugin Settings Card (settings.plugin.item).
|
|
3
2
|
window.__ModuleLoader__.load({
|
|
4
3
|
id: '@goodandready/dsh-clinebot',
|
|
5
4
|
factory: (require) => {
|
|
@@ -298,7 +297,7 @@ window.__ModuleLoader__.load({
|
|
|
298
297
|
)
|
|
299
298
|
)
|
|
300
299
|
}
|
|
301
|
-
return this.props
|
|
300
|
+
return this.props?.children || null
|
|
302
301
|
}
|
|
303
302
|
}
|
|
304
303
|
}
|
|
@@ -357,9 +356,10 @@ window.__ModuleLoader__.load({
|
|
|
357
356
|
const t = props?.t || makeT(ru, en)
|
|
358
357
|
|
|
359
358
|
const scope = React.useMemo(() => {
|
|
360
|
-
|
|
359
|
+
const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
|
|
360
|
+
if (!s?.bind) return undefined
|
|
361
361
|
try {
|
|
362
|
-
return
|
|
362
|
+
return s.bind({ namespace: NS })
|
|
363
363
|
} catch (_) {
|
|
364
364
|
return undefined
|
|
365
365
|
}
|
|
@@ -377,11 +377,11 @@ window.__ModuleLoader__.load({
|
|
|
377
377
|
}, [scope])
|
|
378
378
|
|
|
379
379
|
const getSnapshot = React.useCallback(() => {
|
|
380
|
-
if (!scope?.getSnapshot) return
|
|
380
|
+
if (!scope?.getSnapshot) return SNAPSHOT_LOADING
|
|
381
381
|
try {
|
|
382
|
-
return scope.getSnapshot() ||
|
|
382
|
+
return scope.getSnapshot() || SNAPSHOT_LOADING
|
|
383
383
|
} catch (_) {
|
|
384
|
-
return
|
|
384
|
+
return SNAPSHOT_LOADING
|
|
385
385
|
}
|
|
386
386
|
}, [scope])
|
|
387
387
|
|
|
@@ -390,7 +390,7 @@ window.__ModuleLoader__.load({
|
|
|
390
390
|
getSnapshot,
|
|
391
391
|
React.useCallback(() => SNAPSHOT_LOADING, [])
|
|
392
392
|
)
|
|
393
|
-
const snapshotStatus = snapshot?.status || '
|
|
393
|
+
const snapshotStatus = snapshot?.status || 'loading'
|
|
394
394
|
|
|
395
395
|
const [status, setStatus] = React.useState(null)
|
|
396
396
|
const [draft, setDraft] = React.useState(null)
|
|
@@ -420,6 +420,12 @@ window.__ModuleLoader__.load({
|
|
|
420
420
|
load().catch((e) => setErr(String(e.message || e)))
|
|
421
421
|
}, [load])
|
|
422
422
|
|
|
423
|
+
React.useEffect(() => {
|
|
424
|
+
if (snapshotStatus === 'ready' && snapshot?.value && typeof snapshot.value === 'object') {
|
|
425
|
+
setDraft((prev) => (prev ? { ...prev, ...snapshot.value } : snapshot.value))
|
|
426
|
+
}
|
|
427
|
+
}, [snapshotStatus, snapshot?.value])
|
|
428
|
+
|
|
423
429
|
// Save Key handler
|
|
424
430
|
async function handleSaveKey() {
|
|
425
431
|
if (!apiKeyInput.trim()) {
|
|
@@ -526,6 +532,29 @@ window.__ModuleLoader__.load({
|
|
|
526
532
|
}
|
|
527
533
|
}
|
|
528
534
|
|
|
535
|
+
// Smoke chat test
|
|
536
|
+
async function handleSmoke() {
|
|
537
|
+
setBusy('smoke')
|
|
538
|
+
setErr('')
|
|
539
|
+
setMsg('')
|
|
540
|
+
setSmokeResult(null)
|
|
541
|
+
try {
|
|
542
|
+
const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: { 'Content-Type': 'application/json' },
|
|
545
|
+
body: JSON.stringify({ model: draft?.defaultModel }),
|
|
546
|
+
})
|
|
547
|
+
const data = await res.json().catch(() => ({}))
|
|
548
|
+
if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
549
|
+
setSmokeResult(data)
|
|
550
|
+
setMsg(t('diag.smoke_ok', { latency: data.latencyMs }))
|
|
551
|
+
} catch (e) {
|
|
552
|
+
setErr(String(e.message || e))
|
|
553
|
+
} finally {
|
|
554
|
+
setBusy('')
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
529
558
|
// Fast Browser Login
|
|
530
559
|
async function handleFastLogin() {
|
|
531
560
|
setBusy('fast-login')
|
|
@@ -547,6 +576,9 @@ window.__ModuleLoader__.load({
|
|
|
547
576
|
|
|
548
577
|
// Pin active account
|
|
549
578
|
async function handlePinAccount(accountEnv) {
|
|
579
|
+
if (scope && snapshotStatus === "ready") {
|
|
580
|
+
try { await scope.set("activeAccount", accountEnv) } catch (_) {}
|
|
581
|
+
}
|
|
550
582
|
setBusy(`pin-${accountEnv}`)
|
|
551
583
|
setErr('')
|
|
552
584
|
try {
|
|
@@ -1189,49 +1221,92 @@ window.__ModuleLoader__.load({
|
|
|
1189
1221
|
)
|
|
1190
1222
|
}
|
|
1191
1223
|
|
|
1224
|
+
function refreshMirrorUntilVisible(ctx) {
|
|
1225
|
+
const visible = () => {
|
|
1226
|
+
try {
|
|
1227
|
+
const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
|
|
1228
|
+
const view = s?.describe?.()?.getSnapshot?.()?.view
|
|
1229
|
+
return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
|
|
1230
|
+
} catch (_) {
|
|
1231
|
+
return false
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (visible()) return () => {}
|
|
1235
|
+
let tries = 0
|
|
1236
|
+
const timer = setInterval(() => {
|
|
1237
|
+
if (visible() || tries >= 15) { clearInterval(timer); return }
|
|
1238
|
+
tries += 1
|
|
1239
|
+
try {
|
|
1240
|
+
const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
|
|
1241
|
+
s?.describe?.()?.load?.()
|
|
1242
|
+
} catch (_) {}
|
|
1243
|
+
}, 1000)
|
|
1244
|
+
return () => clearInterval(timer)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1192
1247
|
function apply(ctx) {
|
|
1248
|
+
const addLocale = (locale, dictionary) => {
|
|
1249
|
+
try {
|
|
1250
|
+
return ctx.locale.register(NS, locale, dictionary)
|
|
1251
|
+
} catch (_) {
|
|
1252
|
+
return () => {}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1193
1255
|
if (ctx.locale && ctx.locale.register) {
|
|
1194
|
-
|
|
1256
|
+
if (typeof ctx.effect === 'function') {
|
|
1257
|
+
ctx.effect(() => {
|
|
1258
|
+
const undo = [addLocale('en', en), addLocale('ru', ru)]
|
|
1259
|
+
return () => { for (const off of undo) off() }
|
|
1260
|
+
}, 'dsh-clinebot: dictionaries')
|
|
1261
|
+
} else {
|
|
1262
|
+
addLocale('en', en)
|
|
1263
|
+
addLocale('ru', ru)
|
|
1264
|
+
}
|
|
1195
1265
|
}
|
|
1196
|
-
const t = (ctx.locale && ctx.locale.bind) ? ctx.locale.bind(NS) : makeT(ru, en)
|
|
1197
1266
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
{
|
|
1204
|
-
name: 'settings.plugin.item',
|
|
1205
|
-
key: NS,
|
|
1206
|
-
locale: NS,
|
|
1207
|
-
inject: () => ({ ctx }),
|
|
1208
|
-
},
|
|
1209
|
-
(props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
|
|
1210
|
-
)
|
|
1211
|
-
})
|
|
1212
|
-
if (res === null) placed = false
|
|
1213
|
-
} catch (_) {
|
|
1214
|
-
placed = false
|
|
1267
|
+
if (typeof ctx.effect === 'function') {
|
|
1268
|
+
ctx.effect(
|
|
1269
|
+
() => refreshMirrorUntilVisible(ctx),
|
|
1270
|
+
'dsh-clinebot: re-read the settings mirror until our namespace appears',
|
|
1271
|
+
)
|
|
1215
1272
|
}
|
|
1216
1273
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
{
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1274
|
+
function registerSlotWhenReady(slotName, registerFn) {
|
|
1275
|
+
if (!ctx.slots) return
|
|
1276
|
+
if (typeof ctx.slots.inject === 'function') {
|
|
1277
|
+
try {
|
|
1278
|
+
ctx.slots.inject(slotName, () => {
|
|
1279
|
+
try {
|
|
1280
|
+
return registerFn()
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
console.warn('[dsh-clinebot] Error registering slot ' + slotName + ':', err)
|
|
1283
|
+
}
|
|
1284
|
+
})
|
|
1285
|
+
return
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
console.warn('[dsh-clinebot] Failed to inject slot ' + slotName + ':', err)
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (typeof ctx.slots.register === 'function') {
|
|
1291
|
+
try {
|
|
1292
|
+
registerFn()
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
console.warn('[dsh-clinebot] Failed direct registration for ' + slotName + ':', err)
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1234
1297
|
}
|
|
1298
|
+
|
|
1299
|
+
registerSlotWhenReady('settings.plugin.item', () =>
|
|
1300
|
+
ctx.slots.register(
|
|
1301
|
+
{
|
|
1302
|
+
name: 'settings.plugin.item',
|
|
1303
|
+
key: NS,
|
|
1304
|
+
locale: NS,
|
|
1305
|
+
inject: () => ({ ctx }),
|
|
1306
|
+
},
|
|
1307
|
+
(props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
|
|
1308
|
+
)
|
|
1309
|
+
)
|
|
1235
1310
|
}
|
|
1236
1311
|
|
|
1237
1312
|
module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
|
package/lib/index.js
CHANGED
|
@@ -122,9 +122,10 @@ function resolvePathWithHome(p) {
|
|
|
122
122
|
|
|
123
123
|
async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
124
124
|
const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
|
|
125
|
-
|
|
125
|
+
const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
|
|
126
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
126
127
|
try {
|
|
127
|
-
const hit = await
|
|
128
|
+
const hit = await creds.resolve(credentialRef(refName))
|
|
128
129
|
if (hit?.value) {
|
|
129
130
|
return { envName: refName, value: hit.value, source: 'credentials' }
|
|
130
131
|
}
|
|
@@ -323,7 +324,8 @@ function formatProgressBar(pct, totalWidth = 10) {
|
|
|
323
324
|
}
|
|
324
325
|
|
|
325
326
|
export function apply(ctx, config) {
|
|
326
|
-
let
|
|
327
|
+
let getConfig = () => config
|
|
328
|
+
const live = () => (getConfig() ? Config(structuredClone(getConfig())) : config)
|
|
327
329
|
let settingsApi
|
|
328
330
|
|
|
329
331
|
// Declarative sync helper: auto-registers or unregisters provider based on config & key availability
|
|
@@ -355,7 +357,7 @@ export function apply(ctx, config) {
|
|
|
355
357
|
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
356
358
|
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
357
359
|
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
358
|
-
const next = Config({ ...
|
|
360
|
+
const next = Config({ ...live(), dynamicModels: fromDisk })
|
|
359
361
|
await settingsApi.replace(next)
|
|
360
362
|
await syncProviderState(next)
|
|
361
363
|
}
|
|
@@ -376,7 +378,7 @@ export function apply(ctx, config) {
|
|
|
376
378
|
|
|
377
379
|
if (hasNew && settingsApi?.replace) {
|
|
378
380
|
const next = Config({
|
|
379
|
-
...
|
|
381
|
+
...live(),
|
|
380
382
|
dynamicModels: usageData.dynamicModels,
|
|
381
383
|
})
|
|
382
384
|
await settingsApi.replace(next)
|
|
@@ -391,23 +393,37 @@ export function apply(ctx, config) {
|
|
|
391
393
|
}
|
|
392
394
|
}
|
|
393
395
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
396
|
+
if (typeof ctx.inject === 'function') {
|
|
397
|
+
ctx.inject(['settings'], (sctx) => {
|
|
398
|
+
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
399
|
+
settingsApi = scope
|
|
400
|
+
getConfig = () => (scope?.get?.() ?? config) ?? config
|
|
401
|
+
sctx.effect(() => scope.watch((next) => {
|
|
402
|
+
syncProviderState(live())
|
|
403
|
+
}), 'dsh-clinebot: settings')
|
|
404
|
+
sctx.effect(() => () => {
|
|
405
|
+
getConfig = () => config
|
|
406
|
+
settingsApi = undefined
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
} else {
|
|
410
|
+
const settingsService = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
411
|
+
if (typeof settingsService?.register === 'function') {
|
|
412
|
+
const scope = settingsService.register(NS, Config, { base: config })
|
|
413
|
+
settingsApi = scope
|
|
414
|
+
getConfig = () => (scope?.get?.() ?? config) ?? config
|
|
415
|
+
if (typeof ctx.effect === 'function') {
|
|
416
|
+
ctx.effect(() => scope.watch((next) => {
|
|
417
|
+
syncProviderState(live())
|
|
418
|
+
}), 'dsh-clinebot: settings')
|
|
419
|
+
}
|
|
420
|
+
}
|
|
403
421
|
}
|
|
404
422
|
|
|
405
|
-
const live = () => liveCfg
|
|
406
|
-
|
|
407
423
|
// On startup: ensure provider is synced to llm-pi-ai if key is present
|
|
408
|
-
syncProviderState(
|
|
424
|
+
syncProviderState(live())
|
|
409
425
|
// Background discover plan models on startup
|
|
410
|
-
setTimeout(() => autoDiscoverPlanModels(
|
|
426
|
+
setTimeout(() => autoDiscoverPlanModels(live()), 500)
|
|
411
427
|
|
|
412
428
|
// Web server HTTP route handlers
|
|
413
429
|
if (ctx.webServer?.register) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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",
|