dsh-pi-bridge 0.0.0-stage → 0.1.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 +9 -0
- package/README.md +171 -2
- package/cordis.patch.yml +5 -0
- package/extensions/index.js +221 -0
- package/index.js +230 -0
- package/package.json +68 -4
- package/scripts/e2e-preview.mjs +79 -0
- package/scripts/edge-cases.mjs +57 -0
- package/scripts/entry-check.mjs +78 -0
- package/scripts/fixtures/pi-demo/extensions/index.ts +10 -0
- package/scripts/fixtures/pi-demo/package.json +16 -0
- package/scripts/fixtures/pi-demo/skills/demo-skill/SKILL.md +6 -0
- package/scripts/realhost-check.mjs +125 -0
- package/scripts/selfhost-check.mjs +43 -0
- package/scripts/selftest.mjs +309 -0
- package/skills/dsh-pi-bridge/SKILL.md +62 -0
- package/src/capabilities.js +77 -0
- package/src/capture.js +327 -0
- package/src/inert.js +32 -0
- package/src/load.js +222 -0
- package/src/manifest.js +189 -0
- package/src/replay-dsh.js +159 -0
- package/src/replay-pi.js +101 -0
- package/src/resolve.js +218 -0
- package/src/restore.js +70 -0
- package/src/schema.js +95 -0
- package/src/stubs.js +222 -0
- package/src/tar.js +55 -0
- package/src/transpile.js +93 -0
- package/src/typebox-shim.js +139 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Twofruitsgrape
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM.
|
package/README.md
CHANGED
|
@@ -1,3 +1,172 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-pi-bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**One plugin, two hosts — and each host can install the other’s plugins.**
|
|
4
|
+
|
|
5
|
+
Install it into [pi](https://pi.dev) (`pi install npm:dsh-pi-bridge`) or into
|
|
6
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh plugin add dsh-pi-bridge`),
|
|
7
|
+
and you get the same behaviour either way, plus a bridge that loads packages from *the other
|
|
8
|
+
ecosystem* as native tools, slash commands and skills.
|
|
9
|
+
|
|
10
|
+
一份代码,两个宿主:装到 pi 或装到 DSH —— 装完之后,两边的插件可以互相安装。
|
|
11
|
+
DSH 里用 `pi_install` 装 pi 的插件,pi 里用 `/dsh-install` 装 DSH 的插件,转成本地宿主的
|
|
12
|
+
工具 / 斜杠命令 / 技能。
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# pi coding agent
|
|
18
|
+
pi install npm:dsh-pi-bridge # or: pi install git:github.com/Twofruitsgrape/dsh-pi-bridge
|
|
19
|
+
|
|
20
|
+
# DeepSeek Harness
|
|
21
|
+
dsh plugin add dsh-pi-bridge # or install from the plugin panel: github:Twofruitsgrape/dsh-pi-bridge
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Restart the host afterwards. Both keywords (`pi-package`, `dsh-plugin`) are declared, so the
|
|
25
|
+
package is discoverable in each ecosystem’s own market.
|
|
26
|
+
|
|
27
|
+
## What you get in each host
|
|
28
|
+
|
|
29
|
+
| | pi | DSH |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Install from the other side | `/dsh-install <source>`, tools `dsh_install` / `dsh_preview` | tools `pi_install` / `pi_preview` / `dsh_install` |
|
|
32
|
+
| Manage bridges | `/bridge-list`, `/bridge-remove` | tools `bridge_list` / `bridge_remove` |
|
|
33
|
+
| Skill | `skills/dsh-pi-bridge/SKILL.md` | same `SKILL.md`, mounted through the skill registry |
|
|
34
|
+
|
|
35
|
+
Sources use one grammar on both sides: `npm:<pkg>[@ver]`, `git:github.com/<owner>/<repo>[@ref]`,
|
|
36
|
+
`github:<owner>/<repo>`, a repository URL, or a local path — exactly what each host already accepts.
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
dsh> pi_preview({ source: "npm:pi-subagents" })
|
|
40
|
+
package: pi-subagents@0.78.0
|
|
41
|
+
detected: {"pi":["keyword:pi-package","manifest:pi","layout:extensions|skills"]}
|
|
42
|
+
[tool] task Run a supervised subagent task
|
|
43
|
+
[tool] taskstop Stop a running subagent
|
|
44
|
+
[command] /tasks Show running subagent tasks
|
|
45
|
+
[unsupported] TUI renderer (host-specific)
|
|
46
|
+
|
|
47
|
+
dsh> pi_install({ source: "npm:pi-subagents" })
|
|
48
|
+
bridged npm:pi-subagents as pi-subagents@0.78.0
|
|
49
|
+
tools: pisubagents_task, pisubagents_taskstop
|
|
50
|
+
commands: /pisubagents_tasks
|
|
51
|
+
skipped: event has no DSH equivalent: cache_warming_decision
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
resolve npm registry tarball / shallow git clone / local copy -> ~/.dsh-pi-bridge cache
|
|
58
|
+
mirror a private build tree, so a bridged package never imports a real host
|
|
59
|
+
stub every foreign host import becomes an inert stand-in, or a real bridge shim
|
|
60
|
+
compile TypeScript -> ESM with node:module stripTypeScriptTypes (no toolchain, no download)
|
|
61
|
+
capture load the package against a recording host API and collect what it registers
|
|
62
|
+
replay install those registrations onto the local host, converting schema both ways
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A DSH tool declaration carries a property spec map (`{ text: { type: ‘string’, required: true } }`)
|
|
66
|
+
that `@deepseek-ai/dsh-tools` compiles to JSON Schema; a pi tool carries the JSON Schema directly
|
|
67
|
+
(TypeBox). `src/schema.js` converts both directions, so a bridged tool’s parameters validate in the
|
|
68
|
+
host that receives it. Result shapes are projected the same way: pi’s
|
|
69
|
+
`{ content: [{ type: ‘text’ }] }` to DSH’s `{ ok, result }` and back.
|
|
70
|
+
|
|
71
|
+
Skills need no conversion at all — both hosts implement the
|
|
72
|
+
[Agent Skills specification](https://agentskills.io/specification), so a `SKILL.md` directory simply
|
|
73
|
+
travels. DSH providers are read asynchronously (`list()` is a promise), and the bridge awaits them
|
|
74
|
+
before replay, which is how a bundle like `dsh-comsol` crosses into pi intact.
|
|
75
|
+
|
|
76
|
+
## Verified against real published packages
|
|
77
|
+
|
|
78
|
+
Measured with `npm run test:all` (offline suites plus the network cases), on the
|
|
79
|
+
packages each ecosystem actually ships:
|
|
80
|
+
|
|
81
|
+
| Bridged package | Direction | Arrived | Reported as not crossing |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| `pi-web-access@0.40.0` | pi → DSH | 4 tools, 4 commands, 6 event hooks | `registerShortcut`, 4 unmapped events |
|
|
84
|
+
| `pi-subagents@0.78.0` | pi → DSH | 3 tools, 19 commands, 4 skill dirs | 33 items listed in the report |
|
|
85
|
+
| `@plannotator/pi-extension@0.28.20` | pi → DSH | 216 TS files compiled, 1 skill dir | renderer registrations |
|
|
86
|
+
| `dsh-comsol@1.1.1` (git) | DSH → pi / DSH | its `comsol-dsh` skill | — |
|
|
87
|
+
| `dsh-cost-meter@1.8.26` | DSH → pi | nothing | correctly reports zero registrations |
|
|
88
|
+
|
|
89
|
+
The last row is the honest case: a DSH plugin that is purely a UI/service
|
|
90
|
+
overlay has nothing to bridge, and the preview says so instead of pretending.
|
|
91
|
+
|
|
92
|
+
Bridged tools are executed in the tests, not just listed — `web_search` and
|
|
93
|
+
`source_check` respond with their own argument-validation errors, which proves
|
|
94
|
+
the real package logic runs under the receiving host's tool shape.
|
|
95
|
+
|
|
96
|
+
## What does not cross
|
|
97
|
+
|
|
98
|
+
The bridge reports these instead of failing quietly:
|
|
99
|
+
|
|
100
|
+
- **Model providers and adapters** (`pi.registerProvider`, `ctx.llm.registerAdapter`): wired to host
|
|
101
|
+
internals, not portable.
|
|
102
|
+
- **Host UI**: DSH browser pages / `ctx.slots` and pi’s TUI renderers, `ctx.ui`, shortcuts, CLI flags.
|
|
103
|
+
- **Events without a counterpart**: a small name map covers the lifecycle overlap
|
|
104
|
+
(`session_start` ↔ `agent/created`, `tool_call` ↔ `tools/pre-execute`, …); anything else is listed.
|
|
105
|
+
|
|
106
|
+
Registrations land in the current session immediately, and each host replays the recorded bridges when
|
|
107
|
+
the plugin loads, so an install survives a restart. That replay is cache-only: host startup never waits
|
|
108
|
+
on a network download, and a pruned cache simply reports the bridge as unavailable until it is installed
|
|
109
|
+
again. `restoreOnLoad: false` in the bundle config turns replay off.
|
|
110
|
+
|
|
111
|
+
The bridge keeps its own registry at `~/.dsh-pi-bridge/bridges.json` and writes nothing into either
|
|
112
|
+
host’s settings. `bridge_remove` forgets a bridge and deletes its mirror; tools already registered in
|
|
113
|
+
the running process stay until restart, because silently unregistering host state would be worse than
|
|
114
|
+
leaving it visible.
|
|
115
|
+
|
|
116
|
+
## Security
|
|
117
|
+
|
|
118
|
+
A bridged package **executes code with your permissions**, on whichever side you install it — the
|
|
119
|
+
same trust requirement every extension host has, doubled because the package came from the other
|
|
120
|
+
ecosystem and was not vetted by its market. Read its source before you install it.
|
|
121
|
+
|
|
122
|
+
The bridge limits the blast radius, it does not remove it: packages load from a private mirror whose
|
|
123
|
+
`node_modules` contains only generated stubs, so a bridged extension cannot import the real host API
|
|
124
|
+
or reach either host’s configuration through the import map. `preview` first — it resolves and
|
|
125
|
+
captures but registers nothing.
|
|
126
|
+
|
|
127
|
+
## Develop
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm test # 34 offline checks: both directions, mock hosts, no network
|
|
131
|
+
npm run test:entries # loads both real entries and asserts the host-facing surface
|
|
132
|
+
npm run test:all # the above plus the real-package cases (needs network)
|
|
133
|
+
npm run e2e # just the published-package probes
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The self-test builds fixtures by hand — a TypeScript pi extension using the TypeBox builder, and a
|
|
137
|
+
DSH bundle using `defineTool` with an async skill provider — then runs the full pipeline against a
|
|
138
|
+
recording mock host and executes the bridged tools. `scripts/selfhost-check.mjs` feeds this package's
|
|
139
|
+
own DSH entry through the bridge, so the entry conventions are tested by the same machinery a foreign
|
|
140
|
+
bundle meets.
|
|
141
|
+
|
|
142
|
+
Nothing is written outside `~/.dsh-pi-bridge` (override with `DSH_PI_BRIDGE_HOME`). A bridged package
|
|
143
|
+
sees only its mirrored build tree, so its relative imports work while its host imports resolve to the
|
|
144
|
+
bridge's stubs.
|
|
145
|
+
|
|
146
|
+
## Layout
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
index.js DSH entry: name / inject / apply(ctx), the pi_install tool family
|
|
150
|
+
extensions/index.js pi entry: default(pi) factory, /dsh-install and the dsh_install tools
|
|
151
|
+
skills/dsh-pi-bridge/ SKILL.md, shared by both hosts
|
|
152
|
+
cordis.patch.yml DSH bundle mount (the plugin panel toggle target)
|
|
153
|
+
src/manifest.js host detection from keywords, manifests and directory layout
|
|
154
|
+
src/resolve.js npm / git / url / local sources into a cache
|
|
155
|
+
src/tar.js gzipped-tar reader (npm tarballs), no dependency
|
|
156
|
+
src/transpile.js TypeScript -> ESM via node:module, swc/typescript fallback
|
|
157
|
+
src/stubs.js import scanning + inert stub generation
|
|
158
|
+
src/typebox-shim.js TypeBox- and zod-shaped builders over plain JSON Schema
|
|
159
|
+
src/capture.js recording stand-in hosts for both entry conventions
|
|
160
|
+
src/schema.js parameter schema conversion, both directions
|
|
161
|
+
src/replay-dsh.js capabilities -> ctx.tools / ctx.commands / ctx.skills
|
|
162
|
+
src/replay-pi.js capabilities -> pi.registerTool / registerCommand / resources_discover
|
|
163
|
+
src/capabilities.js normalized capability model, event name map, result projection
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`index.js` imports only `node:` builtins plus this package, and declares `inject: [‘tools’]` with
|
|
167
|
+
everything else acquired through `ctx.inject`, so a profile that composes without the skill registry
|
|
168
|
+
loses skill mounting rather than failing to load.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-pi-bridge — pi entry: the extension half of the plugin.
|
|
3
|
+
*
|
|
4
|
+
* pi loads this file through the package's pi.extensions manifest and calls the
|
|
5
|
+
* default factory with an ExtensionAPI. The factory registers the cross-host
|
|
6
|
+
* installer as pi slash commands plus two model-callable tools, so a pi user or
|
|
7
|
+
* agent can install a DSH plugin bundle into the running pi session.
|
|
8
|
+
*
|
|
9
|
+
* A DSH bundle is loaded against a recording stand-in host (src/capture.js) and
|
|
10
|
+
* its registrations are replayed onto pi's real API (src/replay-pi.js).
|
|
11
|
+
* DSH-only capabilities are reported rather than dropped in silence.
|
|
12
|
+
*
|
|
13
|
+
* @module extensions/index.js
|
|
14
|
+
*/
|
|
15
|
+
import { loadForeign } from "../src/load.js";
|
|
16
|
+
import { replayIntoPi } from "../src/replay-pi.js";
|
|
17
|
+
import { CACHE_ROOT } from "../src/resolve.js";
|
|
18
|
+
import { restoreIntoPi } from "../src/restore.js";
|
|
19
|
+
|
|
20
|
+
const REGISTRY = CACHE_ROOT + "/bridges.json";
|
|
21
|
+
|
|
22
|
+
/** Remember bridges in our own cache, never in pi's settings. */
|
|
23
|
+
async function readState() {
|
|
24
|
+
try {
|
|
25
|
+
const { readFile } = await import("node:fs/promises");
|
|
26
|
+
return JSON.parse(await readFile(REGISTRY, "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return { bridges: {} };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function writeState(state) {
|
|
33
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
34
|
+
await mkdir(CACHE_ROOT, { recursive: true });
|
|
35
|
+
await writeFile(REGISTRY, JSON.stringify(state, null, 2), "utf8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shortPrefix(name) {
|
|
39
|
+
const s = String(name)
|
|
40
|
+
.replace(/^@[^/]+\//, "")
|
|
41
|
+
.replace(/^dsh-/, "")
|
|
42
|
+
.replace(/^pi-/, "")
|
|
43
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/^_+|_+$/g, "")
|
|
46
|
+
.slice(0, 24);
|
|
47
|
+
return s || "bridged";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatReport(spec, captured, report) {
|
|
51
|
+
const lines = ["bridged " + spec + " as " + captured.info.name + "@" + captured.info.version];
|
|
52
|
+
lines.push(" tools: " + (report.tools.length ? report.tools.join(", ") : "none"));
|
|
53
|
+
lines.push(" commands: " + (report.commands.length ? report.commands.map((c) => "/" + c).join(", ") : "none"));
|
|
54
|
+
lines.push(" skills: " + (report.skills.length ? report.skills.join(", ") : "none"));
|
|
55
|
+
if (report.events.length) lines.push(" events: " + report.events.join(", "));
|
|
56
|
+
if (report.skipped.length) lines.push(" skipped: " + report.skipped.join("; "));
|
|
57
|
+
if (captured.errors && captured.errors.length) lines.push(" load errors: " + captured.errors.join("; "));
|
|
58
|
+
lines.push(" bridged tools are callable in this session immediately.");
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function previewText(captured) {
|
|
63
|
+
const lines = [
|
|
64
|
+
"package: " + captured.info.name + "@" + captured.info.version,
|
|
65
|
+
"detected hosts: " + JSON.stringify(captured.info.signals),
|
|
66
|
+
];
|
|
67
|
+
for (const cap of captured.capabilities || []) {
|
|
68
|
+
lines.push(" [" + cap.kind + "] " + cap.name + " - " + String(cap.description || "").slice(0, 100));
|
|
69
|
+
}
|
|
70
|
+
for (const dir of captured.skillDirs || []) lines.push(" [skill-dir] " + dir);
|
|
71
|
+
for (const e of captured.events || []) lines.push(" [event] " + e.event);
|
|
72
|
+
for (const u of captured.unsupported || []) lines.push(" [unsupported] " + u);
|
|
73
|
+
for (const err of captured.errors || []) lines.push(" [error] " + err);
|
|
74
|
+
if (!(captured.capabilities || []).length && !(captured.skillDirs || []).length) {
|
|
75
|
+
lines.push(" (nothing registrable found - the bundle may need host services the bridge cannot stand in for)");
|
|
76
|
+
}
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default async function (pi) {
|
|
81
|
+
async function installDsh(spec, opts) {
|
|
82
|
+
const captured = await loadForeign(spec, { host: "dsh", refresh: !!(opts && opts.refresh) });
|
|
83
|
+
const prefix = (opts && opts.prefix) || shortPrefix(captured.info.name);
|
|
84
|
+
const report = replayIntoPi(pi, captured, { prefix });
|
|
85
|
+
const state = await readState();
|
|
86
|
+
state.bridges = state.bridges || {};
|
|
87
|
+
state.bridges[spec] = {
|
|
88
|
+
name: captured.info.name,
|
|
89
|
+
version: captured.info.version,
|
|
90
|
+
host: "dsh",
|
|
91
|
+
prefix,
|
|
92
|
+
mirror: captured.mirror,
|
|
93
|
+
report,
|
|
94
|
+
at: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
await writeState(state);
|
|
97
|
+
return formatReport(spec, captured, report);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function firstArg(args) {
|
|
101
|
+
return String(args || "").trim().split(/\s+/)[0];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pi.registerCommand("dsh-install", {
|
|
105
|
+
description: "Install a DeepSeek Harness (DSH) plugin bundle into this pi session as tools, commands and skills",
|
|
106
|
+
handler: async (args, ctx) => {
|
|
107
|
+
const spec = firstArg(args);
|
|
108
|
+
if (!spec) return notify(ctx, "usage: /dsh-install <npm:pkg | github:owner/repo | url | local path>", "error");
|
|
109
|
+
notify(ctx, "bridging " + spec + " ...", "info");
|
|
110
|
+
try {
|
|
111
|
+
notify(ctx, await installDsh(spec), "info");
|
|
112
|
+
} catch (e) {
|
|
113
|
+
notify(ctx, "dsh-install failed: " + message(e), "error");
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.registerCommand("dsh-preview", {
|
|
119
|
+
description: "Inspect what a DSH plugin bundle would contribute before installing it",
|
|
120
|
+
handler: async (args, ctx) => {
|
|
121
|
+
const spec = firstArg(args);
|
|
122
|
+
if (!spec) return notify(ctx, "usage: /dsh-preview <source>", "error");
|
|
123
|
+
try {
|
|
124
|
+
notify(ctx, await previewText(await loadForeign(spec, { host: "dsh" })), "info");
|
|
125
|
+
} catch (e) {
|
|
126
|
+
notify(ctx, "dsh-preview failed: " + message(e), "error");
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.registerCommand("bridge-list", {
|
|
132
|
+
description: "List plugin bundles this bridge installed into pi",
|
|
133
|
+
handler: async (_args, ctx) => {
|
|
134
|
+
const state = await readState();
|
|
135
|
+
const keys = Object.keys(state.bridges || {});
|
|
136
|
+
if (!keys.length) return notify(ctx, "no bridged bundles yet", "info");
|
|
137
|
+
notify(ctx, keys.map((k) => {
|
|
138
|
+
const b = state.bridges[k];
|
|
139
|
+
return "- " + k + " (" + b.name + "@" + b.version + ") tools=" + (b.report.tools || []).join(",");
|
|
140
|
+
}).join("\n"), "info");
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
pi.registerCommand("bridge-remove", {
|
|
145
|
+
description: "Forget a bridged bundle and delete its mirror build",
|
|
146
|
+
handler: async (args, ctx) => {
|
|
147
|
+
const spec = firstArg(args);
|
|
148
|
+
const state = await readState();
|
|
149
|
+
if (!spec || !state.bridges || !state.bridges[spec]) {
|
|
150
|
+
return notify(ctx, "not bridged: " + (spec || "(no source given)"), "error");
|
|
151
|
+
}
|
|
152
|
+
const entry = state.bridges[spec];
|
|
153
|
+
delete state.bridges[spec];
|
|
154
|
+
await writeState(state);
|
|
155
|
+
const { rm } = await import("node:fs/promises");
|
|
156
|
+
if (entry.mirror) await rm(entry.mirror, { recursive: true, force: true });
|
|
157
|
+
notify(ctx, "removed " + spec + " (tools registered earlier stay until pi restarts)", "info");
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Model-callable mirrors, so an agent can install a DSH bundle unattended.
|
|
162
|
+
pi.registerTool({
|
|
163
|
+
name: "dsh_install",
|
|
164
|
+
label: "Install DSH plugin",
|
|
165
|
+
description: "Install a DeepSeek Harness plugin bundle into this pi session as native pi tools, slash commands and skills. Accepts the same source grammar pi uses: npm:<pkg>[@ver], git:github.com/<owner>/<repo>[@ref], github:<owner>/<repo>, a URL, or a local path.",
|
|
166
|
+
promptSnippet: "Install a DSH plugin bundle as pi tools and commands",
|
|
167
|
+
parameters: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
source: { type: "string", description: "Bundle source string." },
|
|
171
|
+
prefix: { type: "string", description: "Optional lowercase prefix for bridged names." },
|
|
172
|
+
refresh: { type: "boolean", description: "Re-download even when already cached." },
|
|
173
|
+
},
|
|
174
|
+
required: ["source"],
|
|
175
|
+
},
|
|
176
|
+
async execute(_id, params) {
|
|
177
|
+
try {
|
|
178
|
+
return { content: [{ type: "text", text: await installDsh(params.source, params) }], details: { bridge: "dsh-install" } };
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return { content: [{ type: "text", text: "dsh_install failed: " + message(e) }], details: { error: true } };
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Replay recorded DSH bundles so /dsh-install survives a pi restart.
|
|
186
|
+
const state = await readState();
|
|
187
|
+
const restore = await restoreIntoPi(pi, state, { log: (m) => console.log(m) });
|
|
188
|
+
if (restore.failed.length) console.log("dsh-pi-bridge restore skipped: " + restore.failed.join("; "));
|
|
189
|
+
|
|
190
|
+
pi.registerTool({
|
|
191
|
+
name: "dsh_preview",
|
|
192
|
+
label: "Preview DSH plugin",
|
|
193
|
+
description: "Inspect what a DeepSeek Harness plugin bundle would contribute to this pi session before installing it.",
|
|
194
|
+
promptSnippet: "Preview a DSH bundle's tools, commands and skills",
|
|
195
|
+
parameters: {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: { source: { type: "string", description: "Bundle source string." } },
|
|
198
|
+
required: ["source"],
|
|
199
|
+
},
|
|
200
|
+
async execute(_id, params) {
|
|
201
|
+
try {
|
|
202
|
+
return { content: [{ type: "text", text: await previewText(await loadForeign(params.source, { host: "dsh" })) }], details: { bridge: "dsh-preview" } };
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return { content: [{ type: "text", text: "dsh_preview failed: " + message(e) }], details: { error: true } };
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function notify(ctx, text, level) {
|
|
211
|
+
try {
|
|
212
|
+
if (ctx && ctx.ui && typeof ctx.ui.notify === "function") return ctx.ui.notify(text, level || "info");
|
|
213
|
+
} catch {
|
|
214
|
+
/* fall through to stdout */
|
|
215
|
+
}
|
|
216
|
+
console.log(text);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function message(e) {
|
|
220
|
+
return e && e.message ? String(e.message).slice(0, 400) : String(e);
|
|
221
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-pi-bridge — DSH (DeepSeek Harness) entry: the host half of the plugin.
|
|
3
|
+
*
|
|
4
|
+
* Cordis plugin contract: name, inject, apply(ctx, config). The plugin exposes
|
|
5
|
+
* the cross-host installer as DSH tools, so an agent — or the user through the
|
|
6
|
+
* tool — can install pi packages into the running DSH, preview what a package
|
|
7
|
+
* would provide before installing, and forget bridges again. Registry state
|
|
8
|
+
* lives under ~/.dsh-pi-bridge; nothing here writes into DSH's own profile, so
|
|
9
|
+
* uninstalling the bridge leaves the profile as it was.
|
|
10
|
+
*
|
|
11
|
+
* Services are acquired with ctx.inject rather than a static inject list for
|
|
12
|
+
* everything except tools: a profile that composes without the skills registry
|
|
13
|
+
* loses skill mounting, not the whole plugin.
|
|
14
|
+
*
|
|
15
|
+
* @module index.js
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
21
|
+
import { loadForeign } from './src/load.js'
|
|
22
|
+
import { replayIntoDsh, skillProviderFor } from './src/replay-dsh.js'
|
|
23
|
+
import { CACHE_ROOT } from './src/resolve.js'
|
|
24
|
+
import { restoreIntoDsh } from './src/restore.js'
|
|
25
|
+
|
|
26
|
+
export const name = 'dsh-pi-bridge'
|
|
27
|
+
/** tools is the only hard requirement; the rest degrades through ctx.inject. */
|
|
28
|
+
export const inject = ['tools']
|
|
29
|
+
|
|
30
|
+
const REGISTRY_FILE = path.join(CACHE_ROOT, 'bridges.json')
|
|
31
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
32
|
+
|
|
33
|
+
const OUT = () => ({
|
|
34
|
+
schema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
ok: { type: 'boolean', required: true },
|
|
39
|
+
result: { type: 'string', required: true },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
render: (_args, value) => [{ type: 'text', text: (value && value.result) || '' }],
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** Every host tool answers through one guard: never throw into the agent loop. */
|
|
46
|
+
function guarded (fn) {
|
|
47
|
+
return async (args) => {
|
|
48
|
+
try { return { ok: true, result: String(await fn(args || {})) } }
|
|
49
|
+
catch (e) { return { ok: false, result: 'dsh-pi-bridge: ' + message(e) } }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Host defineTool when the profile composes dsh-tools; a faithful fallback otherwise. */
|
|
54
|
+
async function toolCompiler () {
|
|
55
|
+
try {
|
|
56
|
+
const mod = await import('@deepseek-ai/dsh-tools')
|
|
57
|
+
if (typeof mod.defineTool === 'function') return mod.defineTool
|
|
58
|
+
} catch { /* host without the tools package (self-test) */ }
|
|
59
|
+
const { specToJsonSchema } = await import('./src/schema.js')
|
|
60
|
+
return (options) => ({
|
|
61
|
+
...options,
|
|
62
|
+
parameters: specToJsonSchema(options.parameters),
|
|
63
|
+
execute: async (args, exec) => options.execute(args, exec),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readRegistry () {
|
|
68
|
+
try { return JSON.parse(await readFile(REGISTRY_FILE, 'utf8')) } catch { return { bridges: {} } }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeRegistry (state) {
|
|
72
|
+
await mkdir(CACHE_ROOT, { recursive: true })
|
|
73
|
+
await writeFile(REGISTRY_FILE, JSON.stringify(state, null, 2), 'utf8')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultPrefix (name) {
|
|
77
|
+
const s = String(name)
|
|
78
|
+
.replace(/^@[^/]+\//, '')
|
|
79
|
+
.replace(/^dsh-/, '')
|
|
80
|
+
.replace(/^pi-/, '')
|
|
81
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/^_+|_+$/g, '')
|
|
84
|
+
.slice(0, 24)
|
|
85
|
+
return s || 'bridged'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function message (e) {
|
|
89
|
+
return e && e.message ? String(e.message).slice(0, 400) : String(e)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatReport (spec, captured, report) {
|
|
93
|
+
const lines = ['bridged ' + spec + ' as ' + captured.info.name + '@' + captured.info.version]
|
|
94
|
+
lines.push(' tools: ' + (report.tools.length ? report.tools.join(', ') : 'none'))
|
|
95
|
+
lines.push(' commands: ' + (report.commands.length ? report.commands.map(c => '/' + c).join(', ') : 'none'))
|
|
96
|
+
lines.push(' skills: ' + (report.skills.length ? report.skills.join(', ') : 'none'))
|
|
97
|
+
if (report.events.length) lines.push(' events: ' + report.events.join(', '))
|
|
98
|
+
if (report.skipped.length) lines.push(' skipped: ' + report.skipped.join('; '))
|
|
99
|
+
if (captured.errors && captured.errors.length) lines.push(' load errors: ' + captured.errors.join('; '))
|
|
100
|
+
lines.push(' Tools are callable now. On the next DSH start they are replayed from the bridge cache, so the install persists; other clients pick them up then.')
|
|
101
|
+
return lines.join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function previewText (captured) {
|
|
105
|
+
const lines = ['package: ' + captured.info.name + '@' + captured.info.version, 'detected: ' + JSON.stringify(captured.info.signals)]
|
|
106
|
+
for (const cap of captured.capabilities || []) lines.push(' [' + cap.kind + '] ' + cap.name + ' - ' + String(cap.description || '').slice(0, 100))
|
|
107
|
+
for (const dir of captured.skillDirs || []) lines.push(' [skill-dir] ' + dir)
|
|
108
|
+
for (const e of captured.events || []) lines.push(' [event] ' + e.event)
|
|
109
|
+
for (const u of captured.unsupported || []) lines.push(' [unsupported] ' + u)
|
|
110
|
+
for (const err of captured.errors || []) lines.push(' [error] ' + err)
|
|
111
|
+
if (!(captured.capabilities || []).length && !(captured.skillDirs || []).length) lines.push(' (nothing registrable found - the bundle may need host services the bridge cannot stand in for)')
|
|
112
|
+
return lines.join('\n')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function apply (ctx, config) {
|
|
116
|
+
void config
|
|
117
|
+
const tool = await toolCompiler()
|
|
118
|
+
const state = await readRegistry()
|
|
119
|
+
state.bridges = state.bridges || {}
|
|
120
|
+
const log = (m) => { try { ctx.logger && ctx.logger.info && ctx.logger.info(m) } catch { /* ignore */ } }
|
|
121
|
+
|
|
122
|
+
/** Install one pi package as DSH capabilities and record it. */
|
|
123
|
+
async function installPi (spec, opts) {
|
|
124
|
+
const captured = await loadForeign(spec, { host: 'pi', refresh: !!(opts && opts.refresh), log })
|
|
125
|
+
const prefix = (opts && opts.prefix) || defaultPrefix(captured.info.name)
|
|
126
|
+
const report = await replayIntoDsh(ctx, captured, { prefix })
|
|
127
|
+
state.bridges[spec] = {
|
|
128
|
+
name: captured.info.name,
|
|
129
|
+
version: captured.info.version,
|
|
130
|
+
host: 'pi',
|
|
131
|
+
prefix,
|
|
132
|
+
mirror: captured.mirror,
|
|
133
|
+
report,
|
|
134
|
+
at: new Date().toISOString(),
|
|
135
|
+
}
|
|
136
|
+
await writeRegistry(state)
|
|
137
|
+
return formatReport(spec, captured, report)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const SOURCE = { type: 'string', required: true, description: 'Plugin source in pi grammar: npm:<pkg>[@ver], git:github.com/<owner>/<repo>[@ref], github:<owner>/<repo>, a URL, or a local path.' }
|
|
141
|
+
const PREFIX = { type: 'string', description: 'Optional lowercase prefix for bridged names; defaults to the package name.' }
|
|
142
|
+
|
|
143
|
+
ctx.tools.register(tool({
|
|
144
|
+
name: 'pi_install',
|
|
145
|
+
description: 'Install a pi coding agent package into this DSH session as native DSH tools, slash commands and skills. It resolves the source, compiles its TypeScript to ESM, loads it against a recording host, and replays what it registers onto DSH. Accepts the same source string pi would use. Re-running reloads the package.',
|
|
146
|
+
parameters: {
|
|
147
|
+
source: SOURCE,
|
|
148
|
+
prefix: PREFIX,
|
|
149
|
+
refresh: { type: 'boolean', description: 'Re-download the source even when it is already in the bridge cache.' },
|
|
150
|
+
},
|
|
151
|
+
output: OUT(),
|
|
152
|
+
execute: guarded(async (args) => installPi(args.source, args)),
|
|
153
|
+
}))
|
|
154
|
+
|
|
155
|
+
ctx.tools.register(tool({
|
|
156
|
+
name: 'pi_preview',
|
|
157
|
+
description: 'Inspect what a pi package would provide before installing it: the tools, commands, skills and events it registers, plus anything the bridge cannot carry over. Loads the package into the cache without touching the session.',
|
|
158
|
+
parameters: { source: SOURCE },
|
|
159
|
+
output: OUT(),
|
|
160
|
+
execute: guarded(async (args) => previewText(await loadForeign(args.source, { host: 'pi', log }))),
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
ctx.tools.register(tool({
|
|
164
|
+
name: 'dsh_install',
|
|
165
|
+
description: 'Load another DSH plugin bundle into this session through the bridge, for a bundle the profile does not mount. Inspects the bundle manifest, records its registrations and replays them; it does not replace a bundle the profile already installed.',
|
|
166
|
+
parameters: { source: SOURCE, prefix: PREFIX },
|
|
167
|
+
output: OUT(),
|
|
168
|
+
execute: guarded(async (args) => {
|
|
169
|
+
const captured = await loadForeign(args.source, { host: 'dsh', log })
|
|
170
|
+
const prefix = (args.prefix) || defaultPrefix(captured.info.name)
|
|
171
|
+
const report = await replayIntoDsh(ctx, captured, { prefix })
|
|
172
|
+
state.bridges[args.source] = { name: captured.info.name, version: captured.info.version, host: 'dsh', prefix, mirror: captured.mirror, report, at: new Date().toISOString() }
|
|
173
|
+
await writeRegistry(state)
|
|
174
|
+
return formatReport(args.source, captured, report)
|
|
175
|
+
}),
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
ctx.tools.register(tool({
|
|
179
|
+
name: 'bridge_list',
|
|
180
|
+
description: 'List plugin bundles this bridge installed into DSH, with the tools, commands and skills each contributed.',
|
|
181
|
+
parameters: {},
|
|
182
|
+
output: OUT(),
|
|
183
|
+
execute: guarded(async () => {
|
|
184
|
+
const keys = Object.keys(state.bridges)
|
|
185
|
+
if (!keys.length) return 'no bridged packages yet'
|
|
186
|
+
return keys.map(k => {
|
|
187
|
+
const b = state.bridges[k]
|
|
188
|
+
return '- ' + k + ' (' + b.name + '@' + b.version + ', host=' + b.host + ') tools=' + (b.report.tools || []).join(',') + ' commands=' + (b.report.commands || []).join(',') + ' skills=' + (b.report.skills || []).length
|
|
189
|
+
}).join('\n')
|
|
190
|
+
}),
|
|
191
|
+
}))
|
|
192
|
+
|
|
193
|
+
ctx.tools.register(tool({
|
|
194
|
+
name: 'bridge_remove',
|
|
195
|
+
description: 'Forget a bridged package and delete its mirror build from the bridge cache. Registrations already made in this process stay until DSH restarts.',
|
|
196
|
+
parameters: { source: { ...SOURCE, description: 'The exact source string previously installed.' } },
|
|
197
|
+
output: OUT(),
|
|
198
|
+
execute: guarded(async (args) => {
|
|
199
|
+
if (!state.bridges[args.source]) return 'not bridged: ' + args.source
|
|
200
|
+
const entry = state.bridges[args.source]
|
|
201
|
+
delete state.bridges[args.source]
|
|
202
|
+
await writeRegistry(state)
|
|
203
|
+
if (entry.mirror) await rm(entry.mirror, { recursive: true, force: true })
|
|
204
|
+
return 'removed ' + args.source + ' from the bridge registry (restart DSH to drop its registrations)'
|
|
205
|
+
}),
|
|
206
|
+
}))
|
|
207
|
+
|
|
208
|
+
// Skills from previously recorded bridges remount on every plugin load.
|
|
209
|
+
ctx.inject(['skills'], (scope) => {
|
|
210
|
+
if (!scope || !scope.skills || typeof scope.skills.registerProvider !== 'function') return
|
|
211
|
+
for (const entry of Object.values(state.bridges)) {
|
|
212
|
+
for (const dir of (entry.report && entry.report.skills) || []) {
|
|
213
|
+
if (existsSync(path.join(dir, 'SKILL.md'))) scope.skills.registerProvider(() => skillProviderFor(dir))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// The bridge's own skill, so users find the workflow from the skill list.
|
|
217
|
+
const selfSkill = path.join(HERE, 'skills', 'dsh-pi-bridge')
|
|
218
|
+
if (existsSync(path.join(selfSkill, 'SKILL.md'))) scope.skills.registerProvider(() => skillProviderFor(selfSkill))
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// Replay recorded pi packages so an install survives a restart instead of
|
|
222
|
+
// living one session. Disabled with { restoreOnLoad: false } in bundle config.
|
|
223
|
+
const restore = (config && config.restoreOnLoad === false) ? { restored: [], failed: [] } : await restoreIntoDsh(ctx, state, { log })
|
|
224
|
+
if (restore.failed.length) { try { ctx.logger && ctx.logger.warn && ctx.logger.warn('dsh-pi-bridge restore: ' + restore.failed.join('; ')) } catch { /* ignore */ } }
|
|
225
|
+
|
|
226
|
+
const count = Object.keys(state.bridges).length
|
|
227
|
+
log('dsh-pi-bridge loaded: ' + count + ' bridged package(s) on record, ' + restore.restored.length + ' replayed')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default { name, inject, apply }
|