@moguiyu/dsh-tavily 0.2.8 → 0.3.1

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/src/backend.js ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Routes half of `@moguiyu/dsh-tavily`: key and usage management on the
3
+ * harness webServer (`/api/tavily-usage`, `/api/tavily-manager`). The
4
+ * built-in `web_search` tool is never replaced — no web search provider is
5
+ * registered and `web.searchProvider` is never touched.
6
+ *
7
+ * There is no `/api/tavily-tool` switch route: the tool-level on/off is gone
8
+ * (see `tools.js`). Key management is not a capability gate — it only edits
9
+ * the credentials the tools read.
10
+ */
11
+ import { readFileSync, writeFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
14
+ import { STRATEGIES, isValidStrategy, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
15
+
16
+ const USAGE_TTL_MS = 60000
17
+ const usageCache = new Map()
18
+
19
+ const MANAGER_STATE = 'tavily-manager.json'
20
+ const LEGACY_STATE = 'tavily-settings.json'
21
+ const REFS = ['TAVILY_API_KEYS', 'TAVILY_API_KEY']
22
+
23
+ function statePath(file) {
24
+ return join(resolveDshHome(), file)
25
+ }
26
+
27
+ function readState(file) {
28
+ return readJsonFile(statePath(file), {})
29
+ }
30
+
31
+ function writeState(file, state) {
32
+ writeFileSync(statePath(file), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
33
+ }
34
+
35
+ function readBody(req) {
36
+ return new Promise((resolve, reject) => {
37
+ const chunks = []
38
+ req.on('data', (chunk) => chunks.push(chunk))
39
+ req.on('end', () => {
40
+ const text = Buffer.concat(chunks).toString('utf8')
41
+ try {
42
+ resolve(text.length > 0 ? JSON.parse(text) : {})
43
+ } catch {
44
+ reject(new Error('invalid JSON body'))
45
+ }
46
+ })
47
+ req.on('error', reject)
48
+ })
49
+ }
50
+
51
+ async function fetchUsageDetailsFor(key) {
52
+ const cached = usageCache.get(key)
53
+ if (cached !== undefined && Date.now() - cached.at < USAGE_TTL_MS) return cached.details
54
+ try {
55
+ const controller = new AbortController()
56
+ const timer = setTimeout(() => controller.abort(), 15000)
57
+ let response
58
+ try {
59
+ response = await fetch('https://api.tavily.com/usage', {
60
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
61
+ signal: controller.signal,
62
+ })
63
+ } finally {
64
+ clearTimeout(timer)
65
+ }
66
+ if (!response.ok) return null
67
+ const json = await response.json()
68
+ const account = json !== null && typeof json === 'object' ? json.account : undefined
69
+ const keyUsage = json !== null && typeof json === 'object' ? json.key : undefined
70
+ const usage = keyUsage !== undefined && typeof keyUsage.usage === 'number'
71
+ ? keyUsage.usage
72
+ : (account !== undefined && typeof account.plan_usage === 'number' ? account.plan_usage : null)
73
+ const planUsage = account !== undefined && typeof account.plan_usage === 'number'
74
+ ? account.plan_usage
75
+ : (keyUsage !== undefined && typeof keyUsage.usage === 'number' ? keyUsage.usage : null)
76
+ const planLimit = account !== undefined && typeof account.plan_limit === 'number'
77
+ ? account.plan_limit
78
+ : (keyUsage !== undefined && typeof keyUsage.limit === 'number' ? keyUsage.limit
79
+ : (keyUsage !== undefined && typeof keyUsage.plan_limit === 'number' ? keyUsage.plan_limit : null))
80
+ const currentPlan = account !== undefined && typeof account.plan === 'string'
81
+ ? account.plan
82
+ : (keyUsage !== undefined && typeof keyUsage.plan === 'string' ? keyUsage.plan : null)
83
+ if (usage === null && planUsage === null) return null
84
+ const details = { usage, planUsage, planLimit, currentPlan }
85
+ usageCache.set(key, { at: Date.now(), details })
86
+ return details
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ async function fetchUsageFor(key) {
93
+ const details = await fetchUsageDetailsFor(key)
94
+ if (details === null) return null
95
+ if (details.planLimit !== null && details.planLimit > 0 && details.planUsage !== null) {
96
+ return (details.planUsage / details.planLimit) * 100
97
+ }
98
+ return details.usage !== null ? details.usage : details.planUsage
99
+ }
100
+
101
+ /** Resolve one credential ref to its string value, or `null` when absent. */
102
+ async function resolveRef(credentials, ref) {
103
+ try {
104
+ const hit = await credentials.resolve(ref)
105
+ return hit !== undefined && hit !== null && typeof hit.value === 'string' ? hit.value : null
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Whether a ref can be written here. The launching environment's variables are
113
+ * inherited READ-ONLY: `set`/`unset` throw for them (`describe().writable === false`).
114
+ * Assume writable when the service cannot say.
115
+ */
116
+ async function refWritable(credentials, ref) {
117
+ try {
118
+ const info = await credentials.describe(ref)
119
+ return info === undefined || info === null || info.writable !== false
120
+ } catch {
121
+ return true
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The managed key list, and where it came from.
127
+ *
128
+ * `TAVILY_API_KEYS` is AUTHORITATIVE. `TAVILY_API_KEY` — the derived primary — is
129
+ * read only as a fallback, for a deployment that sets nothing else. The two used to
130
+ * be unioned, and that was a bug with two faces: an environment-supplied
131
+ * `TAVILY_API_KEY` re-added its key on every read, so that key could never be deleted
132
+ * from the card; and every write then tried to re-sync the primary, which the
133
+ * launching environment owns, so the write threw AFTER the list had already changed.
134
+ */
135
+ async function collectStoredKeys(credentials) {
136
+ const fromList = parseKeyList(await resolveRef(credentials, 'TAVILY_API_KEYS') ?? '')
137
+ if (fromList.length > 0) return { values: dedupe([...fromList]), source: 'list' }
138
+ const fromPrimary = parseKeyList(await resolveRef(credentials, 'TAVILY_API_KEY') ?? '')
139
+ return { values: dedupe([...fromPrimary]), source: fromPrimary.length > 0 ? 'primary' : 'list' }
140
+ }
141
+
142
+ function dedupe(values) {
143
+ const out = []
144
+ const seen = new Set()
145
+ for (const key of values) {
146
+ if (!seen.has(key)) {
147
+ seen.add(key)
148
+ out.push(key)
149
+ }
150
+ }
151
+ return out
152
+ }
153
+
154
+ /**
155
+ * Keep the derived primary in step with the list, best effort. The tools read
156
+ * `TAVILY_API_KEYS`, so a primary the environment owns must never fail the request.
157
+ */
158
+ async function syncPrimary(credentials, value) {
159
+ if (!(await refWritable(credentials, 'TAVILY_API_KEY'))) return false
160
+ try {
161
+ if (value === null) await credentials.unset('TAVILY_API_KEY')
162
+ else await credentials.set('TAVILY_API_KEY', value)
163
+ return true
164
+ } catch {
165
+ return false
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Register the Tavily key/usage routes on `ctx.webServer`:
171
+ * `/api/tavily-usage` and `/api/tavily-manager`.
172
+ *
173
+ * Every route is registered through `ctx.effect`, so the calling fiber owns
174
+ * it and unloading the row leaves nothing registered.
175
+ */
176
+ export function installBackend(ctx) {
177
+ const credentials = ctx.get('credentials')
178
+
179
+ const send = (res, status, payload) => {
180
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
181
+ res.end(JSON.stringify(payload))
182
+ }
183
+
184
+ // ── key manager payload ────────────────────────────────────────────────────
185
+ async function buildManagerPayload(keys, source = 'list') {
186
+ const state = readState(MANAGER_STATE)
187
+ const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
188
+ if (Object.keys(keySavedAt).length === 0 && credentials !== undefined) {
189
+ // Migrate dates recorded by the pre-0.2 layout (ref-keyed legacy file).
190
+ const legacy = readJsonFile(statePath(LEGACY_STATE), null)
191
+ if (legacy !== null && typeof legacy === 'object') {
192
+ for (const ref of REFS) {
193
+ const entry = legacy[ref]
194
+ if (entry !== null && typeof entry === 'object' && typeof entry.savedAt === 'string') {
195
+ try {
196
+ const hit = await credentials.resolve(ref)
197
+ if (hit !== undefined) keySavedAt[maskValue(hit.value)] = entry.savedAt
198
+ } catch {
199
+ /* keep going */
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ const primaryMasked = keys.length > 0 ? maskValue(keys[0]) : null
206
+ const display = keys.map((value) => {
207
+ const masked = maskValue(value)
208
+ return {
209
+ masked,
210
+ savedAt: keySavedAt[masked] !== undefined ? keySavedAt[masked] : null,
211
+ }
212
+ }).sort((a, b) => {
213
+ if (a.savedAt === b.savedAt) return 0
214
+ if (a.savedAt === null) return 1
215
+ if (b.savedAt === null) return -1
216
+ return a.savedAt < b.savedAt ? -1 : 1
217
+ })
218
+ // The card needs to know what it may change. A ref inherited from the
219
+ // launching environment is read-only, and saying so beats a raw 500 from
220
+ // `set` after the list has already been written.
221
+ const keysWritable = credentials === undefined ? true : await refWritable(credentials, 'TAVILY_API_KEYS')
222
+ const primaryWritable = credentials === undefined ? true : await refWritable(credentials, 'TAVILY_API_KEY')
223
+ const removable = source === 'list' ? keysWritable : primaryWritable
224
+ return {
225
+ keys: display.map((entry) => ({
226
+ masked: entry.masked,
227
+ savedAt: entry.savedAt,
228
+ primary: entry.masked === primaryMasked,
229
+ removable,
230
+ })),
231
+ strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
232
+ source,
233
+ writable: { keys: keysWritable, primary: primaryWritable },
234
+ }
235
+ }
236
+
237
+ ctx.effect(() => ctx.webServer.register({
238
+ kind: 'exact',
239
+ path: '/api/tavily-usage',
240
+ handler: async (_req, res) => {
241
+ if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
242
+ try {
243
+ const { values: keys } = await collectStoredKeys(credentials)
244
+ if (keys.length === 0) return send(res, 200, { ok: false, error: 'no Tavily API key configured' })
245
+ const state = readState(MANAGER_STATE)
246
+ const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
247
+ const orderedKeys = keys.slice().sort((a, b) => {
248
+ const am = keySavedAt[maskValue(a)] !== undefined ? keySavedAt[maskValue(a)] : null
249
+ const bm = keySavedAt[maskValue(b)] !== undefined ? keySavedAt[maskValue(b)] : null
250
+ if (am === bm) return 0
251
+ if (am === null) return 1
252
+ if (bm === null) return -1
253
+ return am < bm ? -1 : 1
254
+ })
255
+ const perKey = []
256
+ for (const key of orderedKeys) {
257
+ try {
258
+ const usage = await fetchUsageDetailsFor(key)
259
+ perKey.push(usage !== null
260
+ ? { ok: true, masked: maskValue(key), ...usage }
261
+ : { ok: false, masked: maskValue(key), error: 'usage unavailable' })
262
+ } catch (error) {
263
+ perKey.push({ ok: false, masked: maskValue(key), error: String(error && error.message ? error.message : error) })
264
+ }
265
+ }
266
+ const okRows = perKey.filter((row) => row.ok)
267
+ return send(res, 200, {
268
+ ok: true,
269
+ perKey,
270
+ totals: {
271
+ keys: keys.length,
272
+ okKeys: okRows.length,
273
+ usage: okRows.reduce((sum, row) => sum + (row.usage || 0), 0),
274
+ planUsage: okRows.reduce((sum, row) => sum + (row.planUsage || 0), 0),
275
+ planLimit: okRows.every((row) => row.planLimit !== null) ? okRows.reduce((sum, row) => sum + row.planLimit, 0) : null,
276
+ },
277
+ })
278
+ } catch (error) {
279
+ send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
280
+ }
281
+ },
282
+ }), 'tavily-backend: route /api/tavily-usage')
283
+
284
+ ctx.effect(() => ctx.webServer.register({
285
+ kind: 'exact',
286
+ path: '/api/tavily-manager',
287
+ handler: async (req, res) => {
288
+ if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
289
+ try {
290
+ const url = new URL(req.url || '/', 'http://x')
291
+ if (req.method === 'GET') {
292
+ const reveal = url.searchParams.get('reveal')
293
+ const { values: keys, source } = await collectStoredKeys(credentials)
294
+ if (reveal !== null) {
295
+ const match = keys.find((key) => maskValue(key) === reveal)
296
+ if (match === undefined) return send(res, 404, { ok: false, error: 'key not found' })
297
+ return send(res, 200, { ok: true, value: match })
298
+ }
299
+ return send(res, 200, { ok: true, ...(await buildManagerPayload(keys, source)) })
300
+ }
301
+ if (req.method === 'POST') {
302
+ const body = await readBody(req)
303
+ const strategy = isValidStrategy(body.strategy) ? body.strategy : 'rotate'
304
+ const addValues = Array.isArray(body.add)
305
+ ? body.add.map((item) => typeof item === 'string' ? item.trim() : '').filter((value) => value.length > 0)
306
+ : []
307
+ const removeMasked = Array.isArray(body.remove)
308
+ ? body.remove.filter((item) => typeof item === 'string' && item.length > 0)
309
+ : []
310
+
311
+ let { values: stored, source } = await collectStoredKeys(credentials)
312
+ if (removeMasked.length > 0) {
313
+ stored = stored.filter((key) => !removeMasked.includes(maskValue(key)))
314
+ }
315
+ let values = [...stored]
316
+ for (const value of addValues) {
317
+ if (!values.includes(value)) values.push(value)
318
+ }
319
+
320
+ // A ref the launching environment supplies is inherited READ-ONLY: set and
321
+ // unset throw for it. Decide that BEFORE writing anything. The old order
322
+ // wrote `TAVILY_API_KEYS` first and then threw on the derived-primary sync,
323
+ // so the card reported a failure for a write that had half happened — and
324
+ // the unioned read then put the deleted key straight back.
325
+ const canWriteList = await refWritable(credentials, 'TAVILY_API_KEYS')
326
+ const canWritePrimary = await refWritable(credentials, 'TAVILY_API_KEY')
327
+ const READONLY_LIST = 'TAVILY_API_KEYS is supplied read-only by the launching environment, so the key list cannot be changed here. Unset it in the shell you start dsh from, or manage keys in ~/.dsh/.credentials.yaml.'
328
+ const READONLY_PRIMARY = 'That key comes from TAVILY_API_KEY, which the launching environment supplies read-only. Unset it in the shell you start dsh from.'
329
+ if (!canWriteList && (addValues.length > 0 || removeMasked.length > 0)) {
330
+ return send(res, 409, { ok: false, error: READONLY_LIST })
331
+ }
332
+ if (!canWritePrimary && source === 'primary' && removeMasked.length > 0) {
333
+ return send(res, 409, { ok: false, error: READONLY_PRIMARY })
334
+ }
335
+ // Emptying the list while an inherited primary still carries a key would
336
+ // read straight back as that key, so refuse rather than appear to undo itself.
337
+ if (values.length === 0 && !canWritePrimary) {
338
+ const inherited = parseKeyList(await resolveRef(credentials, 'TAVILY_API_KEY') ?? '')
339
+ if (inherited.length > 0) return send(res, 409, { ok: false, error: READONLY_PRIMARY })
340
+ }
341
+
342
+ if (values.length === 0) {
343
+ await credentials.unset('TAVILY_API_KEYS')
344
+ await syncPrimary(credentials, null)
345
+ } else {
346
+ if (strategy !== 'rotate') {
347
+ const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
348
+ const usageOf = (value) => {
349
+ const row = usageRows.find((r) => r.value === value)
350
+ return row !== undefined && row.usage !== null ? row.usage : null
351
+ }
352
+ values = orderKeys(values, strategy, usageOf)
353
+ }
354
+ await credentials.set('TAVILY_API_KEYS', values.join(','))
355
+ await syncPrimary(credentials, values[0])
356
+ }
357
+
358
+ const state = readState(MANAGER_STATE)
359
+ const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
360
+ const now = new Date().toISOString()
361
+ for (const value of values) {
362
+ const masked = maskValue(value)
363
+ if (keySavedAt[masked] === undefined) keySavedAt[masked] = now
364
+ }
365
+ writeState(MANAGER_STATE, { keySavedAt, strategy })
366
+
367
+ return send(res, 200, { ok: true, ...(await buildManagerPayload(values, source)) })
368
+ }
369
+ return send(res, 405, { ok: false, error: 'method not allowed' })
370
+ } catch (error) {
371
+ send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
372
+ }
373
+ },
374
+ }), 'tavily-backend: route /api/tavily-manager')
375
+
376
+ }
package/src/client.js CHANGED
@@ -7,7 +7,6 @@ export const id = '@moguiyu/dsh-tavily'
7
7
 
8
8
  export function factory(require) {
9
9
  const react = require('react')
10
- const _primitives = require('@deepseek-ai/dsh-client-ui-primitives')
11
10
 
12
11
  const inject = ['slots']
13
12
 
@@ -57,18 +56,6 @@ export function factory(require) {
57
56
  }, react.createElement(SvgIcon, { name: icon, size: 13 }))
58
57
  }
59
58
 
60
- function Switch({ checked, disabled, label, onChange }) {
61
- return react.createElement('button', {
62
- type: 'button',
63
- role: 'switch',
64
- 'aria-checked': checked === true,
65
- 'aria-label': label,
66
- disabled: disabled || checked === null,
67
- onClick: onChange,
68
- style: { width: 36, height: 20, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2)', background: checked === true ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-1)', cursor: 'pointer', padding: 0, position: 'relative', flex: 'none', opacity: disabled ? 0.5 : 1 }
69
- }, react.createElement('span', { style: { position: 'absolute', top: 2, left: checked === true ? 18 : 2, width: 14, height: 14, borderRadius: 7, background: '#fff', transition: 'left .12s ease' } }))
70
- }
71
-
72
59
  function UsageCircle({ percent, size, label, onClick }) {
73
60
  const radius = 15
74
61
  const circumference = 2 * Math.PI * radius
@@ -84,8 +71,6 @@ export function factory(require) {
84
71
  }
85
72
 
86
73
  function TavilySettingsCard() {
87
- const [enabled, setEnabled] = react.useState(null)
88
- const [expanded, setExpanded] = react.useState(false)
89
74
  const [server, setServer] = react.useState(null)
90
75
  const [loadError, setLoadError] = react.useState(null)
91
76
  const [strategy, setStrategy] = react.useState('rotate')
@@ -101,13 +86,6 @@ export function factory(require) {
101
86
  const [usageError, setUsageError] = react.useState(null)
102
87
 
103
88
  const refresh = react.useCallback(async () => {
104
- try {
105
- const response = await fetch('/api/tavily-tool', { cache: 'no-store' })
106
- const data = await response.json()
107
- if (data.ok) { setEnabled(data.enabled); setLoadError(null) } else { setLoadError(data.error || 'Failed to load settings') }
108
- } catch (error) {
109
- setLoadError(String(error && error.message ? error.message : error))
110
- }
111
89
  try {
112
90
  const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
113
91
  const data = await response.json()
@@ -127,27 +105,6 @@ export function factory(require) {
127
105
 
128
106
  react.useEffect(() => { refresh() }, [refresh])
129
107
 
130
- const toggleEnabled = async () => {
131
- const next = enabled !== true
132
- setBusy(true)
133
- setNotice(null)
134
- try {
135
- const response = await fetch('/api/tavily-tool', {
136
- method: 'POST',
137
- headers: { 'content-type': 'application/json' },
138
- body: JSON.stringify({ enabled: next })
139
- })
140
- const data = await response.json()
141
- if (!data.ok) { setNotice({ error: data.error || 'Switch failed' }); return }
142
- setEnabled(data.enabled)
143
- setNotice({ ok: data.enabled ? 'Advanced tavily_search tool enabled.' : 'Advanced tavily_search tool disabled — built-in web_search is unchanged.' })
144
- } catch (error) {
145
- setNotice({ error: String(error && error.message ? error.message : error) })
146
- } finally {
147
- setBusy(false)
148
- }
149
- }
150
-
151
108
  const saveStrategy = async (next) => {
152
109
  setBusy(true)
153
110
  setNotice(null)
@@ -288,49 +245,19 @@ export function factory(require) {
288
245
  const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
289
246
  const cellStyle = { padding: '8px', verticalAlign: 'top' }
290
247
 
291
- const isOff = enabled === false
292
-
293
- // Card box mirrors the native plugin-config card (PluginCard.module.css,
294
- // ui-settings-plugins): bg-layer-3 fill, .5px border-l4, 16px radius,
295
- // hover/open border lift, and the open state's bg-layer-2 upgrade — so
296
- // the card sits at the same elevation as native cards in dark mode too.
297
- return react.createElement('div', { className: expanded ? 'dts-card dts-card-open' : 'dts-card', style: { minWidth: 0 } },
298
- react.createElement('style', null, '.dts-card{border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}.dts-card:hover{border-color:var(--dsw-alias-label-dimmed)}.dts-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
299
- react.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, padding: '12px 14px', minHeight: 52, boxSizing: 'border-box' } },
300
- react.createElement('button', {
301
- type: 'button',
302
- 'aria-expanded': expanded,
303
- onClick: () => setExpanded((current) => !current),
304
- style: { flex: 1, minWidth: 0, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 2, font: 'inherit', color: 'inherit' }
305
- },
306
- react.createElement('strong', { style: { fontSize: 14, fontWeight: 600, lineHeight: '20px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Tavily Search'),
307
- react.createElement('span', { style: { fontSize: 12, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Advanced tavily_search tool · key rotation · usage')
308
- ),
309
- react.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' } },
310
- react.createElement(Switch, { checked: enabled, disabled: busy, label: 'Advanced tavily_search tool enabled', onChange: toggleEnabled }),
311
- react.createElement('button', {
312
- type: 'button',
313
- className: 'dts-icon-btn',
314
- title: expanded ? 'Collapse' : 'Expand',
315
- 'aria-label': expanded ? 'Collapse' : 'Expand',
316
- onClick: () => setExpanded((current) => !current),
317
- style: { width: 26, height: 26, borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 0 }
318
- },
319
- react.createElement('span', { style: { display: 'inline-flex', transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform .14s var(--ds-ease-in-out)' } },
320
- react.createElement(_primitives.IconChevronDownOutline14, { size: 12, 'aria-hidden': 'true' })
321
- )
322
- )
323
- )
324
- ),
325
- expanded && react.createElement('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 16 } },
326
- isOff
327
- ? react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
328
- 'The advanced tavily_search tool is off. The built-in web_search keeps its native provider and is never replaced.'
329
- )
330
- : react.createElement(react.Fragment, null,
248
+ // The Plugins page draws the title, the icon, and the crumb — the
249
+ // `plugins.bundle.config` contract puts the form alone in the entry — so
250
+ // this renders no card chrome, no second heading, and no collapse.
251
+ return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 } },
252
+ react.createElement('style', null, '.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
253
+ react.createElement(react.Fragment, null,
331
254
  react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
332
255
  'All keys are listed below; the green dot marks the first (primary) key. The tavily_search tool uses all keys according to the strategy. Built-in web_search is unaffected.'
333
256
  ),
257
+ server !== null && server.writable !== undefined && server.writable.keys === false &&
258
+ react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
259
+ 'Read-only: these keys come from the environment that launched dsh, so this card cannot change them. Unset TAVILY_API_KEYS / TAVILY_API_KEY in that shell and restart dsh to manage keys here.'
260
+ ),
334
261
  loadError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(loadError)),
335
262
  react.createElement('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: 13 } },
336
263
  react.createElement('thead', null,
@@ -386,11 +313,15 @@ export function factory(require) {
386
313
  !isRemoved && react.createElement(IconButton, { icon: 'pencil', title: 'Edit', onClick: () => startReplace(masked), disabled: busy }),
387
314
  react.createElement(IconButton, {
388
315
  icon: isRemoved ? 'restore' : (confirm[masked] === true ? 'check' : 'trash'),
389
- title: isRemoved ? 'Restore' : (confirm[masked] === true ? 'Click again to confirm' : 'Delete'),
316
+ // A key the launching environment supplies is read-only, so the
317
+ // server would refuse. Say so on the control instead.
318
+ title: key.removable === false
319
+ ? 'Read-only: this key comes from the environment that launched dsh. Unset it there and restart dsh to change it here.'
320
+ : (isRemoved ? 'Restore' : (confirm[masked] === true ? 'Click again to confirm' : 'Delete')),
390
321
  danger: !isRemoved,
391
322
  className: isRemoved ? 'dts-icon-btn' : 'dts-icon-btn dts-icon-btn-danger',
392
323
  onClick: () => isRemoved ? restore(masked) : markRemoved(masked),
393
- disabled: busy
324
+ disabled: busy || (key.removable === false && !isRemoved)
394
325
  })
395
326
  )
396
327
  )
@@ -439,35 +370,27 @@ export function factory(require) {
439
370
  usageError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(usageError)),
440
371
  usage !== null && usage.ok === false && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(usage.error))
441
372
  )
442
- )
443
373
  )
444
374
  }
445
375
 
446
- // The card the host renders. 0.1.6-alpha.2 asks a bundle-config entry for
447
- // `view: 'page'` only; the legacy keyed settings slot passes no view at all.
376
+ // The entry the Plugins page renders. The contract asks for two views:
377
+ // `summary` for the one-liner under the title, and `page` for the form with
378
+ // its own save control. The page supplies the title, icon, and crumb, so the
379
+ // summary is a plain string and the page view is the form alone — matching
380
+ // how the shipped cards (WebSearchCard and friends) are written.
448
381
  function TavilyCard(props) {
449
- if (props && props.view === 'summary') return react.createElement(TavilySummary, null)
382
+ if (props && props.view === 'summary') {
383
+ return 'Manage Tavily API keys and the key-usage strategy. The built-in web_search is never replaced.'
384
+ }
450
385
  return react.createElement(TavilySettingsCard, null)
451
386
  }
452
387
 
453
- // One-liner for the summary view. Deliberately static and cheap: the page
454
- // draws the title, the icon, and the crumb itself, and never asks this entry
455
- // for anything but `page`.
456
- function TavilySummary() {
457
- return react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
458
- 'Rotating Tavily API keys for the advanced search tools. The built-in web_search keeps its native provider and is never replaced.'
459
- )
460
- }
461
-
462
388
  function apply(ctx) {
463
- // 0.1.6-alpha.2 moved plugin configuration off the Settings page and
464
- // replaced the keyed `settings.plugin.item` slot with the Plugins page's
465
- // `plugins.*` family (`dsh-client-ui-plugin-manager` declares it).
466
- // `slots.inject` fires only for a slot the host actually declares, so
467
- // injecting both IS the feature detection: exactly one mounts per harness
468
- // line, and neither throws on the other. Keep both -- dropping the retired
469
- // name silently removes the card from rc.7 through 0.1.6-alpha.1, and
470
- // dropping the new one silently removes it from 0.1.6-alpha.2 onward.
389
+ // ONE slot. 0.1.6-alpha.2 moved plugin configuration to the sidebar
390
+ // Plugins page and retired `settings.plugin.item`. This package no longer
391
+ // registers a settings namespace, so the retired keyed slot has no key it
392
+ // could claim. On rc.7 through 0.1.6-alpha.1 the tools and routes work but
393
+ // this card has no host — see `docs/agents/plugin-design.md`.
471
394
  ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
472
395
  name: 'plugins.bundle.config',
473
396
  // Keyed by the bundle's package name. The Plugins page renders this on
@@ -475,18 +398,6 @@ export function factory(require) {
475
398
  key: '@moguiyu/dsh-tavily',
476
399
  id: '@moguiyu/dsh-tavily'
477
400
  }, TavilyCard))
478
-
479
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
480
- name: 'settings.plugin.item',
481
- // rc.7 through 0.1.6-alpha.1: keyed by the settings namespace the Host
482
- // serves (`tavily-search`); the Plugins configuration tab dispatches it
483
- // when the namespace is present. The id/order/label props keep rc.6
484
- // list-slot deployments rendering the card as well.
485
- key: 'tavily-search',
486
- id: 'tavily-search',
487
- order: 30,
488
- label: 'Tavily Search'
489
- }, TavilyCard))
490
401
  }
491
402
 
492
403
  return { apply, inject }