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.
@@ -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())
@@ -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
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,4 @@
1
+ X Y
2
+ 0 1.5
3
+ 1 2.5
4
+ 2 3.5
@@ -0,0 +1,4 @@
1
+ Time,Signal,V
2
+ s,mV,
3
+ 0,1.5,0.1
4
+ 1,2.5,0.2
@@ -0,0 +1,3 @@
1
+ A;B
2
+ 1;2
3
+ 3;4
@@ -0,0 +1,3 @@
1
+ 0 1.50 0.01
2
+ 1 2.50 0.02
3
+ 2 3.50 0.01
@@ -0,0 +1,7 @@
1
+ // instrument: UV-8000
2
+ ! legacy banner
3
+ # run 2026-10-08
4
+ X Y
5
+ 1.0 1.0
6
+ 2.0 2.0
7
+ 3.0 3.0
@@ -0,0 +1,5 @@
1
+ Sample Sample Sample
2
+ Time Signal Flag
3
+ s mV -
4
+ 0 1.5 1
5
+ 1 2.5 0
@@ -0,0 +1,4 @@
1
+ X Y Z
2
+ 0 1.5D+00 NA
3
+ 1 2.5d+00 -
4
+ 2 NA 3.1
@@ -0,0 +1,3 @@
1
+ �¶� �絼
2
+ 25.0 1.23
3
+ 30.0 1.45
@@ -0,0 +1,3 @@
1
+ 波长,吸光度
2
+ 200,0.12
3
+ 210,0.34
@@ -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 };
@@ -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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Origin Bridge",
4
+ "description": "Drive local Origin/OriginPro from chat: import .dat/.csv/Excel/workstation .bin data, plot, fit, and save editable .opju projects. Third-party tool; not affiliated with OriginLab Corporation."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Origin 图表桥",
4
+ "description": "用自然语言驱动本机 Origin/OriginPro:导入 .dat/.csv/Excel/工作站 .bin 数据,画图、拟合,并保存可在 Origin 里继续编辑的 .opju 工程。第三方工具,与 OriginLab Corporation 无关联。"
5
+ }
6
+ }