@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 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
@@ -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**: Injects `['settings', 'webServer', 'credentials']`.
11
- * **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.credentials.resolve()` or `process.env`.
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` (primary) with `key: NS` and `locale: NS`, and graceful fallback to `settings.section` if not declared.
19
- * Registers localized `en` and `ru` dictionaries via `ctx.locale.register()`.
20
- * Reactive binding via `ctx.settingsScope.bind({ namespace: NS })` with `useSyncExternalStore` guarding against `unavailable` / `loading` snapshot states.
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
- // Dedicated Settings Page: Settings → ClineBot (settings.section)
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.children
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
- if (!ctx?.settingsScope?.bind) return undefined
359
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
360
+ if (!s?.bind) return undefined
361
361
  try {
362
- return ctx.settingsScope.bind({ namespace: NS })
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 SNAPSHOT_READY
380
+ if (!scope?.getSnapshot) return SNAPSHOT_LOADING
381
381
  try {
382
- return scope.getSnapshot() || SNAPSHOT_READY
382
+ return scope.getSnapshot() || SNAPSHOT_LOADING
383
383
  } catch (_) {
384
- return SNAPSHOT_READY
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 || 'ready'
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
- try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
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
- let placed = false
1199
- try {
1200
- const res = ctx.slots.inject('settings.plugin.item', () => {
1201
- placed = true
1202
- return ctx.slots.register(
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
- // Fallback to settings.section if settings.plugin.item is not declared
1218
- if (!placed) {
1219
- try {
1220
- ctx.slots.inject('settings.section', () => {
1221
- return ctx.slots.register(
1222
- {
1223
- name: 'settings.section',
1224
- id: '@goodandready/dsh-clinebot',
1225
- order: 28,
1226
- locale: NS,
1227
- label: () => t('title'),
1228
- inject: () => ({ ctx }),
1229
- },
1230
- (props) => React.createElement(ErrorBoundary, null, React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx }))
1231
- )
1232
- })
1233
- } catch (_) {}
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
- if (ctx?.credentials && typeof ctx.credentials.resolve === 'function') {
125
+ const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
126
+ if (creds && typeof creds.resolve === 'function') {
126
127
  try {
127
- const hit = await ctx.credentials.resolve(credentialRef(refName))
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 liveCfg = Config(structuredClone(config || {}))
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({ ...liveCfg, dynamicModels: fromDisk })
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
- ...liveCfg,
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
- const settingsService = ctx.get('settings')
395
- if (typeof settingsService?.register === 'function') {
396
- const scope = settingsService.register(NS, Config, { base: config })
397
- settingsApi = scope
398
- liveCfg = Config(scope.get() ?? config)
399
- ctx.effect(() => scope.watch((next) => {
400
- liveCfg = Config(next ?? config)
401
- syncProviderState(liveCfg)
402
- }), 'dsh-clinebot: settings')
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(liveCfg)
424
+ syncProviderState(live())
409
425
  // Background discover plan models on startup
410
- setTimeout(() => autoDiscoverPlanModels(liveCfg), 500)
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.5",
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",