dsh-molbio-tools 0.22.0 → 0.22.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/CHANGELOG.md +73 -0
- package/README.md +21 -9
- package/build/client-entry.mjs +159 -0
- package/build/panel-core.mjs +38 -0
- package/docs/client-panel.md +21 -5
- package/docs/history.md +20 -0
- package/docs/maintainer.md +1 -1
- package/docs/roadmap.md +5 -1
- package/index.mjs +39 -8
- package/lib/client.js +198 -1
- package/package.json +1 -1
- package/test/client.mjs +62 -3
- package/test/contract.mjs +22 -2
- package/test/map-card.mjs +4 -2
- package/test/panel-render.mjs +77 -1
- package/test/smoke.mjs +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,79 @@
|
|
|
10
10
|
改为按**包名**引用后模块缓存问题随之消失——没有拷贝,也就没有需要保持同步的版本目录。
|
|
11
11
|
下文历史条目里出现的 `vN` 目录保持原样,作为当时的记录。
|
|
12
12
|
|
|
13
|
+
## [0.22.1] — 2026-10-25(PubMed key 改走 DSH 凭据库;环境变量降为后备)
|
|
14
|
+
|
|
15
|
+
**工具数不变(60);没有凭据库的组合,行为与 0.22.0 相同。** 0.22.0 只从 `api_key` 参数与
|
|
16
|
+
`process.env` 读 key;这一版把 DSH 的凭据 seam(`ctx.credentials`)插到两者之间,并把环境变量
|
|
17
|
+
降为最后一级。
|
|
18
|
+
|
|
19
|
+
### 1. 环境变量为什么不够好
|
|
20
|
+
|
|
21
|
+
环境变量会被**每个子进程继承**——包括 agent 自己的 shell 工具:一句 `echo $env:NCBI_API_KEY`
|
|
22
|
+
就能把 key 读进对话;它还会出现在进程环境块、崩溃转储与用户的注册表环境里(Windows 的
|
|
23
|
+
`HKCU\Environment` 是明文)。对任何云端凭证这都是不必要的暴露面;NCBI 的 key 虽然只用于
|
|
24
|
+
归属与限速,泄漏仍意味着别人可以用你的配额、顶着你的身份发请求。
|
|
25
|
+
|
|
26
|
+
DSH 自带的凭据库(`@deepseek-ai/dsh-credentials-local`)就是为这件事存在的:值存在
|
|
27
|
+
`~/.dsh/.credentials.yaml` 里,**不注入环境**、文件仅属主可读、改动热重载,
|
|
28
|
+
`describe()` 只报告是否已设置与来源、从不回显值,而且**存储值优先于启动环境**。
|
|
29
|
+
|
|
30
|
+
### 2. 新的解析顺序
|
|
31
|
+
|
|
32
|
+
| 优先级 | 来源 | 说明 |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| 1 | `api_key` 参数 | 单次调用显式指定;会出现在对话记录与工具卡片里 |
|
|
35
|
+
| 2 | 凭据库 `NCBI_API_KEY` | 推荐;不进入环境,agent 的 shell 子进程读不到 |
|
|
36
|
+
| 3 | 凭据库 `MOLBIO_NCBI_API_KEY` | 同上,插件专用命名 |
|
|
37
|
+
| 4 | 进程环境 `NCBI_API_KEY` / `MOLBIO_NCBI_API_KEY` | 兼容 0.22.0 的用法;暴露面最大 |
|
|
38
|
+
|
|
39
|
+
实现上调用 `ctx.credentials.resolve(ref)`(存在性检查后才调用),因此凭据库自身的回退层
|
|
40
|
+
(启动环境、项目 `.env`、harness home 的 `.env`)也一并继承;provider 不存在或拒绝该引用时
|
|
41
|
+
**静默回退到 `process.env`**,没有凭据库的组合行为不变。
|
|
42
|
+
|
|
43
|
+
### 3. key 放进凭据库的三种方式
|
|
44
|
+
|
|
45
|
+
1. **插件详情页(推荐)**:插件 → 已安装插件 → `dsh-molbio-tools` → **PubMed API key** 一节,
|
|
46
|
+
粘贴后点 **Save**;见 §4。
|
|
47
|
+
2. 编辑 `~/.dsh/.credentials.yaml`(产品创建、仅属主可读、热重载):在 `refs:` 下加一行
|
|
48
|
+
`NCBI_API_KEY: 你的key`;下一次工具调用即生效,无需重启。
|
|
49
|
+
3. 在 DSH 的设置界面保存同名凭据(该界面只显示“是否已设置/来源”,不显示值)。
|
|
50
|
+
|
|
51
|
+
> **这不是对 agent 的硬隔离。** 凭据库自己的文档写得很清楚:agent 的工具进程以同一个 OS 用户
|
|
52
|
+
> 运行,刻意去读那个文件仍然读得到。它解决的是**自动泄漏**:环境变量会被每个 shell 子进程
|
|
53
|
+
> 无条件继承,而凭据库不会。真正要把密钥挡在 agent 之外,需要换提供方(OS 钥匙串/KMS),
|
|
54
|
+
> 当前产品没有随附这类 provider。
|
|
55
|
+
|
|
56
|
+
### 4. 图形入口:插件详情页里的 PubMed API key
|
|
57
|
+
|
|
58
|
+
「改 YAML」对没有计算机基础的用户不是可接受的入口。浏览器半因此注册了一个
|
|
59
|
+
`plugins.detail.section` 条目(`id: molbio-ncbi-key`),只在**本包的 bundle 页**与它声明的**行页**
|
|
60
|
+
上渲染,其它插件返回 `null`:
|
|
61
|
+
|
|
62
|
+
- 输入框是 `type=password`,只写不读;状态行来自 `ctx.remote.credentials.describe(['NCBI_API_KEY'])`,
|
|
63
|
+
只显示「已配置 / 未配置 / 来源」,**不回显值**。
|
|
64
|
+
- **Save** 走 `ctx.remote.credentials.set('NCBI_API_KEY', value)`,**Remove** 走 `unset`;
|
|
65
|
+
保存后输入框立即清空,成功/拒绝消息原样显示(`credential/rejected` 的 provider 原文)。
|
|
66
|
+
- 它和 CLI/工具读的是**同一个**凭据引用,所以两边不需要同步;没有凭据 provider 的部署会显示
|
|
67
|
+
Unavailable,而工具仍按 §2 的顺序回退到环境变量。
|
|
68
|
+
|
|
69
|
+
> **测试中发现并修掉的一处真实缺陷(同一版内)**:第一版把 `ctx.remote.credentials` 当成普通
|
|
70
|
+
> 属性直接访问,而它是 **fiber injection**——官方设置页在 `inject` 列表里显式声明
|
|
71
|
+
> `remote.credentials`。没有声明时该命名空间是 `undefined`,点 Save 什么也不会写(状态看起来
|
|
72
|
+
> 只是"没保存")。现在整段的注册被 `ctx.inject(['remote.credentials'], …)` 门控:有命名空间才
|
|
73
|
+
> 出现,没有则整段不注册(面板不受影响);写入之后还会重新 `describe`,若 Host 仍报"未配置"
|
|
74
|
+
> 会把这句话显示出来,而不是假装成功。
|
|
75
|
+
|
|
76
|
+
### 5. 边界与验证
|
|
77
|
+
|
|
78
|
+
- 默认路径(无 key)仍是 web 检索;有 key 仍是 esearch + esummary + efetch,速率上限不变。
|
|
79
|
+
- 客户端产物因此变了:`lib/client.js` 与 `packages/molbio-panel/lib/client.js` 重新构建,
|
|
80
|
+
面板包 `0.1.5 → 0.1.6`。
|
|
81
|
+
- smoke 新增三条:**凭据库压过环境变量**、**显式参数压过凭据库**、**provider 抛错时回退到环境**;
|
|
82
|
+
原有的“无 key 不碰 E-utilities”与“坏 key 报错”保留。`test/client.mjs` 新增详情页座位的
|
|
83
|
+
等待/注册、本包 subject 渲染、其它 subject 返回 null,以及凭据视图/失败信息三个纯函数。
|
|
84
|
+
- `npm test` 全绿;`client-bundle.mjs --check` 与 `preset-patch.mjs --check` 与源一致。
|
|
85
|
+
|
|
13
86
|
## [0.22.0] — 2026-10-25(DSH 0.2.1-rc.3 适配;PubMed 官方 API + NCBI API key)
|
|
14
87
|
|
|
15
88
|
**工具数不变(60)。新增一条通路:`molbio_pubmed_search` / `molbio_pubmed_abstract` 可以带着
|
package/README.md
CHANGED
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
装完点 **立即启用**(Enable now)——关掉对话框只会装好、不会启用,之后也可以在插件页打开它的开关。
|
|
56
56
|
3. 重启该 profile,然后在预设选择器里选 "Molecular Biology Lab" 新建会话。
|
|
57
57
|
|
|
58
|
+
**(可选)PubMed API key**:重启后打开 **插件 → 已安装插件 → `dsh-molbio-tools` 详情页**,
|
|
59
|
+
在 **PubMed API key** 一节粘贴 NCBI key 并点 **Save**。它写进 DSH 凭据库(`~/.dsh/.credentials.yaml`),
|
|
60
|
+
不需要编辑文件、也不进环境变量,下一次检索即生效。没有 key 时检索仍走原来的 web 检索。
|
|
61
|
+
|
|
58
62
|
### 次选:命令行安装
|
|
59
63
|
|
|
60
64
|
没有图形界面时(例如服务器上只用 CLI),用这条命令;它和插件页安装等价,都从 npm registry 取包:
|
|
@@ -224,7 +228,7 @@ dsh plugin --profile <profile> add dsh-molbio-panel
|
|
|
224
228
|
|
|
225
229
|
| 工具 | 用途 |
|
|
226
230
|
| --- | --- |
|
|
227
|
-
| `molbio_pubmed_search` | 搜 PubMed 文献。提供 NCBI API key(`api_key`
|
|
231
|
+
| `molbio_pubmed_search` | 搜 PubMed 文献。提供 NCBI API key(`api_key` 参数、**DSH 凭据库**的 `NCBI_API_KEY`、或环境变量)时走**官方 E-utilities**(esearch + esummary),返回 PMID/标题/期刊/年份/作者/DOI;无 key 时回退到 harness 的 web 检索 |
|
|
228
232
|
| `molbio_pubmed_abstract` | 按 PMID 拉取摘要(efetch;同样支持 NCBI API key,依赖部署提供 web fetch 能力) |
|
|
229
233
|
| `molbio_paper_add` / `molbio_paper_list` / `molbio_paper_update` / `molbio_paper_remove` | 阅读库(工作区 `papers.json`):加/列/改/删,按 PMID→URL→标题+年份去重 |
|
|
230
234
|
| `molbio_paper_export_bibtex` | `papers.json` → BibTeX `.bib`(可按 tag 过滤) |
|
|
@@ -557,10 +561,16 @@ mutations remain deferred")。所以插件无法在工作区里合法地落一
|
|
|
557
561
|
|
|
558
562
|
- 检索与摘要走 harness 的 **web** 服务(部署不提供时工具会明确报错,而不是假装搜到)。
|
|
559
563
|
- 提供 NCBI API key 时,`molbio_pubmed_search` 用官方 E-utilities:esearch 拿 PMID、esummary
|
|
560
|
-
拿题录(标题/期刊/年份/作者/DOI),`molbio_pubmed_abstract` 用 efetch 拿摘要。key
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
+
拿题录(标题/期刊/年份/作者/DOI),`molbio_pubmed_abstract` 用 efetch 拿摘要。key 的解析顺序是
|
|
565
|
+
**`api_key` 参数 → DSH 凭据库(`NCBI_API_KEY`,其次 `MOLBIO_NCBI_API_KEY`)→ 进程环境变量**。
|
|
566
|
+
NCBI 的速率上限是**无 key 3 次/秒、有 key 10 次/秒**,一次带 key 的检索是 2 个请求
|
|
567
|
+
(esearch + esummary)。没有 key 时检索仍走原来的 web 检索通路,行为不变。
|
|
568
|
+
- **优先把 key 放凭据库,而不是环境变量。** 凭据库是 `~/.dsh/.credentials.yaml`(`refs:` 下写
|
|
569
|
+
`NCBI_API_KEY: …`;图形入口是**插件 → 已安装插件 → dsh-molbio-tools → PubMed API key**):
|
|
570
|
+
不进环境、仅属主可读、改完热重载、下一次调用即生效。环境变量会被**每个子进程继承**——包括 agent 自己的 shell 工具,`echo $env:NCBI_API_KEY`
|
|
571
|
+
就能读出来,而且明文存在用户环境里;内联 `api_key` 还会进入对话记录与工具卡片。
|
|
572
|
+
> 凭据库不是对 agent 的硬隔离(agent 与你是同一个 OS 用户,刻意去读文件仍可能),但它消除了
|
|
573
|
+
> 「所有 shell 子进程自动继承」这条泄漏路径——这正是环境变量的主要问题。
|
|
564
574
|
- 阅读库/协议库/实验日志是工作区里的 `papers.json` / `protocols.json` / `experiments.json`,
|
|
565
575
|
写入遵循会话的**沙箱政策**,与其它文件工具同权;面板(Papers tab)直接读 `papers.json`。
|
|
566
576
|
- `molbio_paper_export_bibtex` 可选按 tag 过滤导出。
|
|
@@ -655,10 +665,12 @@ node <包目录>\preset\install.mjs --profile web --check # 只报告,不组
|
|
|
655
665
|
`amplicon_min/max` 或 `tm_min/max` 放宽一点即可。
|
|
656
666
|
|
|
657
667
|
**PubMed 的 API key 怎么配?**
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
668
|
+
推荐用**插件详情页**:插件 → 已安装插件 → `dsh-molbio-tools` → **PubMed API key**,粘贴后点 Save
|
|
669
|
+
(写入 DSH 凭据库的 `NCBI_API_KEY`,不进环境变量,下一次检索生效)。也可以手动在
|
|
670
|
+
`~/.dsh/.credentials.yaml` 的 `refs:` 下加一行 `NCBI_API_KEY: 你的key`。
|
|
671
|
+
也可以用环境变量 `NCBI_API_KEY`,或在单次调用里传 `api_key`,但两者都更容易泄漏:**环境变量会被
|
|
672
|
+
agent 的 shell 子进程继承**,内联参数会出现在对话记录与工具卡片里。有 key 时走官方 E-utilities
|
|
673
|
+
(PubMed 自己的题录,含 DOI),没有 key 时仍走 harness 的 web 检索。
|
|
662
674
|
|
|
663
675
|
**"帮我把这段序列分析一下" 它却问我要参数?**
|
|
664
676
|
把目标说具体即可:要引物还是探针、产物多长、Tm 多少、哪个区域。工具默认值都是常规实验条件
|
package/build/client-entry.mjs
CHANGED
|
@@ -32,8 +32,12 @@
|
|
|
32
32
|
import { createElement as h, useEffect, useMemo, useRef, useState } from 'react';
|
|
33
33
|
import {
|
|
34
34
|
LIBRARY_FILE,
|
|
35
|
+
NCBI_KEY_REF,
|
|
35
36
|
childPath,
|
|
36
37
|
classifyEntry,
|
|
38
|
+
credentialInfo,
|
|
39
|
+
isMolbioDetailSubject,
|
|
40
|
+
remoteFailure,
|
|
37
41
|
decodeWorkspaceBytes,
|
|
38
42
|
filterPapers,
|
|
39
43
|
isOpenable,
|
|
@@ -489,6 +493,142 @@ function PapersPanel({ sessionId, remote, useSessions }) {
|
|
|
489
493
|
h('div', { style: styles.main }, ...detail));
|
|
490
494
|
}
|
|
491
495
|
|
|
496
|
+
/** The state of the PubMed key, read from the host's credential store. */
|
|
497
|
+
async function readNcbiKey(remote) {
|
|
498
|
+
if (remote?.credentials === undefined || typeof remote.credentials.describe !== 'function') {
|
|
499
|
+
return { phase: 'error', configured: false, writable: false, message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' };
|
|
500
|
+
}
|
|
501
|
+
let response;
|
|
502
|
+
try {
|
|
503
|
+
response = await remote.credentials.describe([NCBI_KEY_REF]);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
506
|
+
}
|
|
507
|
+
const failure = remoteFailure(response);
|
|
508
|
+
if (failure !== '') return { phase: 'error', configured: false, writable: false, message: failure };
|
|
509
|
+
return { phase: 'ready', ...credentialInfo(response, NCBI_KEY_REF), message: '' };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const keyStyles = {
|
|
513
|
+
section: { borderTop: '1px solid rgba(127, 127, 127, 0.28)', marginTop: 16, paddingTop: 12, display: 'grid', gap: 8 },
|
|
514
|
+
title: { margin: 0, fontSize: 14 },
|
|
515
|
+
note: { margin: 0, fontSize: 12, opacity: 0.75, lineHeight: 1.5 },
|
|
516
|
+
status: { margin: 0, fontSize: 12 },
|
|
517
|
+
row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
|
|
518
|
+
input: { flex: '1 1 240px', minWidth: 200, padding: '4px 8px' },
|
|
519
|
+
message: { margin: 0, fontSize: 12 },
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The Plugins → installed plugin → detail section that stores the NCBI key.
|
|
524
|
+
*
|
|
525
|
+
* It renders only for this package's own subject (bundle page, or one of the
|
|
526
|
+
* row pages the bundle declares) and returns null everywhere else, which is the
|
|
527
|
+
* contract `plugins.detail.section` asks a contribution to honour. The value is
|
|
528
|
+
* write-only from here: `credentials.describe` answers whether a key is
|
|
529
|
+
* configured and where it came from, never the key itself.
|
|
530
|
+
*/
|
|
531
|
+
function NcbiKeySection({ subject, remote }) {
|
|
532
|
+
const [draft, setDraft] = useState('');
|
|
533
|
+
const [state, setState] = useState({ phase: 'loading', configured: false, writable: true, message: '' });
|
|
534
|
+
const subjectToken = isMolbioDetailSubject(subject)
|
|
535
|
+
? `${subject.kind}:${subject.pkg.name}:${subject.row?.rowId ?? ''}`
|
|
536
|
+
: '';
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
if (subjectToken === '') return undefined;
|
|
539
|
+
let live = true;
|
|
540
|
+
const load = () => readNcbiKey(remote).then((next) => { if (live) setState(next); });
|
|
541
|
+
load();
|
|
542
|
+
// Another surface may rotate or clear the same reference (Settings, the
|
|
543
|
+
// file watcher); follow the host event instead of showing a stale badge.
|
|
544
|
+
const off = typeof remote?.$on === 'function'
|
|
545
|
+
? remote.$on('credentials/reference-updated', (ref) => { if (ref === NCBI_KEY_REF) load(); })
|
|
546
|
+
: undefined;
|
|
547
|
+
return () => {
|
|
548
|
+
live = false;
|
|
549
|
+
if (typeof off === 'function') off();
|
|
550
|
+
};
|
|
551
|
+
}, [remote, subjectToken]);
|
|
552
|
+
if (!isMolbioDetailSubject(subject)) return null;
|
|
553
|
+
const busy = state.phase === 'saving';
|
|
554
|
+
const save = async () => {
|
|
555
|
+
const value = draft.trim();
|
|
556
|
+
if (value === '') return;
|
|
557
|
+
if (remote?.credentials === undefined || typeof remote.credentials.set !== 'function') {
|
|
558
|
+
setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
|
|
562
|
+
let response;
|
|
563
|
+
try {
|
|
564
|
+
response = await remote.credentials.set(NCBI_KEY_REF, value);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
567
|
+
}
|
|
568
|
+
const failure = remoteFailure(response);
|
|
569
|
+
setDraft('');
|
|
570
|
+
const next = await readNcbiKey(remote);
|
|
571
|
+
setState(failure !== ''
|
|
572
|
+
? { ...next, phase: 'error', message: failure }
|
|
573
|
+
: next.phase === 'error'
|
|
574
|
+
? next
|
|
575
|
+
: next.configured === true
|
|
576
|
+
? { ...next, message: 'Saved. The next PubMed call uses this key.' }
|
|
577
|
+
: { ...next, phase: 'error', message: 'The host accepted the write but still reports no key configured. Restart the profile and try again.' });
|
|
578
|
+
};
|
|
579
|
+
const remove = async () => {
|
|
580
|
+
if (remote?.credentials === undefined || typeof remote.credentials.unset !== 'function') {
|
|
581
|
+
setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
|
|
585
|
+
let response;
|
|
586
|
+
try {
|
|
587
|
+
response = await remote.credentials.unset(NCBI_KEY_REF);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
590
|
+
}
|
|
591
|
+
const failure = remoteFailure(response);
|
|
592
|
+
const next = await readNcbiKey(remote);
|
|
593
|
+
setState(failure !== ''
|
|
594
|
+
? { ...next, phase: 'error', message: failure }
|
|
595
|
+
: next.phase === 'error'
|
|
596
|
+
? next
|
|
597
|
+
: next.configured === false
|
|
598
|
+
? { ...next, message: 'Removed. PubMed search falls back to web search.' }
|
|
599
|
+
: { ...next, phase: 'error', message: 'The host still reports a key configured after Remove.' });
|
|
600
|
+
};
|
|
601
|
+
const status = state.phase === 'loading'
|
|
602
|
+
? 'Checking the credential store…'
|
|
603
|
+
: state.phase === 'error'
|
|
604
|
+
? 'Unavailable'
|
|
605
|
+
: state.configured
|
|
606
|
+
? `Configured${state.source === undefined ? '' : ` (source: ${state.source})`}`
|
|
607
|
+
: 'Not configured';
|
|
608
|
+
return h('section', { 'data-molbio-ncbi-key': '', style: keyStyles.section },
|
|
609
|
+
h('h4', { style: keyStyles.title }, 'PubMed API key'),
|
|
610
|
+
h('p', { style: keyStyles.note },
|
|
611
|
+
'Stored in the harness credential store. molbio_pubmed_search and molbio_pubmed_abstract use it for the official PubMed E-utilities API; without it they fall back to web search.'),
|
|
612
|
+
h('p', { 'data-molbio-key-status': state.phase, style: keyStyles.status }, status),
|
|
613
|
+
h('div', { style: keyStyles.row },
|
|
614
|
+
h('input', {
|
|
615
|
+
type: 'password',
|
|
616
|
+
value: draft,
|
|
617
|
+
placeholder: 'NCBI API key (36 characters)',
|
|
618
|
+
autoComplete: 'off',
|
|
619
|
+
spellCheck: false,
|
|
620
|
+
'aria-label': 'NCBI API key',
|
|
621
|
+
style: keyStyles.input,
|
|
622
|
+
onChange: (event) => setDraft(event.target.value),
|
|
623
|
+
}),
|
|
624
|
+
h('button', { type: 'button', disabled: busy || state.writable === false || draft.trim() === '', onClick: save },
|
|
625
|
+
busy ? 'Saving…' : 'Save'),
|
|
626
|
+
h('button', { type: 'button', disabled: busy || state.writable === false || state.configured !== true, onClick: remove }, 'Remove')),
|
|
627
|
+
state.message === '' ? null : h('p', { 'data-molbio-key-message': '', role: 'status', style: keyStyles.message }, state.message),
|
|
628
|
+
h('p', { style: keyStyles.note },
|
|
629
|
+
'Create a key under NCBI → Account Settings → API Key Management. Prefer this over an environment variable: the store is not inherited by shell commands.'));
|
|
630
|
+
}
|
|
631
|
+
|
|
492
632
|
/** Required browser services. */
|
|
493
633
|
const inject = ['slots', 'sidebarRightTabs', 'remote'];
|
|
494
634
|
|
|
@@ -572,6 +712,25 @@ function apply(ctx) {
|
|
|
572
712
|
key: toolName,
|
|
573
713
|
}, MolbioMapCard)), `molbio panel: ${toolName} card`);
|
|
574
714
|
}
|
|
715
|
+
// Plugins -> the installed plugin's detail page: the one place a user can
|
|
716
|
+
// store the NCBI key without editing a YAML file or exporting a variable.
|
|
717
|
+
//
|
|
718
|
+
// `remote.credentials` is a FIBER INJECTION, not merely a property on the
|
|
719
|
+
// Remote carrier: the shipped settings pages declare it in `inject` for the
|
|
720
|
+
// same reason. Without the declaration `ctx.remote.credentials` is undefined,
|
|
721
|
+
// which is exactly how the first build of this section made Save a no-op.
|
|
722
|
+
// The gate keeps the panel working in deployments that expose no credentials
|
|
723
|
+
// namespace: the section simply never registers there.
|
|
724
|
+
ctx.effect(() => {
|
|
725
|
+
const gate = ctx.inject(['remote.credentials'], (keyCtx) => {
|
|
726
|
+
keyCtx.effect(() => keyCtx.slots.inject('plugins.detail.section', () => keyCtx.slots.register({
|
|
727
|
+
name: 'plugins.detail.section',
|
|
728
|
+
id: 'molbio-ncbi-key',
|
|
729
|
+
order: 100,
|
|
730
|
+
}, (props) => h(NcbiKeySection, { ...props, remote: keyCtx.remote }))), 'molbio panel: NCBI key section');
|
|
731
|
+
});
|
|
732
|
+
return () => { gate.dispose(); };
|
|
733
|
+
}, 'molbio panel: NCBI key section gate');
|
|
575
734
|
}
|
|
576
735
|
|
|
577
736
|
export { apply, inject };
|
package/build/panel-core.mjs
CHANGED
|
@@ -390,3 +390,41 @@ export function paperLink(paper) {
|
|
|
390
390
|
if (typeof paper.pmid === 'string' && paper.pmid !== '') return `https://pubmed.ncbi.nlm.nih.gov/${paper.pmid}/`;
|
|
391
391
|
return undefined;
|
|
392
392
|
}
|
|
393
|
+
|
|
394
|
+
/** The credential reference the PubMed tools read first. */
|
|
395
|
+
export const NCBI_KEY_REF = 'NCBI_API_KEY';
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Whether a Plugins-page detail subject is this package's own entry.
|
|
399
|
+
*
|
|
400
|
+
* Bundles and rows both expose `pkg.name`; the key section is offered on the
|
|
401
|
+
* bundle page and on every row page the bundle declares, and on nothing else.
|
|
402
|
+
*/
|
|
403
|
+
export function isMolbioDetailSubject(subject) {
|
|
404
|
+
if (subject === null || typeof subject !== 'object') return false;
|
|
405
|
+
if (subject.kind !== 'bundle' && subject.kind !== 'row') return false;
|
|
406
|
+
const pkg = subject.pkg;
|
|
407
|
+
return pkg !== null && typeof pkg === 'object' && pkg.name === 'dsh-molbio-tools';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The host failure message carried by a Remote result, or '' on success.
|
|
412
|
+
* Remote calls answer `{ ok: true, value }` or `{ ok: false, error }`.
|
|
413
|
+
*/
|
|
414
|
+
export function remoteFailure(response) {
|
|
415
|
+
if (response === null || typeof response !== 'object' || response.ok !== false) return '';
|
|
416
|
+
const message = response.error?.message;
|
|
417
|
+
return typeof message === 'string' && message !== '' ? message : 'the host refused the request';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** One reference's view from a `credentials.describe` response (never the value). */
|
|
421
|
+
export function credentialInfo(response, ref) {
|
|
422
|
+
const view = response !== null && typeof response === 'object' && response.ok !== false
|
|
423
|
+
? response.value?.[ref]
|
|
424
|
+
: undefined;
|
|
425
|
+
return {
|
|
426
|
+
configured: view?.configured === true,
|
|
427
|
+
writable: view?.writable !== false,
|
|
428
|
+
...typeof view?.source === 'string' && view.source !== '' ? { source: view.source } : {},
|
|
429
|
+
};
|
|
430
|
+
}
|
package/docs/client-panel.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
| **Molbio**(右栏 tab) | 列出会话工作区里的序列文件,选中即画:`.dna`/`.gb`/`.gbk` → 质粒图谱 + 特征表;`.fa`/`.fasta`(≥2 条)→ 序列标识图 | `workspaceFiles.readAll`(二进制,含 SnapGene) |
|
|
14
14
|
| **Papers**(右栏 tab) | 把 `molbio_paper_*` 工具维护的 `papers.json` 渲染成可搜索的阅读列表 + 详情面板(标题/作者/期刊/年份/PMID/URL/标签/笔记,标题链接到 PubMed 或原 URL) | `workspaceFiles.read`(文本) |
|
|
15
15
|
| **图谱调用卡**(`tool.call.toolview`) | `molbio_plasmid_map` / `molbio_plasmid_map_file` 的调用卡:在对话里直接画出这次调用产出的图谱(摘要行 + 写入路径),失败时显示错误文本与 Inspect 入口 | 工具自己声明的 `output.presentationMeta`(见下) |
|
|
16
|
+
| **PubMed API key**(`plugins.detail.section`) | 插件 → 已安装插件 → `dsh-molbio-tools` 详情页里的一段表单:password 输入 + Save/Remove,状态只报「已配置 / 来源 / 未配置」,永不回显值 | `ctx.remote.credentials.describe` / `set` / `unset`(host 的凭据 seam) |
|
|
16
17
|
|
|
17
18
|
**解析与渲染全部在浏览器里跑本仓库自己的模块**(`lib/genbank/snapgene/plasmid/msa/logo`),
|
|
18
19
|
不经过任何工具调用、不落盘 SVG、不弹系统查看器。
|
|
@@ -46,7 +47,7 @@ SVG 随 meta 传输有上限(256 KB):真实载体的图谱 20-60 KB,但
|
|
|
46
47
|
| `build/client-bundle-core.mjs` | 打包器的**生成逻辑**(可 import):`createGenerator({entry, baseDir})` → 模块图 + `renderBundle(packageName)`;`test/contract.mjs` 在进程内用它重算产物做新鲜度检查(不 spawn,受限沙箱下也能验证) |
|
|
47
48
|
| `build/browser-api.mjs` | 浏览器安全面:从**包根的 `.mjs` 源文件**再导出面板可用的一切(单一事实来源) |
|
|
48
49
|
| `build/panel-core.mjs` | 面板的数据通路(分类/解码/解析/渲染/文献库投影),不含 React,可在 Node 里单测 |
|
|
49
|
-
| `build/client-entry.mjs` | 浏览器半本体:两个 tab 类型的注册、正文、标题 chip
|
|
50
|
+
| `build/client-entry.mjs` | 浏览器半本体:两个 tab 类型的注册、正文、标题 chip,两个 map 工具的调用卡,以及插件详情页的 key 配置段 |
|
|
50
51
|
| `lib/client.js` | **构建产物**(`exports["./client"]` 指向它;由 `npm run build:client` 生成) |
|
|
51
52
|
| `packages/molbio-panel/` | 面板专用包(宿主半边空实现),面板与 57 个工具解耦的交付通道 |
|
|
52
53
|
| `test/client.mjs` | 按加载器的方式执行产物 + 驱动两条数据通路(含真实 pUC118 夹具) |
|
|
@@ -131,6 +132,14 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
|
|
|
131
132
|
- **图片式交互**:SVG 里注入的是真实 DOM(可缩放、可选中文本),但没有点特征跳转之类的联动。
|
|
132
133
|
- **CSS 内联**:产物刻意不注入样式表——`factory` 里的样式副作用要自己管拆除;
|
|
133
134
|
内联样式让产物保持单文件、零生命周期负担。
|
|
135
|
+
- **key 配置段只在「本包」的详情页出现**:`plugins.detail.section` 是 list 座位,条目自己判断
|
|
136
|
+
`subject`;本包只在 `kind: bundle|row` 且 `pkg.name === 'dsh-molbio-tools'` 时渲染,其它插件页
|
|
137
|
+
返回 `null`。它依赖宿主挂载了凭据 provider(默认组合都有):没有时整段通过
|
|
138
|
+
`ctx.inject(['remote.credentials'], …)` 门控而不注册,工具仍会按「参数 → 凭据库 → 环境变量」
|
|
139
|
+
的顺序回退,不会因此不可用。**`remote.credentials` 是 fiber injection,不是普通属性**:
|
|
140
|
+
官方设置页同样在 `inject` 里声明它;第一版漏了这一步,`ctx.remote.credentials` 是 `undefined`,
|
|
141
|
+
点 Save 什么也没写(0.22.1 测试中发现并修掉)。**输入框只写不读**:`describe` 的返回里没有值,
|
|
142
|
+
保存成功后输入框立即清空;写后仍报未配置、或 Remove 后仍报已配置,都会把不一致显示出来。
|
|
134
143
|
- **每次改源都要重新构建**:`lib/client.js` 是**产物**,改了根 `.mjs` 或 `build/*.mjs` 后
|
|
135
144
|
必须重跑 `npm run build:client`,否则面板还跑旧逻辑(这与 preset 的"版本目录"规则是
|
|
136
145
|
两码事:客户端产物靠 `rev` 哈希缓存失效,不靠目录名)。
|
|
@@ -146,9 +155,11 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
|
|
|
146
155
|
`guide`、无 `patterns`、guide `order` 40/41)、四个 keyed 座位的注册、两个正文的 `inject`
|
|
147
156
|
工厂都只交出 `remote`。座位那一层从**一个座位都没声明**的最坏顺序启动(`test/slots-stub.mjs`
|
|
148
157
|
复刻 shell 的 SlotCore 语义):`apply()` 必须不抛;右栏座位声明后四条注册落地;调用卡座位在
|
|
149
|
-
ui-tool
|
|
150
|
-
"未声明座位 `register()` 必抛"的那条 SlotCore 异常——0.7.1 把整个
|
|
151
|
-
"Failed to load plugins" 的就是它。
|
|
158
|
+
ui-tool 声明之前一直**处于等待**、声明之后才落地;插件详情页座位同样先等待、后注册;重声明
|
|
159
|
+
不产生重复注册;并当场复刻 "未声明座位 `register()` 必抛"的那条 SlotCore 异常——0.7.1 把整个
|
|
160
|
+
GUI 卡在 "Failed to load plugins" 的就是它。key 段还断言了:本包 subject 渲染出
|
|
161
|
+
`section` + password 输入 + Save,其它包的 subject 返回 `null`,以及凭据视图/失败信息
|
|
162
|
+
三个纯函数(`isMolbioDetailSubject` / `credentialInfo` / `remoteFailure`)。
|
|
152
163
|
3. **组件行为**:`test/panel-render.mjs` 把**真实的面板组件**放进 Node 跑——没有 React
|
|
153
164
|
(harness 里有,浏览器里的 React 由 shell 播种)也没有 DOM,所以这个文件自带一套最小
|
|
154
165
|
钩子宿主(`createElement`/`useState`/`useEffect`/`useMemo`/`useRef`,语义与 React 一致:
|
|
@@ -159,7 +170,9 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
|
|
|
159
170
|
列目录失败的错误态、文献库的列表/搜索/标签过滤/详情/PubMed 链接回退、空库提示、坏库
|
|
160
171
|
报错(**不显示成"空库"**)、以及卸载时中止在途读取。
|
|
161
172
|
这一层抓到过三个真 bug:列表把不可打开的文件也列出来、`sortEntries` 的 `other` 档从不
|
|
162
|
-
生效、以及坏库时列表仍宣称"还没有文献"
|
|
173
|
+
生效、以及坏库时列表仍宣称"还没有文献"。插件详情页的 key 段也被真实驱动:
|
|
174
|
+
`describe` → 输入 → Save(trim 后写入 `NCBI_API_KEY`)→ 状态刷新 + 清空输入 → Remove →
|
|
175
|
+
provider 拒绝时消息原样显示。
|
|
163
176
|
4. **数据通路(质粒)**:真实 pUC118 `.dna` → 记录(名称/长度/拓扑/特征,AmpR 2102-2962
|
|
164
177
|
反链)→ SVG;GenBank 文本路径与 Node 工具解析结果**逐字段一致**;FASTA → 比对 → logo;
|
|
165
178
|
扩展名分类、排序、base64 解码、非法输入的错误路径。
|
|
@@ -234,6 +247,9 @@ shadow(无冲突),面板的 `remote`/`slot` 服务不受影响。
|
|
|
234
247
|
|
|
235
248
|
## 7. 下一步
|
|
236
249
|
|
|
250
|
+
- **插件详情页的 key 配置**:✅ 已落地(0.22.1,见第 1 与第 4 节)。后续可把同一个
|
|
251
|
+
`plugins.detail.section` 模式用于其它凭据(例如文献库的机构代理),前提是它们也有一个
|
|
252
|
+
稳定的环境变量名。
|
|
237
253
|
- **`tool.call.toolview`**:✅ 已落地(见第 1 与第 3 节),覆盖两个 map 工具。可继续做的
|
|
238
254
|
是给 `molbio_sequence_logo` / `molbio_grna_design` 之类的工具也加上卡片(同一套
|
|
239
255
|
`presentationMeta` + 卡片模式)。**注意上线前先跑 `test/client.mjs` 的抢座位顺序那一层**:
|
package/docs/history.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
| 包版本 | preset 版本 | 内容 | 工具数 |
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
|
+
| 0.22.1 | — | **PubMed key 改走 DSH 凭据库**:解析顺序 `api_key` → 凭据库 → 环境变量;无凭据库时行为同 0.22.0 | 60 |
|
|
19
20
|
| 0.22.0 | — | **DSH 0.2.1-rc.3 适配 + PubMed 官方 API**:`molbio_pubmed_search`/`_abstract` 支持 NCBI API key(E-utilities),无 key 行为不变 | 60 |
|
|
20
21
|
| 0.21.1 | — | **DSH STORE 提交前置**:单文件 bundle patch、逐版本兼容声明;根 tarball 只带一个插件清单(工具行为零变化) | 60 |
|
|
21
22
|
| 0.21.0 | — | **DSH 0.2.1-rc.2 适配**:preset 行清单逐行重抄;contract 两条断言重锚到行为;两条 suspended 记录用真实模型重录 | 60 |
|
|
@@ -45,6 +46,25 @@
|
|
|
45
46
|
|
|
46
47
|
---
|
|
47
48
|
|
|
49
|
+
## v0.22.1(2026-10-25,包 0.22.1)
|
|
50
|
+
|
|
51
|
+
**环境变量不是唯一选择。** 0.22.0 只从 `api_key` 参数与 `process.env` 读 key;这一版把 DSH 的
|
|
52
|
+
`ctx.credentials` 插到中间,并把环境变量降为后备。
|
|
53
|
+
|
|
54
|
+
- 新顺序:`api_key` 参数 → 凭据库(`NCBI_API_KEY` / `MOLBIO_NCBI_API_KEY`)→ 进程环境。
|
|
55
|
+
- 为什么:环境变量会被**每个子进程**继承——包括 agent 自己的 shell 工具。凭据库不进入环境、
|
|
56
|
+
仅属主可读、热重载,`describe()` 不回显值。但 DSH 凭据库的文档也写明:agent 与你是同一个 OS
|
|
57
|
+
用户,它不是硬隔离,只是消除了「自动继承」这条路径。
|
|
58
|
+
- 没有凭据库的组合(以及 provider 抛错的组合)静默回退到环境,行为与 0.22.0 一致;显式参数仍然
|
|
59
|
+
最高优先。
|
|
60
|
+
- smoke 新增三条:凭据库压过环境、参数压过凭据库、provider 抛错回退。
|
|
61
|
+
- 0.22.1 同时补了**图形入口**:插件 → 已安装插件 → 本包详情页里的 `plugins.detail.section`
|
|
62
|
+
表单(password 输入 + Save/Remove,值只写不读)。改 YAML 不该是唯一入口。
|
|
63
|
+
- **测试里抓到的缺陷**:`ctx.remote.credentials` 是 fiber injection,不是 Remote 载体的普通属性。
|
|
64
|
+
第一版没在 `inject` 里声明它,命名空间就是 `undefined`,Save 静默地什么也没写;官方设置页
|
|
65
|
+
一直把 `remote.credentials` 写在 `inject` 列表里,那就是这行声明的用途。现在用
|
|
66
|
+
`ctx.inject(['remote.credentials'], …)` 门控整段注册,并在写后重新 `describe` 核对结果。
|
|
67
|
+
|
|
48
68
|
## v0.22.0(2026-10-25,包 0.22.0)
|
|
49
69
|
|
|
50
70
|
一半是适配(rc.3),一半是新能力(PubMed 官方 API + key)。三条教训。
|
package/docs/maintainer.md
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
- **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**60 个 `molbio_*` 工具**(其中
|
|
34
34
|
`molbio_plot` 一个工具承载 8 种图表)。
|
|
35
|
-
- **基线**:DSH **0.2.1-rc.3**;`package.json` 版本 0.22.
|
|
35
|
+
- **基线**:DSH **0.2.1-rc.3**;`package.json` 版本 0.22.1。
|
|
36
36
|
- **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
|
|
37
37
|
**两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
|
|
38
38
|
- **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
|
package/docs/roadmap.md
CHANGED
|
@@ -10,7 +10,7 @@ L = 需要新机制或外部资源)。
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
## 1. 当前状态(0.22.
|
|
13
|
+
## 1. 当前状态(0.22.1)
|
|
14
14
|
|
|
15
15
|
- 60 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
|
|
16
16
|
- 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
|
|
@@ -41,6 +41,10 @@ L = 需要新机制或外部资源)。
|
|
|
41
41
|
`standard` 行清单**逐字节未变**,`preset-health` / `drift-probe` / `contract` 在 rc.3 上
|
|
42
42
|
全绿,因此这一半只改声明与证据);`molbio_pubmed_search` / `molbio_pubmed_abstract` 增加
|
|
43
43
|
NCBI API key 通路,走官方 E-utilities(esearch + esummary),无 key 时保持原有 web 检索。
|
|
44
|
+
- **0.22.1 是 key 存放方式的安全加固 + 图形入口**:`molbio_pubmed_search` / `molbio_pubmed_abstract`
|
|
45
|
+
的 key 解析顺序变为 **`api_key` 参数 → DSH 凭据库 → 环境变量**(环境变量会被 agent 自己的 shell
|
|
46
|
+
子进程继承,凭据库不会);同时插件详情页出现 **PubMed API key** 表单,用户不必改 YAML。
|
|
47
|
+
没有凭据库的组合行为与 0.22.0 相同。
|
|
44
48
|
|
|
45
49
|
---
|
|
46
50
|
|
package/index.mjs
CHANGED
|
@@ -6914,15 +6914,46 @@ async function eutilsRequest(web, url, signal, label) {
|
|
|
6914
6914
|
}
|
|
6915
6915
|
}
|
|
6916
6916
|
|
|
6917
|
+
/** Credential-store references tried, in order, before the process environment. */
|
|
6918
|
+
const PUBMED_KEY_REFS = ['NCBI_API_KEY', 'MOLBIO_NCBI_API_KEY'];
|
|
6919
|
+
|
|
6920
|
+
/**
|
|
6921
|
+
* Resolve the NCBI API key from (1) the call argument, (2) the harness
|
|
6922
|
+
* credential store, (3) the process environment.
|
|
6923
|
+
*
|
|
6924
|
+
* The store is the recommended channel: a stored value is not injected into the
|
|
6925
|
+
* environment, so the agent's own shell children cannot read it by accident, and
|
|
6926
|
+
* `ctx.credentials.resolve` itself falls back through the launch environment and
|
|
6927
|
+
* the .env files. A provider that refuses this ref (a composition without a
|
|
6928
|
+
* credential store, a name outside its grammar) must degrade to the environment
|
|
6929
|
+
* rather than break the tool, hence the per-ref catch.
|
|
6930
|
+
*/
|
|
6931
|
+
async function pubmedKey(args, ctx) {
|
|
6932
|
+
const explicit = typeof args.api_key === 'string' ? args.api_key.trim() : '';
|
|
6933
|
+
if (explicit !== '') return explicit;
|
|
6934
|
+
const credentials = ctx.get('credentials');
|
|
6935
|
+
if (credentials !== undefined && typeof credentials.resolve === 'function') {
|
|
6936
|
+
for (const ref of PUBMED_KEY_REFS) {
|
|
6937
|
+
try {
|
|
6938
|
+
const hit = await credentials.resolve(ref);
|
|
6939
|
+
if (typeof hit?.value === 'string' && hit.value.trim() !== '') return hit.value.trim();
|
|
6940
|
+
} catch {
|
|
6941
|
+
/* fall through to the environment */
|
|
6942
|
+
}
|
|
6943
|
+
}
|
|
6944
|
+
}
|
|
6945
|
+
return pubmedApiKey(args);
|
|
6946
|
+
}
|
|
6947
|
+
|
|
6917
6948
|
const pubmedAbstractTool = (ctx) => define({
|
|
6918
6949
|
name: 'molbio_pubmed_abstract',
|
|
6919
|
-
description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly.
|
|
6950
|
+
description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly. Provide an NCBI API key through the harness credential store (NCBI_API_KEY), the api_key argument, or the environment to attribute the request to your NCBI key and raise the rate limit. Use it to pull abstracts for papers found by molbio_pubmed_search before adding them to the library.',
|
|
6920
6951
|
parameters: {
|
|
6921
6952
|
type: 'object',
|
|
6922
6953
|
required: ['pmids'],
|
|
6923
6954
|
properties: {
|
|
6924
6955
|
pmids: { type: 'array', items: { type: 'string' }, description: 'PubMed IDs, e.g. ["37607951"].' },
|
|
6925
|
-
api_key: { type: 'string', description: 'Optional NCBI API key for E-utilities.
|
|
6956
|
+
api_key: { type: 'string', description: 'Optional NCBI API key for E-utilities. Resolution order: this argument, the harness credential store (NCBI_API_KEY / MOLBIO_NCBI_API_KEY), then the environment. Prefer the credential store: an inline key appears in the conversation transcript.' },
|
|
6926
6957
|
},
|
|
6927
6958
|
},
|
|
6928
6959
|
outputSchema: {
|
|
@@ -6966,7 +6997,7 @@ const pubmedAbstractTool = (ctx) => define({
|
|
|
6966
6997
|
if (web === undefined || typeof web.fetch !== 'function') {
|
|
6967
6998
|
throw new MolbioInputError('this deployment provides no web fetch capability, so PubMed abstracts cannot be retrieved; molbio_pubmed_search still works for titles and snippets');
|
|
6968
6999
|
}
|
|
6969
|
-
const apiKey =
|
|
7000
|
+
const apiKey = await pubmedKey(args, ctx);
|
|
6970
7001
|
const url = pubmedFetchUrl(pmids, apiKey);
|
|
6971
7002
|
const result = await web.fetch({ url }, exec?.signal);
|
|
6972
7003
|
const body = result?.body?.content;
|
|
@@ -7318,14 +7349,14 @@ function paperServiceDeps(ctx, exec, args) {
|
|
|
7318
7349
|
|
|
7319
7350
|
const pubmedSearchTool = (ctx) => define({
|
|
7320
7351
|
name: 'molbio_pubmed_search',
|
|
7321
|
-
description: 'Search PubMed for literature. With an NCBI API key (the api_key argument,
|
|
7352
|
+
description: 'Search PubMed for literature. With an NCBI API key (the api_key argument, the harness credential store under NCBI_API_KEY / MOLBIO_NCBI_API_KEY, or the environment) it queries the official E-utilities API and returns PubMed records with PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web-search service. Add the papers you keep to the reading library with molbio_paper_add.',
|
|
7322
7353
|
parameters: {
|
|
7323
7354
|
type: 'object',
|
|
7324
7355
|
required: ['query'],
|
|
7325
7356
|
properties: {
|
|
7326
7357
|
query: requiredString('Search query, e.g. "CRISPR base editing review 2024" or "site:pubmed.ncbi.nlm.nih.gov KRAS G12D".'),
|
|
7327
7358
|
max_results: { type: 'integer', description: 'Maximum results (1-20, default 8).' },
|
|
7328
|
-
api_key: { type: 'string', description: 'Optional NCBI API key for the official PubMed E-utilities API.
|
|
7359
|
+
api_key: { type: 'string', description: 'Optional NCBI API key for the official PubMed E-utilities API. Resolution order: this argument, the harness credential store (NCBI_API_KEY / MOLBIO_NCBI_API_KEY), then the environment. Prefer the credential store: an inline key appears in the conversation transcript.' },
|
|
7329
7360
|
},
|
|
7330
7361
|
},
|
|
7331
7362
|
outputSchema: {
|
|
@@ -7378,7 +7409,7 @@ const pubmedSearchTool = (ctx) => define({
|
|
|
7378
7409
|
throw new MolbioInputError('max_results must be an integer between 1 and 20');
|
|
7379
7410
|
}
|
|
7380
7411
|
const web = ctx.get('web');
|
|
7381
|
-
const apiKey =
|
|
7412
|
+
const apiKey = await pubmedKey(args, ctx);
|
|
7382
7413
|
if (apiKey !== undefined) {
|
|
7383
7414
|
if (web === undefined || typeof web.fetch !== 'function') {
|
|
7384
7415
|
throw new MolbioInputError('an NCBI API key was provided, but this deployment provides no web fetch capability; the official PubMed API needs ctx.web.fetch');
|
|
@@ -7574,9 +7605,9 @@ const PROMPT_SECTION = `Molecular-biology tools (dsh-molbio-tools) are available
|
|
|
7574
7605
|
- Sequences & files: molbio_align (local alignment with a readable match line), molbio_msa_align (progressive multiple sequence alignment — pass an array of sequences or a workspace FASTA), molbio_conservation (consensus + per-column identity/conservation + variable positions; accepts aligned rows or raw sequences it aligns first), molbio_sequence_logo (draws the same alignment as a sequence logo SVG — pass the molbio_msa_align output rows as the alignment argument; letters are sized by per-position information content), molbio_fasta_fastq (FASTA/FASTQ stats/extract/convert/QC on workspace files), molbio_batch_analyze (run one summary/ORF/enzyme analysis over every matching FASTA/FASTQ/GenBank/.dna file in a directory and write one CSV/TSV table), molbio_extract_region (pull a CDS/promoter/region from a plasmid file by feature label or coordinates).
|
|
7575
7606
|
- CRISPR: molbio_grna_design (family-aware guide RNAs over a sequence or plasmid file: cas_family spcas9/sacas9/cas12a/cas13a/cas13b/cas13d selects the PAM side, crRNA length, seed and target type — Cas12a uses a 5' TTTV PAM and 23 nt crRNA; Cas13 targets RNA and reports a PFS preference rather than a PAM. Both strands are scanned for DNA families, the sense strand by default for Cas13; guides are filtered on GC/poly-T/self-complementarity and ranked, with a mismatch-tolerant off-target search over the same sequence — reaching a whole genome simply means passing it as the target). Pass the top candidates on for ordering with save_path (CSV) or map_path (SVG map); the ranking is a heuristic and says so, so present it as candidate ranking, never as a validated efficiency prediction.
|
|
7576
7607
|
- Map extras: pass gc_skew: true for the GC skew ring, show_unique_cutters: true to mark single-cutting enzymes on the map.
|
|
7577
|
-
- Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch;
|
|
7608
|
+
- Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch; an NCBI key comes from the api_key argument, the credential store's NCBI_API_KEY, or the environment), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
|
|
7578
7609
|
- Bench analysis (v19): molbio_fastq_qc (read-level QC for a FASTQ file: per-base quality with quartiles, per-read quality, base content, GC distribution, length, exact duplication, over-represented sequences, adapter screen, plus a report SVG — use it for "is this run good"), molbio_codon_usage (CAI/RSCU/Nc/GC3, rare-codon list and CDS CpG against E. coli/yeast/human — use it for "will this gene express"; it explains, molbio_codon_optimize rewrites), molbio_phylogenetic_tree (distance method: p-distance/JC/K2P/Tamura-Nei, UPGMA or neighbour-joining, bootstrap support, consensus, Newick file and an SVG tree — it is NOT a maximum-likelihood or Bayesian analysis, and bootstrap percentages are not p-values), molbio_pcr_simulate (where a primer pair amplifies: product sizes and coordinates on both strands, mismatch detail, the 3'-end exact-match requirement, circular templates, screening other templates, and an expected gel — use it for "what band will I see"), molbio_gc_composition (sliding-window GC, CpG islands under the Gardiner-Garden or Takai criteria, GC/AT skew with the cumulative-skew origin/terminus indication, dinucleotide obs/exp, entropy, complexity, N50/L50, plus a figure — use it for "is this a promoter or an origin").
|
|
7579
|
-
- Literature: molbio_pubmed_search — with an NCBI API key (api_key argument,
|
|
7610
|
+
- Literature: molbio_pubmed_search — with an NCBI API key (api_key argument, the credential store under NCBI_API_KEY / MOLBIO_NCBI_API_KEY, or the environment) it uses the official PubMed E-utilities API and returns PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web search. The reading library is molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
|
|
7580
7611
|
|
|
7581
7612
|
Cloning workflow rules (follow them to avoid slow re-derivation):
|
|
7582
7613
|
- To clone a sequence into a plasmid, call molbio_clone_simulate FIRST — pass the BARE insert with add_flanks: true and the tool adds the enzyme recognition sites itself. The tool also validates single cutting, insert orientation, and internal sites itself: never pre-verify enzymes, cutting counts, or flank construction by thinking. Its output already contains final_sequence AND the remapped feature coordinates (features field). NEVER recompute feature coordinates or insert positions by reasoning — always take them from the tool output.
|
package/lib/client.js
CHANGED
|
@@ -8517,6 +8517,44 @@ window.__ModuleLoader__.load({
|
|
|
8517
8517
|
return undefined;
|
|
8518
8518
|
}
|
|
8519
8519
|
|
|
8520
|
+
/** The credential reference the PubMed tools read first. */
|
|
8521
|
+
const NCBI_KEY_REF = 'NCBI_API_KEY';
|
|
8522
|
+
|
|
8523
|
+
/**
|
|
8524
|
+
* Whether a Plugins-page detail subject is this package's own entry.
|
|
8525
|
+
*
|
|
8526
|
+
* Bundles and rows both expose `pkg.name`; the key section is offered on the
|
|
8527
|
+
* bundle page and on every row page the bundle declares, and on nothing else.
|
|
8528
|
+
*/
|
|
8529
|
+
function isMolbioDetailSubject(subject) {
|
|
8530
|
+
if (subject === null || typeof subject !== 'object') return false;
|
|
8531
|
+
if (subject.kind !== 'bundle' && subject.kind !== 'row') return false;
|
|
8532
|
+
const pkg = subject.pkg;
|
|
8533
|
+
return pkg !== null && typeof pkg === 'object' && pkg.name === 'dsh-molbio-tools';
|
|
8534
|
+
}
|
|
8535
|
+
|
|
8536
|
+
/**
|
|
8537
|
+
* The host failure message carried by a Remote result, or '' on success.
|
|
8538
|
+
* Remote calls answer `{ ok: true, value }` or `{ ok: false, error }`.
|
|
8539
|
+
*/
|
|
8540
|
+
function remoteFailure(response) {
|
|
8541
|
+
if (response === null || typeof response !== 'object' || response.ok !== false) return '';
|
|
8542
|
+
const message = response.error?.message;
|
|
8543
|
+
return typeof message === 'string' && message !== '' ? message : 'the host refused the request';
|
|
8544
|
+
}
|
|
8545
|
+
|
|
8546
|
+
/** One reference's view from a `credentials.describe` response (never the value). */
|
|
8547
|
+
function credentialInfo(response, ref) {
|
|
8548
|
+
const view = response !== null && typeof response === 'object' && response.ok !== false
|
|
8549
|
+
? response.value?.[ref]
|
|
8550
|
+
: undefined;
|
|
8551
|
+
return {
|
|
8552
|
+
configured: view?.configured === true,
|
|
8553
|
+
writable: view?.writable !== false,
|
|
8554
|
+
...typeof view?.source === 'string' && view.source !== '' ? { source: view.source } : {},
|
|
8555
|
+
};
|
|
8556
|
+
}
|
|
8557
|
+
|
|
8520
8558
|
exports.PLASMID_EXTENSIONS = PLASMID_EXTENSIONS;
|
|
8521
8559
|
exports.ALIGNMENT_EXTENSIONS = ALIGNMENT_EXTENSIONS;
|
|
8522
8560
|
exports.extensionOf = extensionOf;
|
|
@@ -8541,6 +8579,10 @@ window.__ModuleLoader__.load({
|
|
|
8541
8579
|
exports.paperFields = paperFields;
|
|
8542
8580
|
exports.paperSummary = paperSummary;
|
|
8543
8581
|
exports.paperLink = paperLink;
|
|
8582
|
+
exports.NCBI_KEY_REF = NCBI_KEY_REF;
|
|
8583
|
+
exports.isMolbioDetailSubject = isMolbioDetailSubject;
|
|
8584
|
+
exports.remoteFailure = remoteFailure;
|
|
8585
|
+
exports.credentialInfo = credentialInfo;
|
|
8544
8586
|
return exports;
|
|
8545
8587
|
};
|
|
8546
8588
|
__molbio_modules["build/client-entry.mjs"] = () => {
|
|
@@ -8577,7 +8619,7 @@ window.__ModuleLoader__.load({
|
|
|
8577
8619
|
* inside the factory would also have to be torn down by hand on unload.
|
|
8578
8620
|
*/
|
|
8579
8621
|
const { createElement: h, useEffect, useMemo, useRef, useState } = __ext0;
|
|
8580
|
-
const { LIBRARY_FILE, childPath, classifyEntry, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
|
|
8622
|
+
const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
|
|
8581
8623
|
/** This implementation's identity, and the key its body/title register under. */
|
|
8582
8624
|
const PANEL_ID = 'dsh-molbio-tools';
|
|
8583
8625
|
/** Type discriminator the tab is opened by. */
|
|
@@ -9015,6 +9057,142 @@ window.__ModuleLoader__.load({
|
|
|
9015
9057
|
h('div', { style: styles.main }, ...detail));
|
|
9016
9058
|
}
|
|
9017
9059
|
|
|
9060
|
+
/** The state of the PubMed key, read from the host's credential store. */
|
|
9061
|
+
async function readNcbiKey(remote) {
|
|
9062
|
+
if (remote?.credentials === undefined || typeof remote.credentials.describe !== 'function') {
|
|
9063
|
+
return { phase: 'error', configured: false, writable: false, message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' };
|
|
9064
|
+
}
|
|
9065
|
+
let response;
|
|
9066
|
+
try {
|
|
9067
|
+
response = await remote.credentials.describe([NCBI_KEY_REF]);
|
|
9068
|
+
} catch (error) {
|
|
9069
|
+
response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
9070
|
+
}
|
|
9071
|
+
const failure = remoteFailure(response);
|
|
9072
|
+
if (failure !== '') return { phase: 'error', configured: false, writable: false, message: failure };
|
|
9073
|
+
return { phase: 'ready', ...credentialInfo(response, NCBI_KEY_REF), message: '' };
|
|
9074
|
+
}
|
|
9075
|
+
|
|
9076
|
+
const keyStyles = {
|
|
9077
|
+
section: { borderTop: '1px solid rgba(127, 127, 127, 0.28)', marginTop: 16, paddingTop: 12, display: 'grid', gap: 8 },
|
|
9078
|
+
title: { margin: 0, fontSize: 14 },
|
|
9079
|
+
note: { margin: 0, fontSize: 12, opacity: 0.75, lineHeight: 1.5 },
|
|
9080
|
+
status: { margin: 0, fontSize: 12 },
|
|
9081
|
+
row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
|
|
9082
|
+
input: { flex: '1 1 240px', minWidth: 200, padding: '4px 8px' },
|
|
9083
|
+
message: { margin: 0, fontSize: 12 },
|
|
9084
|
+
};
|
|
9085
|
+
|
|
9086
|
+
/**
|
|
9087
|
+
* The Plugins → installed plugin → detail section that stores the NCBI key.
|
|
9088
|
+
*
|
|
9089
|
+
* It renders only for this package's own subject (bundle page, or one of the
|
|
9090
|
+
* row pages the bundle declares) and returns null everywhere else, which is the
|
|
9091
|
+
* contract `plugins.detail.section` asks a contribution to honour. The value is
|
|
9092
|
+
* write-only from here: `credentials.describe` answers whether a key is
|
|
9093
|
+
* configured and where it came from, never the key itself.
|
|
9094
|
+
*/
|
|
9095
|
+
function NcbiKeySection({ subject, remote }) {
|
|
9096
|
+
const [draft, setDraft] = useState('');
|
|
9097
|
+
const [state, setState] = useState({ phase: 'loading', configured: false, writable: true, message: '' });
|
|
9098
|
+
const subjectToken = isMolbioDetailSubject(subject)
|
|
9099
|
+
? `${subject.kind}:${subject.pkg.name}:${subject.row?.rowId ?? ''}`
|
|
9100
|
+
: '';
|
|
9101
|
+
useEffect(() => {
|
|
9102
|
+
if (subjectToken === '') return undefined;
|
|
9103
|
+
let live = true;
|
|
9104
|
+
const load = () => readNcbiKey(remote).then((next) => { if (live) setState(next); });
|
|
9105
|
+
load();
|
|
9106
|
+
// Another surface may rotate or clear the same reference (Settings, the
|
|
9107
|
+
// file watcher); follow the host event instead of showing a stale badge.
|
|
9108
|
+
const off = typeof remote?.$on === 'function'
|
|
9109
|
+
? remote.$on('credentials/reference-updated', (ref) => { if (ref === NCBI_KEY_REF) load(); })
|
|
9110
|
+
: undefined;
|
|
9111
|
+
return () => {
|
|
9112
|
+
live = false;
|
|
9113
|
+
if (typeof off === 'function') off();
|
|
9114
|
+
};
|
|
9115
|
+
}, [remote, subjectToken]);
|
|
9116
|
+
if (!isMolbioDetailSubject(subject)) return null;
|
|
9117
|
+
const busy = state.phase === 'saving';
|
|
9118
|
+
const save = async () => {
|
|
9119
|
+
const value = draft.trim();
|
|
9120
|
+
if (value === '') return;
|
|
9121
|
+
if (remote?.credentials === undefined || typeof remote.credentials.set !== 'function') {
|
|
9122
|
+
setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
|
|
9123
|
+
return;
|
|
9124
|
+
}
|
|
9125
|
+
setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
|
|
9126
|
+
let response;
|
|
9127
|
+
try {
|
|
9128
|
+
response = await remote.credentials.set(NCBI_KEY_REF, value);
|
|
9129
|
+
} catch (error) {
|
|
9130
|
+
response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
9131
|
+
}
|
|
9132
|
+
const failure = remoteFailure(response);
|
|
9133
|
+
setDraft('');
|
|
9134
|
+
const next = await readNcbiKey(remote);
|
|
9135
|
+
setState(failure !== ''
|
|
9136
|
+
? { ...next, phase: 'error', message: failure }
|
|
9137
|
+
: next.phase === 'error'
|
|
9138
|
+
? next
|
|
9139
|
+
: next.configured === true
|
|
9140
|
+
? { ...next, message: 'Saved. The next PubMed call uses this key.' }
|
|
9141
|
+
: { ...next, phase: 'error', message: 'The host accepted the write but still reports no key configured. Restart the profile and try again.' });
|
|
9142
|
+
};
|
|
9143
|
+
const remove = async () => {
|
|
9144
|
+
if (remote?.credentials === undefined || typeof remote.credentials.unset !== 'function') {
|
|
9145
|
+
setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
|
|
9146
|
+
return;
|
|
9147
|
+
}
|
|
9148
|
+
setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
|
|
9149
|
+
let response;
|
|
9150
|
+
try {
|
|
9151
|
+
response = await remote.credentials.unset(NCBI_KEY_REF);
|
|
9152
|
+
} catch (error) {
|
|
9153
|
+
response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
9154
|
+
}
|
|
9155
|
+
const failure = remoteFailure(response);
|
|
9156
|
+
const next = await readNcbiKey(remote);
|
|
9157
|
+
setState(failure !== ''
|
|
9158
|
+
? { ...next, phase: 'error', message: failure }
|
|
9159
|
+
: next.phase === 'error'
|
|
9160
|
+
? next
|
|
9161
|
+
: next.configured === false
|
|
9162
|
+
? { ...next, message: 'Removed. PubMed search falls back to web search.' }
|
|
9163
|
+
: { ...next, phase: 'error', message: 'The host still reports a key configured after Remove.' });
|
|
9164
|
+
};
|
|
9165
|
+
const status = state.phase === 'loading'
|
|
9166
|
+
? 'Checking the credential store…'
|
|
9167
|
+
: state.phase === 'error'
|
|
9168
|
+
? 'Unavailable'
|
|
9169
|
+
: state.configured
|
|
9170
|
+
? `Configured${state.source === undefined ? '' : ` (source: ${state.source})`}`
|
|
9171
|
+
: 'Not configured';
|
|
9172
|
+
return h('section', { 'data-molbio-ncbi-key': '', style: keyStyles.section },
|
|
9173
|
+
h('h4', { style: keyStyles.title }, 'PubMed API key'),
|
|
9174
|
+
h('p', { style: keyStyles.note },
|
|
9175
|
+
'Stored in the harness credential store. molbio_pubmed_search and molbio_pubmed_abstract use it for the official PubMed E-utilities API; without it they fall back to web search.'),
|
|
9176
|
+
h('p', { 'data-molbio-key-status': state.phase, style: keyStyles.status }, status),
|
|
9177
|
+
h('div', { style: keyStyles.row },
|
|
9178
|
+
h('input', {
|
|
9179
|
+
type: 'password',
|
|
9180
|
+
value: draft,
|
|
9181
|
+
placeholder: 'NCBI API key (36 characters)',
|
|
9182
|
+
autoComplete: 'off',
|
|
9183
|
+
spellCheck: false,
|
|
9184
|
+
'aria-label': 'NCBI API key',
|
|
9185
|
+
style: keyStyles.input,
|
|
9186
|
+
onChange: (event) => setDraft(event.target.value),
|
|
9187
|
+
}),
|
|
9188
|
+
h('button', { type: 'button', disabled: busy || state.writable === false || draft.trim() === '', onClick: save },
|
|
9189
|
+
busy ? 'Saving…' : 'Save'),
|
|
9190
|
+
h('button', { type: 'button', disabled: busy || state.writable === false || state.configured !== true, onClick: remove }, 'Remove')),
|
|
9191
|
+
state.message === '' ? null : h('p', { 'data-molbio-key-message': '', role: 'status', style: keyStyles.message }, state.message),
|
|
9192
|
+
h('p', { style: keyStyles.note },
|
|
9193
|
+
'Create a key under NCBI → Account Settings → API Key Management. Prefer this over an environment variable: the store is not inherited by shell commands.'));
|
|
9194
|
+
}
|
|
9195
|
+
|
|
9018
9196
|
/** Required browser services. */
|
|
9019
9197
|
const inject = ['slots', 'sidebarRightTabs', 'remote'];
|
|
9020
9198
|
|
|
@@ -9098,6 +9276,25 @@ window.__ModuleLoader__.load({
|
|
|
9098
9276
|
key: toolName,
|
|
9099
9277
|
}, MolbioMapCard)), `molbio panel: ${toolName} card`);
|
|
9100
9278
|
}
|
|
9279
|
+
// Plugins -> the installed plugin's detail page: the one place a user can
|
|
9280
|
+
// store the NCBI key without editing a YAML file or exporting a variable.
|
|
9281
|
+
//
|
|
9282
|
+
// `remote.credentials` is a FIBER INJECTION, not merely a property on the
|
|
9283
|
+
// Remote carrier: the shipped settings pages declare it in `inject` for the
|
|
9284
|
+
// same reason. Without the declaration `ctx.remote.credentials` is undefined,
|
|
9285
|
+
// which is exactly how the first build of this section made Save a no-op.
|
|
9286
|
+
// The gate keeps the panel working in deployments that expose no credentials
|
|
9287
|
+
// namespace: the section simply never registers there.
|
|
9288
|
+
ctx.effect(() => {
|
|
9289
|
+
const gate = ctx.inject(['remote.credentials'], (keyCtx) => {
|
|
9290
|
+
keyCtx.effect(() => keyCtx.slots.inject('plugins.detail.section', () => keyCtx.slots.register({
|
|
9291
|
+
name: 'plugins.detail.section',
|
|
9292
|
+
id: 'molbio-ncbi-key',
|
|
9293
|
+
order: 100,
|
|
9294
|
+
}, (props) => h(NcbiKeySection, { ...props, remote: keyCtx.remote }))), 'molbio panel: NCBI key section');
|
|
9295
|
+
});
|
|
9296
|
+
return () => { gate.dispose(); };
|
|
9297
|
+
}, 'molbio panel: NCBI key section gate');
|
|
9101
9298
|
}
|
|
9102
9299
|
|
|
9103
9300
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-molbio-tools",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel and hydropathy plots), methylation-aware restriction analysis with double-digest buffer checks, qPCR analysis and plotting, multiple sequence alignment that conserves every input residue, conservation analysis, sequence logos, codon-usage analysis, phylogenetic trees with bootstrap support, Newick I/O and long-name label wrapping, CpG-island/GC-skew composition analysis, read-level FASTQ QC, batch file analysis with CSV export, silent restriction-site mutagenesis, family-aware CRISPR gRNA design (SpCas9/SaCas9/Cas12a/Cas13) with off-target search, an opt-in hand-off that shows a generated plot to the model as an attached image, plus a browser-side right-sidebar panel (plasmid maps and sequence logos drawn in the Web GUI) and a literature/protocol/experiment record system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
package/test/client.mjs
CHANGED
|
@@ -101,6 +101,7 @@ assert.deepEqual([...module.inject], ['slots', 'sidebarRightTabs', 'remote'], 't
|
|
|
101
101
|
|
|
102
102
|
const effects = [];
|
|
103
103
|
const tabTypes = [];
|
|
104
|
+
const injectedDeps = [];
|
|
104
105
|
const slots = createSlotsStub();
|
|
105
106
|
const ctx = {
|
|
106
107
|
remote: {
|
|
@@ -122,9 +123,16 @@ const ctx = {
|
|
|
122
123
|
effects.push(label);
|
|
123
124
|
return factory();
|
|
124
125
|
},
|
|
126
|
+
inject(deps, callback) {
|
|
127
|
+
injectedDeps.push([...deps]);
|
|
128
|
+
const child = Object.create(ctx);
|
|
129
|
+
child.remote = { ...ctx.remote, credentials: {} };
|
|
130
|
+
return { dispose() {}, value: callback(child) };
|
|
131
|
+
},
|
|
125
132
|
sidebarRightTabs: { register(definition) { tabTypes.push(definition); return () => undefined; } },
|
|
126
133
|
slots,
|
|
127
134
|
};
|
|
135
|
+
assert.deepEqual(injectedDeps, [], 'no dependency gate has run yet');
|
|
128
136
|
assert.doesNotThrow(() => module.apply(ctx), 'apply() must not require any seat to be declared yet');
|
|
129
137
|
|
|
130
138
|
assert.deepEqual(effects, [
|
|
@@ -136,7 +144,10 @@ assert.deepEqual(effects, [
|
|
|
136
144
|
'molbio panel: papers tab title',
|
|
137
145
|
'molbio panel: molbio_plasmid_map card',
|
|
138
146
|
'molbio panel: molbio_plasmid_map_file card',
|
|
147
|
+
'molbio panel: NCBI key section gate',
|
|
148
|
+
'molbio panel: NCBI key section',
|
|
139
149
|
]);
|
|
150
|
+
assert.deepEqual(injectedDeps, [['remote.credentials']], 'the key section waits for the credentials Remote namespace');
|
|
140
151
|
assert.equal(tabTypes.length, 2, 'the package contributes two page tab types');
|
|
141
152
|
assert.deepEqual(
|
|
142
153
|
tabTypes.map((type) => [type.id, type.kind, type.title()]),
|
|
@@ -153,7 +164,7 @@ assert.deepEqual(tabTypes.map((type) => type.guide[0].order), [40, 41], 'the gui
|
|
|
153
164
|
assert.equal(slots.registrations.length, 0, 'nothing registers before its seat exists');
|
|
154
165
|
assert.deepEqual(
|
|
155
166
|
slots.injectedSeats,
|
|
156
|
-
['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview'],
|
|
167
|
+
['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview', 'plugins.detail.section'],
|
|
157
168
|
'each seat is claimed by waiting for its declaration, not by registering blindly',
|
|
158
169
|
);
|
|
159
170
|
// The shell's guard, reproduced on the stub: this is the exact exception a bare
|
|
@@ -169,8 +180,8 @@ slots.declare('sidebar.right.pane.tab');
|
|
|
169
180
|
slots.declare('sidebar.right.pane.tab.title');
|
|
170
181
|
assert.deepEqual(
|
|
171
182
|
slots.pendingSeats,
|
|
172
|
-
['tool.call.toolview'],
|
|
173
|
-
'the transcript card
|
|
183
|
+
['tool.call.toolview', 'plugins.detail.section'],
|
|
184
|
+
'the transcript card and the plugin-detail section wait for the seats their owners declare',
|
|
174
185
|
);
|
|
175
186
|
assert.equal(slots.registrations.length, 4, 'both tab bodies and titles land as soon as their seat exists');
|
|
176
187
|
slots.declare('tool.call.toolview');
|
|
@@ -211,6 +222,39 @@ assert.equal(claim('tool.call.toolview', 'molbio_plasmid_map').registration.inje
|
|
|
211
222
|
slots.declare('tool.call.toolview');
|
|
212
223
|
assert.equal(slots.registrations.length, 6, 're-declaring the seat re-installs both cards instead of duplicating them');
|
|
213
224
|
|
|
225
|
+
// The Plugins page's detail seat belongs to the plugin manager. The section
|
|
226
|
+
// lands when that seat is declared, and the component itself decides whether
|
|
227
|
+
// the open subject is this package.
|
|
228
|
+
slots.declare('plugins.detail.section');
|
|
229
|
+
assert.equal(slots.registrations.length, 7, 'the key section lands when the plugin manager declares its seat');
|
|
230
|
+
const keyClaim = slots.registrations.find((entry) => entry.registration.name === 'plugins.detail.section');
|
|
231
|
+
assert.ok(keyClaim, 'the key section is claimed in plugins.detail.section');
|
|
232
|
+
assert.equal(keyClaim.registration.id, 'molbio-ncbi-key', 'the list seat is addressed by an id of our own');
|
|
233
|
+
assert.equal(typeof keyClaim.component, 'function', 'the section is a component');
|
|
234
|
+
// The registered component binds `remote`; invoking its element type is how a
|
|
235
|
+
// test renders it without React (the hooks are stubbed by this file's reactStub).
|
|
236
|
+
const ours = keyClaim.component({ subject: { kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } } });
|
|
237
|
+
assert.equal(typeof ours.type, 'function', 'the wrapper renders this package\'s section');
|
|
238
|
+
const sectionTree = ours.type(ours.props);
|
|
239
|
+
assert.equal(sectionTree.type, 'section');
|
|
240
|
+
assert.equal(sectionTree.props['data-molbio-ncbi-key'], '');
|
|
241
|
+
const flat = [];
|
|
242
|
+
const walk = (node) => {
|
|
243
|
+
if (node === null || typeof node !== 'object') return;
|
|
244
|
+
flat.push(node);
|
|
245
|
+
for (const child of Array.isArray(node.children) ? node.children : []) walk(child);
|
|
246
|
+
};
|
|
247
|
+
walk(sectionTree);
|
|
248
|
+
assert.ok(flat.some((node) => node.type === 'h4' && node.children.join('') === 'PubMed API key'), 'the section is titled');
|
|
249
|
+
assert.ok(flat.some((node) => node.type === 'input' && node.props.type === 'password'), 'the section offers a password input');
|
|
250
|
+
assert.ok(flat.some((node) => node.type === 'button' && node.children.join('') === 'Save'), 'the section offers a save control');
|
|
251
|
+
const renderFor = (subject) => {
|
|
252
|
+
const wrapper = keyClaim.component({ subject });
|
|
253
|
+
return wrapper.type(wrapper.props);
|
|
254
|
+
};
|
|
255
|
+
assert.equal(renderFor({ kind: 'bundle', pkg: { name: 'another-plugin' } }), null, 'another package gets no section');
|
|
256
|
+
assert.equal(renderFor({ kind: 'item', id: 'web-search' }), null, 'an official-plugin page gets no section');
|
|
257
|
+
|
|
214
258
|
// ── 3. the panel's data path, against the real fixture ──────────────────────
|
|
215
259
|
|
|
216
260
|
assert.equal(panelCore.extensionOf('pUC118.dna'), 'dna');
|
|
@@ -231,6 +275,21 @@ const sorted = panelCore.sortEntries([
|
|
|
231
275
|
]);
|
|
232
276
|
assert.deepEqual(sorted.map((entry) => entry.name), ['a.dna', 'z.dna', 'a.fasta', 'notes.md']);
|
|
233
277
|
|
|
278
|
+
// The plugin-detail key section's pure helpers: which subject it belongs to,
|
|
279
|
+
// how a credential view is read, and how a refused Remote call is surfaced.
|
|
280
|
+
assert.equal(panelCore.isMolbioDetailSubject({ kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } }), true);
|
|
281
|
+
assert.equal(panelCore.isMolbioDetailSubject({ kind: 'row', pkg: { name: 'dsh-molbio-tools' }, row: { rowId: 'molbio-client' } }), true);
|
|
282
|
+
assert.equal(panelCore.isMolbioDetailSubject({ kind: 'bundle', pkg: { name: 'another-plugin' } }), false);
|
|
283
|
+
assert.equal(panelCore.isMolbioDetailSubject({ kind: 'item', id: 'dsh-molbio-tools' }), false, 'official-plugin pages are not ours');
|
|
284
|
+
assert.equal(panelCore.isMolbioDetailSubject(null), false);
|
|
285
|
+
assert.deepEqual(
|
|
286
|
+
panelCore.credentialInfo({ ok: true, value: { NCBI_API_KEY: { configured: true, source: 'file', writable: true } } }, 'NCBI_API_KEY'),
|
|
287
|
+
{ configured: true, writable: true, source: 'file' },
|
|
288
|
+
);
|
|
289
|
+
assert.deepEqual(panelCore.credentialInfo({ ok: true, value: {} }, 'NCBI_API_KEY'), { configured: false, writable: true });
|
|
290
|
+
assert.equal(panelCore.remoteFailure({ ok: false, error: { message: 'credential/rejected: the store is unreadable' } }), 'credential/rejected: the store is unreadable');
|
|
291
|
+
assert.equal(panelCore.remoteFailure({ ok: true, value: {} }), '', 'a successful result carries no failure');
|
|
292
|
+
|
|
234
293
|
assert.equal(panelCore.childPath('D:\\work', 'a.dna'), 'D:\\work/a.dna');
|
|
235
294
|
assert.equal(panelCore.childPath('', 'a.dna'), 'a.dna');
|
|
236
295
|
|
package/test/contract.mjs
CHANGED
|
@@ -277,6 +277,24 @@ check('the built bundle registers the seats and services the framework expects',
|
|
|
277
277
|
// The inject factory must hand over exactly the services the framework does
|
|
278
278
|
// NOT provide, so it can never shadow sessionId or a synthesised hook.
|
|
279
279
|
assert.ok(/inject: \(\) => \(\{ remote \}\)/.test(bundle), 'the body injects only the Remote face');
|
|
280
|
+
|
|
281
|
+
// The plugin-detail key section depends on TWO upstream facts: the plugin
|
|
282
|
+
// manager still declares the seat, and the settings controller still exposes
|
|
283
|
+
// the credentials Remote namespace with the argument order we call. Both are
|
|
284
|
+
// silent if they move (the section simply never renders), so pin them here.
|
|
285
|
+
assert.ok(bundle.includes('plugins.detail.section'), 'the bundle waits for the plugin-manager detail seat');
|
|
286
|
+
for (const call of ['credentials.describe(', 'credentials.set(', 'credentials.unset(']) {
|
|
287
|
+
assert.ok(bundle.includes(call), `the key section calls ${call}…)`);
|
|
288
|
+
}
|
|
289
|
+
assert.ok(bundle.includes("ctx.inject(['remote.credentials']"), 'the key section declares the credentials FIBER INJECTION (a plain property is undefined)');
|
|
290
|
+
const manager = clientBundle('@deepseek-ai/dsh-client-ui-plugin-manager');
|
|
291
|
+
assert.ok(manager !== undefined && manager.text.includes('plugins.detail.section'), 'the plugin manager still declares plugins.detail.section');
|
|
292
|
+
const settings = hostHalf('@deepseek-ai/dsh-api-settings-controller');
|
|
293
|
+
assert.ok(settings !== undefined, 'the settings controller is installed');
|
|
294
|
+
assert.ok(settings.text.includes('namespace: "credentials"'), 'the credentials Remote namespace still exists');
|
|
295
|
+
assert.match(settings.text, /async describe\(refs\)/, 'describe takes the reference list first');
|
|
296
|
+
assert.match(settings.text, /async set\(ref, value\)/, 'set takes (ref, value)');
|
|
297
|
+
assert.match(settings.text, /async unset\(ref\)/, 'unset takes the reference');
|
|
280
298
|
});
|
|
281
299
|
|
|
282
300
|
check('the package manifest keeps the client declaration the scan keys on', () => {
|
|
@@ -460,10 +478,12 @@ check('every seat claim waits for the declaration (slots.inject, not a bare regi
|
|
|
460
478
|
|
|
461
479
|
// Our artifact: every `register` must sit inside a wait for that same seat.
|
|
462
480
|
const bundle = readFileSync(join(packageRoot, 'lib', 'client.js'), 'utf8');
|
|
463
|
-
|
|
481
|
+
// The receiver may be a fiber context (`keyCtx.slots`), not only `ctx.slots`:
|
|
482
|
+
// the key section registers inside a ctx.inject gate.
|
|
483
|
+
const waited = [...bundle.matchAll(/slots\.inject\('([^']+)', \(\) => (?:[A-Za-z_$][\w$]*\.)?slots\.register\(\{\s*name: '([^']+)'/g)];
|
|
464
484
|
assert.ok(waited.length > 0, 'the bundle claims its seats through slots.inject');
|
|
465
485
|
for (const [, seat, name] of waited) assert.equal(seat, name, `the claim of ${name} waits for the seat it registers into`);
|
|
466
|
-
const registers = (bundle.match(/(?:
|
|
486
|
+
const registers = (bundle.match(/(?:[A-Za-z_$][\w$]*\.)?slots\.register\(/g) ?? []).length;
|
|
467
487
|
assert.equal(registers, waited.length, 'and no registration exists outside such a wait');
|
|
468
488
|
});
|
|
469
489
|
|
package/test/map-card.mjs
CHANGED
|
@@ -253,12 +253,14 @@ function mount(Component) {
|
|
|
253
253
|
const registrations = [];
|
|
254
254
|
const module = await import(entryPath.href);
|
|
255
255
|
const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview']);
|
|
256
|
-
|
|
256
|
+
const cardCtx = {
|
|
257
257
|
remote: {},
|
|
258
258
|
effect: (factory) => factory(),
|
|
259
|
+
inject: (_deps, callback) => ({ dispose() {}, value: callback(cardCtx) }),
|
|
259
260
|
sidebarRightTabs: { register: (definition) => registrations.push(definition) },
|
|
260
261
|
slots,
|
|
261
|
-
}
|
|
262
|
+
};
|
|
263
|
+
module.apply(cardCtx);
|
|
262
264
|
const MapCard = slots.registrations.find(
|
|
263
265
|
(entry) => entry.registration.name === 'tool.call.toolview' && entry.registration.key === 'molbio_plasmid_map',
|
|
264
266
|
).component;
|
package/test/panel-render.mjs
CHANGED
|
@@ -308,12 +308,17 @@ const module = await (async () => {
|
|
|
308
308
|
}
|
|
309
309
|
})();
|
|
310
310
|
|
|
311
|
-
const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview']);
|
|
311
|
+
const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview', 'plugins.detail.section']);
|
|
312
312
|
const ctx = {
|
|
313
313
|
remote: remote({}),
|
|
314
314
|
effect(factory) {
|
|
315
315
|
return factory();
|
|
316
316
|
},
|
|
317
|
+
// `apply` gates the key section on the credentials Remote namespace; this
|
|
318
|
+
// file supplies every dependency it asks for, like the default composition.
|
|
319
|
+
inject(_deps, callback) {
|
|
320
|
+
return { dispose() {}, value: callback(ctx) };
|
|
321
|
+
},
|
|
317
322
|
sidebarRightTabs: { register: (definition) => registrations.push(definition) },
|
|
318
323
|
slots,
|
|
319
324
|
};
|
|
@@ -594,6 +599,77 @@ const library = {
|
|
|
594
599
|
harness.unmount();
|
|
595
600
|
}
|
|
596
601
|
|
|
602
|
+
// ── the plugin-detail key section (write-only credential form) ─────────────
|
|
603
|
+
|
|
604
|
+
{
|
|
605
|
+
const claim = slots.registrations.find((entry) => entry.registration.name === 'plugins.detail.section');
|
|
606
|
+
assert.ok(claim, 'the key section registered once its seat was declared');
|
|
607
|
+
const store = { value: undefined, describe: [], set: [], unset: [], refuse: '' };
|
|
608
|
+
ctx.remote.credentials = {
|
|
609
|
+
async describe(refs) {
|
|
610
|
+
store.describe.push(refs);
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
value: {
|
|
614
|
+
NCBI_API_KEY: {
|
|
615
|
+
configured: store.value !== undefined,
|
|
616
|
+
writable: true,
|
|
617
|
+
...store.value === undefined ? {} : { source: 'file' },
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
async set(ref, value) {
|
|
623
|
+
store.set.push([ref, value]);
|
|
624
|
+
if (store.refuse !== '') return { ok: false, error: { message: store.refuse } };
|
|
625
|
+
store.value = value;
|
|
626
|
+
return { ok: true, value: null };
|
|
627
|
+
},
|
|
628
|
+
async unset(ref) {
|
|
629
|
+
store.unset.push(ref);
|
|
630
|
+
store.value = undefined;
|
|
631
|
+
return { ok: true, value: null };
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
const subject = { kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } };
|
|
635
|
+
const wrapper = claim.component({ subject });
|
|
636
|
+
const harness = mount(wrapper.type);
|
|
637
|
+
globalThis.__molbioTestHooks = harness;
|
|
638
|
+
harness.setProps({ subject, remote: ctx.remote });
|
|
639
|
+
await harness.render();
|
|
640
|
+
assert.deepEqual(store.describe[0], ['NCBI_API_KEY'], 'the section asks only about its own reference');
|
|
641
|
+
assert.match(markup(harness.tree), /Not configured/, 'an empty store reads as not configured');
|
|
642
|
+
const input = elements(harness.tree, 'input')[0];
|
|
643
|
+
assert.equal(input.props.type, 'password', 'the key is typed into a password field');
|
|
644
|
+
input.props.onChange({ target: { value: ' key-123 ' } });
|
|
645
|
+
await harness.render();
|
|
646
|
+
const save = elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Save');
|
|
647
|
+
await save.props.onClick();
|
|
648
|
+
await flush();
|
|
649
|
+
await harness.render();
|
|
650
|
+
assert.deepEqual(store.set, [['NCBI_API_KEY', 'key-123']], 'Save trims and stores under NCBI_API_KEY');
|
|
651
|
+
assert.ok(store.describe.length >= 2, 'the status is re-read after the write');
|
|
652
|
+
assert.match(markup(harness.tree), /Configured \(source: file\)/, 'the status names the source, never the value');
|
|
653
|
+
assert.equal(elements(harness.tree, 'input')[0].props.value, '', 'the draft is cleared after saving');
|
|
654
|
+
|
|
655
|
+
const remove = elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Remove');
|
|
656
|
+
await remove.props.onClick();
|
|
657
|
+
await flush();
|
|
658
|
+
await harness.render();
|
|
659
|
+
assert.deepEqual(store.unset, ['NCBI_API_KEY'], 'Remove deletes the reference');
|
|
660
|
+
assert.match(markup(harness.tree), /Not configured/, 'the status follows the removal');
|
|
661
|
+
|
|
662
|
+
// A provider refusal is shown verbatim instead of being swallowed.
|
|
663
|
+
store.refuse = 'credential/rejected: the credentials file is unreadable';
|
|
664
|
+
elements(harness.tree, 'input')[0].props.onChange({ target: { value: 'x' } });
|
|
665
|
+
await harness.render();
|
|
666
|
+
await elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Save').props.onClick();
|
|
667
|
+
await flush();
|
|
668
|
+
await harness.render();
|
|
669
|
+
assert.ok(markup(harness.tree).includes('credential/rejected: the credentials file is unreadable'), 'a refused write keeps the host message');
|
|
670
|
+
harness.unmount();
|
|
671
|
+
}
|
|
672
|
+
|
|
597
673
|
// ── effects are disposed on unmount ─────────────────────────────────────────
|
|
598
674
|
|
|
599
675
|
{
|
package/test/smoke.mjs
CHANGED
|
@@ -1989,6 +1989,55 @@ delete process.env.MOLBIO_NCBI_API_KEY;
|
|
|
1989
1989
|
}
|
|
1990
1990
|
}
|
|
1991
1991
|
|
|
1992
|
+
{
|
|
1993
|
+
// the harness credential store outranks the environment without ever
|
|
1994
|
+
// materializing the key in process.env; an explicit argument still wins.
|
|
1995
|
+
const saved = process.env.NCBI_API_KEY;
|
|
1996
|
+
process.env.NCBI_API_KEY = 'env-loses';
|
|
1997
|
+
extraServices.set('credentials', {
|
|
1998
|
+
async resolve(ref) {
|
|
1999
|
+
return ref === 'NCBI_API_KEY' ? { value: 'store-key', source: 'store' } : undefined;
|
|
2000
|
+
},
|
|
2001
|
+
});
|
|
2002
|
+
try {
|
|
2003
|
+
mockWeb.calls.length = 0;
|
|
2004
|
+
const stored = await run('molbio_pubmed_search', { query: 'stored key' });
|
|
2005
|
+
assert.equal(stored.backend, 'pubmed-eutils');
|
|
2006
|
+
assert.ok(mockWeb.calls.length === 2 && mockWeb.calls.every((url) => url.includes('api_key=store-key')));
|
|
2007
|
+
|
|
2008
|
+
mockWeb.calls.length = 0;
|
|
2009
|
+
const explicit = await run('molbio_pubmed_search', { query: 'explicit key', api_key: 'arg-key' });
|
|
2010
|
+
assert.equal(explicit.backend, 'pubmed-eutils');
|
|
2011
|
+
assert.ok(mockWeb.calls.every((url) => url.includes('api_key=arg-key')));
|
|
2012
|
+
} finally {
|
|
2013
|
+
extraServices.delete('credentials');
|
|
2014
|
+
delete process.env.NCBI_API_KEY;
|
|
2015
|
+
if (saved !== undefined) process.env.NCBI_API_KEY = saved;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
{
|
|
2020
|
+
// a credential provider that refuses the ref must degrade to the environment,
|
|
2021
|
+
// not break the tool (a composition may have no store at all).
|
|
2022
|
+
const saved = process.env.NCBI_API_KEY;
|
|
2023
|
+
process.env.NCBI_API_KEY = 'env-fallback';
|
|
2024
|
+
extraServices.set('credentials', {
|
|
2025
|
+
async resolve() {
|
|
2026
|
+
throw new Error('no credential store in this composition');
|
|
2027
|
+
},
|
|
2028
|
+
});
|
|
2029
|
+
try {
|
|
2030
|
+
mockWeb.calls.length = 0;
|
|
2031
|
+
const out = await run('molbio_pubmed_search', { query: 'fallback' });
|
|
2032
|
+
assert.equal(out.backend, 'pubmed-eutils');
|
|
2033
|
+
assert.ok(mockWeb.calls.length === 2 && mockWeb.calls.every((url) => url.includes('api_key=env-fallback')));
|
|
2034
|
+
} finally {
|
|
2035
|
+
extraServices.delete('credentials');
|
|
2036
|
+
delete process.env.NCBI_API_KEY;
|
|
2037
|
+
if (saved !== undefined) process.env.NCBI_API_KEY = saved;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1992
2041
|
{
|
|
1993
2042
|
// an invalid key is reported as such, not presented as "no results"
|
|
1994
2043
|
await assert.rejects(() => run('molbio_pubmed_search', { query: 'x', api_key: 'bad-key' }), /API key invalid/);
|