feat(plugin): plugin marketplace, durable install persistence, skill manager

Add an in-app plugin marketplace fed by a remote web catalog: the host
plugin-inventory gateway gains marketplaceList/Install/Uninstall remotes,
a durable per-user install table reconciled against the actual profile, and
git/npm/tarball/bundle install paths with non-interactive git and vendored
pnpm. The Web Settings surface gains a sibling 插件市场 tab that lists the
catalog with recommended badges, repository links, and a combined sort
(recommended first, then catalog priority, then id).

Add a host + client skill manager: list local skills by direct filesystem
discovery, and install/uninstall/toggle/edit their SKILL.md from git/npm/
tarball/local sources in the writable user root.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-15 17:01:48 +08:00
parent 5f348f9b1c
commit a53769955b
57 changed files with 5168 additions and 129 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-plugin-marketplace.md
2026-08-15-plugin-marketplace.md: 24b496d41a4103b8891489eb9033d05c0ea09d83
2026-08-15-plugin-marketplace.zh.md: 7dbf44fa666f933a0340b9aa8db018643efb8008
@@ -0,0 +1,75 @@
# Agent Note: plugin marketplace
Status: implemented
English | [中文](2026-08-15-plugin-marketplace.zh.md)
## Problem
The plugin settings page only installs a plugin by typing an arbitrary pnpm specifier
into a text field. There is no curated catalog a user can browse, and no durable record
of which community plugins they have installed, separate from the profile's own bundle
composition. A marketplace needs a remote catalog, a per-plugin install prescription,
and an authoritative "is this installed" table.
## Decision
Add a plugin marketplace to `PluginInventoryGateway` (`packages/host/plugin-inventory`).
**Catalog** (`src/marketplace.ts`). The static web host serves one index JSON
(`plugins/plugins.json`, default URL
`https://deepseek.pinesound.cn/plugins/plugins.json`, overridable via `DSH_MARKETPLACE_URL`)
listing plugins, and a per-plugin JSON beside it (`plugins/<id>.json`) prescribing the
install method — `git`, `npm`, `tarball`, or `bundle` — with the pnpm specifier and the
dependency name that lands in the profile's `dependencies`. The helpers are pure
(fetch + parse + table IO) and unit-test without a Cordis context.
**Three direct Remotes** (`src/index.ts`):
- `marketplaceList()` fetches the catalog and marks each entry installed from the table.
- `marketplaceInstall(id, consentBuilds?)` fetches the per-plugin spec and maps it onto
the existing install path — git/npm/tarball via the registry Remote, bundle via the
offline compose — then records the install in the table on success. It honors the same
two-phase build-consent flow: a `pendingBuilds` result pauses, and the retry carries the
approved set in `consentBuilds`.
- `marketplaceUninstall(id)` resolves the dependency (or bundle name) from the table row,
runs the existing `uninstall`, and drops the row.
**Install table.** A small JSON document at `$DSH_HOME/plugin-marketplace/installed.json`
(`dshHomePath('plugin-marketplace')`) maps plugin id → `{ method, spec, dependency,
installedAt }`. It is written atomically and is the **authoritative** "is this installed"
check for the marketplace list, per the product requirement.
**SSRF posture.** Per-plugin spec URLs are derived from the fixed catalog base plus the
plugin id (`marketplaceBaseUrl` + `marketplaceSpecUrl`), never read from catalog content,
so a hostile catalog cannot point the app at an arbitrary URL. Fetch runs in the harness
service with `global.fetch` (the repo's convention), not in the renderer.
**UI** (`ui-settings-plugin-inventory`). The Plugins settings section gains a third
sibling tab — `settings.plugins.tab` id `marketplace`, order 20 — to the right of the
plugin-list tab `all` (order 10), both behind 插件配置 (order 0). `PluginMarketplaceSettingsTab`
lists catalog cards (name, description, author, Installed tag, Install/Uninstall button)
with its own build-consent modal; a fetch miss shows a retryable failure. The plugin-list
tab stays single-column and unchanged. A catalog entry may carry an optional `recommended`
flag (parsed into `MarketplacePluginMeta.recommended`), which renders a 推荐 badge on that
card.
## Verification
- `tests/marketplace.spec.ts` (pure): catalog/spec parse, HTTP errors, table
read/write/atomicity, base-URL and spec-URL derivation.
- `tests/inventory.spec.ts`: `marketplaceList` overlays the table; `marketplaceInstall`
(fake pnpm) records the row; `marketplaceUninstall` drops it; unknown id fails loud.
All keyed to a temp `$DSH_HOME`.
- `tests/marketplace-tab.client.spec.tsx`: marketplace tab render, install, uninstall,
build-consent, and load-failure retry.
- `pnpm run build:lib:host` regenerates the Typert Host/Client Remote artifacts.
## Alternatives
- **Fetch the catalog in the Electron main and pass it into the harness.** Rejected: the
install must run in the harness anyway, and every other package fetches with
`global.fetch`; keeping fetch next to install avoids a second transport.
- **Derive installed state from profile dependencies instead of a dedicated table.**
Rejected: the product asked for a durable table as the authoritative check, and a
git/tarball install's resolved package name is not reliably recoverable from a spec, so
the table records the dependency name explicitly.
@@ -0,0 +1,65 @@
# Agent Note:插件市场
Status: implemented
[English](2026-08-15-plugin-marketplace.md) | 中文
## 问题
插件设置页只能通过在文本框里输入任意 pnpm specifier 来安装插件。缺少一个可供浏览的策展目录,
也没有与 profile 自身 bundle 组合相独立的、关于用户已安装哪些社区插件的持久记录。
市场需要一份远程目录、每个插件的安装规定,以及一张权威的"是否已安装"表。
## 决策
`PluginInventoryGateway``packages/host/plugin-inventory`)新增插件市场。
**目录**`src/marketplace.ts`)。静态网站宿主提供一份索引 JSON
`plugins/plugins.json`,默认 URL
`https://deepseek.pinesound.cn/plugins/plugins.json`,可用 `DSH_MARKETPLACE_URL` 覆盖)
列出插件,旁边每个插件的 JSON`plugins/<id>.json`)规定安装方式——`git``npm``tarball`
`bundle`——以及 pnpm specifier 和落地到 profile `dependencies` 的依赖名。
这些辅助函数是纯函数(拉取 + 解析 + 表格 IO),可在无 Cordis 上下文的单测中测试。
**三个直接 Remote**`src/index.ts`):
- `marketplaceList()` 拉取目录,并根据表格把每条标记为已安装。
- `marketplaceInstall(id, consentBuilds?)` 拉取该插件的 spec,映射到既有安装路径——
git/npm/tarball 走 registry Remotebundle 走离线组合——成功后写入表格。
沿用同样的两阶段构建脚本同意流程:`pendingBuilds` 时暂停,重试时把已同意集合放在
`consentBuilds` 里。
- `marketplaceUninstall(id)` 从表格行解析依赖名(或 bundle 名),执行既有 `uninstall`
并删除表行。
**安装表**`$DSH_HOME/plugin-marketplace/installed.json`
`dshHomePath('plugin-marketplace')`)下的一小份 JSON 文档,映射
插件 id → `{ method, spec, dependency, installedAt }`。原子写入,是市场列表判断
"是否已安装"的**权威**依据(按产品要求)。
**SSRF 姿态**。每个插件的 spec URL 由固定目录基地址加插件 id 推导
`marketplaceBaseUrl` + `marketplaceSpecUrl`),绝不读取目录内容,因此恶意目录无法把
应用指向任意 URL。拉取在 harness 服务里用 `global.fetch`(仓库惯例),而不是在 renderer。
**UI**`ui-settings-plugin-inventory`)。Plugins 设置区新增第三个同级 tab——
`settings.plugins.tab` id 为 `marketplace`,order 20——位于插件列表 tab `all`
(order 10)右侧,两者都在 插件配置(order 0)之后。`PluginMarketplaceSettingsTab`
列出目录卡片(名称、描述、作者、已安装标签、安装/卸载按钮),带自己的构建脚本同意弹窗;
拉取失败只显示可重试的失败态。插件列表 tab 保持单列、不变。
目录条目可带可选的 `recommended` 标志(解析进 `MarketplacePluginMeta.recommended`),
该卡片会渲染「推荐」角标。
## 验证
- `tests/marketplace.spec.ts`(纯函数):目录/spec 解析、HTTP 错误、表格读写/原子性、
基地址与 spec URL 推导。
- `tests/inventory.spec.ts``marketplaceList` 叠加表格;`marketplaceInstall`(假 pnpm
写入表行;`marketplaceUninstall` 删除表行;未知 id 显式报错。全部以临时 `$DSH_HOME` 为根。
- `tests/marketplace-tab.client.spec.tsx`:市场 tab 渲染、安装、卸载、构建脚本同意、
加载失败重试。
- `pnpm run build:lib:host` 重新生成 Typert Host/Client Remote 产物。
## 备选方案
- **在 Electron 主进程拉取目录再传给 harness。** 已否决:安装本来就必须在 harness 里进行,
而且其它 package 都用 `global.fetch`;让拉取紧挨着安装,可避免多一条传输通道。
- **用 profile 依赖判断已安装,而不建独立表格。** 已否决:产品要求一张持久表作为权威判断,
而且 git/tarball 安装解析出的包名无法从 spec 可靠反推,因此表格显式记录依赖名。
@@ -0,0 +1,71 @@
# Agent Note: skills manager settings tab + built-in host plugin
Status: implemented
English | [中文](2026-08-15-skill-manager.zh.md)
## Problem
Skills are discovered by `dsh-skill-filesystem` from filesystem roots
(`$DSH_HOME/skills`, project `.agents/skills`, bundled, …) as `SKILL.md` bundles,
but there is **no management surface** — no way to see all local skills, install one
from a git/npm/tarball/local source, uninstall it, or toggle its invocation. The
plugin settings section had config/list/marketplace tabs but no skills tab.
## Decision
Add a **skills management tab** beside 插件配置 / 插件列表 / 插件市场 in the Plugins
settings section, backed by a new built-in host plugin.
**Host: `packages/host/skill-manager` (`@deepseek-ai/dsh-host-skill-manager`).**
`SkillManagerGateway` (serviceKey `skillManager`) exposes five direct Remotes:
- `list()` — reads `ctx.skills.list({})` (the global layer: user/bundled/custom/runtime
skills) and marks `managed` = lives in `$DSH_HOME/skills` (`source === 'user-dsh'`).
- `install(spec)` — resolves a `git | npm | tarball | local` source into a validated
`SKILL.md` bundle and copies it to `$DSH_HOME/skills/<name>`; the provider watcher
discovers it with no restart.
- `uninstall(name)` — deletes a user-root skill; refuses names not installed there.
- `setEnabled(name, patch)` — rewrites `SKILL.md` frontmatter
`disable-model-invocation` / `user-invocable`.
- `setDescription(name, description)` — rewrites `SKILL.md` frontmatter `description`.
`src/install.ts` materializes the source (git clone, `npm pack`+tar, tarball fetch+tar,
local dir), unwraps a single top-level dir, locates `SKILL.md` (root or `skills/<name>/`),
and validates the provider-mandated `name` + `description`. Git runs with the same
non-interactive environment as the plugin marketplace (`GIT_TERMINAL_PROMPT=0` +
`StrictHostKeyChecking=accept-new`) so a first-time clone never hangs.
`src/skill-io.ts` parses/rewrites the frontmatter (matching the provider's key semantics)
and does the read/install/delete filesystem work. No durable install table is needed:
the filesystem **is** the installed state, unlike plugin installs which record a profile
dependency.
**Client: `packages/client/ui-settings-skill-manager`
(`@deepseek-ai/dsh-client-ui-settings-skill-manager`).** Registers
`settings.plugins.tab` id `skills` (order 30). `SkillManagerSettingsTab` lists skills
with their invocation tags and manage/read-only labels, plus an install row (source
select + spec) and per-managed-skill toggle / edit-description / delete actions.
**Wiring:** the host + client packages are composed in `packages/bundle/web-app`
(cordis.patch.yml + package.json), `@deepseek-ai/dsh-host-skill-manager` is added to
`REQUIRED_PLUGINS` (protected, not toggleable), and the `skillManager` namespace is
mounted in `packages/api/remotes/src/client/index.ts`.
## Verification
- Host: `tests/skill-manager.spec.ts` (Remote surface + list/install/uninstall/toggle/
description against a temp `$DSH_HOME`), `tests/install.spec.ts` (local source
resolution + validation), `tests/skill-io.spec.ts` (frontmatter parse/rewrite + fs).
- Client: `tests/skill-manager.client.spec.tsx` (render, install, toggle, edit, delete,
load-failure retry).
- `pnpm run build:lib:host` + `pnpm run build:lib:client` (regenerates the `skillManager`
Typert Host/Client Remote artifacts); all modified packages typecheck.
## Alternatives
- **Fold skill management into `plugin-inventory`.** Rejected: skills are filesystem
artifacts with a different install target than plugins (skill root vs profile deps),
so a separate host package keeps each seam's ownership clear.
- **A durable install table like the plugin marketplace.** Rejected: a skill is
installed iff its bundle exists in the user root; the watcher already derives the
catalog from the filesystem, so an extra table would only drift.