dsh-origin-bridge 0.0.0-stage → 0.4.0
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 +21 -0
- package/README.md +233 -2
- package/asciiio.py +169 -0
- package/bin/origin-bridge.mjs +31 -0
- package/binio.py +208 -0
- package/client_check.py +61 -0
- package/cordis.patch.yml +45 -0
- package/docs/preview.png +0 -0
- package/edge_checks.py +183 -0
- package/fixtures/a_tab.dat +4 -0
- package/fixtures/b_csv.csv +4 -0
- package/fixtures/c_semicolon.txt +3 -0
- package/fixtures/d_space.dat +3 -0
- package/fixtures/e_comment_lines.dat +7 -0
- package/fixtures/f_multi_header.dat +5 -0
- package/fixtures/g_na_and_D.dat +4 -0
- package/fixtures/h_gbk_units.dat +3 -0
- package/fixtures/i_bom_utf8.csv +3 -0
- package/fixtures/make_fixtures.py +35 -0
- package/icon.svg +10 -0
- package/index.js +15 -0
- package/install_dsh.ps1 +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/originlab.py +1506 -0
- package/package.json +72 -4
- package/requirements.txt +2 -0
- package/sample.dat +25 -0
- package/scripts/make_icon.py +155 -0
- package/server.py +577 -0
- package/smoke_test.py +420 -0
- package/templates_catalog.py +754 -0
- package/test_asciiio.py +89 -0
- package/test_binio.py +151 -0
package/client_check.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Compatibility check with the official MCP SDK client.
|
|
2
|
+
|
|
3
|
+
smoke_test.py speaks raw JSON-RPC; this one proves a stock MCP client — the same
|
|
4
|
+
protocol dsh's @deepseek-ai/dsh-mcp-client speaks — can list and call our tools.
|
|
5
|
+
|
|
6
|
+
pip install "mcp>=1.0"
|
|
7
|
+
python client_check.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def main():
|
|
19
|
+
from mcp import ClientSession, StdioServerParameters
|
|
20
|
+
from mcp.client.stdio import stdio_client
|
|
21
|
+
|
|
22
|
+
params = StdioServerParameters(
|
|
23
|
+
command=sys.executable,
|
|
24
|
+
args=["-u", os.path.join(HERE, "server.py")],
|
|
25
|
+
cwd=HERE,
|
|
26
|
+
env={**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"},
|
|
27
|
+
)
|
|
28
|
+
async with stdio_client(params) as (read, write):
|
|
29
|
+
async with ClientSession(read, write) as session:
|
|
30
|
+
init = await session.initialize()
|
|
31
|
+
print("server :", init.server_info.name, init.server_info.version)
|
|
32
|
+
print("protocol :", init.protocol_version)
|
|
33
|
+
|
|
34
|
+
tools = (await session.list_tools()).tools
|
|
35
|
+
print("tools :", len(tools))
|
|
36
|
+
for t in tools:
|
|
37
|
+
print(" %-20s %s" % (t.name, t.description.split("。")[0][:60]))
|
|
38
|
+
|
|
39
|
+
res = await session.call_tool("origin_status", {})
|
|
40
|
+
text = "".join(c.text for c in res.content if c.type == "text")
|
|
41
|
+
payload = json.loads(text)
|
|
42
|
+
print("status call :", "connected" if payload.get("connected") else payload)
|
|
43
|
+
|
|
44
|
+
if "--figure" in sys.argv:
|
|
45
|
+
out = os.path.join(HERE, "out")
|
|
46
|
+
os.makedirs(out, exist_ok=True)
|
|
47
|
+
res = await session.call_tool("origin_figure", {
|
|
48
|
+
"source": os.path.join(HERE, "sample.dat"),
|
|
49
|
+
"x_title": "Time (s)", "y_title": "Signal (mV)",
|
|
50
|
+
"output_dir": out, "width": 1400,
|
|
51
|
+
})
|
|
52
|
+
payload = json.loads("".join(c.text for c in res.content if c.type == "text"))
|
|
53
|
+
if not payload.get("ok"):
|
|
54
|
+
print("figure FAILED:", json.dumps(payload, ensure_ascii=False)[:900])
|
|
55
|
+
else:
|
|
56
|
+
print("figure :", payload.get("ok"), payload.get("delivered"),
|
|
57
|
+
payload.get("duration_ms"), "ms")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
asyncio.run(main())
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# dsh-origin-bridge — DeepSeek Harness bundle patch
|
|
2
|
+
#
|
|
3
|
+
# Registers server.py as a native dsh tool provider through the MCP client
|
|
4
|
+
# loader that ships inside the dsh app package (@deepseek-ai/dsh-mcp-client).
|
|
5
|
+
#
|
|
6
|
+
# IMPORTANT — new entries must use `insert:`.
|
|
7
|
+
# A top-level `- id: <new id>` block is an *override* of an entry that already
|
|
8
|
+
# exists in a lower layer; for a brand-new id dsh silently ignores it, which
|
|
9
|
+
# looks exactly like "I edited the config and nothing happened".
|
|
10
|
+
#
|
|
11
|
+
# serverName must be an identifier: tools are exposed as
|
|
12
|
+
# mcp__<serverName>__<tool>, so a hyphen here would produce an unaddressable
|
|
13
|
+
# tool name.
|
|
14
|
+
#
|
|
15
|
+
# Path resolution:
|
|
16
|
+
# market install -> <DSH_HOME>/profiles/<profile>/node_modules/dsh-origin-bridge
|
|
17
|
+
# manual install -> set ORIGIN_BRIDGE_HOME to the folder holding server.py
|
|
18
|
+
# interpreter -> set ORIGIN_BRIDGE_PYTHON to your venv python.exe
|
|
19
|
+
#
|
|
20
|
+
# failOnStartupError: false keeps dsh bootable when Origin is missing or a modal
|
|
21
|
+
# dialog stalls the COM server; the tools just do not register that session.
|
|
22
|
+
|
|
23
|
+
- insert:
|
|
24
|
+
- id: mcp-origin-bridge
|
|
25
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
26
|
+
config:
|
|
27
|
+
serverName: origin_bridge
|
|
28
|
+
transport: stdio
|
|
29
|
+
command: !!js (process.env.ORIGIN_BRIDGE_PYTHON || 'python')
|
|
30
|
+
args:
|
|
31
|
+
- '-u'
|
|
32
|
+
- '-X'
|
|
33
|
+
- utf8
|
|
34
|
+
- !!js (process.env.ORIGIN_BRIDGE_HOME || (String(process.env.DSH_HOME || (String(process.env.USERPROFILE) + '/.dsh')) + '/profiles/desktop/node_modules/dsh-origin-bridge')) + '/server.py'
|
|
35
|
+
env:
|
|
36
|
+
PYTHONIOENCODING: utf-8
|
|
37
|
+
PYTHONUNBUFFERED: '1'
|
|
38
|
+
failOnStartupError: false
|
|
39
|
+
toolCallTimeoutMs: 180000
|
|
40
|
+
# Self-mount. dsh's plugin market decides "live / restart pending" by looking
|
|
41
|
+
# for this bundle's package name among the loaded entry names. Without this
|
|
42
|
+
# row the only entry name is @deepseek-ai/dsh-mcp-client, so the market shows
|
|
43
|
+
# "restart to apply" forever even after a restart. index.js apply() is a no-op.
|
|
44
|
+
- id: dsh-origin-bridge
|
|
45
|
+
name: dsh-origin-bridge
|
package/docs/preview.png
ADDED
|
Binary file
|
package/edge_checks.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Edge-case battery: code paths the main smoke test never executes.
|
|
2
|
+
|
|
3
|
+
Run: python -X utf8 edge_checks.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
sys.path.insert(0, HERE)
|
|
15
|
+
import smoke_test as S # noqa: E402
|
|
16
|
+
|
|
17
|
+
SANDBOX = os.path.join(HERE, "edge")
|
|
18
|
+
results = []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check(label, cond, detail=""):
|
|
22
|
+
ok = bool(cond)
|
|
23
|
+
results.append((label, ok))
|
|
24
|
+
print("%-5s %-42s %s" % ("PASS" if ok else "FAIL", label, str(detail)[:150]))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
shutil.rmtree(SANDBOX, ignore_errors=True)
|
|
29
|
+
os.makedirs(SANDBOX, exist_ok=True)
|
|
30
|
+
rpc = S.Rpc(S.PY, S.SERVER)
|
|
31
|
+
rpc.request("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
|
|
32
|
+
"clientInfo": {"name": "edge", "version": "1"}})
|
|
33
|
+
rpc.notify("notifications/initialized")
|
|
34
|
+
|
|
35
|
+
# 1) every export format the tool claims to support
|
|
36
|
+
imp = rpc.tool("origin_import", {"path": os.path.join(HERE, "sample.dat")})
|
|
37
|
+
ws = imp["worksheet"]
|
|
38
|
+
p = rpc.tool("origin_plot", {"worksheet": ws, "plot_type": "line"})
|
|
39
|
+
gr = p["graph"]
|
|
40
|
+
for fmt, magic in (("svg", b"<"), ("pdf", b"%PDF"), ("tif", b"II*\x00"), ("emf", b"\x01\x00\x00\x00")):
|
|
41
|
+
target = os.path.join(SANDBOX, "chart." + fmt)
|
|
42
|
+
r = rpc.tool("origin_export", {"graph": gr, "path": target, "fmt": fmt})
|
|
43
|
+
head = b""
|
|
44
|
+
if os.path.isfile(target):
|
|
45
|
+
with open(target, "rb") as fh:
|
|
46
|
+
head = fh.read(8)
|
|
47
|
+
check("export %s" % fmt, r.get("ok") and head.startswith(magic),
|
|
48
|
+
(r.get("bytes"), head[:4], r.get("code")))
|
|
49
|
+
|
|
50
|
+
# 2) inline data end to end (no file at all)
|
|
51
|
+
fig = rpc.tool("origin_figure", {
|
|
52
|
+
"columns": {"Dose": [0, 1, 2, 3, 4], "Response": [1.2, 4.1, 8.8, 12.2, 19.5]},
|
|
53
|
+
"x": 1, "y": 2, "plot_type": "scatter", "output_dir": os.path.join(SANDBOX, "inline"),
|
|
54
|
+
"x_title": "Dose (mg)", "y_title": "Response"})
|
|
55
|
+
check("figure from inline columns", fig.get("ok") and fig.get("data", {}).get("rows") == 5,
|
|
56
|
+
(fig.get("data", {}).get("rows"), fig.get("delivered"), fig.get("code")))
|
|
57
|
+
|
|
58
|
+
# 3) address columns by label instead of index
|
|
59
|
+
bylabel = rpc.tool("origin_plot", {"worksheet": ws, "x": "X", "y": "Y", "plot_type": "column"})
|
|
60
|
+
check("plot by column label", bylabel.get("ok") and bylabel.get("y_columns") == [2],
|
|
61
|
+
(bylabel.get("x_column"), bylabel.get("y_columns"), bylabel.get("code")))
|
|
62
|
+
both = rpc.tool("origin_plot", {"worksheet": ws, "x": "X", "y": ["Y", "Y"],
|
|
63
|
+
"plot_type": "line"})
|
|
64
|
+
check("plot several labelled series", both.get("ok") and both.get("y_columns") == [2, 2]
|
|
65
|
+
and both.get("plots_in_layer") == 2,
|
|
66
|
+
(both.get("y_columns"), both.get("plots_in_layer"), both.get("code")))
|
|
67
|
+
badlabel = rpc.tool("origin_plot", {"worksheet": ws, "x": "nope", "y": "Y"})
|
|
68
|
+
check("unknown label lists real ones", badlabel.get("code") == "bad_column"
|
|
69
|
+
and "X" in str(badlabel.get("message")), badlabel.get("message"))
|
|
70
|
+
|
|
71
|
+
# 4) error branches that must be clean, not internal_error
|
|
72
|
+
bs = rpc.tool("origin_style", {"graph": gr, "series": 9, "color": "#000"})
|
|
73
|
+
check("style bad series", bs.get("code") == "bad_series", bs.get("code"))
|
|
74
|
+
bl = rpc.tool("origin_style", {"graph": gr, "layer": 7, "x_title": "z"})
|
|
75
|
+
check("style bad layer", bl.get("code") == "bad_layer", bl.get("code"))
|
|
76
|
+
nf = rpc.tool("origin_fit", {"worksheet": ws, "kind": "nlfitsing", "func": "totally_bogus_fn"})
|
|
77
|
+
check("bogus fit function", nf.get("code") == "fit_function_unavailable", nf.get("code"))
|
|
78
|
+
vwbad = rpc.tool("origin_view", {"graph": "gr-99999"})
|
|
79
|
+
check("view refuses unknown handle", vwbad.get("ok") is False
|
|
80
|
+
and vwbad.get("code") == "graph_not_found", vwbad.get("code"))
|
|
81
|
+
exbad = rpc.tool("origin_export", {"graph": "gr-99999",
|
|
82
|
+
"path": os.path.join(SANDBOX, "wrong.png")})
|
|
83
|
+
check("export refuses unknown handle", exbad.get("ok") is False
|
|
84
|
+
and not os.path.isfile(os.path.join(SANDBOX, "wrong.png")), exbad.get("code"))
|
|
85
|
+
check("write unequal lengths rejected", rpc.tool("origin_write", {"columns": {"a": [1, 2], "b": [1]}}).get("code")
|
|
86
|
+
== "bad_arguments", "bad_arguments")
|
|
87
|
+
strcol = rpc.tool("origin_write", {"columns": {"a": ["x", "y"]}})
|
|
88
|
+
check("non-numeric data gives clean error", strcol.get("code") == "bad_arguments"
|
|
89
|
+
and "a" in str(strcol.get("message")), (strcol.get("code"), str(strcol.get("message"))[:60]))
|
|
90
|
+
check("unknown extra param tolerated", rpc.tool("origin_status", {"wat": 1}).get("connected") is True, "ok")
|
|
91
|
+
|
|
92
|
+
# 5) CJK + spaces in the data path (Origin/LabTalk historically choke here)
|
|
93
|
+
hard = os.path.join(SANDBOX, "中文 目录 with spaces")
|
|
94
|
+
os.makedirs(hard, exist_ok=True)
|
|
95
|
+
hard_dat = os.path.join(hard, "数据 表.dat")
|
|
96
|
+
shutil.copyfile(os.path.join(HERE, "sample.dat"), hard_dat)
|
|
97
|
+
cjk = rpc.tool("origin_import", {"path": hard_dat})
|
|
98
|
+
check("import from CJK+space path", cjk.get("rows") == 24, (cjk.get("rows"), cjk.get("code")))
|
|
99
|
+
cfig = rpc.tool("origin_figure", {"source": hard_dat,
|
|
100
|
+
"output_dir": os.path.join(hard, "输出 out"),
|
|
101
|
+
"x_title": "时间 (s)", "y_title": "信号 (mV)"})
|
|
102
|
+
files = cfig.get("delivered", {})
|
|
103
|
+
check("deliver into CJK+space dir", cfig.get("ok") and all(
|
|
104
|
+
os.path.isfile(v) for v in files.values()), files or cfig.get("message", "")[:80])
|
|
105
|
+
|
|
106
|
+
# 6) xlsx round trip
|
|
107
|
+
try:
|
|
108
|
+
import openpyxl
|
|
109
|
+
wbk = openpyxl.Workbook()
|
|
110
|
+
sh = wbk.active
|
|
111
|
+
sh.append(["Temp", "Value"])
|
|
112
|
+
for i in range(20):
|
|
113
|
+
sh.append([i * 1.0, (i ** 0.5) * 3])
|
|
114
|
+
xp = os.path.join(SANDBOX, "data.xlsx")
|
|
115
|
+
wbk.save(xp)
|
|
116
|
+
xr = rpc.tool("origin_import", {"path": xp})
|
|
117
|
+
check("import xlsx", xr.get("rows") == 20 and xr.get("cols") == 2,
|
|
118
|
+
(xr.get("rows"), xr.get("cols"), xr.get("code")))
|
|
119
|
+
except ImportError:
|
|
120
|
+
print("SKIP import xlsx (openpyxl not installed in this venv)")
|
|
121
|
+
|
|
122
|
+
# 7) formula that creates a brand-new column
|
|
123
|
+
fx = rpc.tool("origin_formula", {"worksheet": ws, "col": 3, "formula": "Col(2)*2", "label": "Doubled"})
|
|
124
|
+
check("formula creates new column", fx.get("ok") and fx.get("computed_rows") == 24,
|
|
125
|
+
(fx.get("computed_rows"), fx.get("code"), fx.get("message", "")[:60]))
|
|
126
|
+
|
|
127
|
+
# 8) big table: 5000 rows through write_block
|
|
128
|
+
rows = [[float(i), float(i * i % 977), float((i * 7) % 311)] for i in range(5000)]
|
|
129
|
+
t0 = time.time()
|
|
130
|
+
big = rpc.tool("origin_write", {"headers": ["i", "a", "b"], "rows": rows})
|
|
131
|
+
dt = time.time() - t0
|
|
132
|
+
check("5000x3 block write", big.get("rows") == 5000 and big.get("cols") == 3,
|
|
133
|
+
"%d rows in %.1fs" % (big.get("rows") or 0, dt))
|
|
134
|
+
bigplot = rpc.tool("origin_plot", {"worksheet": big["worksheet"], "x": 1, "y": 2})
|
|
135
|
+
check("plot big table", bigplot.get("ok") and (bigplot.get("x_axis_to") or 0) >= 4990,
|
|
136
|
+
(bigplot.get("x_axis_to"), bigplot.get("code")))
|
|
137
|
+
|
|
138
|
+
# 9) open a project we saved earlier, then exit and reconnect in one process
|
|
139
|
+
saved = (fig.get("project") or {}).get("path")
|
|
140
|
+
if saved and os.path.isfile(saved):
|
|
141
|
+
op = rpc.tool("origin_open", {"path": saved})
|
|
142
|
+
check("reopen saved opju", op.get("ok") and len(op.get("open_pages", [])) >= 1,
|
|
143
|
+
(op.get("open_pages"), op.get("code")))
|
|
144
|
+
bad_open = rpc.tool("origin_open", {"path": os.path.join(SANDBOX, "nope.opju")})
|
|
145
|
+
check("open missing file", bad_open.get("code") == "file_not_found", bad_open.get("code"))
|
|
146
|
+
|
|
147
|
+
ex = rpc.tool("origin_exit", {})
|
|
148
|
+
after = rpc.tool("origin_status", {}, timeout=200)
|
|
149
|
+
check("exit then auto-reconnect", ex.get("exited") and after.get("connected") is True,
|
|
150
|
+
(ex.get("exited"), after.get("connected"), after.get("code")))
|
|
151
|
+
|
|
152
|
+
rpc.close()
|
|
153
|
+
|
|
154
|
+
# 10) the node launcher shipped in bin/ must actually start the server
|
|
155
|
+
node = shutil.which("node")
|
|
156
|
+
if node:
|
|
157
|
+
mjs = os.path.join(HERE, "bin", "origin-bridge.mjs")
|
|
158
|
+
proc = subprocess.Popen([node, mjs], cwd=HERE, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
159
|
+
stderr=subprocess.PIPE, text=True, encoding="utf-8")
|
|
160
|
+
try:
|
|
161
|
+
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
162
|
+
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
163
|
+
"clientInfo": {"name": "node", "version": "1"}}}) + "\n")
|
|
164
|
+
proc.stdin.flush()
|
|
165
|
+
line = proc.stdout.readline()
|
|
166
|
+
info = json.loads(line)["result"]["serverInfo"]
|
|
167
|
+
check("bin/origin-bridge.mjs launcher", info.get("name") == "optima-plot", info)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
check("bin/origin-bridge.mjs launcher", False, repr(exc)[:120])
|
|
170
|
+
finally:
|
|
171
|
+
proc.kill()
|
|
172
|
+
else:
|
|
173
|
+
print("SKIP node launcher (node not on PATH)")
|
|
174
|
+
|
|
175
|
+
bad = [n for n, ok in results if not ok]
|
|
176
|
+
print("\n%d/%d passed" % (len(results) - len(bad), len(results)))
|
|
177
|
+
if bad:
|
|
178
|
+
print("FAILED:", ", ".join(bad))
|
|
179
|
+
return 1 if bad else 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
sys.exit(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Regenerate the fixtures/ files used by test_asciiio.py.
|
|
2
|
+
|
|
3
|
+
Filenames describe the parser behaviour each one exercises, so a new messy real
|
|
4
|
+
file can be dropped in alongside them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
10
|
+
|
|
11
|
+
FILES = {
|
|
12
|
+
"a_tab.dat": ("utf-8", "X\tY\n0\t1.5\n1\t2.5\n2\t3.5\n"),
|
|
13
|
+
"b_csv.csv": ("utf-8", "Time,Signal,V\ns,mV,\n0,1.5,0.1\n1,2.5,0.2\n"),
|
|
14
|
+
"c_semicolon.txt": ("utf-8", "A;B\n1;2\n3;4\n"),
|
|
15
|
+
"d_space.dat": ("utf-8", "0 1.50 0.01\n1 2.50 0.02\n2 3.50 0.01\n"),
|
|
16
|
+
"e_comment_lines.dat": ("utf-8", "// instrument: UV-8000\n! legacy banner\n"
|
|
17
|
+
"# run 2026-10-08\nX\tY\n1.0\t1.0\n2.0\t2.0\n3.0\t3.0\n"),
|
|
18
|
+
"f_multi_header.dat": ("utf-8", "Sample\tSample\tSample\nTime\tSignal\tFlag\n"
|
|
19
|
+
"s\tmV\t-\n0\t1.5\t1\n1\t2.5\t0\n"),
|
|
20
|
+
"g_na_and_D.dat": ("utf-8", "X\tY\tZ\n0\t1.5D+00\tNA\n1\t2.5d+00\t-\n2\tNA\t3.1\n"),
|
|
21
|
+
"h_gbk_units.dat": ("gbk", "温度\t电导\n25.0\t1.23\n30.0\t1.45\n"),
|
|
22
|
+
"i_bom_utf8.csv": ("utf-8-sig", "波长,吸光度\n200,0.12\n210,0.34\n"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
for name, (enc, text) in sorted(FILES.items()):
|
|
28
|
+
path = os.path.join(HERE, name)
|
|
29
|
+
with open(path, "w", encoding=enc, newline="") as fh:
|
|
30
|
+
fh.write(text)
|
|
31
|
+
print("wrote %s (%s, %d bytes)" % (name, enc, os.path.getsize(path)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|
package/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
|
2
|
+
<rect x="0" y="0" width="512" height="512" rx="113" fill="#0B1B2B"/>
|
|
3
|
+
<path d="M 102.4 409.6 H 430.1
|
|
4
|
+
M 102.4 409.6 V 102.4"
|
|
5
|
+
stroke="#B0C4D6" stroke-width="28.2" stroke-linecap="round" fill="none"/>
|
|
6
|
+
<ellipse cx="279.0" cy="243.2" rx="130.6" ry="79.4"
|
|
7
|
+
transform="rotate(-24.0 279.0 243.2)" fill="none"
|
|
8
|
+
stroke="#39D6C3" stroke-width="36.9"/>
|
|
9
|
+
<circle cx="361.9" cy="283.7" r="26.6" fill="#F7B24A"/>
|
|
10
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// dsh bundle entry point.
|
|
2
|
+
//
|
|
3
|
+
// The Origin tools are NOT implemented here: they come from server.py, which
|
|
4
|
+
// cordis.patch.yml registers through @deepseek-ai/dsh-mcp-client. This module
|
|
5
|
+
// exists so the bundle has a loadable main and so dsh's plugin market sees the
|
|
6
|
+
// package as live after installation. Keep apply() side-effect free — anything
|
|
7
|
+
// done here runs on every dsh startup.
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-origin-bridge';
|
|
10
|
+
|
|
11
|
+
export function activate() {}
|
|
12
|
+
|
|
13
|
+
export function apply() {}
|
|
14
|
+
|
|
15
|
+
export default { name, activate, apply };
|
package/install_dsh.ps1
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Install Origin Bridge into DeepSeek Harness.
|
|
2
|
+
#
|
|
3
|
+
# powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1
|
|
4
|
+
# powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1 -Profile web
|
|
5
|
+
#
|
|
6
|
+
# ASCII only on purpose: Windows PowerShell 5.1 reads .ps1 as ANSI unless a BOM
|
|
7
|
+
# is present, so non-ASCII text can decode into stray quote characters and break
|
|
8
|
+
# parsing.
|
|
9
|
+
#
|
|
10
|
+
# Why an `insert:` block and not a bare `- id:` entry:
|
|
11
|
+
# a top-level `- id: X` in the profile layer OVERRIDES an entry X that a lower
|
|
12
|
+
# (bundle) layer already created. For a brand-new id it silently does nothing,
|
|
13
|
+
# which looks exactly like "I edited the config, restarted, and nothing
|
|
14
|
+
# happened". `- insert:` creates the entry.
|
|
15
|
+
#
|
|
16
|
+
# Why we do not copy into node_modules:
|
|
17
|
+
# a bundle is only applied when it is BOTH a dependency and listed in
|
|
18
|
+
# dsh.profile.bundles in the profile package.json. Registering there means
|
|
19
|
+
# running pnpm against a live profile (it prunes/rewrites node_modules). The
|
|
20
|
+
# profile patch layer is applied unconditionally, so an insert here needs no
|
|
21
|
+
# package-manager surgery.
|
|
22
|
+
|
|
23
|
+
param(
|
|
24
|
+
[string]$Profile = 'desktop',
|
|
25
|
+
[string]$PythonExe = '',
|
|
26
|
+
[string]$DshHome = '',
|
|
27
|
+
[switch]$RemoveOld
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
$ErrorActionPreference = 'Stop'
|
|
31
|
+
$src = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
32
|
+
$entryId = 'mcp-origin-bridge'
|
|
33
|
+
$oldEntryId = 'mcp-originlab-bridge'
|
|
34
|
+
$serverPy = Join-Path $src 'server.py'
|
|
35
|
+
|
|
36
|
+
if (-not (Test-Path $serverPy)) { throw "server.py not next to this script: $serverPy" }
|
|
37
|
+
|
|
38
|
+
if (-not $DshHome) { $DshHome = Join-Path $env:USERPROFILE '.dsh' }
|
|
39
|
+
$profileDir = Join-Path $DshHome "profiles\$Profile"
|
|
40
|
+
if (-not (Test-Path $profileDir)) {
|
|
41
|
+
throw "dsh profile dir not found: $profileDir (pass -Profile web or desktop)"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (-not $PythonExe) {
|
|
45
|
+
$candidate = Join-Path $src '.venv\Scripts\python.exe'
|
|
46
|
+
if (Test-Path $candidate) { $PythonExe = $candidate }
|
|
47
|
+
else { $PythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source }
|
|
48
|
+
}
|
|
49
|
+
if (-not $PythonExe -or -not (Test-Path $PythonExe)) {
|
|
50
|
+
throw "python interpreter not found. Create it first: python -m venv .venv; .\.venv\Scripts\pip install -r requirements.txt"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
$prevEap = $ErrorActionPreference
|
|
54
|
+
$ErrorActionPreference = 'Continue'
|
|
55
|
+
& $PythonExe -c "import originpro" 2>$null | Out-Null
|
|
56
|
+
if ($LASTEXITCODE -ne 0) {
|
|
57
|
+
Write-Warning "$PythonExe has no originpro; the server will start but cannot connect to Origin"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$patch = Join-Path $profileDir 'cordis.patch.yml'
|
|
61
|
+
$q = "'"
|
|
62
|
+
$block = ""
|
|
63
|
+
$block += "`r`n# --- origin-bridge (installed $(Get-Date -Format 'yyyy-MM-dd HH:mm')) ---`r`n"
|
|
64
|
+
$block += "# insert: creates the loader entry in the profile layer. Do NOT add a second`r`n"
|
|
65
|
+
$block += "# full insert for ${entryId}: duplicate loader entry id makes dsh refuse to boot.`r`n"
|
|
66
|
+
$block += "# failOnStartupError keeps dsh usable when Origin is missing.`r`n"
|
|
67
|
+
$block += "- insert:`r`n"
|
|
68
|
+
$block += " - id: $entryId`r`n"
|
|
69
|
+
$block += " name: '@deepseek-ai/dsh-mcp-client'`r`n"
|
|
70
|
+
$block += " config:`r`n"
|
|
71
|
+
$block += " serverName: origin_bridge`r`n"
|
|
72
|
+
$block += " transport: stdio`r`n"
|
|
73
|
+
$block += " command: $q$PythonExe$q`r`n"
|
|
74
|
+
$block += " args:`r`n"
|
|
75
|
+
$block += " - '-u'`r`n"
|
|
76
|
+
$block += " - '-X'`r`n"
|
|
77
|
+
$block += " - utf8`r`n"
|
|
78
|
+
$block += " - $q$serverPy$q`r`n"
|
|
79
|
+
$block += " env:`r`n"
|
|
80
|
+
$block += " PYTHONIOENCODING: utf-8`r`n"
|
|
81
|
+
$block += " PYTHONUNBUFFERED: '1'`r`n"
|
|
82
|
+
$block += " failOnStartupError: false`r`n"
|
|
83
|
+
$block += " toolCallTimeoutMs: 180000`r`n"
|
|
84
|
+
|
|
85
|
+
# Self-mount: without it the plugin only shows up as a tag under the "mcp-client"
|
|
86
|
+
# card. dsh's plugin UI groups by loader module name, and the market decides
|
|
87
|
+
# "live" by looking for this bundle's package name among loaded entry names --
|
|
88
|
+
# so the name must resolve to a real package in the profile's node_modules.
|
|
89
|
+
# We stage a registration-only package (no python code) so there is never a
|
|
90
|
+
# second copy of the server to drift out of sync with C:\...\origin-bridge.
|
|
91
|
+
$pkgName = 'dsh-origin-bridge'
|
|
92
|
+
$pkgDir = Join-Path $profileDir "node_modules\$pkgName"
|
|
93
|
+
if (-not (Test-Path (Join-Path $pkgDir 'index.js'))) {
|
|
94
|
+
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
|
|
95
|
+
@'
|
|
96
|
+
{
|
|
97
|
+
"name": "dsh-origin-bridge",
|
|
98
|
+
"version": "0.4.0",
|
|
99
|
+
"description": "Origin Bridge: natural language" -> local Origin/OriginPro -> editable .opju + publication images",
|
|
100
|
+
"main": "./index.js",
|
|
101
|
+
"type": "module",
|
|
102
|
+
"license": "MIT"
|
|
103
|
+
}
|
|
104
|
+
'@ | Set-Content -Path (Join-Path $pkgDir 'package.json') -Encoding ASCII
|
|
105
|
+
@'
|
|
106
|
+
// Registration-only bundle. The Origin tools come from server.py in this
|
|
107
|
+
// package's own folder, registered by the mcp-origin-bridge insert in the
|
|
108
|
+
// profile's cordis.patch.yml. This module exists so dsh's plugin UI can show
|
|
109
|
+
// origin-bridge as its own card; keep apply() side-effect free because it
|
|
110
|
+
// runs on every dsh startup.
|
|
111
|
+
export const name = 'dsh-origin-bridge';
|
|
112
|
+
export function activate() {}
|
|
113
|
+
export function apply() {}
|
|
114
|
+
export default { name, activate, apply };
|
|
115
|
+
'@ | Set-Content -Path (Join-Path $pkgDir 'index.js') -Encoding ASCII
|
|
116
|
+
Write-Host "staged registration-only package -> $pkgDir"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
$block += "`r`n# self-mount: makes $pkgName appear as its own card in dsh's plugin UI.`r`n"
|
|
120
|
+
$block += "# apply() is a no-op; the tools come from the $entryId insert above.`r`n"
|
|
121
|
+
$block += "# NOTE: a future 'pnpm install' in this profile may prune this folder, which`r`n"
|
|
122
|
+
$block += "# only removes the card -- the origin_* tools keep working.`r`n"
|
|
123
|
+
$block += "- insert:`r`n"
|
|
124
|
+
$block += " - id: $pkgName`r`n"
|
|
125
|
+
$block += " name: $pkgName`r`n"
|
|
126
|
+
|
|
127
|
+
$existing = if (Test-Path $patch) { Get-Content $patch -Raw -Encoding UTF8 } else { '' }
|
|
128
|
+
|
|
129
|
+
# -RemoveOld: drop the previous plugin's insert block. Both entries pointed at
|
|
130
|
+
# C:\...\originlab-bridge, and a dead stdio server still shows up as a tool
|
|
131
|
+
# provider, so leaving it in place would mean two overlapping tool sets.
|
|
132
|
+
$removed = $false
|
|
133
|
+
if ($RemoveOld -and $existing -match [regex]::Escape($oldEntryId)) {
|
|
134
|
+
$lines = @($existing -split "`r?`n")
|
|
135
|
+
$from = -1; $to = $lines.Count
|
|
136
|
+
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
137
|
+
if ($from -lt 0 -and $lines[$i] -match '^# --- originlab-bridge') { $from = $i; continue }
|
|
138
|
+
if ($from -ge 0 -and $lines[$i] -match '^# --- ') { $to = $i; break }
|
|
139
|
+
}
|
|
140
|
+
if ($from -ge 0) {
|
|
141
|
+
$kept = @($lines[0..($from - 1)]) + @($lines[$to..($lines.Count - 1)])
|
|
142
|
+
$existing = ($kept -join "`r`n")
|
|
143
|
+
$removed = $true
|
|
144
|
+
Write-Host "removed the old $oldEntryId block (lines $from..$to)"
|
|
145
|
+
} else {
|
|
146
|
+
Write-Warning "$oldEntryId is referenced but its '# --- originlab-bridge' marker was not found; leaving it alone"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Back up once, before any write on either path below.
|
|
151
|
+
$backupDir = Join-Path $DshHome "backups\origin-bridge-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
|
152
|
+
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
|
|
153
|
+
if (Test-Path $patch) { Copy-Item $patch (Join-Path $backupDir 'cordis.patch.yml.bak') }
|
|
154
|
+
|
|
155
|
+
if ($existing -match [regex]::Escape($entryId)) {
|
|
156
|
+
$tail = ''
|
|
157
|
+
if ($existing -notmatch [regex]::Escape($pkgName)) {
|
|
158
|
+
$tail = $block -replace '(?s)^.*?(# self-mount)', '$1'
|
|
159
|
+
}
|
|
160
|
+
if ($removed -or $tail) {
|
|
161
|
+
Set-Content -Path $patch -Value ($existing + $tail) -Encoding UTF8 -NoNewline
|
|
162
|
+
}
|
|
163
|
+
if ($tail) {
|
|
164
|
+
Write-Host "added the self-mount card entry only (tools insert was already present)"
|
|
165
|
+
} elseif (-not $removed) {
|
|
166
|
+
Write-Host "profile patch already has both entries; nothing appended"
|
|
167
|
+
}
|
|
168
|
+
$ErrorActionPreference = $prevEap
|
|
169
|
+
exit 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
Set-Content -Path $patch -Value ($existing + $block) -Encoding UTF8 -NoNewline
|
|
173
|
+
Write-Host "appended insert block; backup -> $backupDir"
|
|
174
|
+
|
|
175
|
+
$checkers = @($PythonExe, (Get-Command python -ErrorAction SilentlyContinue).Source) |
|
|
176
|
+
Where-Object { $_ -and (Test-Path $_ -ErrorAction SilentlyContinue) }
|
|
177
|
+
$checked = $false
|
|
178
|
+
$code = "import yaml,io,sys;d=yaml.safe_load(io.open(sys.argv[1],encoding='utf-8'));assert isinstance(d,list);" + `
|
|
179
|
+
"ins=[e for e in d if isinstance(e,dict) and 'insert' in e];" + `
|
|
180
|
+
"print('patch parses OK:',len(d),'entries; inserted ids:',[x.get('id') for e in ins for x in e['insert']])"
|
|
181
|
+
foreach ($c in $checkers) {
|
|
182
|
+
& $c -c "import yaml" 2>$null | Out-Null
|
|
183
|
+
if ($LASTEXITCODE -eq 0) {
|
|
184
|
+
& $c -c $code "$patch"
|
|
185
|
+
if ($LASTEXITCODE -ne 0) {
|
|
186
|
+
$ErrorActionPreference = $prevEap
|
|
187
|
+
throw "cordis.patch.yml does not parse; restore it from $backupDir"
|
|
188
|
+
}
|
|
189
|
+
$checked = $true
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
$ErrorActionPreference = $prevEap
|
|
194
|
+
if (-not $checked) { Write-Warning "no interpreter with PyYAML found; skipped YAML validation" }
|
|
195
|
+
|
|
196
|
+
Write-Host ""
|
|
197
|
+
Write-Host "DONE. Fully quit dsh (tray -> exit, not just close the window), reopen, then ask" -ForegroundColor Green
|
|
198
|
+
Write-Host "a new chat to list tools whose names contain origin_." -ForegroundColor Green
|
package/locale/en.json
ADDED