dsh-provider-show 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MosCloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,126 @@
1
- # Temporary Holding Version
1
+ # dsh-provider-show
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![check](https://github.com/MosCloud/dsh-provider-show/actions/workflows/check.yml/badge.svg)](https://github.com/MosCloud/dsh-provider-show/actions/workflows/check.yml)
4
+
5
+ 在 DSH 输入框右下角的「模型 · 推理强度」前面,补上**当前模型所属的供应商**。
6
+ 已被其他插件接管的供应商(例如 WorkBuddy)保持原样,不重复显示。
7
+
8
+ ```
9
+ 未接管(火山方舟) 火山方舟 DeepSeek V4.1 Flash · Default ← 新增「火山方舟」
10
+ 已接管(WorkBuddy) DeepSeek-V4.1-Flash · x0.11 … ← 本插件不出现
11
+ ```
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ dsh plugin --profile <你的-profile> add dsh-provider-show
17
+ ```
18
+
19
+ 也可以在插件管理页的「添加插件」里填包名或 GitHub 仓库地址。安装后**刷新页面**
20
+ (客户端模块是新增的,必须刷新才会加载)。
21
+
22
+ 从源码开发时改用本地目录,改动即时生效:
23
+
24
+ ```bash
25
+ dsh plugin --profile <你的-profile> add /path/to/dsh-provider-show
26
+ ```
27
+
28
+ 卸载通过插件管理页,或从 profile 的插件清单移除。
29
+
30
+ > npm 12 会留下一个 `0.0.0-stage` 占位版本(339 字节),是其暂存发布机制的产物;
31
+ > `latest` 指向真正的发布版本,安装不受影响。
32
+
33
+ ## 配置
34
+
35
+ 写在 profile 的 `cordis.patch.yml` 的插件条目下。**每项都有默认值,但一处配置会替换整个
36
+ `config` 对象,所以只写需要改的键,其余自动取默认。**
37
+
38
+ ```yaml
39
+ - id: dsh-provider-show
40
+ name: dsh-provider-show
41
+ config:
42
+ enabled: true # 总开关,默认 true
43
+
44
+ shortNames: # 供应商短名,覆盖目录里的显示名
45
+ volcano-api: 火山
46
+
47
+ overrides: # 强制某个供应商的处置
48
+ some-connector-provider: third-party # 让位给接管它的插件
49
+ another-provider: mine # 即使探测说被接管也显示
50
+ noisy-provider: hide # 不显示,但不声称被别人接管
51
+
52
+ coreNamespaces: # 宿主自有的 settings 命名空间,宿主升级可扩展
53
+ - llm-pi-ai
54
+ - llm-deepseek
55
+ - llm-deepseek-account
56
+ ```
57
+
58
+ ## 判定规则
59
+
60
+ 「是否已被第三方插件接管」由 Host 半读取 LLM 目录后判定,优先级自上而下:
61
+
62
+ | 顺序 | 规则 | 结果 |
63
+ |---|---|---|
64
+ | 1 | `overrides[providerId]` 显式配置 | `third-party` 让位 / `mine` 显示 / `hide` 不显示 |
65
+ | 2 | `settingsNs` 不在 `coreNamespaces` 内 | 判定为**已被插件接管**,让位 |
66
+ | 3 | 目录里查不到,或目录不可读 | 判定为**未知**,**照常显示**(fail-open) |
67
+
68
+ `settingsNs` 是「注册该 provider 的 Loader entry id」:
69
+
70
+ - `llm-pi-ai` —— 你在设置里自建的 API 路由(火山、OpenRouter、任何 OpenAI 兼容服务)
71
+ - `llm-deepseek` / `llm-deepseek-account` —— 官方 DeepSeek 两条路由
72
+ - 其他任何值 —— 某个插件用**自己的** entry id 注册的,例如 `dsh-connect-workbuddy`
73
+
74
+ 第 3 条选 fail-open 是因为两种错误代价不对等:漏显只是信息不全,而误判会让**所有**供应商都不显示。
75
+
76
+ **新会话显示什么**:会话还没有任何模型选择时(没切过模型、也没发过消息),`modelSelection`
77
+ 投影是空的 —— 此时显示的是**部署默认模型所属的供应商**。这个默认值由 Host 从
78
+ `agentDefaultModel` 服务读出,与模型座位同源,所以芯片和座位不会各说各话。
79
+ 发出第一条消息或切一次模型之后,改由该会话自己的选择决定。
80
+ 默认通道若被第三方插件接管,仍然让位。
81
+
82
+ ## 架构
83
+
84
+ | 文件 | 职责 |
85
+ |---|---|
86
+ | `lib/ownership.js` | 归属判定与文案选择,纯函数、零 import,Host/Client 共用 |
87
+ | `lib/index.js` | Host 半:读 LLM 目录 → 决策 → 只读路由 `GET /plugins/dsh-provider-show/providers` |
88
+ | `lib/client.js` | Client 半:注册 `conversation.input.right`,读 Host 文档并渲染 |
89
+
90
+ 分层原则:**浏览器看不到的运行时事实(`settingsNs`)在 Host 决策,客户端只消费结论**,
91
+ 规则只有一份实现。设计取舍见 [docs/design.md](docs/design.md)。
92
+
93
+ ## 开发
94
+
95
+ ```bash
96
+ npm test # 单元 + 行为测试(纯 Node,不需要 DSH 运行时)
97
+ npm run check # 语法检查 + 静态校验 + 全部测试
98
+ ```
99
+
100
+ `scripts/verify.mjs` 的每项检查都对应一条「否则只会在运行时才暴露」的规则:manifest 完整性、
101
+ 图标合法性、locale 非占位文案、客户端不 import Harness Client 包、样式只用主题 token 且有宽度上界、
102
+ 路由是只读 GET 且由 `scope.effect` 持有、归属规则只有一份实现、排版度量与宿主一致且禁止位移补偿。
103
+
104
+ 零外部依赖,所以 CI 没有安装步骤——干净 checkout 直接跑同一套检查(Node 22 / 24)。
105
+
106
+ ## 已知限制
107
+
108
+ - 芯片会让模型名更早折叠为图标(`max-width: 140px`,宿主按整行宽度判定折叠)。
109
+ - `conversation.input.activity` 展开整行时,宿主会隐藏整组 `standardControls`,芯片随之隐藏。
110
+ - 供应商显示名默认取目录里的 `displayName`;想更短就配 `shortNames`。
111
+ - 判定依赖第三方插件用自有 `settingsNs` 注册 provider;若某插件复用核心命名空间,
112
+ 请用 `overrides` 显式标注。
113
+ - 只读路由仅接受本页同源请求;它是 `/plugins/` 下的 exact 路由,优先级高于客户端模块的
114
+ `/plugins` 前缀路由,两者不冲突。
115
+
116
+ ## 文档
117
+
118
+ | 文件 | 内容 |
119
+ |---|---|
120
+ | [docs/lessons.md](docs/lessons.md) | 开发经验与踩坑记录——插槽生命周期、验证方法论、发布流程。做同类插件前先读 |
121
+ | [docs/design.md](docs/design.md) | 架构决策(ADR)、边界约束、验收标准、渲染与排版细节、返工留档 |
122
+ | [docs/research.md](docs/research.md) | 技术考据:槽位拓扑、归属判定信号、排版实测数据,均带源码文件与行号 |
123
+
124
+ ## 许可
125
+
126
+ [MIT](LICENSE)
@@ -0,0 +1,24 @@
1
+ # dsh-provider-show — bundle patch.
2
+ #
3
+ # Inserts one plugin row. The row's id is also the settings namespace the Host
4
+ # half's route is namespaced under, and the `provider` namespaces it treats as
5
+ # core by default come from the shipped dsh-base patch, not from this row.
6
+ #
7
+ # Configuration (optional; every key has a default, and this patch replaces the
8
+ # whole `config` object when it names one):
9
+ #
10
+ # - id: dsh-provider-show
11
+ # name: dsh-provider-show
12
+ # config:
13
+ # enabled: true
14
+ # shortNames:
15
+ # volcano-api: 火山
16
+ # overrides:
17
+ # some-connector-provider: third-party
18
+ # coreNamespaces:
19
+ # - llm-pi-ai
20
+ # - llm-deepseek
21
+ # - llm-deepseek-account
22
+ - insert:
23
+ - id: dsh-provider-show
24
+ name: dsh-provider-show
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Provider Label">
2
+ <title>Provider Label</title>
3
+ <!-- A tag/label silhouette: the chip this plugin draws beside the model seat.
4
+ Two tags, the smaller behind, read as "the model, and the provider it
5
+ came from". Rendered in the product's brand blue and a muted second tier
6
+ so it stays legible on both the light and dark plugin panels, which have
7
+ their own surface colours rather than the page's. -->
8
+ <rect x="6" y="18" width="34" height="24" rx="6" fill="#5686fe" opacity="0.35"/>
9
+ <path d="M24 12h22a6 6 0 0 1 6 6v14a6 6 0 0 1-6 6H34l-10-8 10-8z" fill="#5686fe"/>
10
+ <circle cx="46" cy="21" r="3" fill="#ffffff"/>
11
+ <rect x="24" y="30" width="20" height="3" rx="1.5" fill="#ffffff" opacity="0.85"/>
12
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,226 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-provider-show',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const { useState, useEffect } = React
7
+
8
+ const NS = 'dsh-provider-show'
9
+ const SLOT = 'conversation.input.right'
10
+ const ENTRY_ID = 'dsh-provider-show-provider'
11
+
12
+ /**
13
+ * The Host half's read-only document.
14
+ *
15
+ * Every ownership decision is already made there — this half performs a
16
+ * lookup, never a rule. That asymmetry is deliberate: the rule needs the
17
+ * Host's LLM directory (`settingsNs`), which the browser cannot see, and
18
+ * one authoritative copy cannot drift from itself.
19
+ */
20
+ const PROVIDERS_PATH = '/plugins/dsh-provider-show/providers'
21
+
22
+ /** How long one document is reused, in milliseconds. */
23
+ const CACHE_MS = 5000
24
+
25
+ const zh = {
26
+ 'chip.aria': '当前供应商:{provider}',
27
+ 'meta.title': '供应商信息补全',
28
+ 'meta.description': '在输入框右下角补全当前模型所属的供应商;已被其他插件接管的供应商不再重复显示。',
29
+ }
30
+ const en = {
31
+ 'chip.aria': 'Current provider: {provider}',
32
+ 'meta.title': 'Provider Label',
33
+ 'meta.description': 'Shows which provider the selected model belongs to in the composer row, and stays silent for a provider another plugin already presents.',
34
+ }
35
+
36
+ /** The chip's own stylesheet; theme tokens only, so light/dark both work. */
37
+ const CSS = [
38
+ // 13px / 400 / 20px / 28px are the model trigger's own metrics
39
+ // (`.htuFrW_trigger`), reproduced exactly and with no `font-family` of our
40
+ // own so both inherit one stack. That is the whole alignment mechanism:
41
+ // inside a 20px line box the baseline sits at `half-leading + ascent`,
42
+ // which is fixed by font-size and font — NOT by line-height — so equal
43
+ // metrics put both labels on one baseline with no nudging.
44
+ //
45
+ // Nothing else is needed, and a `translateY` for Han labels was measured
46
+ // out of the design: with the size matched, a screenshot showed the Han
47
+ // ink centroid sitting 1.85 CSS px LOW once nudged, and level with the
48
+ // Latin centroid at zero. Han glyphs extending above the Latin cap line is
49
+ // normal mixed-script typesetting, not a misalignment.
50
+ //
51
+ // The host separates tiers by COLOUR, not size: the model name is
52
+ // label-secondary and the effort is label-caption, both 13px. The provider
53
+ // is the third tier, label-tertiary at the same size.
54
+ '.dpsp-root{display:inline-flex;align-items:center;box-sizing:border-box;',
55
+ 'height:28px;min-width:0;max-width:140px;padding:0;',
56
+ // The row spaces its children 12px apart, and the model trigger carries
57
+ // 8px of its own leading padding, so an unpadded chip would sit ~20px from
58
+ // the model text. Pulling the pair together makes them read as one line.
59
+ 'margin-inline-end:-10px;',
60
+ 'font-size:13px;font-weight:400;line-height:20px;',
61
+ 'color:var(--dsw-alias-label-tertiary);white-space:nowrap;user-select:none}',
62
+ '.dpsp-name{min-width:0;overflow:hidden;text-overflow:ellipsis}',
63
+ ].join('')
64
+
65
+ /**
66
+ * Mount the stylesheet once.
67
+ *
68
+ * @returns {() => void} the disposer that removes it on unload.
69
+ */
70
+ function installStyles() {
71
+ if (typeof document === 'undefined') return () => {}
72
+ const tagId = 'dsh-provider-show/chip.css'
73
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) !== null) return () => {}
74
+ const tag = document.createElement('style')
75
+ tag.dataset.plugin = 'dsh-provider-show'
76
+ tag.dataset.pluginCss = tagId
77
+ tag.textContent = CSS
78
+ document.head.appendChild(tag)
79
+ return () => {
80
+ tag.remove()
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Last document read from the Host, shared by every mounted composer so a
86
+ * session switch reuses one answer instead of re-asking.
87
+ */
88
+ const cache = { value: undefined, at: 0, inflight: undefined }
89
+
90
+ /**
91
+ * Read the Host's provider document, at most once per cache window.
92
+ *
93
+ * Any failure returns `undefined`, which the component renders as "draw
94
+ * nothing": a missing label is a smaller fault than a wrong one, and this
95
+ * path only runs when the Host is unreachable anyway.
96
+ *
97
+ * @returns {Promise<{ providers: Array<object>, enabled: boolean } | undefined>} the document.
98
+ */
99
+ async function readDocument() {
100
+ const now = Date.now()
101
+ if (cache.value !== undefined && now - cache.at < CACHE_MS) return cache.value
102
+ if (cache.inflight !== undefined) return cache.inflight
103
+ const operation = (async () => {
104
+ try {
105
+ const response = await fetch(PROVIDERS_PATH, {
106
+ headers: { accept: 'application/json' },
107
+ credentials: 'same-origin',
108
+ })
109
+ if (!response.ok) return undefined
110
+ const body = await response.json()
111
+ if (body?.ok !== true) return undefined
112
+ const value = body.value
113
+ if (value === null || typeof value !== 'object' || !Array.isArray(value.providers)) return undefined
114
+ cache.value = value
115
+ cache.at = Date.now()
116
+ return value
117
+ } catch {
118
+ return undefined
119
+ }
120
+ })()
121
+ cache.inflight = operation
122
+ try {
123
+ return await operation
124
+ } finally {
125
+ if (cache.inflight === operation) cache.inflight = undefined
126
+ }
127
+ }
128
+
129
+ /**
130
+ * One provider's decided row, or undefined when the Host has no opinion.
131
+ *
132
+ * @param {{ providers: Array<object> } | undefined} document - Host document.
133
+ * @param {string} providerId - the selected provider route.
134
+ * @returns {{ label?: string, visible?: boolean } | undefined} the row.
135
+ */
136
+ function rowFor(document, providerId) {
137
+ if (document === undefined || providerId === '') return undefined
138
+ for (const row of document.providers) {
139
+ if (row !== null && typeof row === 'object' && row.provider === providerId) return row
140
+ }
141
+ return undefined
142
+ }
143
+
144
+ /**
145
+ * The provider label beside the model seat.
146
+ *
147
+ * Which provider is in effect is decided by a three-leg chain, strongest
148
+ * first: a pending selection, the last logged request header, then the
149
+ * deployment default. The `modelSelection` projection carries the first two
150
+ * (`next` is `pending ?? lastUsed`); the third exists only on the Host,
151
+ * because it is read from the `agentDefaultModel` service — the same one the
152
+ * model seat falls back to. That is why the document ships a `default`:
153
+ * without it a brand-new Session, which has no selection and no request yet,
154
+ * would show no provider at all while the seat already shows its model.
155
+ *
156
+ * Reading the seat's own authority is what keeps the two in agreement; this
157
+ * half still only looks a row up, and still draws nothing while the document
158
+ * is in flight, so a label that is about to be suppressed cannot flash.
159
+ *
160
+ * @param {object} props - slot props; `useProjection` and `t` are supplied by the slot.
161
+ * @returns {import('react').ReactElement | null} the label, or null.
162
+ */
163
+ function ProviderLabel(props) {
164
+ const { useProjection, sessionId, t } = props
165
+ const selection = useProjection('modelSelection')
166
+ const [document, setDocument] = useState(undefined)
167
+
168
+ // Fetch unconditionally, including when nothing is selected: on a fresh
169
+ // Session the document's `default` is the ONLY source for this label.
170
+ useEffect(() => {
171
+ let live = true
172
+ readDocument().then((next) => {
173
+ if (live) setDocument(next)
174
+ })
175
+ return () => {
176
+ live = false
177
+ }
178
+ }, [sessionId])
179
+
180
+ const providerId = selection?.next?.provider ?? document?.default?.provider ?? ''
181
+ if (providerId === '') return null
182
+ const row = rowFor(document, providerId)
183
+ // `visible` is decided by the Host: it carries the plugin switch, the
184
+ // user's overrides, and the third-party check in one boolean.
185
+ if (row === undefined || row.visible !== true) return null
186
+ const label = typeof row.label === 'string' ? row.label.trim() : ''
187
+ if (label === '') return null
188
+
189
+ return h(
190
+ 'span',
191
+ {
192
+ className: 'dpsp-root',
193
+ title: t('chip.aria', { provider: label }),
194
+ 'aria-label': t('chip.aria', { provider: label }),
195
+ },
196
+ h('span', { className: 'dpsp-name' }, label),
197
+ )
198
+ }
199
+
200
+ return {
201
+ // Only what the component actually needs, spelled the way the cordis
202
+ // Loader expects (a plain array of REQUIRED service keys — the Loader has
203
+ // no "optional" inject form, and a key listed here parks the plugin until
204
+ // that service exists). `remote` is deliberately absent: this half reads
205
+ // its own Host route, which works in every composition and keeps the
206
+ // ownership rule on the side that can evaluate it. The deployment default
207
+ // arrives inside that document for the same reason: the browser cannot see
208
+ // the Host's `agentDefaultModel`.
209
+ inject: ['slots', 'locale'],
210
+ apply(ctx) {
211
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-provider-show: dictionaries')
212
+ ctx.effect(installStyles, 'dsh-provider-show: stylesheet')
213
+ ctx.slots.inject(SLOT, () => ctx.slots.register({
214
+ name: SLOT,
215
+ id: ENTRY_ID,
216
+ // `input.right` is a list slot rendered immediately before the model
217
+ // seat. Order only matters if another plugin also occupies this slot;
218
+ // a connector that draws its own supplier chip suppresses ours for its
219
+ // providers, so in practice the two never render together.
220
+ order: 6,
221
+ locale: NS,
222
+ }, ProviderLabel))
223
+ },
224
+ }
225
+ },
226
+ })
package/lib/index.js ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Host half of dsh-provider-show.
3
+ *
4
+ * The browser can see the model catalog, but it cannot see WHO registered a
5
+ * provider route: the catalog group carries only `{ id, name, models }`, and the
6
+ * registry strips adapter metadata before it reaches the page. That answer
7
+ * lives in the Host's LLM directory, so this half reads it and publishes one
8
+ * small, already-decided document:
9
+ *
10
+ * GET /plugins/dsh-provider-show/providers
11
+ * { ok: true, value: {
12
+ * providers: [{ provider, displayName, label, owner, reason }],
13
+ * default: { provider, model } | null,
14
+ * } }
15
+ *
16
+ * Each row is decided HERE by the shared pure resolver, so the ownership rule
17
+ * has exactly one implementation and the client half stays a lookup. No Client
18
+ * package, no `remote.llm` dependency, and no second copy of the rule that could
19
+ * drift from this one.
20
+ *
21
+ * `default` is the deployment's own default selection, read from the same
22
+ * `agentDefaultModel` service the composer's model seat falls back to. It is
23
+ * published because a brand-new Session has no model selection yet — the
24
+ * `modelSelection` projection stays `{ lastUsed: null, next: null }` until the
25
+ * first pick or the first request — and the browser has no other way to learn
26
+ * which provider is in effect before then. Handing over the VALUE instead of the
27
+ * service keeps the rule on this side: the client still only looks a row up.
28
+ *
29
+ * A Host-only bundle would need the route's consumer to exist; the Client half
30
+ * is what consumes it, so the two ship together in one bundle.
31
+ *
32
+ * @module dsh-provider-show
33
+ */
34
+
35
+ import {
36
+ DEFAULT_CORE_NAMESPACES,
37
+ providerLabel,
38
+ resolveOwnership,
39
+ shouldRender,
40
+ } from './ownership.js'
41
+
42
+ /** Route this plugin owns, namespaced under `/plugins/` like other bundles. */
43
+ const PROVIDERS_PATH = '/plugins/dsh-provider-show/providers'
44
+
45
+ /** How long one directory snapshot is reused, in milliseconds. */
46
+ const CACHE_MS = 5000
47
+
48
+ /**
49
+ * The plugin's configuration shape, described once as plain data.
50
+ *
51
+ * Deliberately NOT a Schemastery schema. A bundle installed by linking this
52
+ * directory resolves its imports through the profile's `node_modules`, and the
53
+ * shipped Harness packages are NOT there for a bundle that declares no
54
+ * dependency on them — so importing a schema library from the Harness tree
55
+ * would make the whole Host half fail to load. The Loader treats a missing
56
+ * `Config` export as "no schema" and passes the row's config through
57
+ * (`resolveConfig` returns early when `runtime.Config` is absent), so the
58
+ * defaults are applied here instead, in {@link effectiveConfig}, where they cost
59
+ * no import.
60
+ *
61
+ * The shapes below are documented for the patch author and validated at use.
62
+ */
63
+ export const CONFIG_KEYS = Object.freeze({
64
+ enabled: 'boolean — draw the label (default true)',
65
+ shortNames: 'Record<providerId, string> — a shorter label than the catalog name',
66
+ overrides: 'Record<providerId, "mine" | "third-party" | "hide"> — force one decision',
67
+ coreNamespaces: 'string[] — settings namespaces the Harness itself owns',
68
+ })
69
+
70
+ /** The ownership decision values `overrides` accepts. */
71
+ const OVERRIDE_VALUES = Object.freeze(['mine', 'third-party', 'hide'])
72
+
73
+ /**
74
+ * Coerce one unknown value to a string record.
75
+ *
76
+ * Configuration arrives from YAML, so a value can be a number, a list, or
77
+ * absent. Anything unusable is dropped rather than throwing inside a request.
78
+ *
79
+ * @param {unknown} value - raw configured value.
80
+ * @returns {Record<string, string>} only the string-valued entries.
81
+ */
82
+ function stringRecord(value) {
83
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
84
+ const out = {}
85
+ for (const [key, entry] of Object.entries(value)) {
86
+ if (typeof key !== 'string' || key === '') continue
87
+ if (typeof entry !== 'string') continue
88
+ out[key] = entry
89
+ }
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * Resolve the row's configuration into the struct the rest of this file uses.
95
+ *
96
+ * Every field falls back to a defined behaviour, because `cordis.patch.yml`
97
+ * replaces the WHOLE `config` object when it names one key: a user overriding
98
+ * `shortNames` must not thereby disable the default core namespace list.
99
+ *
100
+ * @param {unknown} raw - the row's config as the Loader supplied it.
101
+ * @returns {{ enabled: boolean, shortNames: Record<string,string>, overrides: Record<string,string>, coreNamespaces: string[] }}
102
+ * effective configuration.
103
+ */
104
+ export function effectiveConfig(raw) {
105
+ const base = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
106
+ const shortNames = stringRecord(base.shortNames)
107
+ const overrides = {}
108
+ for (const [key, value] of Object.entries(stringRecord(base.overrides))) {
109
+ // An unrecognised override would silently do nothing; dropping it keeps the
110
+ // resolver's single authority intact and the behaviour predictable.
111
+ if (OVERRIDE_VALUES.includes(value)) overrides[key] = value
112
+ }
113
+ const coreNamespaces = Array.isArray(base.coreNamespaces)
114
+ ? base.coreNamespaces.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim())
115
+ : []
116
+ return {
117
+ enabled: base.enabled !== false,
118
+ shortNames,
119
+ overrides,
120
+ coreNamespaces: coreNamespaces.length > 0 ? coreNamespaces : [...DEFAULT_CORE_NAMESPACES],
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Read the live LLM registry into the raw rows the resolver consumes.
126
+ *
127
+ * Both calls are optional in a composition. A profile without an LLM service
128
+ * simply produces no rows, which the resolver reports as `unknown` — ours.
129
+ *
130
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
131
+ * @returns {{ rows: unknown[], providers: unknown[] }} directory rows and registered routes.
132
+ */
133
+ function readDirectory(ctx) {
134
+ const llm = ctx.get('llm')
135
+ if (llm === undefined || llm === null) return { rows: [], providers: [] }
136
+ const rows = read(() => llm.listConfigurableProviders?.())
137
+ const providers = read(() => llm.listProviders?.())
138
+ return {
139
+ rows: Array.isArray(rows) ? rows : [],
140
+ providers: Array.isArray(providers) ? providers : [],
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Call one optional registry method, treating any failure as "not available".
146
+ *
147
+ * @param {() => unknown} read - the call.
148
+ * @returns {unknown} its result, or undefined.
149
+ */
150
+ function read(call) {
151
+ try {
152
+ return call()
153
+ } catch {
154
+ return undefined
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Read the deployment's default model selection.
160
+ *
161
+ * This is the third and last leg of the chain that decides which provider is in
162
+ * effect — `pending selection → last logged request header → deployment default`
163
+ * — and the only leg the browser cannot see. The model seat resolves it through
164
+ * the same service (`buildModelCatalog` defaults to
165
+ * `ctx.agentDefaultModel.currentSelection()`), so publishing this value is what
166
+ * makes the chip agree with the seat instead of guessing at it.
167
+ *
168
+ * The service is optional: a composition without `agent-default-model` has no
169
+ * default to publish, and a failing read is not a failing route. Both degrade to
170
+ * `null`, which the client renders as "draw nothing" — the failure mode this
171
+ * plugin has always chosen over showing a wrong label.
172
+ *
173
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
174
+ * @returns {{ provider: string, model: string } | null} the default selection.
175
+ */
176
+ function readDefaultSelection(ctx) {
177
+ try {
178
+ const service = ctx.get('agentDefaultModel')
179
+ const picked = service?.currentSelection?.()
180
+ if (picked === null || typeof picked !== 'object') return null
181
+ const { provider, model } = picked
182
+ if (typeof provider !== 'string' || provider === '') return null
183
+ if (typeof model !== 'string' || model === '') return null
184
+ return { provider, model }
185
+ } catch {
186
+ return null
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Build the document the client half reads.
192
+ *
193
+ * Registered routes decide WHICH providers exist; the configurable directory
194
+ * decides WHO registers each one. The two can legitimately differ — a route
195
+ * may be registered without a directory entry and vice versa — so they are
196
+ * unioned, and a route with no directory row resolves to `unknown`, which
197
+ * renders our label.
198
+ *
199
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
200
+ * @param {ReturnType<typeof effectiveConfig>} config - effective configuration.
201
+ * @returns {{ providers: Array<{provider: string, displayName: string, label: string, owner: string, reason: string}>, coreNamespaces: string[], default: { provider: string, model: string } | null }}
202
+ * one decided row per provider route, plus the deployment default.
203
+ */
204
+ export function describeProviders(ctx, config) {
205
+ const { rows, providers } = readDirectory(ctx)
206
+ const coreNamespaces = config.coreNamespaces
207
+
208
+ const ids = []
209
+ const seen = new Set()
210
+ const claim = (id) => {
211
+ if (typeof id !== 'string' || id === '' || seen.has(id)) return
212
+ seen.add(id)
213
+ ids.push(id)
214
+ }
215
+ for (const provider of providers) claim(provider?.id)
216
+ for (const row of rows) claim(row?.provider)
217
+
218
+ const out = []
219
+ for (const providerId of ids) {
220
+ const decision = resolveOwnership({
221
+ providerId,
222
+ directoryRows: rows,
223
+ coreNamespaces,
224
+ overrides: config.overrides,
225
+ })
226
+ const label = providerLabel({
227
+ providerId,
228
+ displayName: decision.displayName,
229
+ shortNames: config.shortNames,
230
+ })
231
+ out.push({
232
+ provider: providerId,
233
+ displayName: decision.displayName ?? providerId,
234
+ label,
235
+ // The client draws the label only when `visible`. Both decisions — who
236
+ // owns the provider, and whether to draw anything — are made here so the
237
+ // page never has to re-derive them.
238
+ visible: label !== '' && shouldRender(decision, config),
239
+ owner: decision.owner,
240
+ reason: decision.reason,
241
+ })
242
+ }
243
+ return { providers: out, coreNamespaces, default: readDefaultSelection(ctx) }
244
+ }
245
+
246
+ /**
247
+ * Send one JSON reply that no cache should reuse.
248
+ *
249
+ * @param {import('node:http').ServerResponse} res - response to write.
250
+ * @param {number} status - HTTP status code.
251
+ * @param {unknown} body - JSON-serialisable payload.
252
+ * @returns {void}
253
+ */
254
+ function json(res, status, body) {
255
+ res.writeHead(status, {
256
+ 'content-type': 'application/json; charset=utf-8',
257
+ 'cache-control': 'no-store',
258
+ })
259
+ res.end(JSON.stringify(body))
260
+ }
261
+
262
+ /**
263
+ * Accept only requests issued by the page this Host itself serves.
264
+ *
265
+ * The document exposes provider topology, so the route is held to the loopback
266
+ * rule other plugin routes use: the Host header must name this listener, and a
267
+ * present Origin must be loopback too. A same-origin `fetch` from the served
268
+ * page satisfies both.
269
+ *
270
+ * @param {import('node:http').IncomingMessage} req - incoming request.
271
+ * @returns {boolean} whether the request may be served.
272
+ */
273
+ function fromThisPage(req) {
274
+ const local = req.socket?.localAddress
275
+ const host = req.headers.host
276
+ if (typeof host === 'string' && local !== undefined) {
277
+ const address = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
278
+ if (!(address === local || address === 'localhost' || address === '127.0.0.1' || address === '::1')) {
279
+ return false
280
+ }
281
+ }
282
+ const origin = req.headers.origin
283
+ if (typeof origin === 'string' && origin !== '') {
284
+ try {
285
+ const { hostname } = new URL(origin)
286
+ if (!(hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === local)) {
287
+ return false
288
+ }
289
+ } catch {
290
+ return false
291
+ }
292
+ }
293
+ return true
294
+ }
295
+
296
+ /**
297
+ * Host entry point: register the read-only provider directory route.
298
+ *
299
+ * The route is registered inside `ctx.effect`, so unloading the plugin removes
300
+ * it and drops the snapshot with it.
301
+ *
302
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
303
+ * @param {unknown} [rawConfig] - the row's configuration.
304
+ * @returns {void}
305
+ */
306
+ export function apply(ctx, rawConfig) {
307
+ const config = effectiveConfig(rawConfig)
308
+ let snapshot
309
+ let snapshotAt = 0
310
+
311
+ // `ctx.inject` — not `ctx.get` — because the web server is an ordinary service
312
+ // in the composition: at plugin activation it may not exist yet, and a
313
+ // one-shot `get` would silently leave the route unregistered forever.
314
+ //
315
+ // `scope.effect` — not a returned value — because a plugin's return value is
316
+ // DISCARDED by the Loader; only an effect disposer is run on unload. Returning
317
+ // the route disposer would register the route and then leak it forever.
318
+ ctx.inject(['webServer'], (scope) => {
319
+ const webServer = scope.webServer
320
+ if (webServer === undefined || typeof webServer.register !== 'function') return
321
+ scope.effect(() => webServer.register({
322
+ kind: 'exact',
323
+ path: PROVIDERS_PATH,
324
+ handler: async (req, res) => {
325
+ if (req.method !== 'GET') {
326
+ json(res, 405, { ok: false, error: 'method-not-allowed' })
327
+ return
328
+ }
329
+ if (!fromThisPage(req)) {
330
+ json(res, 403, { ok: false, error: 'not-this-page' })
331
+ return
332
+ }
333
+ const now = Date.now()
334
+ if (snapshot === undefined || now - snapshotAt >= CACHE_MS) {
335
+ try {
336
+ snapshot = describeProviders(ctx, config)
337
+ snapshotAt = now
338
+ } catch (error) {
339
+ // A failed read is not a failed route: the client treats an absent
340
+ // answer as "draw nothing", and the reason is reported rather than
341
+ // thrown into the request lifecycle.
342
+ json(res, 200, {
343
+ ok: false,
344
+ error: 'directory-unavailable',
345
+ message: error instanceof Error ? error.message : String(error),
346
+ })
347
+ return
348
+ }
349
+ }
350
+ json(res, 200, {
351
+ ok: true,
352
+ value: { ...snapshot, enabled: config.enabled },
353
+ })
354
+ },
355
+ }), 'dsh-provider-show: provider directory route')
356
+ })
357
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Ownership resolution for the composer's provider chip.
3
+ *
4
+ * The composer's model seat already renders "model name · reasoning effort"
5
+ * itself. What it cannot show is WHICH PROVIDER the selection belongs to,
6
+ * because the catalog name is the only provider-derived text it draws. A
7
+ * third-party plugin may already own a provider's whole presentation (the
8
+ * WorkBuddy connector draws its own supplier chip, credit readout, promotion
9
+ * badge and effort marker in the same row), and duplicating that is worse than
10
+ * staying silent.
11
+ *
12
+ * This module answers exactly one question, as a pure function of directory
13
+ * rows and configuration:
14
+ *
15
+ * Is this provider's presentation already owned elsewhere?
16
+ *
17
+ * It is deliberately free of DSH imports so it can be unit-tested with plain
18
+ * Node: the Host and Client halves both consume this same function.
19
+ *
20
+ * @module dsh-provider-show/ownership
21
+ */
22
+
23
+ /**
24
+ * Provider routes the Harness itself registers, keyed by the settings namespace
25
+ * that configures each one.
26
+ *
27
+ * These come from the shipped `dsh-base` bundle patch, where a Loader row's id
28
+ * is also the settings namespace its provider directory entry names:
29
+ * `llm-pi-ai` (user-configured API-key routes), and the two official DeepSeek
30
+ * routes. A provider whose `settingsNs` is one of these is core; anything else
31
+ * means some plugin registered the route under its own namespace.
32
+ *
33
+ * Kept as data rather than a switch so a deployment can extend it through the
34
+ * plugin's Config when a future Harness release adds another built-in route.
35
+ */
36
+ export const DEFAULT_CORE_NAMESPACES = Object.freeze([
37
+ 'llm-pi-ai',
38
+ 'llm-deepseek',
39
+ 'llm-deepseek-account',
40
+ ])
41
+
42
+ /** The three answers {@link resolveOwnership} can give. */
43
+ export const OWNERSHIP = Object.freeze({
44
+ /** Core Harness route: no third-party plugin presents it, so show our chip. */
45
+ CORE: 'core',
46
+ /** Another plugin registered this route and presents it; we stay silent. */
47
+ THIRD_PARTY: 'third-party',
48
+ /** No directory row for this route: treat it as ours (fail-open). */
49
+ UNKNOWN: 'unknown',
50
+ })
51
+
52
+ /**
53
+ * Normalise a configuration list of namespaces.
54
+ *
55
+ * Configuration arrives from YAML, so a missing field, `null`, a scalar, or a
56
+ * list with non-string members must all degrade to the default rather than
57
+ * throwing inside a render path. Duplicates and empty strings are dropped
58
+ * because a namespace is compared by equality.
59
+ *
60
+ * @param {unknown} value - raw configured value.
61
+ * @param {readonly string[]} fallback - list used when nothing usable was given.
62
+ * @returns {string[]} a usable namespace list.
63
+ */
64
+ export function normalizeNamespaces(value, fallback = DEFAULT_CORE_NAMESPACES) {
65
+ if (!Array.isArray(value)) return [...fallback]
66
+ const seen = new Set()
67
+ const out = []
68
+ for (const entry of value) {
69
+ if (typeof entry !== 'string') continue
70
+ const name = entry.trim()
71
+ if (name === '' || seen.has(name)) continue
72
+ seen.add(name)
73
+ out.push(name)
74
+ }
75
+ return out.length === 0 ? [...fallback] : out
76
+ }
77
+
78
+ /**
79
+ * Read one provider's directory row out of a `listConfigurableProviders()` reply.
80
+ *
81
+ * The remote reply is untrusted JSON (a Host generation may have changed shape),
82
+ * so every field is checked before use.
83
+ *
84
+ * @param {unknown} rows - raw reply.
85
+ * @param {string} providerId - provider route to find.
86
+ * @returns {{ provider: string, displayName?: string, settingsNs?: string, settingsPath?: readonly string[] } | undefined}
87
+ * the matching row, or undefined when the reply is unusable.
88
+ */
89
+ export function findProviderRow(rows, providerId) {
90
+ if (!Array.isArray(rows) || typeof providerId !== 'string' || providerId === '') return undefined
91
+ for (const row of rows) {
92
+ if (row === null || typeof row !== 'object') continue
93
+ if (row.provider !== providerId) continue
94
+ return {
95
+ provider: providerId,
96
+ ...(typeof row.displayName === 'string' && row.displayName !== ''
97
+ ? { displayName: row.displayName }
98
+ : {}),
99
+ ...(typeof row.settingsNs === 'string' && row.settingsNs !== ''
100
+ ? { settingsNs: row.settingsNs }
101
+ : {}),
102
+ ...(Array.isArray(row.settingsPath) ? { settingsPath: row.settingsPath } : {}),
103
+ }
104
+ }
105
+ return undefined
106
+ }
107
+
108
+ /**
109
+ * Decide who presents one provider route.
110
+ *
111
+ * Order of authority, strongest first:
112
+ *
113
+ * 1. The user's explicit override. A person who has looked at the composer and
114
+ * said "this one is somebody else's" outranks every inference below, because
115
+ * it is the only signal that cannot be wrong about intent.
116
+ * 2. The directory row's `settingsNs`. A namespace outside the core list means
117
+ * a plugin registered the route under its own Loader entry id.
118
+ * 3. Fail-open. A route with no row — or a directory that could not be read —
119
+ * is treated as ours. The asymmetry is deliberate: showing a provider that
120
+ * was in fact owned elsewhere costs a duplicate label, while hiding one that
121
+ * was not owned costs the entire feature, silently, for every provider.
122
+ *
123
+ * @param {object} input - resolution input.
124
+ * @param {string} input.providerId - selected provider route.
125
+ * @param {unknown} [input.directoryRows] - `listConfigurableProviders()` reply.
126
+ * @param {readonly string[]} [input.coreNamespaces] - namespaces the Harness owns.
127
+ * @param {Record<string, string>} [input.overrides] - provider id → decision.
128
+ * @returns {{ owner: string, reason: string, settingsNs?: string, displayName?: string }}
129
+ * the decision plus the evidence that produced it.
130
+ */
131
+ export function resolveOwnership(input) {
132
+ const providerId = input?.providerId
133
+ if (typeof providerId !== 'string' || providerId === '') {
134
+ return { owner: OWNERSHIP.UNKNOWN, reason: 'no-provider' }
135
+ }
136
+
137
+ const overrides = input.overrides
138
+ const override = overrides !== null && typeof overrides === 'object' && !Array.isArray(overrides)
139
+ ? overrides[providerId]
140
+ : undefined
141
+ if (override === 'third-party') return { owner: OWNERSHIP.THIRD_PARTY, reason: 'override' }
142
+ if (override === 'mine') return { owner: OWNERSHIP.CORE, reason: 'override' }
143
+ // `hide` is not an ownership claim: it says "draw nothing" without asserting
144
+ // that somebody else owns the provider. It rides along on the decision so
145
+ // there is still exactly ONE reader of the overrides table.
146
+ if (override === 'hide') return { owner: OWNERSHIP.CORE, reason: 'override-hidden', hidden: true }
147
+
148
+ const row = findProviderRow(input.directoryRows, providerId)
149
+ if (row === undefined) return { owner: OWNERSHIP.UNKNOWN, reason: 'no-directory-row' }
150
+
151
+ const core = normalizeNamespaces(input.coreNamespaces)
152
+ const settingsNs = row.settingsNs
153
+ const carries = {
154
+ ...(settingsNs === undefined ? {} : { settingsNs }),
155
+ ...(row.displayName === undefined ? {} : { displayName: row.displayName }),
156
+ }
157
+ if (settingsNs === undefined) {
158
+ return { owner: OWNERSHIP.UNKNOWN, reason: 'no-settings-namespace', ...carries }
159
+ }
160
+ if (core.includes(settingsNs)) {
161
+ return { owner: OWNERSHIP.CORE, reason: 'core-namespace', ...carries }
162
+ }
163
+ return { owner: OWNERSHIP.THIRD_PARTY, reason: 'plugin-namespace', ...carries }
164
+ }
165
+
166
+ /**
167
+ * Whether our chip should render at all for one selection.
168
+ *
169
+ * Three things suppress it, and only three: the plugin switch, an explicit
170
+ * `hide` override, and a confirmed third-party owner. `UNKNOWN` renders, for the
171
+ * reason given on {@link resolveOwnership}: a duplicate label is a cosmetic
172
+ * fault, a missing feature is the whole request.
173
+ *
174
+ * This function deliberately does NOT re-read `overrides`. Every decision that
175
+ * needs that table is made once, inside {@link resolveOwnership}, and arrives
176
+ * here on the decision object — two readers of one table could disagree after an
177
+ * edit, and the disagreement would be invisible until a user saw it.
178
+ *
179
+ * @param {{ owner: string, hidden?: boolean }} decision - result of {@link resolveOwnership}.
180
+ * @param {{ enabled?: boolean }} [config] - plugin configuration.
181
+ * @returns {boolean} whether to draw the chip.
182
+ */
183
+ export function shouldRender(decision, config = {}) {
184
+ if (config?.enabled === false) return false
185
+ if (decision?.hidden === true) return false
186
+ return decision?.owner !== OWNERSHIP.THIRD_PARTY
187
+ }
188
+
189
+ /**
190
+ * The text our chip shows for one provider.
191
+ *
192
+ * Model name and reasoning effort are already drawn by the composer's own model
193
+ * seat, so repeating them here would print each fact twice in one row. The chip
194
+ * therefore carries the one missing fact: the provider.
195
+ *
196
+ * @param {object} input - label input.
197
+ * @param {string} input.providerId - selected provider route.
198
+ * @param {string} [input.displayName] - provider name from the directory row.
199
+ * @param {Record<string, string>} [input.shortNames] - configured short names.
200
+ * @returns {string} the label, or an empty string when nothing usable exists.
201
+ */
202
+ export function providerLabel(input) {
203
+ const providerId = input?.providerId
204
+ const shortNames = input?.shortNames
205
+ if (shortNames !== null && typeof shortNames === 'object' && !Array.isArray(shortNames)) {
206
+ const short = typeof providerId === 'string' ? shortNames[providerId] : undefined
207
+ if (typeof short === 'string' && short.trim() !== '') return short.trim()
208
+ }
209
+ const displayName = input?.displayName
210
+ if (typeof displayName === 'string' && displayName.trim() !== '') return displayName.trim()
211
+ // A provider id is the last resort: it is always present and always accurate,
212
+ // and showing `volcano-cn` beats showing an empty chip. Callers that would
213
+ // rather show nothing pass fallbackToId: false.
214
+ if (input?.fallbackToId === false) return ''
215
+ return typeof providerId === 'string' ? providerId : ''
216
+ }
package/locale/en.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "meta": {
3
+ "title": "Provider Label",
4
+ "description": "Shows which provider the selected model belongs to in the composer row, and stays silent for a provider another plugin already presents."
5
+ },
6
+ "chip": {
7
+ "aria": "Current provider: {provider}"
8
+ }
9
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "meta": {
3
+ "title": "供应商信息补全",
4
+ "description": "在输入框右下角补全当前模型所属的供应商;已被其他插件接管的供应商不再重复显示。"
5
+ },
6
+ "chip": {
7
+ "aria": "当前供应商:{provider}"
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "dsh-provider-show",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Shows which provider the selected model belongs to in the composer row, and stays silent for providers another plugin already presents.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./ownership": "./lib/ownership.js",
13
+ "./icon": "./icon.svg",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "icon": "./icon.svg",
17
+ "files": [
18
+ "lib",
19
+ "locale",
20
+ "icon.svg",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-ui-conversation",
33
+ "@deepseek-ai/dsh-client-locale"
34
+ ]
35
+ }
36
+ },
37
+ "scripts": {
38
+ "test": "node --test",
39
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/ownership.js && node scripts/verify.mjs && node --test"
40
+ },
41
+ "keywords": [
42
+ "dsh-plugin",
43
+ "deepseek-harness",
44
+ "provider",
45
+ "composer"
46
+ ],
47
+ "license": "MIT",
48
+ "engines": {
49
+ "node": "^22.19.0 || >=24.0.0"
50
+ },
51
+ "author": {
52
+ "name": "MosCloud"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/MosCloud/dsh-provider-show.git"
57
+ },
58
+ "homepage": "https://github.com/MosCloud/dsh-provider-show#readme",
59
+ "bugs": {
60
+ "url": "https://github.com/MosCloud/dsh-provider-show/issues"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public",
64
+ "registry": "https://registry.npmjs.org/"
65
+ }
66
+ }