dsh-plugin-git-commit-push 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ygzhang-lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,473 @@
1
+ # dsh-plugin-git-commit-push
2
+
3
+ English | [中文](README.md)
4
+
5
+ DSH (DeepSeek Harness) Git commit and push plugin, one call to complete: summarize changes, automatically generate commit messages for each changed file according to Conventional Commits, ask for tagging if necessary, and then push to the Git remote repository configured for the current project.
6
+
7
+ - Users can complete a commit and push with just a 0 Token through the `/git-commit-push` slash command (without going through the model, as the script automatically generates simple and clear commits according to the rules);
8
+ - DSH can utilize the `git_commit_push` tool, requiring minimal tokens to accomplish high-quality annotations and complete a single commit and push.
9
+ This plugin serves as a tool-based replacement for the earlier `.agents/skills/git-commit-push` Skill. It condenses the process of running dozens of Shell scripts and reading through a pile of raw Git output, which is typically required for pure Skill work, into **1-2 tool invocations + a compact card**.
10
+
11
+ Installing gives you three surfaces:
12
+
13
+ | Surface | Name | Used by |
14
+ | ------------- | ------------------------------------------------ | ----------------------------------------------- |
15
+ | Tool | `git_commit_push` (`prepare` / `apply` / `auto`) | the model |
16
+ | Slash command | `/git-commit-push` | you, **with no model round-trip at all** |
17
+ | Skill | `git-commit-push` | the model, loading the full procedure on demand |
18
+
19
+ - Platforms: **Windows and macOS / Linux** (see "Platforms")
20
+ - Requirements: DSH `>=0.2.0-rc.1 <0.3.0`, Node `>=20`, git `>=2.36`
21
+
22
+ ## Install
23
+
24
+ ### A. Plugins page (recommended)
25
+
26
+ DSH → **Settings → Plugins** → type the package name into the install field:
27
+
28
+ ```
29
+ dsh-plugin-git-commit-push
30
+ ```
31
+
32
+ Then **restart DSH**. The same page can **enable / disable / uninstall** it — which is exactly why this
33
+ package declares `dsh.bundle.patch` and makes itself a DSH _bundle_. Without that declaration the page
34
+ answers every request with **"这个包没有声明组合包,不能作为插件管理"** (host code `not-bundle`).
35
+
36
+ ### B. Command line
37
+
38
+ ```sh
39
+ dsh plugin --profile <profile> add dsh-plugin-git-commit-push # install
40
+ dsh plugin --profile <profile> remove dsh-plugin-git-commit-push # uninstall
41
+ ```
42
+
43
+ `<profile>` is your profile name (`web`, `headless`, a custom one). Both directions edit the profile's
44
+ `package.json` (dependency + `dsh.profile.bundles`) and **need a DSH restart**.
45
+
46
+ ### C. From a checkout (development / offline)
47
+
48
+ Clone the repository and use the installer to wire the checkout into a profile as a `link:` dependency:
49
+
50
+ ```powershell
51
+ # Windows (default profile: desktop)
52
+ powershell -ExecutionPolicy Bypass -File .\setup.ps1
53
+ powershell -ExecutionPolicy Bypass -File .\setup.ps1 -Profile web
54
+ powershell -ExecutionPolicy Bypass -File .\setup.ps1 -Uninstall
55
+ ```
56
+
57
+ ```bash
58
+ # macOS / Linux
59
+ sh setup.sh # default profile: desktop
60
+ sh setup.sh web # a specific profile
61
+ sh setup.sh web --uninstall
62
+ ```
63
+
64
+ The scripts do two things (idempotent, with backups): add the checkout as a `link:` dependency and add the
65
+ package name to `dsh.profile.bundles`, then run `pnpm install` inside the profile. **The mount row is not
66
+ written by the scripts** — it comes from this package's own bundle patch (see "The DSH bundle contract").
67
+ They also **remove** the mount row an older revision wrote into the profile's `cordis.patch.yml`, because
68
+ Loader `insert` is append-only and the same id mounted twice would register the plugin twice.
69
+
70
+ > Do not mix the three ways: a package should be mounted exactly once per profile.
71
+
72
+ ### After installing
73
+
74
+ Once DSH is restarted:
75
+
76
+ - the model can call `git_commit_push`, and you can type `/git-commit-push`;
77
+ - Settings → Plugins lists this package (title "Git 提交与推送", with an icon) and can enable/disable/uninstall it;
78
+ - the model's skill catalog contains `git-commit-push`;
79
+ - Settings shows this plugin's **configuration form** (11 fields, applied live with no restart).
80
+
81
+ ## Trigger policy (read this first)
82
+
83
+ **Only two situations may use it; it never fires on its own:**
84
+
85
+ 1. **the user typed the `/git-commit-push` slash command** — the command runs it, no model involved;
86
+ 2. **the user explicitly asked** to commit and/or push ("commit", "push", "提交", "推送").
87
+
88
+ **Finishing an edit, completing a task, an almost-over session, or a user saying "save it" are NOT
89
+ triggers.** Editing files is not a request to commit them. When in doubt, ask instead of committing.
90
+
91
+ The rule is stated in three model-visible places: the `git_commit_push` tool description (the first thing
92
+ a model reads when choosing a tool), [SKILL.md](./SKILL.md) (registered as an embedded skill at mount
93
+ time), and the command description. The plugin cannot enforce it inside `execute` — by then the decision
94
+ has already been made.
95
+
96
+ ## Is "0 tokens for commit + push" accurate?
97
+
98
+ **Partly — it needs qualification.** The honest split is by path:
99
+
100
+ | Path | Model tokens | Why |
101
+ | ---------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | **1. `/git-commit-push` slash command** | **0 (really)** | No model request is made at all. Command discovery, execution and UI output cost no model tokens, and the result is rendered in the UI only — it never enters the transcript. This is the **only** truly 0-token path. |
103
+ | **2. `git_commit_push(auto)` tool call** | a few | The model has to emit the call (arguments + thinking). The plugin writes the message itself, so there is **no** second round-trip; the card is ~200 tokens. One round trip. |
104
+ | **3. `prepare` + `apply` (default)** | ~2 round trips | Only the `prepare` card (~200 tokens) and the `apply` `message` argument enter the context — **no diff is read**. |
105
+
106
+ So: "the slash command costs 0 tokens" is accurate; "the tool costs 0 tokens" is **not** — the model still
107
+ pays for each call. What the plugin saves is **comparative**: reading raw git output (thousands of tokens →
108
+ ~200) and several round trips.
109
+
110
+ One **fixed cost** must also be stated: while the plugin is installed and the tool is visible to the model,
111
+ its schema (a ~1.5 KB description plus parameters) enters **every** request — roughly **600 tokens**, paid
112
+ even when you never commit. If you only want `/git-commit-push` and never let the model call the tool, moving
113
+ the tool out of the model-visible surface (or `deferLoading` it) removes that cost.
114
+
115
+ ## Usage
116
+
117
+ ```
118
+ git_commit_push({ action: "prepare" }) # survey only, no writes
119
+ git_commit_push({ action: "apply", message: "feat(x): …" }) # commit + push
120
+ git_commit_push({ action: "auto" }) # commit with the rule-generated message
121
+ git_commit_push({ action: "apply", message: "…", tag: "v1.2.3" })
122
+ git_commit_push({ action: "prepare", cwd: "/path/to/repo" }) # session cwd is not the repository
123
+ ```
124
+
125
+ `/git-commit-push` variants (**0 tokens, no model involved**):
126
+ `/git-commit-push`, `/git-commit-push --prepare`, `/git-commit-push --no-push`, `/git-commit-push --en`, `/git-commit-push --tag=v1.2.3`, `/git-commit-push fix login timeout`.
127
+
128
+ ### What the card looks like
129
+
130
+ ```
131
+ 🔎 **改动预览(未提交)** · `main` · 3 files · +48 / -12
132
+ status: 1 added / 2 modified
133
+ added src/foo/bar.ts +40/-0 → feat(foo): add bar component
134
+ modified src/foo/baz.ts +8/-10 → fix(foo): correct parseThing decision
135
+ deleted src/old.ts → refactor: remove old
136
+ tag evidence: version 1.2.3 → 1.2.4 (package.json)
137
+ recent style: "feat(ui): add theme switch" "fix(api): correct retry decision"
138
+ subject: `feat(foo): update bar component`
139
+ (preview only — nothing committed, nothing pushed)
140
+ ```
141
+
142
+ And after a commit:
143
+
144
+ ```
145
+ ✅ **Git 提交并推送成功** · `main` · `a1b2c3d`
146
+ 信息:feat(foo): 更新 bar 组件
147
+ 提交 3 个文件 · +48 / -12
148
+ - feat(foo): 新增 bar 组件 · src/foo/bar.ts
149
+ - fix(foo): 修正 parseThing 判断 · src/foo/baz.ts
150
+ - refactor: 移除 old · src/old.ts
151
+ 标签:v1.2.4
152
+ 推送:已推送(含标签)
153
+ ```
154
+
155
+ Four outcomes are unmistakable, so a card can no longer be mistaken for "nothing happened":
156
+
157
+ | First line | Meaning |
158
+ | ------------------------------- | --------------------------------------------------------------------- |
159
+ | `✅ **Git 提交并推送成功**` | committed and pushed |
160
+ | `✅ **Git 提交成功(未推送)**` | committed; `autoPush` is off or this call said `--no-push` |
161
+ | `⚠️ **已提交,但推送失败**` | the commit is local, the push failed (reason in `说明:`) |
162
+ | `❌ **提交失败**` | nothing was committed; your changes are untouched in the working tree |
163
+
164
+ Other states: `ℹ️ **没有需要提交的改动**`, `⚠️ **当前目录不是 Git 仓库**` (with the candidate repositories listed),
165
+ `❌ **未初始化 Git**`, `❌ **找不到 git**`.
166
+
167
+ ### Multiple files: one commit, one note per file
168
+
169
+ Several files are still **one commit**, but its body carries one typed Conventional note per file instead of a single
170
+ sentence pretending to cover all of them:
171
+
172
+ ```
173
+ feat(api): 更新 decideRetry
174
+
175
+ - feat(api): 更新 decideRetry · src/api/retry.ts
176
+ - docs: 更新文档 guide · docs/guide.md
177
+ - test(api): 新增 retry.test.ts · src/api/retry.test.ts
178
+ ```
179
+
180
+ - Every note describes **that one file**: the type comes from the file itself (`docs/` → `docs:`, `*.test.ts` →
181
+ `test:`), the scope is its directory (dropped when it would only repeat the type, so `docs(docs)` never appears),
182
+ and the summary prefers a symbol the file's own diff declares (`update decideRetry`).
183
+ - Supply only a **subject** (a single-line `message`) and the per-file notes are appended for you; supply your own
184
+ body (a multi-line `message`) and it is used **verbatim**, with no generated notes added.
185
+ - A **single-file commit has no body** — its subject says it all.
186
+ - The body names at most `maxFilesShown` files (12 by default); the rest collapse into `- …另有 N 个文件`.
187
+
188
+ (Card text is localized: `defaultLanguage: "zh"` or `"en"` — including the per-file notes. The card _format_ is
189
+ Chinese-and-English-mixed by design: the verdict line and labels are Chinese in both locales, the file notes follow
190
+ the message language.)
191
+
192
+ ## Settings
193
+
194
+ **Prefer DSH's own settings form.** The plugin exports a Cordis `Config` schema, so its row accepts a `config`
195
+ mapping: DSH's settings service derives a namespace from it, the Settings page renders the fields, and
196
+ `@deepseek-ai/dsh-config-editor` writes the choice into the active profile's `cordis.patch.yml` — on the very
197
+ row this bundle inserts — applying it through the Loader. Every field is declared **volatile**, so a change
198
+ does not remount the plugin and needs no DSH restart: it takes effect on the next tool call.
199
+
200
+ **Three sources, highest first:**
201
+
202
+ | Layer | Location | Written by |
203
+ | ----- | --------------------------------------------------------------------------------------- | ------------------------------------------------------- |
204
+ | 1 | the row's `config` (the profile's `cordis.patch.yml`) | DSH's settings form |
205
+ | 2 | `<DSH_HOME>/git-commit-push.config.json` (default `~/.dsh/git-commit-push.config.json`) | you, by hand; the fallback for `link:`/offline installs |
206
+ | 3 | the shipped template `git-commit-push.config.json` | the package |
207
+ | — | built-in defaults | whatever none of the above sets |
208
+
209
+ A missing file is not an error. A file that exists but is not valid JSON is **reported** — the result card
210
+ gains a "配置未生效: …" line rather than silently doing nothing. Omitted keys keep their defaults.
211
+
212
+ | Key | Default | Meaning | In the form |
213
+ | --------------------------- | -------- | -------------------------------------------------------------------------------------- | ----------- |
214
+ | `autoPush` | `true` | push after a successful commit | ✅ |
215
+ | `autoAdd` | `true` | `git add -A` before committing | ✅ |
216
+ | `tagOnVersionChange` | `true` | ask about a tag when a version file changed | ✅ |
217
+ | `tagOnBreaking` | `true` | ask when a public declaration was removed | ✅ |
218
+ | `tagOnFileCount` | `10` | ask when ≥ N files changed (`0` disables) | ✅ |
219
+ | `tagPrefix` | `"v"` | suggested tag prefix | ✅ |
220
+ | `askBeforeTag` | `true` | `false` tags silently (the only "no question" switch) | ✅ |
221
+ | `askTimeoutMs` | `120000` | how long the tag question waits | ✅ |
222
+ | `defaultLanguage` | `"zh"` | language of the generated message (`zh`/`en`) | ✅ |
223
+ | `maxFilesShown` | `12` | how many paths the card lists | ✅ |
224
+ | `pinnedIdentity.name/email` | empty | applied per commit with `-c user.name/-c user.email`; your git config is never written | ✅ |
225
+
226
+ > Where exactly the form lives depends on your DSH version (the plugins/config entry in Settings). The
227
+ > **Settings → Plugins → Plugin list** tab is explicitly **read-only** (it lets users inspect plugins
228
+ > _without changing their configuration_); the editable form comes from the settings service. Field
229
+ > descriptions are Chinese, matching the default message language — DSH has no per-field localization yet.
230
+
231
+ **Why a JSON file still exists**: the `Config` schema needs `@deepseek-ai/schemastery`, declared here as an
232
+ ordinary **dependency** so an npm install brings it along. With a `link:` (source checkout) install pnpm does
233
+ not install a link target's dependencies and the host's module resolution may not reach it either. In that
234
+ case the plugin **still works** — it just has no form (`Config === undefined`), and the JSON file is the only
235
+ way in. That is why the file stays, and why it outranks the shipped template.
236
+
237
+ Environment: `DSH_HOME` is the DSH home (default `~/.dsh`, Windows `%USERPROFILE%\.dsh`); it also decides
238
+ where the user settings file lives.
239
+
240
+ ## The `git-commit-push` skill
241
+
242
+ [SKILL.md](./SKILL.md) is registered as an **embedded skill** (`ctx.skills.register(...)`) when the
243
+ plugin mounts, so there is nothing to copy into a skills directory after an npm install. The skill name
244
+ matches the package name (minus the `dsh-plugin-` prefix): the tool, the command, the skill and the npm
245
+ package share one name instead of two.
246
+
247
+ - To **override** it, keep a project-level skill of the same name (`.agents/skills/git-commit-push/SKILL.md`).
248
+ The registry ranks **project > runtime registration**, so your copy wins.
249
+ - A host without a skill registry still works: the skill and the `/git-commit-push` command are **optional
250
+ capabilities** (awaited through a scoped `ctx.inject`), and losing either never affects the
251
+ `git_commit_push` tool.
252
+ - **Coming from an early revision**: if you manually installed the old SKILL.md into
253
+ `~/.agents/skills/git-commit/`, that is a user-level skill under a _different_ name and will appear next
254
+ to the bundled `git-commit-push`. The package now owns that content, so deleting that directory is
255
+ recommended (or keep it and put your own rules there — different names, so neither shadows the other).
256
+
257
+ ## Safety boundary
258
+
259
+ **Never**: modify `.gitignore`, write git config (including `user.name`/`user.email`), `push --force`,
260
+ `reset --hard`, `git clean`, `checkout -- <path>`, or `commit --no-verify`.
261
+ [lib/git.js](./lib/git.js) is the only place that talks to git and its command set is fixed — adding a
262
+ destructive verb means changing that file first.
263
+
264
+ **Handled automatically**: `push -u origin <branch>` when there is no upstream; a rejected push
265
+ (the remote moved) retries once after `pull --rebase`; on a rebase conflict it aborts **only the rebase it
266
+ started itself** (probing `rebase-merge`/`rebase-apply` first, so your own rebase progress is never
267
+ discarded) and reports honestly; an existing or invalid tag name is skipped with an explanation.
268
+
269
+ **Tagging is conservative**: a tag is created only with your explicit consent (or `askBeforeTag: false`).
270
+ An explicitly passed `tag` argument is treated as an instruction and skips the question. No answerer
271
+ available, you are not present (a delegated call), or the wait timed out — all mean **do not tag**, and the
272
+ card tells you how to add it later with the `tag` argument.
273
+
274
+ **It does not guess**: when the session directory is not a repository it reports the candidate repositories
275
+ for you to pick with `cwd` instead of committing in one of them, and "git is not installed" is a different
276
+ failure from "this is not a repository".
277
+
278
+ ## Platforms
279
+
280
+ | Capability | Windows | macOS / Linux |
281
+ | --------------------------- | ----------------------------------------- | ------------------------------------- |
282
+ | Runtime (the plugin itself) | ✅ | ✅ audited: no missed platform branch |
283
+ | Installers (path C) | `setup.ps1` (PowerShell) | `setup.sh` (POSIX sh) |
284
+ | Uninstall | `setup.ps1 -Uninstall` | `setup.sh <profile> --uninstall` |
285
+ | Profile manifest edit | both call the same `lib/profile-edit.mjs` | same |
286
+
287
+ Audit notes for `index.js` + `lib/*`:
288
+
289
+ - the only platform branch is `DEV_NULL` (Windows `NUL` vs `/dev/null`), used solely by the **test fixture**
290
+ to isolate git's global config; the plugin's real git calls deliberately keep your global config
291
+ (credential helper, `pull.rebase`, `core.autocrlf` live there, and clearing it would change how your
292
+ repositories behave);
293
+ - no hard-coded drive letters, no `C:\`, no dependency on `powershell`; modules use relative `./`
294
+ specifiers (safe on case-sensitive filesystems); temporary directories come from `os.tmpdir()`;
295
+ - `setup.sh` avoids two cross-platform traps on purpose: **no `sed -i`** (BSD/macOS and GNU differ) and
296
+ **no `readlink -f`** (absent on macOS), using POSIX `awk` and `cd`+`pwd` instead.
297
+
298
+ ## Self-check
299
+
300
+ No DSH required, and it never touches your repositories (tests build their own repos under the system temp
301
+ directory and delete them):
302
+
303
+ ```bash
304
+ npm test # = node self-test.mjs && node self-test-git.mjs
305
+ node self-test.mjs # pure logic + packaging/config/form/skill contracts (86 checks)
306
+ node self-test-git.mjs # real git: porcelain -z framing, rename attribution, version detection, end-to-end commit, per-file notes, card verdicts (24 checks)
307
+ node capture-git-format.mjs # prints raw git -z bytes, for diagnosing framing
308
+ ```
309
+
310
+ On Windows with the runtime DSH ships:
311
+
312
+ ```powershell
313
+ & "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" self-test.mjs
314
+ ```
315
+
316
+ One test in `self-test-git.mjs` is **deliberately independent of the implementation**: it asks git the same
317
+ question twice — once with `git status --porcelain -z` and once with the plain, non-NUL format — and
318
+ requires both to describe the same set of paths. A broken parser therefore fails the test instead of
319
+ agreeing with itself.
320
+
321
+ That test has a history worth keeping: the first `lib/survey.js` imported a function from a module that did
322
+ not export it. The ESM link error made the whole plugin graph unevaluable, so neither `git_commit_push` nor
323
+ `/git-commit-push` registered — and the pure-logic test never touched `survey.js`. Both test files now import
324
+ the complete module graph explicitly, and `apply()` validates the schema with `toolDefinitionProblems()`
325
+ before registering.
326
+
327
+ The last two sections of `self-test.mjs` check **packaging and runtime contracts** rather than algorithms:
328
+
329
+ - the declared `dsh.bundle.patch` exists and is non-empty, inserts **exactly one** mount row (two rows mount
330
+ the plugin twice), uses the stable id `git-commit-push` and names this package; neither installer writes a
331
+ mount row of its own;
332
+ - **the `files` whitelist covers every relative module the entry point imports** — the classic npm
333
+ publishing accident, where the package installs cleanly and then fails to load;
334
+ - the `exports` subpaths, `locale/*.json` and `icon` the Plugins page reads (relative path, allowed type,
335
+ ≤256 KiB);
336
+ - every DSH peer is `optional` (otherwise pnpm tries to install a host package into the user's profile),
337
+ plus `dsh.manifestVersion` and `engines.dsh`;
338
+ - **the settings layers and the field table agree**: the field table, the built-in defaults and the shipped
339
+ template cannot drift, the UI layer (row config) beats the JSON file, `pinnedIdentity` merges per key, and
340
+ only a parsed value that differs from the default counts as "the user set it";
341
+ - **the form is either published or degraded**: when `@deepseek-ai/schemastery` resolves, `Config` must be
342
+ built with every field volatile; when it does not, `Config` must be `undefined` and the tool must still
343
+ register;
344
+ - settings precedence (user file > shipped template) and the rule that **a malformed config must be
345
+ reported**;
346
+ - the skill definition parsed from SKILL.md satisfies the registry's `validateRuntimeSkill` rules, and
347
+ `apply()` on a mock host really registers the tool, the command and the skill.
348
+
349
+ Two small readers exist for exactly the syntax this project writes (the YAML patch, the skill frontmatter):
350
+ the package ships zero dependencies, so the test will not pull in a YAML parser to read three lines — and a
351
+ line it cannot read fails the test instead of being ignored.
352
+
353
+ ## Layout
354
+
355
+ ```
356
+ index.js plugin entry: tool definition, /git-commit-push command, skill registration, orchestration
357
+ cordis.patch.yml the bundle patch: the single mount row (dsh.bundle.patch points at it)
358
+ icon.svg Plugins page icon (package.json "icon")
359
+ locale/en.json Plugins page display text (meta.title / meta.description, English)
360
+ locale/zh.json same, Chinese
361
+ lib/git.js the only git layer: fixed argv, timeouts, output caps, porcelain parsing, platform probing
362
+ lib/analyze.js change classification + rule-based Conventional Commits + card rendering
363
+ lib/survey.js one repository survey: status / numstat / log / bounded diff
364
+ lib/config.js settings + the field table: row config > user file > template > defaults, and reporting a broken file
365
+ lib/schema.js the Cordis Config (schemastery): the visual form + volatile fields, degrading gracefully when the library is unreachable
366
+ lib/skill.js parses SKILL.md into the runtime skill definition (frontmatter included)
367
+ lib/profile-edit.mjs profile manifest editor shared by both installers (idempotent, keeps unknown fields, no BOM, self-verifying)
368
+ setup.ps1 Windows install / uninstall (path C)
369
+ setup.sh macOS / Linux install / uninstall (path C)
370
+ self-test.mjs pure logic + packaging/config/form contracts (86 checks)
371
+ self-test-git.mjs real-git integration (24 checks, own temporary repository)
372
+ capture-git-format.mjs prints raw git -z bytes (framing diagnostics)
373
+ e2e-check.mjs calls run() directly, to verify the commit path without restarting DSH
374
+ ```
375
+
376
+ Design trade-offs. The tool definition is a **hand-written object** instead of `defineTool(...)`; the only
377
+ host import in the runtime is a deliberate exception — `lib/schema.js` uses `createRequire` to obtain
378
+ `@deepseek-ai/schemastery` so the plugin can declare a `Config`, and the whole thing is wrapped in a
379
+ `try/catch`: when it cannot be reached, `Config === undefined` (no form) and the plugin and its tool keep
380
+ working. Every other module still imports only Node built-ins and relative paths, because this package may be
381
+ `link:`ed from outside a profile or sit inside `node_modules`, and mounting must not fail because the host's
382
+ module resolution does not reach it. (`@deepseek-ai/schemastery` is an ordinary `dependencies` entry, so an
383
+ npm install brings it; a `link:` install does not install a link target's dependencies, which makes that
384
+ degradation path real rather than theoretical.)
385
+
386
+ The price is writing a **real JSON Schema by hand**: `parameters` needs `type: "object"` + `properties` +
387
+ `required: []`, and `output.schema`'s `required` must be an **array of strings** — `defineTool`'s
388
+ per-property `required: true` form is compiled by `defineTool` itself, and a hand-written definition
389
+ carrying it is rejected **at registration time**, taking the whole plugin down.
390
+ `toolDefinitionProblems()` is the regression test for that rule.
391
+
392
+ `exports` carries `./package.json` and `./locale/*` besides `.`: the Plugins page resolves
393
+ `<specifier>/package.json` and `<specifier>/locale/en.json` through Node's module resolver
394
+ (`readPluginMeta`), and an exports map with only `.` makes both lookups fail with
395
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`, degrading the title to the bare module specifier.
396
+
397
+ `peerDependencies` declares only `@deepseek-ai/dsh-tools`, marked **optional**: the peer exists so DSH's
398
+ compatibility gate (`evaluatePluginCompatibility`, which reads `peerDependencies`) can compare the host
399
+ version, and `optional` guarantees pnpm never downloads a host package to satisfy it. The plugin imports
400
+ nothing from it.
401
+
402
+ ## The DSH bundle contract (what this package had to learn)
403
+
404
+ Everything below was verified against the implementation inside the shipped `dsh` bundle
405
+ (`packages/boot/plugin-manager`, `packages/boot/app-boot`, `packages/boot/package-manifest`,
406
+ `packages/skill/skill`) rather than guessed.
407
+
408
+ 1. **A bundle is a `package.json` with `dsh.bundle.patch`** — one file path or an ordered list of paths,
409
+ relative to the package root. For a name selected in `dsh.profile.bundles` the launcher resolves it with
410
+ `bundlePatchFiles` / `bundlePatchPaths` and applies that patch as one layer. When `dsh.bundle` cannot be
411
+ resolved it throws `profile bundle "…" declares no dsh.bundle in its package.json` and **skips the
412
+ layer** (recorded in `skippedBundles`) instead of failing the boot.
413
+ 2. **The Plugins page only manages bundles.** `listBundles()` lists a selected name with no `dsh.bundle` as
414
+ `error.code = "not-bundle"` — the sentence the page shows. Enable/disable only change membership in
415
+ `dsh.profile.bundles` (the dependency stays); uninstall touches the dependency. An unselected plain
416
+ dependency is not listed at all.
417
+ 3. **The compatibility check only looks at `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` peers**, compares with
418
+ `includePrerelease`, and takes the runtime version from `dsh-app-boot`. `peerDependenciesMeta.optional`
419
+ does not affect it.
420
+ 4. **`dsh.manifestVersion` and `engines.dsh` are declarative only** — installers and loaders do not enforce
421
+ them — but they are the public author fields documented by `@deepseek-ai/dsh-package-manifest`, so this
422
+ package declares both.
423
+ 5. **Display metadata** comes from `readPluginMeta`, which resolves `<name>/package.json`,
424
+ `<name>/locale/*.json` and the `icon` field (relative path, SVG/PNG/JPEG/WebP, ≤256 KiB, must stay inside
425
+ the package directory) through Node; `locale/en.json` is the reference file.
426
+ 6. **Embedded skills** use `ctx.skills.register({ name, description, content, … })`: `name` must match
427
+ `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, `description` and `content` must be non-empty strings (re-validated on
428
+ load by `validateDefinition`), the registry fills in the `runtime` provider, and precedence is
429
+ **project > runtime > user**.
430
+ 7. **Visual configuration means exporting a `Config` schema** (schemastery, zod-style). DSH's settings
431
+ service (`@deepseek-ai/dsh-settings` + `@deepseek-ai/dsh-config-editor`) derives a namespace and a form for
432
+ entries that declare one (`SettingsNamespaceView.autoGenerate`), and writes land in that entry's `config`
433
+ in the profile patch; a plugin can opt out with `settings.configure({ auto: false })`. A field marked
434
+ `.volatile()` only commits new values and announces `loader/volatile-update` (the Loader compares with
435
+ `equalExceptVolatile`) — **no remount** — while an ordinary field change remounts the row. Volatile
436
+ placement is strict: a fixed object path, never a dict value, array item, map key or union/lazy branch
437
+ (`validateVolatileSchema` throws otherwise).
438
+ 8. **The settings page is not a general plugin settings UI**: `@deepseek-ai/dsh-settings` shows only volatile
439
+ fields of active, uniquely addressable entries, the **Plugin list** tab is read-only, and the
440
+ `pluginManager/*` RPCs only install/enable/disable/uninstall — so the one requirement for "visually
441
+ configurable" is that the plugin declares `Config` itself.
442
+
443
+ Two more traps worth knowing:
444
+
445
+ - Loader `insert` is append-only and does not deduplicate — the same `id` inserted twice mounts the plugin
446
+ twice, so there must be exactly one mount.
447
+ - A profile's `pnpm-workspace.yaml` normally sets `autoInstallPeers: false` and `nodeLinker: hoisted`, so
448
+ peers are never auto-installed and an unmet one is a warning. That is the second reason this package marks
449
+ its peer optional.
450
+
451
+ ## Maintainers: publishing to npm
452
+
453
+ ```sh
454
+ npm login # or NPM_TOKEN in CI
455
+ npm test # prepublishOnly runs it too
456
+ npm publish # publishConfig pins the registry to registry.npmjs.org
457
+ ```
458
+
459
+ - `publishConfig.registry` is set explicitly because a machine-level `~/.npmrc` pointing at a read-only
460
+ mirror (npmmirror) would otherwise send `npm publish` to the wrong place.
461
+ - Mirrors take a while to sync after a publish, so a user installing immediately may not see the new version.
462
+ - Bump the version by SemVer; `dsh.manifestVersion` is the **manifest format** identifier and is unrelated
463
+ to the package version, so it does not move with it.
464
+ - When runtime behaviour changes, update `engines.dsh` and the `@deepseek-ai/dsh-tools` peer range together —
465
+ they decide whether the Plugins page reports an incompatibility.
466
+ - `@deepseek-ai/schemastery` in `dependencies` is a **real dependency** (the settings form needs it) and pnpm
467
+ installs it for the user. Before widening its range, check that the target DSH version still has the schema
468
+ API used here (`.default/.description/.min/.volatile`); otherwise the form takes the degraded path
469
+ (`Config === undefined` — everything still works, there is just no visual configuration).
470
+
471
+ ## License
472
+
473
+ MIT © ygzhang-lab. See [LICENSE](./LICENSE).