From a53769955b2f1d3eecdf17675c67e92f23bd3709 Mon Sep 17 00:00:00 2001 From: Pine Date: Sat, 15 Aug 2026 17:01:48 +0800 Subject: [PATCH] feat(plugin): plugin marketplace, durable install persistence, skill manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-08-15-plugin-marketplace.i18n.yaml | 6 + .../feature/2026-08-15-plugin-marketplace.md | 75 ++++ .../2026-08-15-plugin-marketplace.zh.md | 65 ++++ .../feature/2026-08-15-skill-manager.md | 71 ++++ apps/cli/src/plugin.ts | 22 +- packages/api/remotes/package.json | 14 +- packages/api/remotes/src/client/index.ts | 20 +- packages/boot/app-boot/src/profile.ts | 22 +- packages/bundle/web-app/cordis.patch.yml | 13 + packages/bundle/web-app/package.json | 24 +- .../PluginInventorySettingsTab.module.css | 121 +++++++ .../src/client/PluginInventorySettingsTab.tsx | 169 +++++++++- .../PluginMarketplaceSettingsTab.module.css | 280 +++++++++++++++ .../client/PluginMarketplaceSettingsTab.tsx | 262 ++++++++++++++ .../src/client/index.ts | 70 +++- .../src/client/locales.ts | 36 +- .../tests/browser-plugin.client.spec.tsx | 4 +- .../tests/components.client.spec.tsx | 65 ++++ .../tests/marketplace-tab.client.spec.tsx | 128 +++++++ .../ui-settings-skill-manager/package.json | 80 +++++ .../client/SkillManagerSettingsTab.module.css | 243 +++++++++++++ .../src/client/SkillManagerSettingsTab.tsx | 241 +++++++++++++ .../src/client/index.ts | 79 +++++ .../src/client/locales.ts | 70 ++++ .../src/css-modules.d.ts | 6 + .../ui-settings-skill-manager/src/index.ts | 4 + .../src/invariant.ts | 20 ++ .../tests/skill-manager.client.spec.tsx | 116 +++++++ .../ui-settings-skill-manager/tsconfig.json | 36 ++ .../tsdown.config.ts | 3 + packages/host/plugin-inventory/README.md | 12 +- packages/host/plugin-inventory/README.zh.md | 8 +- packages/host/plugin-inventory/package.json | 2 + packages/host/plugin-inventory/src/index.ts | 319 ++++++++++++++++-- packages/host/plugin-inventory/src/install.ts | 294 +++++++++++++--- .../host/plugin-inventory/src/marketplace.ts | 169 ++++++++++ .../host/plugin-inventory/src/required.ts | 1 + packages/host/plugin-inventory/src/types.ts | 81 ++++- .../plugin-inventory/tests/install.spec.ts | 264 ++++++++++++++- .../plugin-inventory/tests/inventory.spec.ts | 285 +++++++++++++++- .../tests/marketplace.spec.ts | 143 ++++++++ .../tests/persistence.spec.ts | 92 +++++ packages/host/skill-manager/package.json | 69 ++++ packages/host/skill-manager/src/discovery.ts | 137 ++++++++ packages/host/skill-manager/src/index.ts | 178 ++++++++++ packages/host/skill-manager/src/install.ts | 145 ++++++++ packages/host/skill-manager/src/invariant.ts | 20 ++ packages/host/skill-manager/src/skill-io.ts | 131 +++++++ packages/host/skill-manager/src/types.ts | 50 +++ .../skill-manager/tests/discovery.spec.ts | 102 ++++++ .../host/skill-manager/tests/install.spec.ts | 63 ++++ .../host/skill-manager/tests/skill-io.spec.ts | 96 ++++++ .../skill-manager/tests/skill-manager.spec.ts | 163 +++++++++ packages/host/skill-manager/tsconfig.json | 27 ++ pnpm-lock.yaml | 79 +++++ tsconfig.client.json | 1 + tsconfig.host.json | 1 + 57 files changed, 5168 insertions(+), 129 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-15-plugin-marketplace.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-15-plugin-marketplace.md create mode 100644 .agents/notes/implemented/feature/2026-08-15-plugin-marketplace.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-15-skill-manager.md create mode 100644 packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.module.css create mode 100644 packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.tsx create mode 100644 packages/client/ui-settings-plugin-inventory/tests/marketplace-tab.client.spec.tsx create mode 100644 packages/client/ui-settings-skill-manager/package.json create mode 100644 packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.module.css create mode 100644 packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.tsx create mode 100644 packages/client/ui-settings-skill-manager/src/client/index.ts create mode 100644 packages/client/ui-settings-skill-manager/src/client/locales.ts create mode 100644 packages/client/ui-settings-skill-manager/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-skill-manager/src/index.ts create mode 100644 packages/client/ui-settings-skill-manager/src/invariant.ts create mode 100644 packages/client/ui-settings-skill-manager/tests/skill-manager.client.spec.tsx create mode 100644 packages/client/ui-settings-skill-manager/tsconfig.json create mode 100644 packages/client/ui-settings-skill-manager/tsdown.config.ts create mode 100644 packages/host/plugin-inventory/src/marketplace.ts create mode 100644 packages/host/plugin-inventory/tests/marketplace.spec.ts create mode 100644 packages/host/plugin-inventory/tests/persistence.spec.ts create mode 100644 packages/host/skill-manager/package.json create mode 100644 packages/host/skill-manager/src/discovery.ts create mode 100644 packages/host/skill-manager/src/index.ts create mode 100644 packages/host/skill-manager/src/install.ts create mode 100644 packages/host/skill-manager/src/invariant.ts create mode 100644 packages/host/skill-manager/src/skill-io.ts create mode 100644 packages/host/skill-manager/src/types.ts create mode 100644 packages/host/skill-manager/tests/discovery.spec.ts create mode 100644 packages/host/skill-manager/tests/install.spec.ts create mode 100644 packages/host/skill-manager/tests/skill-io.spec.ts create mode 100644 packages/host/skill-manager/tests/skill-manager.spec.ts create mode 100644 packages/host/skill-manager/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.i18n.yaml new file mode 100644 index 0000000000..1a7d70072a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.md b/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.md new file mode 100644 index 0000000000..24b496d41a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.md @@ -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/.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. diff --git a/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.zh.md b/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.zh.md new file mode 100644 index 0000000000..7dbf44fa66 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-plugin-marketplace.zh.md @@ -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/.json`)规定安装方式——`git`、`npm`、`tarball` +或 `bundle`——以及 pnpm specifier 和落地到 profile `dependencies` 的依赖名。 +这些辅助函数是纯函数(拉取 + 解析 + 表格 IO),可在无 Cordis 上下文的单测中测试。 + +**三个直接 Remote**(`src/index.ts`): +- `marketplaceList()` 拉取目录,并根据表格把每条标记为已安装。 +- `marketplaceInstall(id, consentBuilds?)` 拉取该插件的 spec,映射到既有安装路径—— + git/npm/tarball 走 registry Remote,bundle 走离线组合——成功后写入表格。 + 沿用同样的两阶段构建脚本同意流程:`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 可靠反推,因此表格显式记录依赖名。 diff --git a/.agents/notes/implemented/feature/2026-08-15-skill-manager.md b/.agents/notes/implemented/feature/2026-08-15-skill-manager.md new file mode 100644 index 0000000000..13e3e054ae --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-skill-manager.md @@ -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/`; 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//`), +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. diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index ed75c89f07..602029e903 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -19,6 +19,7 @@ import { PROFILE_TEMPLATES, readProfileManifest, reconcileProfileBundles, + resolvePnpm, resolveProfileDir, } from '@deepseek-ai/dsh-app-boot' import { INSTALL_ANCHOR } from './profile-boot.ts' @@ -59,17 +60,22 @@ export function runPlugin(profile: string, args: readonly string[]): number { process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`) } const before = readProfileManifest(NAME, dir) - // Windows resolves pnpm through its .cmd shim, which spawn() refuses - // without a shell since the CVE-2024-27980 hardening. - const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), { - cwd: dir, - stdio: 'inherit', - shell: process.platform === 'win32', - }) + // Prefer the pnpm vendored into the packaged harness (deterministic, matching + // the desktop's in-app install and needing no pnpm on the machine); fall back + // to pnpm on PATH in a development checkout. Windows resolves pnpm through + // its .cmd shim, which spawn() refuses without a shell since the + // CVE-2024-27980 hardening; the vendored path runs node against pnpm.cjs. + const anchored = args.map(argument => anchorPathSpec(argument, process.cwd())) + const vendored = resolvePnpm(process.execPath) + const result = vendored === undefined + ? spawnSync('pnpm', anchored, { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' }) + : spawnSync(process.execPath, [vendored, ...anchored], { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' }) if (result.error !== undefined) { const code = (result.error as NodeJS.ErrnoException).code if (code === 'ENOENT') { - process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`) + process.stderr.write( + `${NAME}: pnpm not found on PATH and no bundled pnpm is available — install pnpm to manage profile plugins\n`, + ) return 127 } throw result.error diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 42a7d14070..033623594f 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -60,14 +60,15 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-host-skill-manager": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -76,21 +77,22 @@ "@deepseek-ai/dsh-typert-registry": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-host-skill-manager": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 7b7f27b9f3..5cb8db3916 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -5,6 +5,7 @@ import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' +import skillManagerRemote from '@deepseek-ai/dsh-host-skill-manager/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' @@ -14,11 +15,28 @@ export type { AvailableBundlesSnapshot, InstallResult, InstallSpec, + InstalledBundle, + InstalledBundlesSnapshot, + InstalledMarketplacePlugin, + MarketplaceEntry, + MarketplaceInstallMethod, + MarketplaceInstallSpec, + MarketplacePluginMeta, + MarketplaceSnapshot, PluginInventorySnapshot, } from '@deepseek-ai/dsh-host-plugin-inventory/types' +export type { + SkillInstallSource, + SkillInstallSpec, + SkillInvocationPatch, + SkillManagerEntry, + SkillManagerSnapshot, + SkillMutationResult, +} from '@deepseek-ai/dsh-host-skill-manager/types' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' +export type {} from '@deepseek-ai/dsh-host-skill-manager/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -112,7 +130,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, + commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, skillManagerRemote, messageFeedbackRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 78485592ef..7d930d341e 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -26,7 +26,7 @@ import { createRequire } from 'node:module' import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' @@ -311,6 +311,26 @@ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileMan return normalized } +/** + * Locate the pnpm CLI bundled into the harness. Honors a `DSH_PNPM` override, + * then looks for the vendored pnpm beside the bundled Node's harness root. + * Both the desktop's in-app plugin install and the external `dsh plugin` CLI + * use this, so an external install runs the same vendored pnpm as the app and + * needs no pnpm installed on the machine. In a development checkout (no + * vendored pnpm) it returns undefined and the caller falls back to PATH pnpm. + * @param nodeBin - the Node executable path (`process.execPath`). + * @param env - the process environment. + * @returns the pnpm.cjs path, or undefined when none is vendored. + */ +export function resolvePnpm(nodeBin: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + if (env.DSH_PNPM) return env.DSH_PNPM + // nodeBin is harness/bin/node in the packaged app, so the harness root is + // one level up from bin/; pnpm is vendored under harness/pnpm/. + const harnessRoot = resolve(dirname(nodeBin), '..') + const candidate = join(harnessRoot, 'pnpm', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') + return existsSync(candidate) ? candidate : undefined +} + /** * Resolve a package's root directory from one anchor without depending on the * package exporting `./package.json` (`require.resolve` would need that): diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index b06e63b54f..414ac87dd6 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -94,10 +94,20 @@ - id: plugin-inventory name: '@deepseek-ai/dsh-host-plugin-inventory' + # Local skill management: list, install, uninstall, and toggle skills. + - id: skill-manager + name: '@deepseek-ai/dsh-host-skill-manager' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + config: + # Expose the third-party image-understanding plugin's settings card to + # the web configuration client. The plugin registers its namespace + # without `configurable: true`, so the deployment opts it in here. + exposeSettings: + - describe-image - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' @@ -195,6 +205,9 @@ - id: ui-settings-plugin-inventory name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory' + - id: ui-settings-skill-manager + name: '@deepseek-ai/dsh-client-ui-settings-skill-manager' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index ca4709a2a9..adf0300eea 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -59,47 +59,49 @@ "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-skill-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-host-skill-manager": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0" diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index fc20685ad7..2264e6363d 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -302,6 +302,51 @@ flex: none; } +.installed { + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--dsh-border); +} + +.installed h3 { + margin: 0 0 0.5rem; + font-size: 13px; + font-weight: 600; +} + +.installedList { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.installedRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 8px 12px; + background: var(--dsw-alias-bg-layer-3); +} + +.installedName { + min-width: 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + +.installedAction { + flex: none; +} + .installRestart { margin: 0 0 0.5rem; color: var(--dsw-alias-label-secondary); @@ -314,6 +359,82 @@ font-size: 12px; } +.overlay { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, var(--dsw-alias-bg-scrim, rgba(0, 0, 0, 0.5)) 45%, transparent); +} + +.consent { + box-sizing: border-box; + width: 100%; + max-width: 440px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + padding: 18px; + background: var(--dsw-alias-bg-layer-3); + box-shadow: var(--dsw-shadow-lv2, 0 12px 32px rgba(0, 0, 0, 0.2)); +} + +.consent h3 { + margin: 0 0 8px; + font-size: 15px; + line-height: 22px; + font-weight: 600; +} + +.consentBody { + margin: 0 0 12px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +.consentList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 0 16px; + padding: 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + list-style: none; + max-height: 200px; + overflow: auto; +} + +.consentList label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.consentList code { + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 12px; + overflow-wrap: anywhere; +} + +.consentActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.consentAction { + flex: none; +} + @media (prefers-reduced-motion: no-preference) { .chevron { transition: transform 140ms var(--ds-ease-in-out); diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index c20e50f2b8..5208dbc21e 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -1,5 +1,7 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' -import type { InstallResult, InstallSpec, PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client' +import type { + InstallResult, InstallSpec, InstalledBundlesSnapshot, PluginInventorySnapshot, +} from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconChevronDownOutline14, @@ -17,6 +19,10 @@ export interface PluginInventorySettingsTabInjected { setEnabled: (entryId: PluginInventoryEntry['entryId'], enabled: boolean) => Promise /** Install a registry plugin by package name; the host persists the change. */ installPlugin: (spec: InstallSpec) => Promise + /** List the profile's user-installed plugin dependencies (uninstallable ones). */ + installedBundles: () => Promise + /** Uninstall a user-installed plugin; the host persists the change. */ + uninstall: (name: string) => Promise } type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] @@ -67,7 +73,7 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean /** Render the current Loader inventory with per-plugin enable/disable and install. */ export function PluginInventorySettingsTab({ - list, setEnabled, installPlugin, t, + list, setEnabled, installPlugin, installedBundles, uninstall, t, }: PluginInventorySettingsTabProps): ReactNode { const catalogId = useId() const [request, setRequest] = useState(0) @@ -78,6 +84,10 @@ export function PluginInventorySettingsTab({ const [spec, setSpec] = useState('') const [installBusy, setInstallBusy] = useState(false) const [installNote, setInstallNote] = useState<{ kind: 'restart' | 'error'; text: string } | null>(null) + const [pendingConsent, setPendingConsent] = useState<{ spec: string; builds: readonly string[] } | null>(null) + const [consentChecked, setConsentChecked] = useState([]) + const [installed, setInstalled] = useState([]) + const [uninstallBusy, setUninstallBusy] = useState(null) useEffect(() => { let current = true @@ -85,8 +95,14 @@ export function PluginInventorySettingsTab({ (snapshot) => { if (current) setState({ status: 'ready', snapshot }) }, () => { if (current) setState({ status: 'error' }) }, ) + // The installed-dependency list is secondary; a failure to load it only + // hides the uninstall section, never the inventory. + void Promise.resolve().then(() => installedBundles()).then( + (snapshot) => { if (current) setInstalled(snapshot.installed) }, + () => {}, + ) return () => { current = false } - }, [list, request]) + }, [list, installedBundles, request]) /** Toggle one entry then re-read the inventory. */ const toggle = (entryId: PluginInventoryEntry['entryId'], enabled: boolean): void => { @@ -98,7 +114,19 @@ export function PluginInventorySettingsTab({ ).finally(() => { setBusy(null) }) } - /** Install a plugin by package name via the registry, then re-read. */ + /** Record a successful install: show the restart/active note, clear, re-list. */ + const installSettled = (result: InstallResult): void => { + // A live recompose activates the plugin immediately; only fall back to a + // restart notice when no reload handle exists. + setInstallNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'installed') }) + setSpec('') + setRequest(value => value + 1) + } + + /** + * Install a plugin by spec, then re-read. A result carrying `pendingBuilds` + * pauses for the user's per-package build consent instead of settling. + */ const installRegistry = (): void => { const trimmed = spec.trim() if (trimmed.length === 0 || installBusy) return @@ -106,11 +134,13 @@ export function PluginInventorySettingsTab({ setInstallNote(null) void installPlugin({ type: 'registry', spec: trimmed }).then( (result) => { - // A live recompose activates the plugin immediately; only fall back to a - // restart notice when no reload handle exists. - setInstallNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'installed') }) - setSpec('') - setRequest(value => value + 1) + if (result.pendingBuilds !== undefined && result.pendingBuilds.length > 0) { + // Keep the spec in the field; the modal re-submits it on consent. + setPendingConsent({ spec: trimmed, builds: result.pendingBuilds }) + setConsentChecked(result.pendingBuilds) + return + } + installSettled(result) }, (error: unknown) => { const detail = error instanceof Error ? error.message : String(error) @@ -119,6 +149,57 @@ export function PluginInventorySettingsTab({ ).finally(() => { setInstallBusy(false) }) } + /** Re-submit the paused install with the exact packages the user approved. */ + const confirmConsent = (): void => { + if (pendingConsent === null || installBusy || consentChecked.length === 0) return + const { spec: consentedSpec } = pendingConsent + const approved = [...consentChecked] + setInstallBusy(true) + setInstallNote(null) + void installPlugin({ type: 'registry', spec: consentedSpec, consentBuilds: approved }).then( + (result) => { + setPendingConsent(null) + setConsentChecked([]) + if (result.pendingBuilds !== undefined && result.pendingBuilds.length > 0) { + setPendingConsent({ spec: consentedSpec, builds: result.pendingBuilds }) + setConsentChecked(result.pendingBuilds) + return + } + installSettled(result) + }, + (error: unknown) => { + setPendingConsent(null) + setConsentChecked([]) + const detail = error instanceof Error ? error.message : String(error) + setInstallNote({ kind: 'error', text: `${t('installFailed')}: ${detail}` }) + }, + ).finally(() => { setInstallBusy(false) }) + } + + /** Dismiss the consent prompt without installing. */ + const cancelConsent = (): void => { + if (installBusy) return + setPendingConsent(null) + setConsentChecked([]) + } + + /** Uninstall a user-installed plugin dependency, then re-read. */ + const removeInstalled = (name: string): void => { + if (uninstallBusy !== null) return + setUninstallBusy(name) + setInstallNote(null) + void uninstall(name).then( + (result) => { + setInstallNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'uninstalled') }) + setRequest(value => value + 1) + }, + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setInstallNote({ kind: 'error', text: `${t('uninstallFailed')}: ${detail}` }) + }, + ).finally(() => { setUninstallBusy(null) }) + } + const normalizedQuery = query.trim().toLocaleLowerCase() const filteredEntries = useMemo( () => state.status === 'ready' @@ -264,6 +345,28 @@ export function PluginInventorySettingsTab({ ?

{installNote.text}

: null} + {installed.length > 0 ? ( +
+

{t('installedPlugins')}

+
    + {installed.map(({ name }) => ( +
  • + {name} + +
  • + ))} +
+
+ ) : null}
) : null} + {pendingConsent !== null ? ( +
{ if (event.target === event.currentTarget) cancelConsent() }} + > +
+ +

{t('consentBody')}

+
    + {pendingConsent.builds.map(name => ( +
  • + +
  • + ))} +
+
+ + +
+
+
+ ) : null} ) } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.module.css new file mode 100644 index 0000000000..9743e9acf3 --- /dev/null +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.module.css @@ -0,0 +1,280 @@ +.section { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + max-width: 760px; + color: var(--dsw-alias-label-primary); +} + +.heading { + margin: 0; + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +.titleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.refresh { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + padding: 3px 10px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.refresh:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + border-color: var(--dsw-alias-border-l1); +} + +.refresh:disabled { + opacity: 0.6; + cursor: default; +} + +.status, +.failure p { + margin: 0; +} + +.status, +.failure { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.failure { + display: flex; + align-items: center; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.failure button { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + padding: 4px 10px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + cursor: pointer; +} + +.restart { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; +} + +.error { + margin: 0; + color: var(--dsw-alias-danger-text); + font-size: 12px; +} + +.cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.card { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + padding: 14px; + background: var(--dsw-alias-bg-layer-3); +} + +.header { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +.recommended { + flex: none; + border-radius: 5px; + padding: 1px 6px; + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f5a623) 12%, transparent); + color: var(--dsw-alias-state-warning-primary, #b8860b); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +.desc { + /* 最多两行,超出省略 */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.author { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.repo { + flex: none; + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + text-decoration: none; +} + +.repo:hover { + color: var(--dsw-alias-label-primary); + text-decoration: underline; +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + /* 卡片按内容自适应高度,按钮行紧跟内容,避免大块空白 */ + margin-top: 4px; +} + +.installed { + border-radius: 5px; + padding: 1px 6px; + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); + font-size: 11px; + line-height: 16px; +} + +.action { + flex: none; +} + +.overlay { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, var(--dsw-alias-bg-scrim, rgba(0, 0, 0, 0.5)) 45%, transparent); +} + +.consent { + box-sizing: border-box; + width: 100%; + max-width: 440px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + padding: 18px; + background: var(--dsw-alias-bg-layer-3); + box-shadow: var(--dsw-shadow-lv2, 0 12px 32px rgba(0, 0, 0, 0.2)); +} + +.consent h3 { + margin: 0 0 8px; + font-size: 15px; + line-height: 22px; + font-weight: 600; +} + +.consentBody { + margin: 0 0 12px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +.consentList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 0 16px; + padding: 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + list-style: none; + max-height: 200px; + overflow: auto; +} + +.consentList label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.consentList code { + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 12px; + overflow-wrap: anywhere; +} + +.consentActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.consentAction { + flex: none; +} + +@media (max-width: 680px) { + .cards { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.tsx new file mode 100644 index 0000000000..44e77545b0 --- /dev/null +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginMarketplaceSettingsTab.tsx @@ -0,0 +1,262 @@ +import { useEffect, useId, useState, type ReactNode } from 'react' +import type { + InstallResult, MarketplaceEntry, MarketplaceSnapshot, +} from '@deepseek-ai/dsh-api-remotes/client' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './PluginMarketplaceSettingsTab.module.css' + +/** Registration-side Remote face used by the marketplace tab. */ +export interface PluginMarketplaceSettingsTabInjected { + /** Fetch the remote marketplace catalog with each entry's installed state. */ + marketplaceList: () => Promise + /** Install a marketplace plugin by id; may return pending build consent. */ + marketplaceInstall: (id: string, consentBuilds?: readonly string[]) => Promise + /** Uninstall a marketplace plugin by id; the host persists the change. */ + marketplaceUninstall: (id: string) => Promise +} + +/** Full component props assembled by the Settings slot renderer. */ +export type PluginMarketplaceSettingsTabProps = + PropsRuntime<'settings.plugins.tab'> + & PropsLocale<'settings.pluginInventory'> + & InjectFace + +/** + * The marketplace's combined order: recommended picks first, then the catalog's + * `priority` (higher closer to the front), then id as a stable tie-break. + * Sorting lives on the client so the wire order is never trusted for display. + */ +function compareMarketplace(a: MarketplaceEntry, b: MarketplaceEntry): number { + if (a.recommended !== b.recommended) return a.recommended ? -1 : 1 + const priorityDelta = (b.priority ?? 0) - (a.priority ?? 0) + if (priorityDelta !== 0) return priorityDelta + return a.id.localeCompare(b.id) +} + +type ViewState = + | { readonly status: 'loading' } + | { readonly status: 'error' } + | { readonly status: 'ready'; readonly entries: readonly MarketplaceEntry[] } + +/** A pending build-consent prompt for one marketplace install. */ +interface ConsentState { + readonly id: string + readonly builds: readonly string[] +} + +/** Browse the remote marketplace and install/uninstall its plugins. */ +export function PluginMarketplaceSettingsTab({ + marketplaceList, marketplaceInstall, marketplaceUninstall, t, +}: PluginMarketplaceSettingsTabProps): ReactNode { + const consentId = useId() + const [request, setRequest] = useState(0) + const [state, setState] = useState({ status: 'loading' }) + const [busy, setBusy] = useState(null) + const [installBusy, setInstallBusy] = useState(false) + const [note, setNote] = useState<{ kind: 'restart' | 'error'; text: string } | null>(null) + const [pendingConsent, setPendingConsent] = useState(null) + const [consentChecked, setConsentChecked] = useState([]) + + useEffect(() => { + let current = true + void Promise.resolve().then(() => marketplaceList()).then( + (snapshot) => { if (current) setState({ status: 'ready', entries: snapshot.entries }) }, + () => { if (current) setState({ status: 'error' }) }, + ) + return () => { current = false } + }, [marketplaceList, request]) + + /** Re-fetch the marketplace after a load failure or an install/uninstall. */ + const refresh = (): void => { + setState({ status: 'loading' }) + setRequest(value => value + 1) + } + + /** Install a marketplace plugin by id, then re-read. */ + const install = (id: string): void => { + if (busy !== null) return + setBusy(id) + setNote(null) + void marketplaceInstall(id).then( + (result) => { + if (result.pendingBuilds !== undefined && result.pendingBuilds.length > 0) { + setPendingConsent({ id, builds: result.pendingBuilds }) + setConsentChecked(result.pendingBuilds) + return + } + setNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'installed') }) + setRequest(value => value + 1) + }, + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setNote({ kind: 'error', text: `${t('installFailed')}: ${detail}` }) + }, + ).finally(() => { setBusy(null) }) + } + + /** Uninstall a marketplace plugin by id, then re-read. */ + const uninstall = (id: string): void => { + if (busy !== null) return + setBusy(id) + setNote(null) + void marketplaceUninstall(id).then( + (result) => { + setNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'uninstalled') }) + setRequest(value => value + 1) + }, + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setNote({ kind: 'error', text: `${t('uninstallFailed')}: ${detail}` }) + }, + ).finally(() => { setBusy(null) }) + } + + /** Re-submit the paused install with the exact packages the user approved. */ + const confirmConsent = (): void => { + if (pendingConsent === null || installBusy || consentChecked.length === 0) return + const { id } = pendingConsent + const approved = [...consentChecked] + setInstallBusy(true) + setNote(null) + void marketplaceInstall(id, approved).then( + (result) => { + setPendingConsent(null) + setConsentChecked([]) + if (result.pendingBuilds !== undefined && result.pendingBuilds.length > 0) { + setPendingConsent({ id, builds: result.pendingBuilds }) + setConsentChecked(result.pendingBuilds) + return + } + setNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'installed') }) + setRequest(value => value + 1) + }, + (error: unknown) => { + setPendingConsent(null) + setConsentChecked([]) + const detail = error instanceof Error ? error.message : String(error) + setNote({ kind: 'error', text: `${t('installFailed')}: ${detail}` }) + }, + ).finally(() => { setInstallBusy(false) }) + } + + /** Dismiss the consent prompt without installing. */ + const cancelConsent = (): void => { + if (installBusy) return + setPendingConsent(null) + setConsentChecked([]) + } + + return ( +
+
+

{t('marketplace')}

+ +
+ {note !== null + ?

{note.text}

+ : null} + {state.status === 'loading' ?

{t('loading')}

: null} + {state.status === 'error' ? ( +
+

{t('marketplaceLoadFailed')}

+ +
+ ) : null} + {state.status === 'ready' ? ( + state.entries.length === 0 + ?

{t('marketplaceEmpty')}

+ : ( +
    + {[...state.entries].sort(compareMarketplace).map(entry => ( +
  • +
    + {entry.name} + {entry.recommended ? {t('recommended')} : null} +
    + {entry.description ?

    {entry.description}

    : null} +
    + {entry.author ? {entry.author} : null} + {entry.repository + ? + {t('marketplaceRepo')} + + : null} +
    +
    + {entry.installed ? {t('marketplaceInstalled')} : null} + +
    +
  • + ))} +
+ ) + ) : null} + {pendingConsent !== null ? ( +
{ if (event.target === event.currentTarget) cancelConsent() }} + > +
+

{t('consentTitle')}

+

{t('consentBody')}

+
    + {pendingConsent.builds.map(name => ( +
  • + +
  • + ))} +
+
+ + +
+
+
+ ) : null} +
+ ) +} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index 4142d46278..919b618f68 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -1,17 +1,25 @@ -/** Read-only Host plugin inventory registered into Web Settings. */ +/** Host plugin inventory and marketplace registered into Web Settings. */ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx' +import { + PluginInventorySettingsTab, + type PluginInventorySettingsTabInjected, +} from './PluginInventorySettingsTab.tsx' +import { + PluginMarketplaceSettingsTab, + type PluginMarketplaceSettingsTabInjected, +} from './PluginMarketplaceSettingsTab.tsx' import { en, zh, type PluginInventoryLocaleKey } from './locales.ts' export type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx' +export type { PluginMarketplaceSettingsTabInjected, PluginMarketplaceSettingsTabProps } from './PluginMarketplaceSettingsTab.tsx' export type { PluginInventoryLocaleKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Read-only Host plugin inventory copy. */ + /** Host plugin inventory and marketplace copy. */ 'settings.pluginInventory': PluginInventoryLocaleKey } } @@ -19,10 +27,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ export const NS = 'settings.pluginInventory' -/** Services required by the Settings registration and generated Remote face. */ +/** Services required by the Settings registrations and generated Remote face. */ export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] -/** Contribute the lazy inventory tab to the Plugins settings section. */ +/** Contribute the plugin-list and marketplace tabs to the Plugins settings section. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugin-inventory: dictionaries') @@ -47,8 +55,46 @@ export function apply(ctx: ClientContext): void { } return result.value } - const injected = (): PluginInventorySettingsTabInjected => ({ - list, setEnabled, installPlugin, + const installedBundles: PluginInventorySettingsTabInjected['installedBundles'] = async () => { + const result = await ctx.remote.pluginInventory.installedBundles() + if (!result.ok) { + throw new Error(`pluginInventory.installedBundles failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const uninstall: PluginInventorySettingsTabInjected['uninstall'] = async (name) => { + const result = await ctx.remote.pluginInventory.uninstall(name) + if (!result.ok) { + throw new Error(`pluginInventory.uninstall failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const marketplaceList: PluginMarketplaceSettingsTabInjected['marketplaceList'] = async () => { + const result = await ctx.remote.pluginInventory.marketplaceList() + if (!result.ok) { + throw new Error(`pluginInventory.marketplaceList failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const marketplaceInstall: PluginMarketplaceSettingsTabInjected['marketplaceInstall'] = async (id, consentBuilds) => { + const result = await ctx.remote.pluginInventory.marketplaceInstall(id, consentBuilds) + if (!result.ok) { + throw new Error(`pluginInventory.marketplaceInstall failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const marketplaceUninstall: PluginMarketplaceSettingsTabInjected['marketplaceUninstall'] = async (id) => { + const result = await ctx.remote.pluginInventory.marketplaceUninstall(id) + if (!result.ok) { + throw new Error(`pluginInventory.marketplaceUninstall failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const inventoryInjected = (): PluginInventorySettingsTabInjected => ({ + list, setEnabled, installPlugin, installedBundles, uninstall, + }) + const marketplaceInjected = (): PluginMarketplaceSettingsTabInjected => ({ + marketplaceList, marketplaceInstall, marketplaceUninstall, }) ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ @@ -57,6 +103,14 @@ export function apply(ctx: ClientContext): void { order: 10, label: () => t('tab'), locale: NS, - inject: injected, + inject: inventoryInjected, }, PluginInventorySettingsTab)) + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'marketplace', + order: 20, + label: () => t('marketplace'), + locale: NS, + inject: marketplaceInjected, + }, PluginMarketplaceSettingsTab)) } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index d158bbc902..95858cc92b 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -25,12 +25,28 @@ export const zh = { toggling: '切换中…', required: '应用必需插件,不可切换', installPlugin: '安装插件', - installSpec: '输入插件包名,目前只支持已发布npm的包;如 @scope/plugin', + installSpec: '输入安装来源:npm 包名 / tarball 路径或URL / GitHub 仓库地址,如 dsh-better-sidebar、./plugin.tgz、github:user/repo', install: '安装', installing: '安装中…', restartRequired: '已安装,重启后生效', installed: '已安装并生效', installFailed: '安装失败', + consentTitle: '需要放行构建脚本', + consentBody: '安装需要执行以下包的构建脚本(如 node-pty、protobufjs 的原生编译)。这些脚本会在沙箱之外运行,仅放行你信任的包。', + consentAllow: '放行并继续', + consentCancel: '取消', + installedPlugins: '已安装的插件', + uninstall: '卸载', + uninstalling: '卸载中…', + uninstalled: '已卸载并生效', + uninstallFailed: '卸载失败', + marketplace: '插件市场', + marketplaceEmpty: '市场中暂无插件。', + marketplaceLoadFailed: '暂时无法读取插件市场。', + marketplaceInstalled: '已安装', + marketplaceRepo: '仓库', + recommended: '推荐', + marketplaceRefresh: '刷新', } satisfies Record /** Plugin inventory locale key union. */ @@ -61,10 +77,26 @@ export const en = { toggling: 'Toggling…', required: 'Required by the app; cannot be toggled', installPlugin: 'Install plugin', - installSpec: 'Package name, e.g. @scope/plugin', + installSpec: 'Install source: npm name / tarball path or URL / GitHub repo URL, e.g. dsh-better-sidebar, ./plugin.tgz, github:user/repo', install: 'Install', installing: 'Installing…', restartRequired: 'Installed; restart to activate', installed: 'Installed and active', installFailed: 'Install failed', + consentTitle: 'Build scripts need approval', + consentBody: 'Installing runs the build scripts of these packages (e.g. native builds of node-pty, protobufjs). They execute outside the sandbox; allow only packages you trust.', + consentAllow: 'Allow and continue', + consentCancel: 'Cancel', + installedPlugins: 'Installed plugins', + uninstall: 'Uninstall', + uninstalling: 'Uninstalling…', + uninstalled: 'Uninstalled and active', + uninstallFailed: 'Uninstall failed', + marketplace: 'Plugin marketplace', + marketplaceEmpty: 'No plugins are available in the marketplace yet.', + marketplaceLoadFailed: 'The plugin marketplace is temporarily unavailable.', + marketplaceInstalled: 'Installed', + marketplaceRepo: 'Repository', + recommended: 'Recommended', + marketplaceRefresh: 'Refresh', } satisfies Record diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx index b955db8a5b..9382046644 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx @@ -74,9 +74,11 @@ describe('ui-settings-plugin-inventory browser plugin', () => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) const stop = declare(b.slots) - await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(1) }) + // The plugin registers two sibling tabs: the plugin list and the marketplace. + await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(2) }) b.locale.setLocale('en') expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[0]!.options.label)).toBe('Plugin list') + expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[1]!.options.label)).toBe('Plugin marketplace') stop() expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 0a6a701f37..e422c91bf5 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -16,12 +16,16 @@ const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginInventor function props( list: PluginInventorySettingsTabInjected['list'], installPlugin: PluginInventorySettingsTabInjected['installPlugin'] = async () => ({ ok: true as const, restartRequired: true }), + installedBundles: PluginInventorySettingsTabInjected['installedBundles'] = async () => ({ installed: [] }), + uninstall: PluginInventorySettingsTabInjected['uninstall'] = async () => ({ ok: true as const, restartRequired: true }), ): PluginInventorySettingsTabProps { return { t, list, setEnabled: vi.fn(async () => {}), installPlugin, + installedBundles, + uninstall, } as PluginInventorySettingsTabProps } @@ -180,4 +184,65 @@ describe('PluginInventorySettingsTab', () => { expect(await screen.findByText(en.installed)).toBeTruthy() expect(screen.queryByText(en.restartRequired)).toBeNull() }) + + it('pauses for build consent and retries with the approved set', async () => { + const installPlugin = vi.fn() + .mockResolvedValueOnce({ ok: true, restartRequired: false, pendingBuilds: ['node-pty', 'protobufjs'] }) + .mockResolvedValueOnce({ ok: true, restartRequired: true }) + render( SNAPSHOT, installPlugin)} />) + + const input = await screen.findByRole('textbox', { name: en.installSpec }) + fireEvent.change(input, { target: { value: '@scope/native-plugin' } }) + fireEvent.click(screen.getByRole('button', { name: en.install })) + // The consent dialog lists the blocked packages and the sandbox warning. + expect(await screen.findByRole('dialog', { name: en.consentTitle })).toBeTruthy() + expect(screen.getByText(en.consentBody)).toBeTruthy() + expect(screen.getByText('node-pty')).toBeTruthy() + expect(screen.getByText('protobufjs')).toBeTruthy() + // Both are checked by default; approving retries with exactly that set. + fireEvent.click(screen.getByRole('button', { name: en.consentAllow })) + expect(installPlugin).toHaveBeenLastCalledWith({ + type: 'registry', + spec: '@scope/native-plugin', + consentBuilds: ['node-pty', 'protobufjs'], + }) + expect(await screen.findByText(en.restartRequired)).toBeTruthy() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('cancelling consent leaves the spec in the field for a retry', async () => { + const installPlugin = vi.fn( + async () => ({ ok: true, restartRequired: false, pendingBuilds: ['node-pty'] }), + ) + render( SNAPSHOT, installPlugin)} />) + + const input = await screen.findByRole('textbox', { name: en.installSpec }) + fireEvent.change(input, { target: { value: '@scope/native-plugin' } }) + fireEvent.click(screen.getByRole('button', { name: en.install })) + expect(await screen.findByRole('dialog', { name: en.consentTitle })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.consentCancel })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('textbox', { name: en.installSpec }).value) + .toBe('@scope/native-plugin') + }) + + it('lists user-installed plugins and uninstalls one', async () => { + const installedBundles = vi.fn( + async () => ({ installed: [{ name: 'user-plugin' }] }), + ) + const uninstall = vi.fn( + async () => ({ ok: true, restartRequired: true }), + ) + render( SNAPSHOT, undefined, installedBundles, uninstall)} />) + + expect(await screen.findByText('user-plugin')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.uninstall })) + expect(uninstall).toHaveBeenCalledWith('user-plugin') + expect(await screen.findByText(en.restartRequired)).toBeTruthy() + }) + + it('omits the installed section when nothing is installed', async () => { + render( SNAPSHOT)} />) + expect(screen.queryByRole('heading', { name: en.installedPlugins })).toBeNull() + }) }) diff --git a/packages/client/ui-settings-plugin-inventory/tests/marketplace-tab.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/marketplace-tab.client.spec.tsx new file mode 100644 index 0000000000..b892cfd0ad --- /dev/null +++ b/packages/client/ui-settings-plugin-inventory/tests/marketplace-tab.client.spec.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + PluginMarketplaceSettingsTab, + type PluginMarketplaceSettingsTabInjected, + type PluginMarketplaceSettingsTabProps, +} from '../src/client/PluginMarketplaceSettingsTab.tsx' +import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginMarketplaceSettingsTabProps['t'] + +function props( + marketplaceList: PluginMarketplaceSettingsTabInjected['marketplaceList'], + marketplaceInstall: PluginMarketplaceSettingsTabInjected['marketplaceInstall'] = async () => ({ ok: true as const, restartRequired: true }), + marketplaceUninstall: PluginMarketplaceSettingsTabInjected['marketplaceUninstall'] = async () => ({ ok: true as const, restartRequired: true }), +): PluginMarketplaceSettingsTabProps { + return { t, marketplaceList, marketplaceInstall, marketplaceUninstall } as PluginMarketplaceSettingsTabProps +} + +describe('PluginMarketplaceSettingsTab', () => { + it('renders marketplace entries with a recommended badge and installs one', async () => { + const list = vi.fn(async () => ({ + entries: [ + { id: 'a', name: 'Alpha', description: 'desc', author: 'Dev', repository: 'https://github.com/dev/alpha', installed: false, recommended: true }, + { id: 'b', name: 'Beta', description: '', installed: false }, + ], + })) + const install = vi.fn(async () => ({ ok: true as const, restartRequired: true })) + render() + + expect(await screen.findByRole('heading', { name: en.marketplace })).toBeTruthy() + expect(await screen.findByText('Alpha')).toBeTruthy() + expect(screen.getByText('desc')).toBeTruthy() + expect(screen.getByText('Dev')).toBeTruthy() + // The repository link opens the plugin's source in a new tab. + const repoLink = screen.getByText(en.marketplaceRepo).closest('a')! + expect(repoLink).toHaveProperty('href', 'https://github.com/dev/alpha') + // Only the recommended entry carries the badge. + expect(screen.getAllByText(en.recommended)).toHaveLength(1) + expect(screen.getByText('Beta')).toBeTruthy() + // Click the install button on Alpha's card specifically. + const alphaCard = screen.getByText('Alpha').closest('li')! + fireEvent.click(within(alphaCard).getByRole('button', { name: en.install })) + expect(install).toHaveBeenCalledWith('a') + expect(await screen.findByText(en.restartRequired)).toBeTruthy() + }) + + it('sorts recommended first, then by priority, then id', async () => { + const list = vi.fn(async () => ({ + entries: [ + { id: 'c', name: 'Charlie', description: '', recommended: true, priority: 1, installed: false }, + { id: 'a', name: 'Alpha', description: '', recommended: true, priority: 3, installed: false }, + // Highest priority of all, but not recommended: it still sorts last. + { id: 'b', name: 'Beta', description: '', recommended: false, priority: 100, installed: false }, + ], + })) + render() + const items = await screen.findAllByRole('listitem') + const names = items.map(item => item.querySelector('strong')?.textContent) + expect(names).toEqual(['Alpha', 'Charlie', 'Beta']) + }) + + it('uninstalls an installed marketplace entry', async () => { + const list = vi.fn(async () => ({ + entries: [{ id: 'a', name: 'Alpha', description: '', installed: true }], + })) + const uninstall = vi.fn(async () => ({ ok: true as const, restartRequired: true })) + render() + + expect(await screen.findByText(en.marketplaceInstalled)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.uninstall })) + expect(uninstall).toHaveBeenCalledWith('a') + expect(await screen.findByText(en.restartRequired)).toBeTruthy() + }) + + it('pauses for build consent and retries with the approved set', async () => { + const list = vi.fn(async () => ({ + entries: [{ id: 'a', name: 'Alpha', description: '', installed: false }], + })) + const install = vi.fn() + .mockResolvedValueOnce({ ok: true, restartRequired: false, pendingBuilds: ['node-pty', 'protobufjs'] }) + .mockResolvedValueOnce({ ok: true, restartRequired: true }) + render() + + await screen.findByText('Alpha') + fireEvent.click(screen.getByRole('button', { name: en.install })) + expect(await screen.findByRole('dialog', { name: en.consentTitle })).toBeTruthy() + expect(screen.getByText('node-pty')).toBeTruthy() + expect(screen.getByText('protobufjs')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.consentAllow })) + expect(install).toHaveBeenLastCalledWith('a', ['node-pty', 'protobufjs']) + expect(await screen.findByText(en.restartRequired)).toBeTruthy() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('shows a marketplace load failure with a retry', async () => { + const list = vi.fn() + .mockRejectedValueOnce(new Error('network miss')) + .mockResolvedValueOnce({ entries: [] }) + render() + + expect((await screen.findByRole('alert')).textContent).toBe(en.marketplaceLoadFailed) + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText(en.marketplaceEmpty)).toBeTruthy() + }) + + it('refreshes the marketplace via the header refresh button', async () => { + const list = vi.fn() + .mockResolvedValueOnce({ entries: [{ id: 'a', name: 'Alpha', description: '', installed: false }] }) + .mockResolvedValueOnce({ + entries: [ + { id: 'a', name: 'Alpha', description: '', installed: false }, + { id: 'b', name: 'Beta', description: '', installed: false }, + ], + }) + render() + + await screen.findByText('Alpha') + expect(screen.queryByText('Beta')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.marketplaceRefresh })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText('Beta')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-skill-manager/package.json b/packages/client/ui-settings-skill-manager/package.json new file mode 100644 index 0000000000..3466a974db --- /dev/null +++ b/packages/client/ui-settings-skill-manager/package.json @@ -0,0 +1,80 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-skill-manager", + "description": "Skills management tab in Web Plugins settings: list, install, uninstall, and toggle local skills", + "version": "0.1.0-rc.5", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-skill-manager" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.module.css b/packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.module.css new file mode 100644 index 0000000000..2416f06124 --- /dev/null +++ b/packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.module.css @@ -0,0 +1,243 @@ +.section { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + max-width: 760px; + color: var(--dsw-alias-label-primary); +} + +.heading { + margin: 0; + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +.status, +.failure p, +.restart, +.error { + margin: 0; +} + +.status, +.failure { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.restart { + color: var(--dsw-alias-label-secondary); + font-size: 12px; +} + +.error { + color: var(--dsw-alias-danger-text); + font-size: 12px; +} + +.failure { + display: flex; + align-items: center; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.failure button { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + padding: 4px 10px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + cursor: pointer; +} + +.install { + margin-bottom: 4px; + padding-bottom: 12px; + border-bottom: 1px solid var(--dsh-border); +} + +.install h4 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; +} + +.installRow { + display: flex; + align-items: center; + gap: 8px; +} + +.sourceSelect select { + height: 36px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 0 8px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; +} + +.installInput, +.descInput { + height: 36px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 0 12px; + outline: none; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; +} + +.installInput { + flex: 1; + min-width: 0; +} + +.installAction { + flex: none; +} + +.list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.card { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + padding: 12px 14px; + background: var(--dsw-alias-bg-layer-3); +} + +.cardHead { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +.managedTag { + flex: none; + border-radius: 5px; + padding: 1px 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.managedTag[data-managed='true'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.desc { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.editRow { + display: flex; + align-items: center; + gap: 8px; +} + +.descInput { + flex: 1; + min-width: 0; +} + +.editAction { + flex: none; +} + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.source { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.invocationTag { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 5px; + padding: 1px 7px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 11px; + line-height: 16px; + cursor: default; +} + +.invocationTag[data-enabled='true'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.invocationTag[data-managed='true'] { + cursor: pointer; +} + +.invocationTag[data-managed='true']:hover:not(:disabled) { + border-color: var(--dsw-alias-border-l1); +} + +.invocationTag:disabled { + opacity: 0.6; +} + +.actions { + display: flex; + gap: 8px; +} + +.action { + flex: none; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.tsx b/packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.tsx new file mode 100644 index 0000000000..242eda3190 --- /dev/null +++ b/packages/client/ui-settings-skill-manager/src/client/SkillManagerSettingsTab.tsx @@ -0,0 +1,241 @@ +import { useEffect, useId, useState, type ReactNode } from 'react' +import type { + SkillInstallSpec, SkillInvocationPatch, SkillManagerEntry, SkillManagerSnapshot, +} from '@deepseek-ai/dsh-api-remotes/client' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './SkillManagerSettingsTab.module.css' + +/** Registration-side Remote face used by the section. */ +export interface SkillManagerSettingsTabInjected { + /** Read a current Host skill snapshot. */ + list: () => Promise + /** Install a skill from a git/npm/tarball/local source. */ + install: (spec: SkillInstallSpec) => Promise<{ ok: true; name?: string }> + /** Delete a user-root skill. */ + uninstall: (name: string) => Promise<{ ok: true; name?: string }> + /** Toggle a skill's model/user invocation surfaces. */ + setEnabled: (name: string, patch: SkillInvocationPatch) => Promise<{ ok: true; name?: string }> + /** Edit a skill's description. */ + setDescription: (name: string, description: string) => Promise<{ ok: true; name?: string }> +} + +/** Full component props assembled by the Settings slot renderer. */ +export type SkillManagerSettingsTabProps = + PropsRuntime<'settings.plugins.tab'> + & PropsLocale<'settings.skillManager'> + & InjectFace + +type ViewState = + | { readonly status: 'loading' } + | { readonly status: 'error' } + | { readonly status: 'ready'; readonly entries: readonly SkillManagerEntry[] } + +const SOURCES: readonly SkillInstallSpec['source'][] = ['git', 'npm', 'tarball', 'local'] + +/** Render the local skill catalog with install/uninstall and invocation management. */ +export function SkillManagerSettingsTab({ + list, install, uninstall, setEnabled, setDescription, t, +}: SkillManagerSettingsTabProps): ReactNode { + const titleId = useId() + const [request, setRequest] = useState(0) + const [state, setState] = useState({ status: 'loading' }) + const [source, setSource] = useState('git') + const [spec, setSpec] = useState('') + const [installBusy, setInstallBusy] = useState(false) + const [busy, setBusy] = useState(null) + const [note, setNote] = useState<{ kind: 'restart' | 'error'; text: string } | null>(null) + const [editing, setEditing] = useState(null) + const [descValue, setDescValue] = useState('') + + useEffect(() => { + let current = true + void Promise.resolve().then(() => list()).then( + (snapshot) => { if (current) setState({ status: 'ready', entries: snapshot.skills }) }, + () => { if (current) setState({ status: 'error' }) }, + ) + return () => { current = false } + }, [list, request]) + + /** Re-fetch the catalog after a load failure or a mutation. */ + const refresh = (): void => { + setState({ status: 'loading' }) + setRequest(value => value + 1) + } + + const installSkill = (): void => { + const trimmed = spec.trim() + if (trimmed.length === 0 || installBusy) return + setInstallBusy(true) + setNote(null) + void install({ source, spec: trimmed }).then( + (result) => { + setNote({ kind: 'restart', text: result.name !== undefined ? `${t('installed')}: ${result.name}` : t('installed') }) + setSpec('') + setRequest(value => value + 1) + }, + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setNote({ kind: 'error', text: `${t('installFailed')}: ${detail}` }) + }, + ).finally(() => { setInstallBusy(false) }) + } + + const removeSkill = (name: string): void => { + if (busy !== null) return + setBusy(name) + setNote(null) + void uninstall(name).then( + () => { + setNote({ kind: 'restart', text: t('updated') }) + setRequest(value => value + 1) + }, + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setNote({ kind: 'error', text: `${t('uninstallFailed')}: ${detail}` }) + }, + ).finally(() => { setBusy(null) }) + } + + const toggleInvocation = (entry: SkillManagerEntry, key: 'model' | 'user', value: boolean): void => { + if (busy !== null) return + setBusy(entry.name) + setNote(null) + void setEnabled(entry.name, { [key]: value }).then( + () => { setNote({ kind: 'restart', text: t('updated') }); setRequest(value => value + 1) }, + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setNote({ kind: 'error', text: `${t('updateFailed')}: ${detail}` }) + }, + ).finally(() => { setBusy(null) }) + } + + const saveDescription = (name: string): void => { + const value = descValue.trim() + if (value.length === 0 || busy !== null) return + setBusy(name) + setNote(null) + void setDescription(name, value).then( + () => { setEditing(null); setNote({ kind: 'restart', text: t('updated') }); setRequest(value => value + 1) }, + (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + setNote({ kind: 'error', text: `${t('saveFailed')}: ${detail}` }) + }, + ).finally(() => { setBusy(null) }) + } + + const invocationTag = (entry: SkillManagerEntry, key: 'model' | 'user', enabled: boolean): ReactNode => { + const label = `${t(key)} · ${t(enabled ? 'enabled' : 'disabled')}` + const handle = entry.managed + ? () => { toggleInvocation(entry, key, !enabled) } + : undefined + return ( + + ) + } + + return ( +
+

{t('tab')}

+ {note !== null + ?

{note.text}

+ : null} +
+

{t('installSkill')}

+
+ + { setSpec(event.currentTarget.value) }} + onKeyDown={(event) => { if (event.key === 'Enter') installSkill() }} + /> + +
+
+ {state.status === 'loading' ?

{t('loading')}

: null} + {state.status === 'error' ? ( +
+

{t('error')}

+ +
+ ) : null} + {state.status === 'ready' ? ( + state.entries.length === 0 + ?

{t('skillsEmpty')}

+ : ( +
    + {state.entries.map(entry => ( +
  • +
    + {entry.name} + + {t(entry.managed ? 'managed' : 'notManaged')} + +
    + {editing === entry.name ? ( +
    + { setDescValue(event.currentTarget.value) }} + onKeyDown={(event) => { if (event.key === 'Enter') saveDescription(entry.name) }} + /> + +
    + ) : ( +

    {entry.description}

    + )} +
    + {`${t('source')}: ${entry.source}`} + {invocationTag(entry, 'model', entry.invocation.modelInvocable)} + {invocationTag(entry, 'user', entry.invocation.userInvocable)} +
    + {entry.managed ? ( +
    + + +
    + ) : null} +
  • + ))} +
+ ) + ) : null} +
+ ) +} diff --git a/packages/client/ui-settings-skill-manager/src/client/index.ts b/packages/client/ui-settings-skill-manager/src/client/index.ts new file mode 100644 index 0000000000..0601d77abc --- /dev/null +++ b/packages/client/ui-settings-skill-manager/src/client/index.ts @@ -0,0 +1,79 @@ +/** Host skill manager registered into Web Settings. */ + +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { SkillManagerSettingsTab, type SkillManagerSettingsTabInjected } from './SkillManagerSettingsTab.tsx' +import { en, zh, type SkillManagerLocaleKey } from './locales.ts' + +export type { SkillManagerSettingsTabInjected, SkillManagerSettingsTabProps } from './SkillManagerSettingsTab.tsx' +export type { SkillManagerLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Host skill manager copy. */ + 'settings.skillManager': SkillManagerLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.skillManager' + +/** Services required by the Settings registration and generated Remote face. */ +export const inject = ['slots', 'locale', 'remote', 'remote.skillManager'] + +/** Contribute the skills management tab to the Plugins settings section. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-skill-manager: dictionaries') + + const t = ctx.locale.bind(NS) + const list: SkillManagerSettingsTabInjected['list'] = async () => { + // The optional `cwd` parameter must still be sent (as undefined) so the + // typert transport sees the declared argument count. + const result = await ctx.remote.skillManager.list(undefined) + if (!result.ok) { + throw new Error(`skillManager.list failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const install: SkillManagerSettingsTabInjected['install'] = async (spec) => { + const result = await ctx.remote.skillManager.installSkill(spec) + if (!result.ok) { + throw new Error(`skillManager.installSkill failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const uninstall: SkillManagerSettingsTabInjected['uninstall'] = async (name) => { + const result = await ctx.remote.skillManager.uninstallSkill(name) + if (!result.ok) { + throw new Error(`skillManager.uninstallSkill failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const setEnabled: SkillManagerSettingsTabInjected['setEnabled'] = async (name, patch) => { + const result = await ctx.remote.skillManager.setEnabled(name, patch) + if (!result.ok) { + throw new Error(`skillManager.setEnabled failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const setDescription: SkillManagerSettingsTabInjected['setDescription'] = async (name, description) => { + const result = await ctx.remote.skillManager.setDescription(name, description) + if (!result.ok) { + throw new Error(`skillManager.setDescription failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const injected = (): SkillManagerSettingsTabInjected => ({ + list, install, uninstall, setEnabled, setDescription, + }) + + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'skills', + order: 30, + label: () => t('tab'), + locale: NS, + inject: injected, + }, SkillManagerSettingsTab)) +} diff --git a/packages/client/ui-settings-skill-manager/src/client/locales.ts b/packages/client/ui-settings-skill-manager/src/client/locales.ts new file mode 100644 index 0000000000..2324353aeb --- /dev/null +++ b/packages/client/ui-settings-skill-manager/src/client/locales.ts @@ -0,0 +1,70 @@ +/** Copy dictionaries for the skills manager Settings section. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + tab: '技能管理', + loading: '正在读取技能…', + error: '暂时无法读取技能。', + retry: '重试', + skillsEmpty: '暂无技能。', + installSkill: '安装技能', + installSource: '来源', + installSpec: 'git 仓库地址 / npm 包名 / tarball 路径或URL / 本地目录(含 SKILL.md)', + install: '安装', + installing: '安装中…', + installFailed: '安装失败', + installed: '已安装', + uninstall: '删除', + uninstalling: '删除中…', + uninstallFailed: '删除失败', + source: '来源', + model: '模型', + user: '用户', + enabled: '启用', + disabled: '停用', + managed: '可管理', + notManaged: '只读', + editDescription: '编辑描述', + descriptionPlaceholder: '输入新的技能描述', + save: '保存', + saving: '保存中…', + saveFailed: '保存失败', + updated: '已更新', + updateFailed: '更新失败', +} satisfies Record + +/** Skills manager locale key union. */ +export type SkillManagerLocaleKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + tab: 'Skills', + loading: 'Reading skills…', + error: 'Skills are temporarily unavailable.', + retry: 'Retry', + skillsEmpty: 'No skills are available.', + installSkill: 'Install skill', + installSource: 'Source', + installSpec: 'git repo URL / npm name / tarball path or URL / local directory (containing SKILL.md)', + install: 'Install', + installing: 'Installing…', + installFailed: 'Install failed', + installed: 'Installed', + uninstall: 'Delete', + uninstalling: 'Deleting…', + uninstallFailed: 'Delete failed', + source: 'Source', + model: 'Model', + user: 'User', + enabled: 'Enabled', + disabled: 'Disabled', + managed: 'Manageable', + notManaged: 'Read-only', + editDescription: 'Edit description', + descriptionPlaceholder: 'Enter a new skill description', + save: 'Save', + saving: 'Saving…', + saveFailed: 'Save failed', + updated: 'Updated', + updateFailed: 'Update failed', +} satisfies Record diff --git a/packages/client/ui-settings-skill-manager/src/css-modules.d.ts b/packages/client/ui-settings-skill-manager/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-skill-manager/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-skill-manager/src/index.ts b/packages/client/ui-settings-skill-manager/src/index.ts new file mode 100644 index 0000000000..db29cc610f --- /dev/null +++ b/packages/client/ui-settings-skill-manager/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the skills-manager tab browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the skills manager tab. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-skill-manager/src/invariant.ts b/packages/client/ui-settings-skill-manager/src/invariant.ts new file mode 100644 index 0000000000..c9e1514e16 --- /dev/null +++ b/packages/client/ui-settings-skill-manager/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-settings-skill-manager/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-skill-manager' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-skill-manager-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this package owns a settings contribution over host-owned skill state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-skill-manager/tests/skill-manager.client.spec.tsx b/packages/client/ui-settings-skill-manager/tests/skill-manager.client.spec.tsx new file mode 100644 index 0000000000..cdce2e8fc5 --- /dev/null +++ b/packages/client/ui-settings-skill-manager/tests/skill-manager.client.spec.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + SkillManagerSettingsTab, + type SkillManagerSettingsTabInjected, + type SkillManagerSettingsTabProps, +} from '../src/client/SkillManagerSettingsTab.tsx' +// Loads the `LocaleNamespaceMap` augmentation so `PropsLocale<'settings.skillManager'>` carries `t`. +import type {} from '../src/client/index.ts' +import { en, type SkillManagerLocaleKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = ((key: SkillManagerLocaleKey): string => en[key]) as SkillManagerSettingsTabProps['t'] + +type Entry = NonNullable>>['skills'][number] + +const ENTRY: Entry = { + name: 'my-skill', + description: 'A demo skill', + source: 'user-dsh', + provider: 'filesystem', + invocation: { modelInvocable: true, userInvocable: true }, + managed: true, +} + +function props( + list: SkillManagerSettingsTabInjected['list'], + install: SkillManagerSettingsTabInjected['install'] = async () => ({ ok: true as const }), + uninstall: SkillManagerSettingsTabInjected['uninstall'] = async () => ({ ok: true as const }), + setEnabled: SkillManagerSettingsTabInjected['setEnabled'] = async () => ({ ok: true as const }), + setDescription: SkillManagerSettingsTabInjected['setDescription'] = async () => ({ ok: true as const }), +): SkillManagerSettingsTabProps { + return { t, list, install, uninstall, setEnabled, setDescription } as SkillManagerSettingsTabProps +} + +describe('SkillManagerSettingsTab', () => { + it('renders skills with invocation tags and manage/read-only labels', async () => { + const list = vi.fn(async () => ({ + skills: [ + ENTRY, + { ...ENTRY, name: 'bundled-one', description: 'A bundled skill', source: 'bundled', managed: false }, + ], + })) + render() + + expect(await screen.findByText('my-skill')).toBeTruthy() + expect(screen.getByText('A demo skill')).toBeTruthy() + expect(screen.getByText(en.managed)).toBeTruthy() + expect(screen.getByText(en.notManaged)).toBeTruthy() + expect(screen.getAllByText(`${en.model} · ${en.enabled}`)).toHaveLength(2) + expect(screen.getByText(`${en.source}: user-dsh`)).toBeTruthy() + }) + + it('installs a skill from the selected source and spec', async () => { + const list = vi.fn(async () => ({ skills: [] })) + const install = vi.fn(async () => ({ ok: true as const, name: 'new-skill' })) + render() + + const input = await screen.findByRole('textbox', { name: en.installSpec }) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'npm' } }) + fireEvent.change(input, { target: { value: '@scope/skill-pack' } }) + fireEvent.click(screen.getByRole('button', { name: en.install })) + expect(install).toHaveBeenCalledWith({ source: 'npm', spec: '@scope/skill-pack' }) + expect(await screen.findByText(`${en.installed}: new-skill`)).toBeTruthy() + }) + + it('toggles model invocation via setEnabled', async () => { + const list = vi.fn(async () => ({ skills: [ENTRY] })) + const setEnabled = vi.fn(async () => ({ ok: true as const })) + render() + + await screen.findByText('my-skill') + fireEvent.click(screen.getByRole('button', { name: `${en.model} · ${en.enabled}` })) + expect(setEnabled).toHaveBeenCalledWith('my-skill', { model: false }) + expect(await screen.findByText(en.updated)).toBeTruthy() + }) + + it('edits the skill description', async () => { + const list = vi.fn(async () => ({ skills: [ENTRY] })) + const setDescription = vi.fn(async () => ({ ok: true as const })) + render() + + await screen.findByText('my-skill') + fireEvent.click(screen.getByRole('button', { name: en.editDescription })) + const input = screen.getByRole('textbox', { name: en.descriptionPlaceholder }) + fireEvent.change(input, { target: { value: 'A new description' } }) + fireEvent.click(screen.getByRole('button', { name: en.save })) + expect(setDescription).toHaveBeenCalledWith('my-skill', 'A new description') + expect(await screen.findByText(en.updated)).toBeTruthy() + }) + + it('deletes a managed skill', async () => { + const list = vi.fn(async () => ({ skills: [ENTRY] })) + const uninstall = vi.fn(async () => ({ ok: true as const })) + render() + + await screen.findByText('my-skill') + fireEvent.click(screen.getByRole('button', { name: en.uninstall })) + expect(uninstall).toHaveBeenCalledWith('my-skill') + expect(await screen.findByText(en.updated)).toBeTruthy() + }) + + it('shows a load failure and retries', async () => { + const list = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ skills: [] }) + render() + + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText(en.skillsEmpty)).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-skill-manager/tsconfig.json b/packages/client/ui-settings-skill-manager/tsconfig.json new file mode 100644 index 0000000000..dc07f1b2f8 --- /dev/null +++ b/packages/client/ui-settings-skill-manager/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/client/ui-settings-skill-manager/tsdown.config.ts b/packages/client/ui-settings-skill-manager/tsdown.config.ts new file mode 100644 index 0000000000..e9348b91cd --- /dev/null +++ b/packages/client/ui-settings-skill-manager/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-skill-manager', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index 54b5be19ba..461f67baf9 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -2,13 +2,17 @@ English | [中文](README.zh.md) -Host projection of the current Cordis Loader tree with per-plugin enable/disable. `PluginInventoryGateway` registers the `pluginInventory` service and publishes two generated direct Remotes: `pluginInventory/list` and `pluginInventory/setEnabled`. `list` reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase. +Host projection of the current Cordis Loader tree with per-plugin enable/disable. `PluginInventoryGateway` registers the `pluginInventory` service and publishes generated direct Remotes (`list`, `setEnabled`, `availableBundles`, `installPlugin`, `installedBundles`, `uninstall`). `list` reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase. The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, or event stream. `setEnabled` toggles one entry live through `ctx.loader.update` and persists an explicit `disabled` override into the profile's user patch layer so the choice survives a restart (a bundle-default disable needs the `disabled: false` override to stick). -Every entry carries a `protected` flag. The guard in `src/required.ts` is default-open with two code-editable lists: `REQUIRED_PLUGINS` (the blacklist of load-bearing core that must never be disabled — the entry tree, the Remote RPC spine, the session and agent spines) and `USER_TOGGLEABLE_PLUGINS` (the whitelist, which overrides the blacklist for an explicitly toggleable plugin); a plugin on neither list is toggleable by default. The full dependency-derived taxonomy of the shipped base bundle is in [`docs/plugin-system.md`](../../../docs/plugin-system.md). `setEnabled` refuses to disable a required plugin and, after enabling, verifies the fiber becomes active (reverting a dependency-missing enable). The Web plugin-list tab renders one flat list of every entry: each shows its real enabled state, a toggleable plugin carries an enable or disable button (so a bundle-default-disabled plugin can be re-enabled), and a required plugin shows only a read-only note. +Every entry carries a `protected` flag. The guard in `src/required.ts` is default-open with two code-editable lists: `REQUIRED_PLUGINS` (the blacklist of load-bearing core that must never be disabled — the entry tree, the Remote RPC spine, the session and agent spines) and `USER_TOGGLEABLE_PLUGINS` (the whitelist, which overrides the blacklist for an explicitly toggleable plugin); a plugin on neither list is toggleable by default. The full dependency-derived taxonomy of the shipped base bundle is in [`docs/plugin-system.md`](../../../docs/plugin-system.md). `setEnabled` refuses to disable a required plugin; a plugin whose `apply` actually fails rejects through the loader's own start error, while a plugin merely pending on a dependency is left enabled and activates once the dependency resolves. The Web plugin-list tab renders one flat list of every entry: each shows its real enabled state, a toggleable plugin carries an enable or disable button (so a bundle-default-disabled plugin can be re-enabled), and a required plugin shows only a read-only note. -The gateway also manages installation through `availableBundles`/`installPlugin`/`uninstall`. `availableBundles` lists the curated offline-installable optional bundles in `src/bundles.ts` (`AVAILABLE_BUNDLES`); that catalog is empty until an optional bundle ships — the profile's default bundles (`dsh-base`, `dsh-web-app`, `dsh-image-recognition-bundle`) are part of the deployment, not optional add-ons, and `uninstall` refuses to remove them. `installPlugin` runs pnpm against the writable profile directory via the bundled Node and vendored pnpm for a registry package spec (the settings plugin-list tab offers this as the "install plugin" form); a registry install is gated behind the `dshAllowPluginInstall` context flag, which only the desktop boot sets. It tries the ordered `INSTALL_REGISTRIES` list (`src/install.ts`) until one succeeds, with the official npm registry last as the fallback, and errors only when every registry is unreachable. When the boot provides a `dshReloadProfile` handle, the gateway recomposes the running tree after the write so the plugin activates immediately (`restartRequired: false`); without it, the install persists the manifest and requires a restart (`restartRequired: true`). Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. +The gateway also manages installation through `availableBundles`/`installPlugin`/`installedBundles`/`uninstall`. `availableBundles` lists the curated offline-installable optional bundles in `src/bundles.ts` (`AVAILABLE_BUNDLES`); that catalog is empty until an optional bundle ships — the profile's default bundles (`dsh-base`, `dsh-web-app`, `dsh-image-recognition-bundle`) are part of the deployment, not optional add-ons, and `uninstall` refuses to remove them. `installedBundles` lists the profile's user-installed dependencies (the ones the user can uninstall). `uninstall` fully removes a user-installed dependency with `pnpm remove` (dropping both the dependency and any bundle layer it declared, gated by `dshAllowPluginInstall` like install), un-composes an offline optional bundle that was composed, and refuses the in-box default bundles. `installPlugin` runs pnpm against the writable profile directory for any `pnpm add` specifier — via the pnpm vendored into the harness, or the `pnpm` on PATH when no vendored pnpm is present (a development checkout), failing loudly only when neither exists — a bare npm name, a tarball path or URL (including a GitHub archive `.tar.gz`), or a git/GitHub URL — so every community install source shares one path. A registry install is gated behind the `dshAllowPluginInstall` context flag, which only the desktop boot sets; a registry-name spec tries the ordered `INSTALL_REGISTRIES` list (`src/install.ts`) until one succeeds, with the official npm registry last as the fallback, and errors only when every registry is unreachable, while a git, tarball, or path spec runs once with no registry. A registry-name spec also passes `--minimum-release-age-exclude` so a freshly published plugin installs. + +Because pnpm 11 blocks dependency build scripts by default, install is two-phase when one is blocked: the first call returns the blocked packages as `pendingBuilds` instead of failing, the settings tab shows them with a sandbox warning for the user's per-package consent, and the retry carries the approved set in `consentBuilds` — the host writes the per-package `allowBuilds` map (pnpm ≥11) and `onlyBuiltDependencies` array (pnpm 10) directly into the profile's `pnpm-workspace.yaml` for exactly those packages, then reinstalls. Writing the config directly, rather than running `pnpm approve-builds`, does not depend on the packages being in pnpm's pending-build state. When the boot provides a `dshReloadProfile` handle, the gateway recomposes the running tree after the write so the plugin activates immediately (`restartRequired: false`); without it, the install persists the manifest and requires a restart (`restartRequired: true`). Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +A plugin marketplace is exposed through `marketplaceList`/`marketplaceInstall`/`marketplaceUninstall`. The catalog lives on the static web host (default `https://deepseek.pinesound.cn/plugins/plugins.json`, overridable via `DSH_MARKETPLACE_URL`): one index JSON lists plugins, and a per-plugin JSON beside it prescribes the install method (`git`, `npm`, `tarball`, or `bundle`) with the pnpm specifier and the dependency name. `marketplaceList` overlays the durable install table — a small JSON document under `$DSH_HOME/plugin-marketplace/installed.json` — to mark each entry installed; that table is the authoritative installed check. `marketplaceInstall` maps a method onto the existing install path (git/npm/tarball via the registry Remote, bundle via the offline compose), records the install in the table on success, and honors the same two-phase build-consent flow; `marketplaceUninstall` resolves the dependency from the table, runs the existing uninstall, and drops the row. Per-plugin spec URLs are derived from the fixed catalog base plus the plugin id (never from catalog content), so a hostile catalog cannot point the app at an arbitrary URL. The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. @@ -23,4 +27,4 @@ None; this package never assembles model input. ## Known Limitations and Deferred Work - **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. -- **No provenance or add/remove** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot add or remove plugins. Enable/disable persists to the profile's user patch layer; a row the profile does not mount (absent from every bundle) cannot be toggled from here. +- **No provenance** — the service does not identify which bundle, profile, or override introduced an entry, and a row the profile does not mount (absent from every bundle) cannot be toggled from here. Enable/disable persists to the profile's user patch layer; user-installed dependencies can be uninstalled (`installedBundles`/`uninstall`), but the in-box default bundles are part of the installation and are not removable. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 6c3870b368..9f7b6bb09a 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -6,9 +6,13 @@ 阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型或事件流。`setEnabled` 通过 `ctx.loader.update` 实时切换单条条目,并把显式 `disabled` 覆盖写进 profile 的用户补丁层,使选择在重启后保留(bundle 默认禁用的行需要 `disabled: false` 覆盖才能保持启用)。 -每条条目带 `protected` 标记。`src/required.ts` 中的守卫默认开放,含两个可由代码编辑的名单:`REQUIRED_PLUGINS`(黑名单,即不可停用的承重核心——入口树、Remote RPC 主干、session 与 agent 主干)与 `USER_TOGGLEABLE_PLUGINS`(白名单,覆盖黑名单、对显式可开关的插件生效);未列入任何名单的插件默认可切换。随包 base bundle 的完整依赖图分类见 [`docs/plugin-system.md`](../../../docs/plugin-system.md)。`setEnabled` 拒绝停用必需插件;启用后会校验 fiber 变为 active(依赖缺失的启用会回滚)。Web 插件列表 tab 渲染单一扁平列表,包含所有条目:每项按真实启用状态显示,可切换插件带"启用"或"停用"按钮(这样 bundle 默认禁用的插件也能重新启用),必需插件只显示只读说明。 +每条条目带 `protected` 标记。`src/required.ts` 中的守卫默认开放,含两个可由代码编辑的名单:`REQUIRED_PLUGINS`(黑名单,即不可停用的承重核心——入口树、Remote RPC 主干、session 与 agent 主干)与 `USER_TOGGLEABLE_PLUGINS`(白名单,覆盖黑名单、对显式可开关的插件生效);未列入任何名单的插件默认可切换。随包 base bundle 的完整依赖图分类见 [`docs/plugin-system.md`](../../../docs/plugin-system.md)。`setEnabled` 拒绝停用必需插件;`apply` 真正失败的插件会经 loader 自身的启动错误被拒绝,而仅仅等待依赖(PENDING)的插件会保持启用,待依赖就绪后自动激活。Web 插件列表 tab 渲染单一扁平列表,包含所有条目:每项按真实启用状态显示,可切换插件带"启用"或"停用"按钮(这样 bundle 默认禁用的插件也能重新启用),必需插件只显示只读说明。 -网关还通过 `availableBundles`/`installPlugin`/`uninstall` 管理安装。`availableBundles` 列出 `src/bundles.ts`(`AVAILABLE_BUNDLES`)中策展的离线可安装可选 bundle;该目录目前为空,直到有可选 bundle 随包——profile 的默认 bundle(`dsh-base`、`dsh-web-app`、`dsh-image-recognition-bundle`)是部署的一部分,非可选插件,`uninstall` 拒绝移除它们。`installPlugin` 对 registry 包 spec 用内置 Node 与 vendored pnpm 在可写的 profile 目录运行 pnpm(设置页插件列表 tab 以"安装插件"表单提供此入口);registry 安装受 `dshAllowPluginInstall` 上下文标志门禁,仅桌面启动会开启。它会依次尝试 `src/install.ts` 中排序的 `INSTALL_REGISTRIES` 镜像源列表,直到其中一个成功——官方 npm 源排在最后作为保底,只有所有镜像源都不可达才报错。当 boot 提供了 `dshReloadProfile` 句柄时,网关在写入后重组合运行中的树,使插件立即生效(`restartRequired: false`);否则安装持久化 profile 清单,需重启生效(`restartRequired: true`)。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 +网关还通过 `availableBundles`/`installPlugin`/`installedBundles`/`uninstall` 管理安装。`availableBundles` 列出 `src/bundles.ts`(`AVAILABLE_BUNDLES`)中策展的离线可安装可选 bundle;该目录目前为空,直到有可选 bundle 随包——profile 的默认 bundle(`dsh-base`、`dsh-web-app`、`dsh-image-recognition-bundle`)是部署的一部分,非可选插件,`uninstall` 拒绝移除它们。`installedBundles` 列出 profile 中用户安装的依赖(即可卸载的那些)。`uninstall` 用 `pnpm remove` 完整移除用户安装的依赖(同时删除依赖与它声明的 bundle 层,和安装一样受 `dshAllowPluginInstall` 门禁),对已组合的离线可选 bundle 仅取消组合,并拒绝内置默认 bundle。`installPlugin` 在可写的 profile 目录对任意 `pnpm add` specifier 运行 pnpm——用内置进 harness 的 pnpm,或当没有内置 pnpm(如开发 checkout)时用 PATH 上的 `pnpm`,只有两者都不存在才显式报错——裸 npm 包名、tarball 路径或 URL(含 GitHub archive `.tar.gz`)、git/GitHub 仓库地址,所有社区安装来源共用同一条路径。registry 安装受 `dshAllowPluginInstall` 上下文标志门禁,仅桌面启动会开启;裸包名 spec 会依次尝试 `src/install.ts` 中排序的 `INSTALL_REGISTRIES` 镜像源列表,直到其中一个成功——官方 npm 源排在最后作为保底,只有所有镜像源都不可达才报错,而 git/tarball/path spec 只运行一次、不经过镜像源。裸包名 spec 还会带上 `--minimum-release-age-exclude`,使刚发布的插件也能安装。 + +由于 pnpm 11 默认拦截依赖的构建脚本,当有脚本被拦截时安装分两阶段:首次调用把被拦截的包作为 `pendingBuilds` 返回而非失败,设置页 tab 以沙箱警告展示这些包供用户逐包同意,重试时把已同意的集合放在 `consentBuilds` 里——宿主把恰好这些包的按包 `allowBuilds` map(pnpm ≥11)与 `onlyBuiltDependencies` 数组(pnpm 10)直接写进 profile 的 `pnpm-workspace.yaml`,然后重新安装。直接写配置而非运行 `pnpm approve-builds`,不依赖这些包是否处于 pnpm 的 pending 状态。当 boot 提供了 `dshReloadProfile` 句柄时,网关在写入后重组合运行中的树,使插件立即生效(`restartRequired: false`);否则安装持久化 profile 清单,需重启生效(`restartRequired: true`)。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 + +插件市场通过 `marketplaceList`/`marketplaceInstall`/`marketplaceUninstall` 暴露。目录放在静态网站宿主上(默认 `https://deepseek.pinesound.cn/plugins/plugins.json`,可用 `DSH_MARKETPLACE_URL` 覆盖):一个索引 JSON 列出插件,旁边每个插件的 JSON 规定安装方式(`git`、`npm`、`tarball` 或 `bundle`)以及 pnpm specifier 和依赖名。`marketplaceList` 叠加持久化安装表——`$DSH_HOME/plugin-marketplace/installed.json` 下的一小份 JSON 文档——把每条标记为已安装;该表是判断是否已安装的权威依据。`marketplaceInstall` 把安装方式映射到既有安装路径(git/npm/tarball 走 registry Remote,bundle 走离线组合),成功后在表中记录安装,并沿用同样的两阶段构建脚本同意流程;`marketplaceUninstall` 从表中解析依赖名,执行既有卸载并删除表行。每个插件的 spec URL 由固定目录基地址加插件 id 推导(绝不取自目录内容),因此恶意目录无法把应用指向任意 URL。 该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index f007f5d3fe..012d2a2c28 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -64,6 +65,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index e90a8ec32d..80b6731b23 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -8,13 +8,35 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' import { AVAILABLE_BUNDLES } from './bundles.ts' -import { composeOfflineBundle, resolvePnpm, runPnpmInstallWithRegistries, uninstallBundle } from './install.ts' +import { + composeOfflineBundle, + registryPackageName, + resolvePnpmCommand, + runPnpmInstall, + runPnpmInstallWithRegistries, + runPnpmRemove, + uninstallBundle, + writeAllowBuilds, +} from './install.ts' +import { + fetchMarketplaceCatalog, + fetchMarketplaceSpec, + MARKETPLACE_URL, + marketplaceBaseUrl, + marketplaceDataDir, + readInstallTable, + writeInstallTable, +} from './marketplace.ts' import { persistPluginDisabled } from './persist.ts' import { isRequiredPlugin } from './required.ts' import type { AvailableBundlesSnapshot, InstallResult, InstallSpec, + InstalledBundlesSnapshot, + InstalledMarketplacePlugin, + MarketplaceInstallSpec, + MarketplaceSnapshot, PluginEntryId, PluginFiberPhase, PluginInventoryEntry, @@ -23,8 +45,40 @@ import type { export type * from './types.ts' export { AVAILABLE_BUNDLES } from './bundles.ts' -export { INSTALL_REGISTRIES } from './install.ts' -export type { AvailableBundle, AvailableBundlesSnapshot, InstallResult, InstallSpec } from './types.ts' +export { + INSTALL_REGISTRIES, + parseBlockedBuilds, + registryPackageName, + resolvePnpmCommand, + runPnpmRemove, + writeAllowBuilds, +} from './install.ts' +export type { PnpmAddResult, ResolvedPnpmCommand } from './install.ts' +export { + CATALOG_FILE, + MARKETPLACE_URL, + fetchMarketplaceCatalog, + fetchMarketplaceSpec, + marketplaceBaseUrl, + marketplaceDataDir, + marketplaceSpecUrl, + readInstallTable, + writeInstallTable, +} from './marketplace.ts' +export type { + AvailableBundle, + AvailableBundlesSnapshot, + InstallResult, + InstallSpec, + InstalledBundle, + InstalledBundlesSnapshot, + InstalledMarketplacePlugin, + MarketplaceEntry, + MarketplaceInstallMethod, + MarketplaceInstallSpec, + MarketplacePluginMeta, + MarketplaceSnapshot, +} from './types.ts' /** * The profile's default bundle layers, composed by the shipped template and not @@ -98,7 +152,10 @@ export class PluginInventoryGateway extends TypertRemoteService { * explicit `disabled` override into the profile's user patch layer so the * choice survives a restart. A plugin enabled by a bundle patch must carry * the `disabled: false` override too, or the bundle's default would win on - * the next reload. + * the next reload. A plugin that is PENDING on a dependency (a service + * another enabled plugin provides) is left enabled — the loader activates it + * once the dependency resolves; a plugin whose apply actually fails rejects + * here via the loader's own start error. * @param entryId - the loader tree entry id (as `list` reports it). * @param enabled - the desired effective state. * @returns a confirmation; the caller re-lists to observe the new phase. @@ -113,13 +170,13 @@ export class PluginInventoryGateway extends TypertRemoteService { throw new Error(`plugin ${String(entryId)} is required by the application and cannot be disabled`) } const rowId = entry.options.id + // update() throws when the plugin's apply/config fails (and restores the + // previous disabled state), so a resolved enable has either started or is + // legitimately PENDING on a dependency that another enabled plugin + // provides. PENDING is not a failure: reverting it here would stop + // interdependent plugins — typically several freshly installed ones — + // from being enabled together. await this.ctx.loader.update(entryId, { disabled: !enabled }) - // An enable whose injected services are unavailable would fail the next - // boot (the dependent never becomes active). Revert and refuse loudly. - if (enabled && entry.fiber !== undefined && entry.fiber.state !== FIBER_STATE.ACTIVE) { - await this.ctx.loader.update(entryId, { disabled: true }) - throw new Error(`plugin ${String(entryId)} could not start; its dependencies are unavailable`) - } if (this.ctx.baseUrl !== undefined) { persistPluginDisabled(fileURLToPath(this.ctx.baseUrl), rowId, !enabled) } @@ -148,10 +205,19 @@ export class PluginInventoryGateway extends TypertRemoteService { * the profile's bundle layer list (no network); a `registry` spec runs pnpm * against the writable profile directory via the bundled Node and vendored * pnpm, which requires the `dshAllowPluginInstall` context flag (set only by - * the desktop boot). Persists the manifest; the running tree recomposes at - * the next boot. + * the desktop boot). A registry spec is any pnpm `add` specifier — a bare npm + * name, a tarball path/URL, or a git/GitHub URL — so every community install + * source shares this path. + * + * Build-script consent is two-phase. The first call (no `consentBuilds`) + * installs; when pnpm blocks dependency build scripts the host returns them + * as `pendingBuilds` instead of failing. The caller shows those to the user, + * and the retry carries the exact consented set in `consentBuilds`: the host + * runs `pnpm approve-builds` for those packages, then reinstalls. The running + * tree recomposes at the next boot. * @param spec - the bundle name or registry package spec to install. - * @returns a confirmation; `restartRequired` tells the caller to restart. + * @returns a confirmation; `pendingBuilds` pauses the install for consent, + * otherwise `restartRequired` tells the caller to restart. */ @Remote('installPlugin') async installPlugin(spec: InstallSpec): Promise { @@ -170,41 +236,236 @@ export class PluginInventoryGateway extends TypertRemoteService { if (this.ctx.get('dshAllowPluginInstall') !== true) { throw new Error('dsh: plugin install is not permitted in this runtime') } - const pnpmCjs = resolvePnpm(process.execPath) - if (pnpmCjs === undefined) { - throw new Error('dsh: bundled pnpm is unavailable in this runtime') + // Prefer the pnpm vendored into the packaged harness; fall back to pnpm on + // PATH so a development checkout (which has no vendored pnpm) can install. + const pnpm = resolvePnpmCommand(process.execPath) + if (pnpm === undefined) { + throw new Error('dsh: bundled pnpm is unavailable in this runtime and pnpm is not on PATH') } // The reconcile below re-reads the manifest, so a missing file fails there; // this snapshot fallback is only reached in that same unreachable-to-succeed case. /* v8 ignore next */ const before = this.profileManifest(profileDir) ?? { dependencies: {} } - // Try each registry until one succeeds; the official npm registry is last. - runPnpmInstallWithRegistries({ - binName: 'dsh', - profileDir, - installAnchor: anchor, - nodeBin: process.execPath, - pnpmCjs, - spec: spec.spec, - before, - }) + // A consenting retry allows exactly the packages the user confirmed in the + // profile's pnpm-workspace.yaml (deterministic — no dependency on pnpm's + // pending-build state), then the add proceeds with them buildable. + if (spec.consentBuilds !== undefined && spec.consentBuilds.length > 0) { + writeAllowBuilds(profileDir, spec.consentBuilds) + } + // A registry-name spec participates in the registry fallback loop and the + // minimum-release-age exemption; a git, tarball, or path spec runs once. + const registryName = registryPackageName(spec.spec) + const result = registryName === undefined + ? runPnpmInstall({ + binName: 'dsh', + profileDir, + installAnchor: anchor, + nodeBin: pnpm.nodeBin, + pnpmCjs: pnpm.pnpmCjs, + spec: spec.spec, + before, + }) + : runPnpmInstallWithRegistries({ + binName: 'dsh', + profileDir, + installAnchor: anchor, + nodeBin: pnpm.nodeBin, + pnpmCjs: pnpm.pnpmCjs, + spec: spec.spec, + before, + minimumReleaseAgeExclude: registryName, + }) + if (result.pendingBuilds !== undefined) { + return { ok: true, restartRequired: false, pendingBuilds: result.pendingBuilds } + } return { ok: true, restartRequired: !(await this.reload()) } } /** - * Un-compose an offline optional bundle from the profile's bundle layer list. - * @param name - the bundle package name to remove. + * List the profile's user-installed plugin dependencies (the packages pnpm + * manages in the profile, excluding the in-box bundles, which ship with the + * installation and are never dependencies). These are the ones the user can + * uninstall. + * @returns the installed-dependency snapshot. + */ + @Remote('installedBundles') + installedBundles(): InstalledBundlesSnapshot { + const profileDir = this.profileDir() + const dependencies = this.profileManifest(profileDir)?.dependencies ?? {} + // In-box bundles (dsh-base & friends) are never profile dependencies, so the + // dependency list is exactly the set the user installed. + return { installed: Object.keys(dependencies).map(name => ({ name })) } + } + + /** + * Uninstall a plugin. A user-installed dependency (managed by pnpm) is removed + * with `pnpm remove`, which drops both the dependency and any bundle layer it + * had declared — this requires the `dshAllowPluginInstall` context flag, set + * only by the desktop boot. An offline optional bundle that a profile composed + * is un-composed (no dependency to remove). In-box bundles are part of the + * installation and are refused. + * @param name - the plugin package name to remove. * @returns a confirmation; `restartRequired` tells the caller to restart. */ @Remote('uninstall') async uninstall(name: string): Promise { + const profileDir = this.profileDir() if (DEFAULT_BUNDLES.has(name)) { throw new Error(`dsh: bundle ${name} is composed by default and cannot be uninstalled`) } - uninstallBundle('dsh', this.profileDir(), name) + const before = this.profileManifest(profileDir) + if (before?.dependencies?.[name] !== undefined) { + // A user-installed dependency: remove it with pnpm, then reconcile (the + // reconcile drops the layer when the removed package had declared one). + const anchor = this.ctx.get('dshInstallAnchor') as string | undefined + if (anchor === undefined) { + throw new Error('dsh: install anchor is unavailable in this runtime') + } + if (this.ctx.get('dshAllowPluginInstall') !== true) { + throw new Error('dsh: plugin uninstall is not permitted in this runtime') + } + const pnpm = resolvePnpmCommand(process.execPath) + if (pnpm === undefined) { + throw new Error('dsh: bundled pnpm is unavailable in this runtime and pnpm is not on PATH') + } + runPnpmRemove({ + binName: 'dsh', + profileDir, + installAnchor: anchor, + nodeBin: pnpm.nodeBin, + pnpmCjs: pnpm.pnpmCjs, + spec: name, + before, + }) + return { ok: true, restartRequired: !(await this.reload()) } + } + // Not a profile dependency: an offline optional bundle the profile composed. + uninstallBundle('dsh', profileDir, name) return { ok: true, restartRequired: !(await this.reload()) } } + /** + * List the remote marketplace catalog, marking each entry installed. The + * durable install table records what the user installed; an entry is reported + * installed only when it is BOTH recorded there AND actually present in the + * current profile's dependencies (or bundle layers, for a bundle install) — + * so a plugin removed by another path or a reset profile is not shown as + * installed after a restart. When no profile is anchored, the table alone is + * the fallback. The catalog is fetched from the static web host; a transient + * network miss throws so the caller can surface a clean failure. + * @returns the marketplace catalog with installed state. + */ + @Remote('marketplaceList') + async marketplaceList(): Promise { + const dataDir = marketplaceDataDir() + const table = readInstallTable(dataDir) + const entries = await fetchMarketplaceCatalog(MARKETPLACE_URL) + const profile = this.ctx.baseUrl !== undefined ? this.profileManifest(this.profileDir()) : undefined + const dependencies = profile?.dependencies ?? {} + const bundles = profile?.dsh?.profile?.bundles ?? [] + return { + entries: entries.map((meta) => { + const row = table[meta.id] + const installed = row !== undefined && ( + row.method === 'bundle' + ? bundles.includes(row.spec) + : dependencies[row.dependency ?? row.spec] !== undefined + ) + return { ...meta, installed } + }), + } + } + + /** + * Install a marketplace plugin by id. Fetches the plugin's install spec from + * the catalog base (derived from the fixed catalog URL, never from catalog + * content), maps it onto the existing install path (git/npm/tarball via the + * registry Remote, bundle via the offline compose), and records the install + * in the durable table once it succeeds. A `pendingBuilds` result pauses for + * build consent exactly as a direct registry install does; the caller shows + * the blocked packages and re-invokes with the consented set. + * @param id - the marketplace plugin id. + * @param consentBuilds - the packages the user consented to run build scripts + * for, sent on the retry after a `pendingBuilds` result. + * @returns the underlying install result (restart notice or pending consent). + */ + @Remote('marketplaceInstall') + async marketplaceInstall(id: string, consentBuilds?: readonly string[]): Promise { + const spec = await fetchMarketplaceSpec(marketplaceBaseUrl(MARKETPLACE_URL), id) + const result = spec.method === 'bundle' + ? await this.installPlugin({ type: 'bundle', name: spec.spec }) + : consentBuilds !== undefined && consentBuilds.length > 0 + ? await this.installPlugin({ type: 'registry', spec: spec.spec, consentBuilds }) + : await this.installPlugin({ type: 'registry', spec: spec.spec }) + // A paused install awaits consent; only a completed install is recorded. + if (result.pendingBuilds !== undefined) return result + this.recordMarketplaceInstall(id, spec) + return result + } + + /** + * Uninstall a marketplace plugin by id. Resolves the profile dependency (or + * the bundle name) from the install table row, calls the existing uninstall + * path, and drops the table row once it succeeds. + * @param id - the marketplace plugin id. + * @returns the underlying uninstall result. + */ + @Remote('marketplaceUninstall') + async marketplaceUninstall(id: string): Promise { + const dataDir = marketplaceDataDir() + const table = readInstallTable(dataDir) + const row = table[id] + if (row === undefined) throw new Error(`marketplace plugin ${id} is not installed`) + // A bundle install un-composes by its bundle name; registry installs remove + // the profile dependency, falling back to the spec when no explicit + // dependency name was recorded. + const result = await this.uninstall(row.method === 'bundle' ? row.spec : row.dependency ?? row.spec) + const next = { ...table } + Reflect.deleteProperty(next, id) + writeInstallTable(dataDir, next) + return result + } + + /** Record a successful marketplace install in the durable install table. */ + private recordMarketplaceInstall(id: string, spec: MarketplaceInstallSpec): void { + const dataDir = marketplaceDataDir() + const table = readInstallTable(dataDir) + // The web spec's `dependency` is a best-effort guess; for a git (or path) + // install the resolved package name is only known once pnpm runs, and it can + // carry a scope the guess omitted (e.g. `@dsh-external/dsh-visualize`). The + // marketplace's installed check matches the profile by this name, so record + // the real one — resolved from the just-updated profile — to keep the table + // in sync with the dependency the loader actually holds. + const dependency = this.resolveInstalledDependency(spec) ?? spec.dependency + const row: InstalledMarketplacePlugin = { + method: spec.method, + spec: spec.spec, + installedAt: new Date().toISOString(), + ...(dependency !== undefined ? { dependency } : {}), + } + writeInstallTable(dataDir, { ...table, [id]: row }) + } + + /** + * Resolve the real profile dependency name for a just-completed install. An + * exact dependency-name match wins; otherwise a git/path spec installs under + * the package's own name, which surfaces as the dependency whose version range + * equals the spec. + * @param spec - the marketplace install spec that succeeded. + * @returns the profile dependency name, or undefined when unresolvable. + */ + private resolveInstalledDependency(spec: MarketplaceInstallSpec): string | undefined { + if (this.ctx.baseUrl === undefined) return undefined + const dependencies = this.profileManifest(this.profileDir())?.dependencies ?? {} + if (spec.dependency !== undefined && dependencies[spec.dependency] !== undefined) { + return spec.dependency + } + for (const [name, range] of Object.entries(dependencies)) { + if (range === spec.spec) return name + } + return undefined + } + /** Trigger a live tree recomposition; false when no reload handle is provided. */ private async reload(): Promise { const reload = this.ctx.get('dshReloadProfile') as (() => Promise) | undefined diff --git a/packages/host/plugin-inventory/src/install.ts b/packages/host/plugin-inventory/src/install.ts index 74dccdebee..f82080aac7 100644 --- a/packages/host/plugin-inventory/src/install.ts +++ b/packages/host/plugin-inventory/src/install.ts @@ -7,16 +7,25 @@ * writable profile directory via a bundled Node + vendored pnpm, then * reconciles the bundle layer list exactly as `dsh plugin add` does. All * functions are dependency-free of Cordis so they unit-test without a context. + * + * pnpm 11 blocks dependency build scripts by default and refuses to install + * registry packages published too recently, so a `pnpm add` can fail for + * reasons the caller must turn into a user decision. The install helpers + * surface a blocked build as a parseable `pendingBuilds` result instead of a + * throw; consenting re-runs `pnpm approve-builds` for exactly those packages + * before retrying the add. * @module @deepseek-ai/dsh-plugin-inventory/install */ import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { dump, load } from 'js-yaml' import { readProfileManifest, reconcileProfileBundles, resolveBundleDir, + resolvePnpm, writeProfileManifest, type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' @@ -32,6 +41,15 @@ export const INSTALL_REGISTRIES: readonly string[] = [ 'https://registry.npmjs.org', ] +/** What a single pnpm add run concluded. */ +export interface PnpmAddResult { + /** + * Build scripts pnpm refused to run. The install did not complete; the + * caller should ask the user and retry with `writeAllowBuilds` first. + */ + readonly pendingBuilds?: readonly string[] +} + /** * Compose an offline optional bundle into the profile's bundle layer list. * Validates that the bundle resolves from the installation anchor (so a bad @@ -69,14 +87,62 @@ export function uninstallBundle(binName: string, profileDir: string, name: strin writeProfileManifest(profileDir, manifest) } +/** + * The bare package name when `spec` is a registry specifier, else undefined. + * Registry specs are bare names with an optional version/tag suffix + * (`name`, `name@1.0.0`, `@scope/name@next`). Everything pnpm treats as a + * non-registry source — paths, `file:`/`link:`/`github:`/`git+` prefixes, + * tarball paths and archive URLs, git hosts — is not a registry spec. Only a + * registry spec participates in the `--registry` fallback loop and the + * `minimumReleaseAge` exemption, both of which are meaningless for a git or + * filesystem source. + * @param spec - the pnpm specifier verbatim. + * @returns the bare package name for a registry spec, else undefined. + */ +export function registryPackageName(spec: string): string | undefined { + const trimmed = spec.trim() + if (trimmed.length === 0) return undefined + if (/^(?:file:|link:|github:|gitlab:|bitbucket:|git\+|git@)/.test(trimmed)) return undefined + if (/^(?:\.{1,2}|~|[/\\])/.test(trimmed) || /^[a-zA-Z]:[\\/]/.test(trimmed)) return undefined + // A tarball or archive: ends in .tgz / .tar.gz (allowing query/fragment). + if (/\.(?:tgz|tar\.gz)(?:[?#]|$)/.test(trimmed)) return undefined + // An http(s) URL with at least one path segment (repo URL, archive URL, git). + if (/^https?:\/\//.test(trimmed) && trimmed.replace(/^https?:\/\//, '').split('/').length > 1) return undefined + // A `.git` marker (optionally with a #branch / @ref): a git checkout spec. + if (/\.git(?:[#@]|$)/.test(trimmed)) return undefined + // Strip a trailing version/tag (`@`), whose part has no `/` — the + // scope separator always does — so `@scope/name@1.0.0` → `@scope/name`. + return trimmed.replace(/@[^/@]+$/, '') +} + +/** + * Extract the packages whose build scripts pnpm refused to run from captured + * pnpm output. pnpm reports a blocked build as `ERR_PNPM_IGNORED_BUILDS` with + * a message naming the packages; matching the message line is the stable + * signal. Returns undefined when the output shows no blocked build, so a plain + * failure stays an ordinary error. + * @param output - the combined stdout/stderr of the pnpm run. + * @returns the blocked package names, or undefined when no build was blocked. + */ +export function parseBlockedBuilds(output: string): readonly string[] | undefined { + const match = /Ignored build scripts:\s*([^\r\n]+)/.exec(output) + const namesText = match?.[1] + if (namesText === undefined) return undefined + const names = namesText.split(',').map(name => name.trim()).filter(name => name.length > 0) + return names.length > 0 ? names : undefined +} + /** Options for one pnpm install run. */ export interface PnpmInstallOptions { readonly binName: string readonly profileDir: string readonly installAnchor: string - /** Absolute path of the Node executable to run pnpm with (the bundled node). */ - readonly nodeBin: string - /** Absolute path of the pnpm CLI entry (pnpm.cjs). */ + /** + * The Node executable to run `pnpmCjs` under; `undefined` when `pnpmCjs` is a + * command on PATH (the `pnpm` fallback), which spawn() then runs directly. + */ + readonly nodeBin: string | undefined + /** The vendored pnpm.cjs path, or the `pnpm` command on PATH. */ readonly pnpmCjs: string /** The package specifier to install. */ readonly spec: string @@ -84,31 +150,55 @@ export interface PnpmInstallOptions { readonly before: ProfileManifest /** An npm registry to install from (`pnpm add --registry`); defaults to pnpm's configured one. */ readonly registry?: string + /** A bare registry package name to exempt from pnpm's minimum-release-age check. */ + readonly minimumReleaseAgeExclude?: string } /** - * Run `pnpm add ` in the profile directory via a bundled Node, then - * reconcile `dsh.profile.bundles` against the installed state. Throws when the - * package manager fails (nonzero exit or spawn error). + * Run `pnpm add ` in the profile directory via the resolved pnpm, then + * reconcile `dsh.profile.bundles` against the installed state. A run whose + * build scripts were blocked returns them as `pendingBuilds` instead of + * throwing — the caller asks the user and retries after `writeAllowBuilds`. + * Any other nonzero exit throws. * @param options - the run options. + * @returns `pendingBuilds` when pnpm blocked build scripts, else an empty result. */ -export function runPnpmInstall(options: PnpmInstallOptions): void { - const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before, registry } = options - const args = registry === undefined - ? [pnpmCjs, 'add', spec] - : [pnpmCjs, 'add', spec, '--registry', registry] - const result = spawn(nodeBin, args, profileDir) +export function runPnpmInstall(options: PnpmInstallOptions): PnpmAddResult { + const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before, registry, minimumReleaseAgeExclude } = options + const args = ['add', spec] + if (registry !== undefined) args.push('--registry', registry) + // `--minimum-release-age-exclude` is understood only by pnpm ≥10.7. It is safe + // with the vendored pnpm (pinned 11.7); a PATH-pnpm fallback in a development + // checkout may be older and reject the option, so skip it there. + if (minimumReleaseAgeExclude !== undefined && nodeBin !== undefined) { + args.push(`--minimum-release-age-exclude=${minimumReleaseAgeExclude}`) + } + const result = nodeBin === undefined ? spawn(pnpmCjs, args, profileDir) : spawn(nodeBin, [pnpmCjs, ...args], profileDir) if (result.exitCode !== 0) { - throw new Error(`${binName}: pnpm install failed with exit code ${result.exitCode}`) + const pendingBuilds = parseBlockedBuilds(result.output) + if (pendingBuilds !== undefined) return { pendingBuilds } + // Surface pnpm's own output so the real cause (a missing package, a build + // failure, a registry error) is diagnosable instead of an opaque exit code. + throw new Error(`${binName}: pnpm install failed with exit code ${result.exitCode}${truncateOutput(result.output)}`) } reconcileProfileBundles(binName, before, profileDir, installAnchor) + return {} +} + +/** Compact a child's captured output for an error message (first 400 chars). */ +function truncateOutput(output: string): string { + const trimmed = output.trim() + if (trimmed.length === 0) return '' + return trimmed.length <= 400 ? `: ${trimmed}` : `: ${trimmed.slice(0, 400)}…` } /** * Run `pnpm add` trying each registry in {@link INSTALL_REGISTRIES} until one - * succeeds (the first success wins). The official registry is last, so a - * deployment falls back to it when every mirror is down; throws with the last - * error only when every registry fails. + * succeeds (the first success wins). A blocked build returns immediately — the + * same build would block on every registry, so a consent decision precedes any + * retry. The official registry is last, so a deployment falls back to it when + * every mirror is down; throws with the last error only when every registry + * fails with an ordinary error. * @param options - the run options (without a fixed `registry`). * @param registries - the ordered registries to try (defaults to * {@link INSTALL_REGISTRIES}). @@ -116,12 +206,13 @@ export function runPnpmInstall(options: PnpmInstallOptions): void { export function runPnpmInstallWithRegistries( options: Omit, registries: readonly string[] = INSTALL_REGISTRIES, -): void { +): PnpmAddResult { let lastError: unknown for (const registry of registries) { try { - runPnpmInstall({ ...options, registry }) - return + // A blocked build is source-independent: surface it without trying more + // registries (the same build would block on every registry). + return runPnpmInstall({ ...options, registry }) } catch (error) { lastError = error } @@ -133,34 +224,159 @@ export function runPnpmInstallWithRegistries( ) } -/** Spawn one synchronous child and return its exit code (0 on success). */ -function spawn(command: string, args: readonly string[], cwd: string): { exitCode: number } { +/** The pnpm workspace settings a fresh profile needs alongside an allowlist. */ +const PROFILE_WORKSPACE_BASE: Record = { + packages: ['.'], + nodeLinker: 'hoisted', + autoInstallPeers: false, +} + +/** + * Allow the build scripts of the given packages by writing the per-package + * `allowBuilds` map (pnpm ≥11) and `onlyBuiltDependencies` array (pnpm 10) into + * the profile's `pnpm-workspace.yaml`. Writing the config directly is + * deterministic: unlike `pnpm approve-builds`, it does not depend on the + * packages being in pnpm's pending-build state, so it works even when an + * install aborted before those packages materialized. Existing workspace + * settings are preserved and existing allowlist entries are merged. + * @param profileDir - the writable profile directory. + * @param names - the packages whose build scripts the user consented to run. + */ +export function writeAllowBuilds(profileDir: string, names: readonly string[]): void { + const workspacePath = join(profileDir, 'pnpm-workspace.yaml') + let doc: Record + try { + const parsed = load(readFileSync(workspacePath, 'utf8')) + doc = parsed !== null && typeof parsed === 'object' + ? parsed as Record + : { ...PROFILE_WORKSPACE_BASE } + } catch { + doc = { ...PROFILE_WORKSPACE_BASE } + } + const allowBuilds = (doc.allowBuilds ?? {}) as Record + const onlyBuilt = new Set((doc.onlyBuiltDependencies ?? []) as string[]) + for (const name of names) { + allowBuilds[name] = true + onlyBuilt.add(name) + } + doc.allowBuilds = allowBuilds + doc.onlyBuiltDependencies = [...onlyBuilt] + writeFileSync(workspacePath, dump(doc)) +} + +/** Options for removing one plugin dependency. */ +export interface PnpmRemoveOptions { + readonly binName: string + readonly profileDir: string + readonly installAnchor: string + /** The Node executable to run `pnpmCjs` under; `undefined` spawns `pnpmCjs` directly. */ + readonly nodeBin: string | undefined + /** The vendored pnpm.cjs path, or the `pnpm` command on PATH. */ + readonly pnpmCjs: string + /** The dependency name to remove. */ + readonly spec: string + /** The profile manifest read before the removal, for reconciliation. */ + readonly before: ProfileManifest +} + +/** + * Run `pnpm remove ` in the profile directory via the resolved pnpm, then + * reconcile `dsh.profile.bundles` against the installed state — a removed + * dependency that had been a bundle layer leaves the layer stack too. Throws + * when the removal fails. + * @param options - the removal run options. + */ +export function runPnpmRemove(options: PnpmRemoveOptions): void { + const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before } = options + const result = nodeBin === undefined + ? spawn(pnpmCjs, ['remove', spec], profileDir) + : spawn(nodeBin, [pnpmCjs, 'remove', spec], profileDir) + if (result.exitCode !== 0) { + throw new Error(`${binName}: pnpm remove failed with exit code ${result.exitCode}`) + } + reconcileProfileBundles(binName, before, profileDir, installAnchor) +} + +/** Captured outcome of one synchronous child run. */ +interface SpawnResult { + readonly exitCode: number + /** Combined stdout and stderr, decoded as UTF-8. */ + readonly output: string +} + +/** + * Spawn one synchronous child and return its exit code and captured output. + * Output is captured so a blocked build can be parsed back out; on a successful + * spawn status is always a number, so the fallback is unreachable. The child + * runs with a non-interactive git environment so a first-time git clone never + * hangs on a terminal prompt under the piped install subprocess. + */ +function spawn(command: string, args: readonly string[], cwd: string): SpawnResult { const result = spawnSync(command, args, { cwd, - stdio: 'inherit', + encoding: 'utf8', shell: process.platform === 'win32', + env: gitNonInteractiveEnv(), }) if (result.error !== undefined) { throw result.error } - // On a successful spawn status is always a number; null coincides with the - // spawn-error path above, so the fallback is unreachable. + const output = `${result.stdout}${result.stderr}` /* v8 ignore next */ - return { exitCode: result.status ?? 1 } + return { exitCode: result.status ?? 1, output } } /** - * Locate the pnpm CLI bundled into the harness. Honors a `DSH_PNPM` override, - * then looks for the vendored pnpm beside the bundled Node's harness root. - * @param nodeBin - the bundled Node executable path (`process.execPath`). - * @param env - the process environment. - * @returns the pnpm.cjs path, or undefined when none is vendored. + * The child environment with git forced non-interactive. A first-time git + * source (`github:...`, `git+ssh://...`) prompts "Are you sure you want to + * continue connecting (yes/no)?" for an unknown SSH host key; under a piped + * install subprocess there is no terminal to answer, so the clone hangs or + * dies. `GIT_SSH_COMMAND` with `StrictHostKeyChecking=accept-new` auto-accepts + * a new host key (the "yes"), while `BatchMode=yes` forbids password prompts, + * and `GIT_TERMINAL_PROMPT=0` turns any remaining git prompt into a failure + * instead of a hang. The host's own `GIT_SSH_COMMAND`, when set, is preserved. */ -export function resolvePnpm(nodeBin: string, env: NodeJS.ProcessEnv = process.env): string | undefined { - if (env.DSH_PNPM) return env.DSH_PNPM - // nodeBin is harness/bin/node in the packaged app, so the harness root is - // one level up from bin/; pnpm is vendored under harness/pnpm/. - const harnessRoot = resolve(dirname(nodeBin), '..') - const candidate = join(harnessRoot, 'pnpm', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') - return existsSync(candidate) ? candidate : undefined +function gitNonInteractiveEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new', + } +} + +export { resolvePnpm } from '@deepseek-ai/dsh-app-boot' + +/** How pnpm is invoked for one install: the vendored pnpm or the `pnpm` on PATH. */ +export interface ResolvedPnpmCommand { + /** + * The Node executable to run `pnpmCjs` under, when the vendored pnpm is used. + * `undefined` means `pnpmCjs` is a command to spawn directly (`pnpm` on PATH). + */ + readonly nodeBin: string | undefined + /** The vendored pnpm.cjs path, or the `pnpm` command on PATH. */ + readonly pnpmCjs: string +} + +/** + * Resolve how to invoke pnpm: the vendored pnpm bundled into the harness when + * present, else the `pnpm` command on PATH (a development checkout has no + * vendored pnpm). Returns undefined when neither exists. + * @param nodeBin - the Node executable path (`process.execPath`). + * @param env - the process environment. + * @returns the invocation, or undefined when no pnpm is available. + */ +export function resolvePnpmCommand(nodeBin: string, env: NodeJS.ProcessEnv = process.env): ResolvedPnpmCommand | undefined { + const vendored = resolvePnpm(nodeBin, env) + if (vendored !== undefined) return { nodeBin, pnpmCjs: vendored } + return pnpmOnPath(env) ? { nodeBin: undefined, pnpmCjs: 'pnpm' } : undefined +} + +/** Whether a `pnpm` executable resolves on `PATH`. */ +function pnpmOnPath(env: NodeJS.ProcessEnv): boolean { + const path = env.PATH ?? env.Path ?? '' + const sep = process.platform === 'win32' ? ';' : ':' + const extensions = process.platform === 'win32' ? ['', '.cmd', '.exe', '.bat'] : [''] + return path.split(sep).some( + directory => directory.length > 0 && extensions.some(extension => existsSync(join(directory, `pnpm${extension}`))), + ) } diff --git a/packages/host/plugin-inventory/src/marketplace.ts b/packages/host/plugin-inventory/src/marketplace.ts new file mode 100644 index 0000000000..b0646b1f0d --- /dev/null +++ b/packages/host/plugin-inventory/src/marketplace.ts @@ -0,0 +1,169 @@ +/** + * Pure helpers behind the plugin-marketplace Remotes: catalog fetch and the + * durable per-user install table. + * + * The catalog lives on the static web host (deepseek-harness-web): one index + * JSON listing plugins plus a per-plugin JSON prescribing the install method. + * The install table is a small JSON document under the user data root and is + * the authoritative "is this plugin installed" check for the marketplace UI. + * Neither helper touches Cordis, so both unit-test without a context. + * + * Per-plugin spec URLs are derived from the catalog base plus the plugin id, + * never read from catalog content, so a hostile catalog cannot point the app + * at an arbitrary URL. + * @module @deepseek-ai/dsh-host-plugin-inventory/marketplace + */ + +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { dshHomePath } from '@deepseek-ai/dsh-home-paths' +import type { + InstalledMarketplacePlugin, + MarketplaceInstallMethod, + MarketplaceInstallSpec, + MarketplacePluginMeta, +} from './types.ts' + +/** The marketplace catalog index file name. */ +export const CATALOG_FILE = 'plugins.json' + +/** + * The remote marketplace catalog index. Defaults to the static web host that + * also serves the app's update manifest; a deployment overrides it with + * `DSH_MARKETPLACE_URL`. + */ +export const MARKETPLACE_URL = process.env.DSH_MARKETPLACE_URL + ?? 'https://deepseek.pinesound.cn/plugins/plugins.json' + +/** The directory under the user data root that holds the marketplace install table. */ +const DATA_DIR = 'plugin-marketplace' + +/** The install table file name under {@link DATA_DIR}. */ +const TABLE_FILE = 'installed.json' + +/** The install methods the catalog may prescribe. */ +const METHODS: readonly MarketplaceInstallMethod[] = ['git', 'npm', 'tarball', 'bundle'] + +/** + * The directory where the marketplace persists the user's install table. + * @returns the absolute `$DSH_HOME/plugin-marketplace` directory. + */ +export function marketplaceDataDir(): string { + return dshHomePath(DATA_DIR) +} + +/** + * Derive the marketplace base URL (the catalog's directory, ending in `/`) + * from its index URL, so per-plugin specs resolve beside the index. + * @param catalogUrl - the catalog index URL. + * @returns the base directory URL. + */ +export function marketplaceBaseUrl(catalogUrl: string): string { + return catalogUrl.endsWith(`/${CATALOG_FILE}`) ? catalogUrl.slice(0, -CATALOG_FILE.length) : catalogUrl +} + +/** + * The per-plugin spec URL for `id`. Built from the fixed catalog base plus the + * id, never from catalog content. + * @param baseUrl - the marketplace base URL from {@link marketplaceBaseUrl}. + * @param id - the plugin id. + * @returns the spec URL. + */ +export function marketplaceSpecUrl(baseUrl: string, id: string): string { + return `${baseUrl}${encodeURIComponent(id)}.json` +} + +/** + * Fetch and parse the marketplace catalog index. Throws on an HTTP error or a + * malformed entry so the caller can surface a clean failure. + * @param catalogUrl - the catalog index URL. + * @returns the catalog entries. + */ +export async function fetchMarketplaceCatalog(catalogUrl: string): Promise { + const response = await fetch(catalogUrl) + if (!response.ok) throw new Error(`marketplace catalog HTTP ${response.status}`) + const body = await response.json() as { plugins?: unknown } + const plugins = Array.isArray(body.plugins) ? body.plugins : [] + return plugins.map(parseMeta) +} + +/** + * Fetch and parse one plugin's install spec. Throws on an HTTP error or a + * malformed body. + * @param baseUrl - the marketplace base URL from {@link marketplaceBaseUrl}. + * @param id - the plugin id. + * @returns the install spec. + */ +export async function fetchMarketplaceSpec(baseUrl: string, id: string): Promise { + const response = await fetch(marketplaceSpecUrl(baseUrl, id)) + if (!response.ok) throw new Error(`marketplace plugin ${id} HTTP ${response.status}`) + const body = await response.json() as Record + return parseSpec(body) +} + +/** + * Read the user's plugin install table, or an empty table when the file is + * absent or malformed (a missing or corrupt table simply reports nothing + * installed, never crashes the marketplace). + * @param dataDir - the marketplace data directory. + * @returns the install table keyed by plugin id. + */ +export function readInstallTable(dataDir: string): Record { + try { + const parsed: unknown = JSON.parse(readFileSync(join(dataDir, TABLE_FILE), 'utf8')) + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + } catch { + return {} + } +} + +/** + * Write the install table atomically, creating the data directory if needed. + * @param dataDir - the marketplace data directory. + * @param table - the install table keyed by plugin id. + */ +export function writeInstallTable(dataDir: string, table: Record): void { + const path = join(dataDir, TABLE_FILE) + mkdirSync(dirname(path), { recursive: true }) + const tmp = `${path}.tmp` + writeFileSync(tmp, `${JSON.stringify(table, null, 2)}\n`, 'utf8') + renameSync(tmp, path) +} + +/** Validate one catalog index entry into {@link MarketplacePluginMeta}. */ +function parseMeta(raw: unknown): MarketplacePluginMeta { + const value = raw as Record + if (typeof value.id !== 'string' || value.id.length === 0) { + throw new Error('marketplace catalog entry missing an id') + } + return { + id: value.id, + name: typeof value.name === 'string' ? value.name : value.id, + description: typeof value.description === 'string' ? value.description : '', + ...(typeof value.author === 'string' ? { author: value.author } : {}), + ...(typeof value.version === 'string' ? { version: value.version } : {}), + ...(typeof value.repository === 'string' ? { repository: value.repository } : {}), + ...(typeof value.recommended === 'boolean' ? { recommended: value.recommended } : {}), + ...(typeof value.priority === 'number' ? { priority: value.priority } : {}), + } +} + +/** Validate one per-plugin install spec into {@link MarketplaceInstallSpec}. */ +function parseSpec(raw: Record): MarketplaceInstallSpec { + const id = typeof raw.id === 'string' ? raw.id : '' + const install = raw.install as Record | undefined + const method = install?.method + if (!METHODS.includes(method as MarketplaceInstallMethod)) { + throw new Error(`marketplace plugin ${id || '?'} prescribes no supported install method`) + } + const spec = typeof install?.spec === 'string' ? install.spec : '' + if (spec.length === 0) throw new Error(`marketplace plugin ${id || '?'} prescribes no install spec`) + return { + id, + method: method as MarketplaceInstallMethod, + spec, + ...(typeof install?.dependency === 'string' ? { dependency: install.dependency } : {}), + } +} diff --git a/packages/host/plugin-inventory/src/required.ts b/packages/host/plugin-inventory/src/required.ts index f1ff8015c0..b437334bbb 100644 --- a/packages/host/plugin-inventory/src/required.ts +++ b/packages/host/plugin-inventory/src/required.ts @@ -81,6 +81,7 @@ const REQUIRED_PLUGINS = new Set([ '@deepseek-ai/dsh-host-directory-picker-auto', '@deepseek-ai/dsh-host-directory-picker-native', '@deepseek-ai/dsh-host-plugin-inventory', + '@deepseek-ai/dsh-host-skill-manager', '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-image-recognition', '@deepseek-ai/dsh-image-recognition-http', diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index 496c5afba5..23c6f80563 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -42,14 +42,93 @@ export interface AvailableBundlesSnapshot { readonly available: readonly AvailableBundle[] } +/** One user-installed plugin dependency of the profile. */ +export interface InstalledBundle { + /** The installed dependency's package name. */ + readonly name: string +} + +/** Snapshot of the profile's user-installed plugin dependencies. */ +export interface InstalledBundlesSnapshot { + readonly installed: readonly InstalledBundle[] +} + /** What a plugin-install request targets. */ export type InstallSpec = | { readonly type: 'bundle'; readonly name: string } - | { readonly type: 'registry'; readonly spec: string } + | { + readonly type: 'registry' + /** Any pnpm `add` specifier: a bare name, a tarball path/URL, a git or GitHub URL. */ + readonly spec: string + /** + * The exact packages the user consented to run build scripts for, sent on + * the retry after the host reported them in {@link InstallResult.pendingBuilds}. + */ + readonly consentBuilds?: readonly string[] + } /** Result of an install/uninstall request. */ export interface InstallResult { readonly ok: true /** Whether the app must restart for the change to take effect. */ readonly restartRequired: boolean + /** + * Build scripts pnpm refused to run; the install paused awaiting the user's + * per-package consent. Present only then — the caller should show these and + * re-request with the same spec plus `InstallSpec.consentBuilds`. + */ + readonly pendingBuilds?: readonly string[] +} + +/** One plugin offered by the remote marketplace catalog. */ +export interface MarketplacePluginMeta { + /** The stable marketplace id, also the per-plugin spec file name stem. */ + readonly id: string + readonly name: string + readonly description: string + readonly author?: string + readonly version?: string + /** The publisher's source repository or homepage URL. */ + readonly repository?: string + /** Whether the publisher curates this plugin as a recommended pick. */ + readonly recommended?: boolean + /** Sort priority for the marketplace list; higher sorts closer to the front. */ + readonly priority?: number +} + +/** How a marketplace plugin is installed. */ +export type MarketplaceInstallMethod = 'git' | 'npm' | 'tarball' | 'bundle' + +/** The install prescription for one marketplace plugin. */ +export interface MarketplaceInstallSpec { + readonly id: string + readonly method: MarketplaceInstallMethod + /** The pnpm `add` specifier (git URL, npm name, tarball URL) or a bundle name. */ + readonly spec: string + /** + * The package name that lands in the profile's `dependencies` after install, + * used for uninstall. Bundle installs un-compose by `spec` instead. + */ + readonly dependency?: string +} + +/** A marketplace catalog entry with its installed state. */ +export interface MarketplaceEntry extends MarketplacePluginMeta { + /** Whether the user has installed this plugin, per the durable install table. */ + readonly installed: boolean +} + +/** Snapshot returned by the marketplace-list Remote. */ +export interface MarketplaceSnapshot { + readonly entries: readonly MarketplaceEntry[] +} + +/** One row of the durable per-user plugin install table. */ +export interface InstalledMarketplacePlugin { + readonly method: MarketplaceInstallMethod + readonly spec: string + /** The package name used for uninstall; bundle installs use `spec` instead. */ + readonly dependency?: string + /** When the install was recorded (ISO 8601). */ + readonly installedAt: string } diff --git a/packages/host/plugin-inventory/tests/install.spec.ts b/packages/host/plugin-inventory/tests/install.spec.ts index 25799b397b..9f0d37fb30 100644 --- a/packages/host/plugin-inventory/tests/install.spec.ts +++ b/packages/host/plugin-inventory/tests/install.spec.ts @@ -1,11 +1,12 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { - composeOfflineBundle, INSTALL_REGISTRIES, resolvePnpm, runPnpmInstall, - runPnpmInstallWithRegistries, uninstallBundle, + composeOfflineBundle, INSTALL_REGISTRIES, parseBlockedBuilds, registryPackageName, + resolvePnpm, resolvePnpmCommand, runPnpmInstall, writeAllowBuilds, + runPnpmInstallWithRegistries, runPnpmRemove, uninstallBundle, } from '../src/install.ts' const dirs: string[] = [] @@ -54,6 +55,52 @@ function makeRecordingPnpm(dir: string): string { return file } +/** + * A fake pnpm that reports a blocked build (`ERR_PNPM_IGNORED_BUILDS` naming + * node-pty and protobufjs) and exits 1 — the failure `runPnpmInstall` turns + * into `pendingBuilds` instead of a throw. + */ +function makeBlockedBuildPnpm(dir: string): string { + const file = join(dir, 'blocked.cjs') + writeFileSync(file, + "process.stderr.write('ERR_PNPM_IGNORED_BUILDS\\nIgnored build scripts: node-pty, protobufjs\\n'); process.exit(1)\n") + return file +} + +/** + * A self-executable fake pnpm (shebang + executable bit) that records its argv + * and exits 0 — simulates a `pnpm` command invoked directly off PATH, without a + * `node` prefix (as the PATH-pnpm fallback does). + */ +function makeExecutablePnpm(dir: string): string { + const file = join(dir, 'pnpm-path') + writeFileSync(file, [ + '#!/usr/bin/env node', + "const fs = require('fs')", + 'const args = process.argv.slice(2)', + 'fs.writeFileSync(process.env.RECORD, JSON.stringify(args))', + 'process.exit(0)', + ].join('\n')) + chmodSync(file, 0o755) + return file +} + +/** + * A fake pnpm that, for `remove `, drops the named dependency from the + * profile's package.json (as pnpm does) and exits 0. + */ +function makeRemovingPnpm(dir: string): string { + const file = join(dir, 'removing.cjs') + writeFileSync(file, [ + "const fs = require('fs')", + "const pkg = JSON.parse(fs.readFileSync('package.json','utf8'))", + 'delete pkg.dependencies[process.argv[3]]', + "fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\n')", + 'process.exit(0)', + ].join('\n')) + return file +} + afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) @@ -123,6 +170,158 @@ describe('runPnpmInstall', () => { delete process.env.EXIT } }) + + it('passes minimum-release-age-exclude for a registry name', () => { + const dir = makeProfile() + const record = join(dir, 'record.json') + const pnpm = makeRecordingPnpm(dir) + process.env.RECORD = record + process.env.EXIT = '0' + try { + runPnpmInstall({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'x', + before: readProfileManifest('dsh', dir), minimumReleaseAgeExclude: 'x', + }) + const args = JSON.parse(readFileSync(record, 'utf8')) as string[] + expect(args).toContain('--minimum-release-age-exclude=x') + } finally { + delete process.env.RECORD + delete process.env.EXIT + } + }) + + it('omits minimum-release-age-exclude for a PATH-pnpm fallback (may be an older pnpm)', () => { + const dir = makeProfile() + const record = join(dir, 'record.json') + const pnpm = makeExecutablePnpm(dir) + process.env.RECORD = record + try { + runPnpmInstall({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: undefined, pnpmCjs: pnpm, spec: 'x', + before: readProfileManifest('dsh', dir), minimumReleaseAgeExclude: 'x', + }) + const args = JSON.parse(readFileSync(record, 'utf8')) as string[] + expect(args).toEqual(['add', 'x']) + } finally { + delete process.env.RECORD + } + }) + + it('returns pendingBuilds when pnpm blocks build scripts', () => { + const dir = makeProfile() + const before = readProfileManifest('dsh', dir) + const pnpm = makeBlockedBuildPnpm(dir) + const result = runPnpmInstall({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'example', before, + }) + expect(result.pendingBuilds).toEqual(['node-pty', 'protobufjs']) + }) + + it('runs pnpm with a non-interactive git environment for git installs', () => { + const dir = makeProfile() + const record = join(dir, 'env.json') + const pnpm = join(dir, 'env-pnpm.cjs') + writeFileSync(pnpm, [ + "const fs = require('fs')", + 'fs.writeFileSync(process.env.RECORD, JSON.stringify({', + ' terminal: process.env.GIT_TERMINAL_PROMPT,', + ' ssh: process.env.GIT_SSH_COMMAND,', + '}))', + 'process.exit(0)', + ].join('\n')) + process.env.RECORD = record + const savedTerminal = process.env.GIT_TERMINAL_PROMPT + const savedSsh = process.env.GIT_SSH_COMMAND + delete process.env.GIT_TERMINAL_PROMPT + delete process.env.GIT_SSH_COMMAND + try { + runPnpmInstall({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'github:user/repo', + before: readProfileManifest('dsh', dir), + }) + const env = JSON.parse(readFileSync(record, 'utf8')) as { terminal: string; ssh: string } + expect(env.terminal).toBe('0') + expect(env.ssh).toContain('StrictHostKeyChecking=accept-new') + expect(env.ssh).toContain('BatchMode=yes') + } finally { + delete process.env.RECORD + if (savedTerminal === undefined) delete process.env.GIT_TERMINAL_PROMPT + else process.env.GIT_TERMINAL_PROMPT = savedTerminal + if (savedSsh === undefined) delete process.env.GIT_SSH_COMMAND + else process.env.GIT_SSH_COMMAND = savedSsh + } + }) +}) + +describe('writeAllowBuilds', () => { + it('writes the allowBuilds map and onlyBuiltDependencies array for the consented packages', () => { + const dir = makeProfile() + writeFileSync(join(dir, 'pnpm-workspace.yaml'), + 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n') + writeAllowBuilds(dir, ['node-pty', 'protobufjs']) + const written = readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8') + expect(written).toContain('node-pty: true') + expect(written).toContain('protobufjs: true') + expect(written).toContain('- node-pty') + expect(written).toContain('- protobufjs') + expect(written).toContain('nodeLinker: hoisted') + }) + + it('preserves existing workspace settings and merges existing allowlist entries', () => { + const dir = makeProfile() + writeFileSync(join(dir, 'pnpm-workspace.yaml'), + 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nallowBuilds:\n existing: true\n') + writeAllowBuilds(dir, ['node-pty']) + const written = readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8') + expect(written).toContain('existing: true') + expect(written).toContain('node-pty: true') + expect(written).toContain('nodeLinker: hoisted') + expect(written).toContain('autoInstallPeers: false') + }) + + it('creates the workspace file from scratch when absent', () => { + const dir = makeProfile() + // makeProfile writes only package.json, so pnpm-workspace.yaml is absent. + writeAllowBuilds(dir, ['node-pty']) + const written = readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8') + expect(written).toContain('node-pty: true') + expect(written).toContain('nodeLinker: hoisted') + }) +}) + +describe('runPnpmRemove', () => { + it('removes the dependency and reconciles its bundle layer away', () => { + const dir = makeProfile() + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + dependencies: { 'b': '1.0.0' }, + dsh: { profile: { bundles: ['b'] } }, + }, undefined, 2)) + makeBundle(dir, 'b') + const pnpm = makeRemovingPnpm(dir) + runPnpmRemove({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'b', + before: readProfileManifest('dsh', dir), + }) + const after = readProfileManifest('dsh', dir) + expect(after.dependencies).not.toHaveProperty('b') + expect(after.dsh?.profile?.bundles).toEqual([]) + }) + + it('throws when pnpm remove fails', () => { + const dir = makeProfile() + const pnpm = makeFakePnpm(dir, 1) + expect(() => { runPnpmRemove({ + binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), + nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'x', + before: readProfileManifest('dsh', dir), + }) }).toThrow(/pnpm remove failed/) + }) }) describe('runPnpmInstallWithRegistries', () => { @@ -203,6 +402,65 @@ describe('runPnpmInstall spawn failure', () => { }) }) +describe('registryPackageName', () => { + it('keeps a bare name and strips a version or tag suffix', () => { + expect(registryPackageName('dsh-better-sidebar')).toBe('dsh-better-sidebar') + expect(registryPackageName('dsh-better-sidebar@0.12.0')).toBe('dsh-better-sidebar') + expect(registryPackageName('@scope/plugin')).toBe('@scope/plugin') + expect(registryPackageName('@scope/plugin@1.0.0')).toBe('@scope/plugin') + expect(registryPackageName('name@next')).toBe('name') + }) + + it('returns undefined for paths, tarballs, and git sources', () => { + expect(registryPackageName('')).toBeUndefined() + expect(registryPackageName(' ')).toBeUndefined() + expect(registryPackageName('./dir')).toBeUndefined() + expect(registryPackageName('../dir')).toBeUndefined() + expect(registryPackageName('~/plugin')).toBeUndefined() + expect(registryPackageName('file:./plugin')).toBeUndefined() + expect(registryPackageName('link:./plugin')).toBeUndefined() + expect(registryPackageName('/abs/plugin.tgz')).toBeUndefined() + expect(registryPackageName('C:\\plugin.tgz')).toBeUndefined() + expect(registryPackageName('github:user/repo')).toBeUndefined() + expect(registryPackageName('git+https://github.com/user/repo.git')).toBeUndefined() + expect(registryPackageName('user/repo.git')).toBeUndefined() + expect(registryPackageName('https://github.com/user/repo')).toBeUndefined() + expect(registryPackageName('https://github.com/user/repo/archive/refs/heads/main.tar.gz')).toBeUndefined() + }) +}) + +describe('parseBlockedBuilds', () => { + it('extracts the blocked package names from the pnpm message', () => { + const output = 'ERR_PNPM_IGNORED_BUILDS\nIgnored build scripts: node-pty, protobufjs\nhint: Run "pnpm approve-builds"' + expect(parseBlockedBuilds(output)).toEqual(['node-pty', 'protobufjs']) + }) + + it('returns undefined when no build is blocked', () => { + expect(parseBlockedBuilds('some other failure')).toBeUndefined() + expect(parseBlockedBuilds('')).toBeUndefined() + }) +}) + +describe('resolvePnpmCommand', () => { + it('prefers the vendored pnpm via DSH_PNPM', () => { + expect(resolvePnpmCommand('/x/bin/node', { DSH_PNPM: '/vendored/pnpm.cjs' })) + .toEqual({ nodeBin: '/x/bin/node', pnpmCjs: '/vendored/pnpm.cjs' }) + }) + + it('falls back to the pnpm command on PATH', () => { + const dir = makeProfile() + mkdirSync(join(dir, 'bin'), { recursive: true }) + writeFileSync(join(dir, 'bin', 'pnpm'), '') + expect(resolvePnpmCommand('/x/bin/node', { PATH: join(dir, 'bin') })) + .toEqual({ nodeBin: undefined, pnpmCjs: 'pnpm' }) + }) + + it('returns undefined when neither a vendored pnpm nor one on PATH exists', () => { + const dir = makeProfile() + expect(resolvePnpmCommand('/x/bin/node', { PATH: dir })).toBeUndefined() + }) +}) + describe('resolvePnpm', () => { it('honors a DSH_PNPM override', () => { expect(resolvePnpm('/x/bin/node', { DSH_PNPM: '/vendored/pnpm.cjs' })).toBe('/vendored/pnpm.cjs') diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 9f14780b47..69c7355e0e 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -7,7 +7,12 @@ import { Context, type Plugin } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' -import PluginInventoryGateway, { type PluginEntryId } from '../src/index.ts' +import PluginInventoryGateway, { + marketplaceDataDir, + readInstallTable, + writeInstallTable, + type PluginEntryId, +} from '../src/index.ts' const contexts: Context[] = [] @@ -20,6 +25,7 @@ const pendingPlugin: Plugin.Object = { inject: ['neverReady'], apply() {}, } +const failingPlugin: Plugin.Function = () => { throw new Error('boom') } async function harness(): Promise<{ ctx: Context @@ -30,6 +36,7 @@ async function harness(): Promise<{ await ctx.plugin(Loader) ctx.loader.builtins.active = activePlugin ctx.loader.builtins.pending = pendingPlugin + ctx.loader.builtins.failing = failingPlugin // `cordis:` builtins the toggle tests create; they resolve without a package install. ctx.loader.builtins['user-toggleable'] = activePlugin ctx.loader.builtins['required'] = activePlugin @@ -50,7 +57,11 @@ describe('PluginInventoryGateway', () => { { method: 'setEnabled', invocation: { kind: 'direct' } }, { method: 'availableBundles', invocation: { kind: 'direct' } }, { method: 'installPlugin', invocation: { kind: 'direct' } }, + { method: 'installedBundles', invocation: { kind: 'direct' } }, { method: 'uninstall', invocation: { kind: 'direct' } }, + { method: 'marketplaceList', invocation: { kind: 'direct' } }, + { method: 'marketplaceInstall', invocation: { kind: 'direct' } }, + { method: 'marketplaceUninstall', invocation: { kind: 'direct' } }, ]) }) @@ -219,6 +230,95 @@ describe('PluginInventoryGateway', () => { } }) + it('pauses for build consent, then writes the allowlist and retries', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles: [] } } })) + // A fake pnpm whose `add` reports a blocked build until the profile's + // pnpm-workspace.yaml allows node-pty (which the gateway writes on consent). + const fakePnpm = join(dir, 'pnpm.cjs') + writeFileSync(fakePnpm, [ + "const fs = require('fs')", + "const ws = fs.existsSync('pnpm-workspace.yaml') ? fs.readFileSync('pnpm-workspace.yaml','utf8') : ''", + "if (ws.includes('node-pty')) process.exit(0)", + "process.stderr.write('ERR_PNPM_IGNORED_BUILDS\\nIgnored build scripts: node-pty, protobufjs\\n')", + 'process.exit(1)', + ].join('\n')) + process.env.DSH_PNPM = fakePnpm + try { + ctx.provide('dshInstallAnchor', join(dir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + const first = await inventory.installPlugin({ type: 'registry', spec: 'some-pkg' }) + expect(first.pendingBuilds).toEqual(['node-pty', 'protobufjs']) + expect(first.restartRequired).toBe(false) + const second = await inventory.installPlugin({ + type: 'registry', spec: 'some-pkg', consentBuilds: ['node-pty', 'protobufjs'], + }) + expect(second.pendingBuilds).toBeUndefined() + expect(second.restartRequired).toBe(true) + // The gateway wrote the allowlist directly into pnpm-workspace.yaml. + expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('node-pty: true') + expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('protobufjs: true') + } finally { + delete process.env.DSH_PNPM + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('uninstalls a user-installed dependency via pnpm remove and reconciles', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + dependencies: { 'user-plugin': '1.0.0' }, + dsh: { profile: { bundles: ['user-plugin'] } }, + })) + const record = join(dir, 'remove.json') + const fakePnpm = join(dir, 'pnpm.cjs') + writeFileSync(fakePnpm, [ + "const fs = require('fs')", + 'fs.writeFileSync(process.env.REMOVE_RECORD, JSON.stringify(process.argv.slice(2)))', + "const pkg = JSON.parse(fs.readFileSync('package.json','utf8'))", + 'delete pkg.dependencies[process.argv[3]]', + "fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\n')", + 'process.exit(0)', + ].join('\n')) + process.env.DSH_PNPM = fakePnpm + process.env.REMOVE_RECORD = record + try { + ctx.provide('dshInstallAnchor', join(dir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + const result = await inventory.uninstall('user-plugin') + expect(result.restartRequired).toBe(true) + expect(JSON.parse(readFileSync(record, 'utf8'))).toEqual(['remove', 'user-plugin']) + const manifest = readProfileManifest('dsh', dir) + expect(manifest.dependencies).not.toHaveProperty('user-plugin') + expect(manifest.dsh?.profile?.bundles).toEqual([]) + } finally { + delete process.env.DSH_PNPM + delete process.env.REMOVE_RECORD + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('lists user-installed dependencies for uninstall', async () => { + const { ctx, inventory } = await harness() + const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) + ctx.baseUrl = pathToFileURL(dir + '/').href + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + dependencies: { 'user-plugin': '1.0.0', 'lib': '2.0.0' }, + dsh: { profile: { bundles: ['user-plugin'] } }, + })) + try { + expect(inventory.installedBundles().installed).toEqual([{ name: 'user-plugin' }, { name: 'lib' }]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('fails loud without a profile directory', async () => { const { inventory } = await harness() expect(() => inventory.availableBundles()).toThrow(/profile directory/) @@ -253,24 +353,40 @@ describe('PluginInventoryGateway', () => { } }) - it('setEnabled reverts an enable whose fiber cannot activate', async () => { + it('setEnabled allows enabling a plugin pending on a dependency', async () => { const { ctx, inventory } = await harness() const id = await ctx.loader.create({ name: 'cordis:pending' }) as PluginEntryId - await expect(inventory.setEnabled(id, true)).rejects.toThrow(/could not start/) + await expect(inventory.setEnabled(id, true)).resolves.toEqual({ ok: true }) + // A plugin awaiting a service another enabled plugin provides stays + // enabled (the loader activates it when the dependency resolves), so + // interdependent plugins can be enabled together. + expect(inventory.list().entries.find(entry => entry.entryId === id)?.enabled).toBe(true) + }) + + it('setEnabled fails loud when a plugin cannot apply', async () => { + const { ctx, inventory } = await harness() + const id = await ctx.loader.create({ name: 'cordis:failing', disabled: true }) as PluginEntryId + await expect(inventory.setEnabled(id, true)).rejects.toThrow(/apply/) expect(inventory.list().entries.find(entry => entry.entryId === id)?.enabled).toBe(false) }) - it('registry install fails loud when bundled pnpm is absent', async () => { + it('registry install fails loud when no pnpm is available at all', async () => { const { ctx, inventory } = await harness() const dir = mkdtempSync(join(tmpdir(), 'dsh-inv-')) ctx.baseUrl = pathToFileURL(dir + '/').href writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles: [] } } })) delete process.env.DSH_PNPM + const savedPath = process.env.PATH try { ctx.provide('dshInstallAnchor', join(dir, 'package.json')) ctx.provide('dshAllowPluginInstall', true) + // Neither a vendored pnpm (DSH_PNPM deleted, no harness root) nor a PATH + // pnpm (empty PATH) is present, so the gateway refuses loudly. + process.env.PATH = '' await expect(inventory.installPlugin({ type: 'registry', spec: 'x' })).rejects.toThrow(/bundled pnpm is unavailable/) } finally { + if (savedPath === undefined) delete process.env.PATH + else process.env.PATH = savedPath rmSync(dir, { recursive: true, force: true }) } }) @@ -311,4 +427,165 @@ describe('PluginInventoryGateway', () => { rmSync(dir, { recursive: true, force: true }) } }) + + it('marketplaceList marks entries installed only when the table and profile agree', async () => { + const { ctx, inventory } = await harness() + const dataDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-')) + const profileDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-profile-')) + ctx.baseUrl = pathToFileURL(profileDir + '/').href + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + dependencies: { x: '1.0.0' }, + dsh: { profile: { bundles: [] } }, + })) + const savedHome = process.env.DSH_HOME + process.env.DSH_HOME = dataDir + const catalog = { plugins: [{ id: 'a', name: 'A', description: 'd', repository: 'https://github.com/dev/a', priority: 5 }] } + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => catalog }))) + try { + // No table entry: not installed. + expect((await inventory.marketplaceList()).entries).toEqual([ + { id: 'a', name: 'A', description: 'd', repository: 'https://github.com/dev/a', priority: 5, installed: false }, + ]) + // Table entry but the profile lacks the dependency: not installed (drift). + writeInstallTable(marketplaceDataDir(), { a: { method: 'git', spec: 'y', installedAt: 'now' } }) + expect((await inventory.marketplaceList()).entries).toEqual([ + { id: 'a', name: 'A', description: 'd', repository: 'https://github.com/dev/a', priority: 5, installed: false }, + ]) + // Table entry whose dependency is present in the profile: installed. + writeInstallTable(marketplaceDataDir(), { a: { method: 'git', spec: 'x', installedAt: 'now' } }) + expect((await inventory.marketplaceList()).entries).toEqual([ + { id: 'a', name: 'A', description: 'd', repository: 'https://github.com/dev/a', priority: 5, installed: true }, + ]) + } finally { + vi.unstubAllGlobals() + if (savedHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = savedHome + rmSync(dataDir, { recursive: true, force: true }) + rmSync(profileDir, { recursive: true, force: true }) + } + }) + + it('marketplaceInstall installs a git spec and records it', async () => { + const { ctx, inventory } = await harness() + const profileDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-')) + ctx.baseUrl = pathToFileURL(profileDir + '/').href + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', dsh: { profile: { bundles: [] } } })) + const dataDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-home-')) + const savedHome = process.env.DSH_HOME + process.env.DSH_HOME = dataDir + const fakePnpm = join(profileDir, 'pnpm.cjs') + writeFileSync(fakePnpm, 'process.exit(0)\n') + process.env.DSH_PNPM = fakePnpm + const catalog = { plugins: [{ id: 'a', name: 'A', description: 'd' }] } + const spec = { id: 'a', install: { method: 'git', spec: 'github:user/a', dependency: 'a' } } + vi.stubGlobal('fetch', vi.fn(async (url: string) => ({ + ok: true, status: 200, + json: async () => (url.endsWith('/plugins.json') ? catalog : spec), + }))) + try { + ctx.provide('dshInstallAnchor', join(profileDir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + const result = await inventory.marketplaceInstall('a') + expect(result.restartRequired).toBe(true) + expect(readInstallTable(marketplaceDataDir())['a']).toMatchObject({ + method: 'git', spec: 'github:user/a', dependency: 'a', + }) + } finally { + vi.unstubAllGlobals() + delete process.env.DSH_PNPM + if (savedHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = savedHome + rmSync(profileDir, { recursive: true, force: true }) + rmSync(dataDir, { recursive: true, force: true }) + } + }) + + it('records the real scoped package name when a git install resolves differently', async () => { + const { ctx, inventory } = await harness() + const profileDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-')) + ctx.baseUrl = pathToFileURL(profileDir + '/').href + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + // The git spec installed under its own package name, which carries a scope + // the web spec's `dependency` guess omitted. + dependencies: { '@dsh-external/a': 'github:user/a' }, + dsh: { profile: { bundles: [] } }, + })) + const dataDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-home-')) + const savedHome = process.env.DSH_HOME + process.env.DSH_HOME = dataDir + const fakePnpm = join(profileDir, 'pnpm.cjs') + writeFileSync(fakePnpm, 'process.exit(0)\n') + process.env.DSH_PNPM = fakePnpm + const catalog = { plugins: [{ id: 'a', name: 'A', description: 'd' }] } + const spec = { id: 'a', install: { method: 'git', spec: 'github:user/a', dependency: 'a' } } + vi.stubGlobal('fetch', vi.fn(async (url: string) => ({ + ok: true, status: 200, + json: async () => (url.endsWith('/plugins.json') ? catalog : spec), + }))) + try { + ctx.provide('dshInstallAnchor', join(profileDir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + const result = await inventory.marketplaceInstall('a') + expect(result.restartRequired).toBe(true) + // The recorded dependency matches the profile, so the marketplace reports + // it installed instead of a phantom not-installed state. + expect(readInstallTable(marketplaceDataDir())['a']).toMatchObject({ + method: 'git', spec: 'github:user/a', dependency: '@dsh-external/a', + }) + expect((await inventory.marketplaceList()).entries).toEqual([ + { id: 'a', name: 'A', description: 'd', installed: true }, + ]) + } finally { + vi.unstubAllGlobals() + delete process.env.DSH_PNPM + if (savedHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = savedHome + rmSync(profileDir, { recursive: true, force: true }) + rmSync(dataDir, { recursive: true, force: true }) + } + }) + + it('marketplaceUninstall removes the record after uninstalling the dependency', async () => { + const { ctx, inventory } = await harness() + const profileDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-')) + ctx.baseUrl = pathToFileURL(profileDir + '/').href + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', + dependencies: { a: '1.0.0' }, + dsh: { profile: { bundles: [] } }, + })) + const dataDir = mkdtempSync(join(tmpdir(), 'dsh-inv-mp-home-')) + const savedHome = process.env.DSH_HOME + process.env.DSH_HOME = dataDir + const fakePnpm = join(profileDir, 'pnpm.cjs') + writeFileSync(fakePnpm, [ + "const fs = require('fs')", + "const pkg = JSON.parse(fs.readFileSync('package.json','utf8'))", + 'delete pkg.dependencies[process.argv[3]]', + "fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\n')", + 'process.exit(0)', + ].join('\n')) + process.env.DSH_PNPM = fakePnpm + writeInstallTable(marketplaceDataDir(), { a: { method: 'npm', spec: 'a', dependency: 'a', installedAt: 'now' } }) + try { + ctx.provide('dshInstallAnchor', join(profileDir, 'package.json')) + ctx.provide('dshAllowPluginInstall', true) + const result = await inventory.marketplaceUninstall('a') + expect(result.restartRequired).toBe(true) + expect(readInstallTable(marketplaceDataDir())).toEqual({}) + } finally { + delete process.env.DSH_PNPM + if (savedHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = savedHome + rmSync(profileDir, { recursive: true, force: true }) + rmSync(dataDir, { recursive: true, force: true }) + } + }) + + it('marketplaceUninstall fails loud for a plugin not in the table', async () => { + const { inventory } = await harness() + await expect(inventory.marketplaceUninstall('missing')).rejects.toThrow(/not installed/) + }) }) diff --git a/packages/host/plugin-inventory/tests/marketplace.spec.ts b/packages/host/plugin-inventory/tests/marketplace.spec.ts new file mode 100644 index 0000000000..5c7a887fef --- /dev/null +++ b/packages/host/plugin-inventory/tests/marketplace.spec.ts @@ -0,0 +1,143 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + fetchMarketplaceCatalog, + fetchMarketplaceSpec, + marketplaceBaseUrl, + marketplaceSpecUrl, + readInstallTable, + writeInstallTable, +} from '../src/marketplace.ts' +import type { InstalledMarketplacePlugin } from '../src/types.ts' + +const dirs: string[] = [] + +afterEach(() => { + vi.unstubAllGlobals() + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-marketplace-')) + dirs.push(dir) + return dir +} + +/** Stub global fetch to return `body` for every URL, or a per-URL value. */ +function stubFetch(body: unknown, resolve?: (url: string) => unknown): void { + const fetchMock = vi.fn(async (url: string) => ({ + ok: true, + status: 200, + async json() { return resolve === undefined ? body : resolve(url) }, + })) + vi.stubGlobal('fetch', fetchMock) +} + +describe('marketplaceBaseUrl', () => { + it('strips the trailing plugins.json to the catalog directory', () => { + expect(marketplaceBaseUrl('https://x/plugins/plugins.json')).toBe('https://x/plugins/') + }) + + it('leaves a URL without the catalog filename unchanged', () => { + expect(marketplaceBaseUrl('https://x/plugins/')).toBe('https://x/plugins/') + }) +}) + +describe('marketplaceSpecUrl', () => { + it('appends the id and .json under the base, encoding the id', () => { + expect(marketplaceSpecUrl('https://x/plugins/', 'my-plugin')).toBe('https://x/plugins/my-plugin.json') + expect(marketplaceSpecUrl('https://x/plugins/', 'a b')).toBe('https://x/plugins/a%20b.json') + }) +}) + +describe('fetchMarketplaceCatalog', () => { + it('parses the catalog list, filling display defaults', async () => { + stubFetch({ + plugins: [ + { id: 'a', name: 'A', description: 'desc', author: 'Dev', version: '1.0.0', recommended: true }, + { id: 'b' }, + { id: 'c', name: 'C', description: '', recommended: false }, + ], + }) + await expect(fetchMarketplaceCatalog('https://x/plugins/plugins.json')).resolves.toEqual([ + { id: 'a', name: 'A', description: 'desc', author: 'Dev', version: '1.0.0', recommended: true }, + { id: 'b', name: 'b', description: '' }, + { id: 'c', name: 'C', description: '', recommended: false }, + ]) + }) + + it('rejects an entry without an id', async () => { + stubFetch({ plugins: [{ name: 'A' }] }) + await expect(fetchMarketplaceCatalog('https://x/plugins/plugins.json')).rejects.toThrow(/missing an id/) + }) + + it('throws on an HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503 }))) + await expect(fetchMarketplaceCatalog('https://x/plugins/plugins.json')).rejects.toThrow(/HTTP 503/) + }) +}) + +describe('fetchMarketplaceSpec', () => { + it('parses a git install method with its dependency', async () => { + stubFetch( + { id: 'a', install: { method: 'git', spec: 'github:user/a', dependency: 'a' } }, + (url) => { + expect(url).toBe('https://x/plugins/a.json') + return { id: 'a', install: { method: 'git', spec: 'github:user/a', dependency: 'a' } } + }, + ) + await expect(fetchMarketplaceSpec('https://x/plugins/', 'a')).resolves.toEqual({ + id: 'a', method: 'git', spec: 'github:user/a', dependency: 'a', + }) + }) + + it('parses a bundle method without a dependency', async () => { + stubFetch({ id: 'a', install: { method: 'bundle', spec: '@scope/b' } }) + await expect(fetchMarketplaceSpec('https://x/plugins/', 'a')).resolves.toEqual({ + id: 'a', method: 'bundle', spec: '@scope/b', + }) + }) + + it('rejects an unsupported install method', async () => { + stubFetch({ id: 'a', install: { method: 'cargo', spec: 'a' } }) + await expect(fetchMarketplaceSpec('https://x/plugins/', 'a')).rejects.toThrow(/no supported install method/) + }) + + it('rejects a missing install spec', async () => { + stubFetch({ id: 'a', install: { method: 'npm' } }) + await expect(fetchMarketplaceSpec('https://x/plugins/', 'a')).rejects.toThrow(/no install spec/) + }) +}) + +describe('install table persistence', () => { + it('reads an empty table when the file is absent', () => { + expect(readInstallTable(join(tempDir(), 'nonexistent'))).toEqual({}) + }) + + it('reads an empty table for a malformed file', () => { + const dir = tempDir() + writeInstallTable(dir, { a: { method: 'npm', spec: 'a', installedAt: 'x' } }) + writeFileSync(join(dir, 'installed.json'), 'not json', 'utf8') + expect(readInstallTable(dir)).toEqual({}) + }) + + it('round-trips the table, creating the directory and writing atomically', () => { + const dir = tempDir() + const row: InstalledMarketplacePlugin = { method: 'git', spec: 'github:user/a', dependency: 'a', installedAt: '2026-08-15T00:00:00.000Z' } + writeInstallTable(dir, { a: row }) + expect(readInstallTable(dir)).toEqual({ a: row }) + const raw = readFileSync(join(dir, 'installed.json'), 'utf8') + expect(raw).toContain('"github:user/a"') + // The atomic write leaves no temp file behind. + expect(existsSync(join(dir, 'installed.json.tmp'))).toBe(false) + }) + + it('overwrites the table on a second write', () => { + const dir = tempDir() + writeInstallTable(dir, { a: { method: 'npm', spec: 'a', installedAt: 'x' } }) + writeInstallTable(dir, { b: { method: 'bundle', spec: 'b', installedAt: 'y' } }) + expect(readInstallTable(dir)).toEqual({ b: { method: 'bundle', spec: 'b', installedAt: 'y' } }) + }) +}) diff --git a/packages/host/plugin-inventory/tests/persistence.spec.ts b/packages/host/plugin-inventory/tests/persistence.spec.ts new file mode 100644 index 0000000000..3911009332 --- /dev/null +++ b/packages/host/plugin-inventory/tests/persistence.spec.ts @@ -0,0 +1,92 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { composeEntries, initProfile, loadProfile, readProfileManifest, writeProfileManifest } from '@deepseek-ai/dsh-app-boot' +import { composeOfflineBundle } from '../src/install.ts' +import { persistPluginDisabled } from '../src/persist.ts' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-persist-')) + dirs.push(dir) + return dir +} + +/** Make a dsh.bundle package resolvable from a profile dir, inserting a plugin row. */ +function makeBundle(profileDir: string, name: string, rowId: string): void { + const pkgDir = join(profileDir, 'node_modules', name) + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ + name, + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(pkgDir, 'cordis.patch.yml'), `- insert:\n - id: ${rowId}\n name: cordis:active\n`) +} + +/** Simulate a restart: reload the profile and compose the effective entry list. */ +function composeOnReload(binName: string, name: string, anchor: string, home: string) { + const profile = loadProfile(binName, name, anchor, home) + return composeEntries([...profile.layers.map(layer => layer.patches), profile.patches]) +} + +describe('plugin install + enable persistence across restart', () => { + it('restores an installed bundle layer after a reload', () => { + const home = tempDir() + const profileName = 'persist' + const profileDir = join(home, 'profiles', profileName) + const anchor = join(profileDir, 'package.json') + initProfile(profileDir, []) + makeBundle(profileDir, 'example-bundle', 'b-row') + + composeOfflineBundle('dsh', profileDir, anchor, 'example-bundle') + expect(readProfileManifest('dsh', profileDir).dsh?.profile?.bundles).toEqual(['example-bundle']) + + // Restart recomposes the bundle layer into the Loader entry tree. + const entries = composeOnReload('dsh', profileName, anchor, home) + expect(entries.some(entry => entry.id === 'b-row' && entry.name === 'cordis:active')).toBe(true) + }) + + it('restores a persisted enable/disable override after a reload', () => { + const home = tempDir() + const profileName = 'persist' + const profileDir = join(home, 'profiles', profileName) + const anchor = join(profileDir, 'package.json') + initProfile(profileDir, []) + makeBundle(profileDir, 'example-bundle', 'b-row') + composeOfflineBundle('dsh', profileDir, anchor, 'example-bundle') + + // User disables, then enables the plugin; the override is persisted. + persistPluginDisabled(profileDir, 'b-row', true) + let entries = composeOnReload('dsh', profileName, anchor, home) + expect(entries.find(entry => entry.id === 'b-row')?.disabled).toBe(true) + + persistPluginDisabled(profileDir, 'b-row', false) + entries = composeOnReload('dsh', profileName, anchor, home) + expect(entries.find(entry => entry.id === 'b-row')?.disabled).toBe(false) + }) + + it('keeps a registry-installed bundle plugin loadable after a reload', () => { + const home = tempDir() + const profileName = 'persist' + const profileDir = join(home, 'profiles', profileName) + const anchor = join(profileDir, 'package.json') + initProfile(profileDir, []) + makeBundle(profileDir, 'registry-bundle', 'reg-row') + + // A registry install reconciles the bundle layer (the pnpm part is out of + // scope here; the layer persistence is what survives the restart). + const manifest = readProfileManifest('dsh', profileDir) + manifest.dependencies = { ...manifest.dependencies, 'registry-bundle': '1.0.0' } + manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles: ['registry-bundle'] } } + writeProfileManifest(profileDir, manifest) + + const entries = composeOnReload('dsh', profileName, anchor, home) + expect(entries.some(entry => entry.id === 'reg-row')).toBe(true) + }) +}) diff --git a/packages/host/skill-manager/package.json b/packages/host/skill-manager/package.json new file mode 100644 index 0000000000..b671e3b777 --- /dev/null +++ b/packages/host/skill-manager/package.json @@ -0,0 +1,69 @@ +{ + "name": "@deepseek-ai/dsh-host-skill-manager", + "description": "Remote projection and management of local DSH skills: list, install, uninstall, and toggle", + "version": "0.1.0-rc.5", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/skill-manager" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "yaml": "^2.7.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + } +} diff --git a/packages/host/skill-manager/src/discovery.ts b/packages/host/skill-manager/src/discovery.ts new file mode 100644 index 0000000000..177eb751b6 --- /dev/null +++ b/packages/host/skill-manager/src/discovery.ts @@ -0,0 +1,137 @@ +/** + * Direct filesystem enumeration of local skills for the skill-manager surface. + * + * The runtime catalog (`ctx.skills`) is provider-layered: in the web profile the + * host `skill-filesystem` row is disabled and local discovery is owned by agent + * presets, so a host-context `ctx.skills.list()` sees almost nothing. Skill + * management needs the machine's local skills regardless of presets, so this + * module scans the same roots the filesystem provider would — user DSH/agents + * homes, the bundled dir, and the project's `.dsh/skills` / `.agents/skills` — + * parses each `SKILL.md` the same way, and dedups by name with the provider's + * rank precedence. Dependency-free of Cordis. + * @module @deepseek-ai/dsh-host-skill-manager/discovery + */ + +import { existsSync, readdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { dshHomePath } from '@deepseek-ai/dsh-home-paths' +import type { SkillInvocationPolicy, SkillSource } from '@deepseek-ai/dsh-skill' +import { SKILL_NAME, parseSkillFile, readSkillText } from './skill-io.ts' + +/** One locally discovered skill. */ +export interface DiscoveredSkill { + readonly name: string + readonly description: string + readonly whenToUse?: string + readonly source: SkillSource + readonly invocation: SkillInvocationPolicy + /** Whether the skill lives in the writable user DSH root. */ + readonly managed: boolean + /** Absolute path of the skill body. */ + readonly path: string + /** The provider rank this skill was discovered at (lower wins dedup). */ + readonly rank: number +} + +/** One scan root with its source label and provider rank. */ +interface SkillRoot { + readonly path: string + readonly source: SkillSource + readonly rank: number +} + +/** + * Enumerate the machine's local skills across the user, agents, bundled, and + * (when a `cwd` is supplied) project skill roots, deduped by name with the + * provider's rank precedence and sorted by name. + * @param cwd - workspace selector for the project `.dsh/skills` / `.agents/skills` + * roots; omitted skips project roots (a machine-level view). + * @returns the discovered skills, sorted by name. + */ +export function discoverLocalSkills(cwd?: string): DiscoveredSkill[] { + const byName = new Map() + for (const root of skillRoots(cwd)) { + if (!existsSync(root.path)) continue + // A skill root may be unreadable in a given environment (permissions, a + // file where a directory is expected); skip it rather than fail discovery. + let entries + try { + entries = readdirSync(root.path, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const skill = readSkillEntry(root, entry.name, entry.isDirectory()) + if (skill === undefined) continue + const existing = byName.get(skill.name) + if (existing !== undefined && existing.rank <= skill.rank) continue + byName.set(skill.name, skill) + } + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)) +} + +/** Parse one `/SKILL.md` or `.md` entry under a root into a skill. */ +function readSkillEntry(root: SkillRoot, name: string, isDirectory: boolean): DiscoveredSkill | undefined { + const skillName = isDirectory ? name : name.endsWith('.md') ? name.slice(0, -3) : '' + if (!SKILL_NAME.test(skillName)) return undefined + const path = isDirectory ? join(root.path, name, 'SKILL.md') : join(root.path, name) + if (!existsSync(path)) return undefined + let parsed + try { + parsed = parseSkillFile(readSkillText(path)) + } catch { + return undefined + } + // The frontmatter name must match the on-disk bundle name, like the provider. + if (parsed.name !== skillName) return undefined + return { + name: parsed.name, + description: parsed.description, + ...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}), + source: root.source, + invocation: parsed.invocation, + managed: root.source === 'user-dsh', + path, + rank: root.rank, + } +} + +/** The skill roots this manager scans, in provider rank order. */ +function skillRoots(cwd?: string): SkillRoot[] { + const roots: SkillRoot[] = [] + if (cwd !== undefined) { + const projectRoot = findProjectRoot(cwd) + roots.push( + { path: join(projectRoot, '.dsh', 'skills'), source: 'project-dsh', rank: 100 }, + { path: join(projectRoot, '.agents', 'skills'), source: 'project-agents', rank: 200 }, + ) + } + roots.push( + { path: dshHomePath('skills'), source: 'user-dsh', rank: 400 }, + { path: join(agentsHome(), 'skills'), source: 'user-agents', rank: 500 }, + ) + const bundled = process.env.DSH_BUNDLED_SKILL_DIR + if (bundled !== undefined && bundled.trim().length > 0) { + roots.push({ path: resolve(bundled), source: 'bundled', rank: 600 }) + } + return roots +} + +/** The user agents home: `$DSH_AGENTS_HOME` or `~/.agents`. */ +function agentsHome(): string { + return resolve(process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) +} + +/** Nearest ancestor of `cwd` containing a `.git`, else `cwd` itself. */ +function findProjectRoot(cwd: string): string { + const start = resolve(cwd) + let current = start + while (true) { + if (existsSync(join(current, '.git'))) return current + const parent = dirname(current) + if (parent === current) return start + current = parent + } +} diff --git a/packages/host/skill-manager/src/index.ts b/packages/host/skill-manager/src/index.ts new file mode 100644 index 0000000000..17ffd9c5b9 --- /dev/null +++ b/packages/host/skill-manager/src/index.ts @@ -0,0 +1,178 @@ +/** Remote management of local DSH skills: list, install, uninstall, and toggle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtempSync, rmSync } from 'node:fs' +import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' +import type {} from 'zod' +import { discoverLocalSkills } from './discovery.ts' +import { resolveSkill } from './install.ts' +import { + deleteSkill, + installSkillDir, + readSkillText, + rewriteSkillFile, + skillExists, + skillRoot, + writeSkillFile, +} from './skill-io.ts' +import type { + SkillInstallSpec, + SkillInvocationPatch, + SkillManagerSnapshot, + SkillMutationResult, +} from './types.ts' + +export type * from './types.ts' +export { + SKILL_NAME, + deleteSkill, + installSkillDir, + parseSkillFile, + readSkillText, + rewriteSkillFile, + skillExists, + skillRoot, + writeSkillFile, +} from './skill-io.ts' +export { resolveSkill } from './install.ts' +export { discoverLocalSkills, type DiscoveredSkill } from './discovery.ts' +export type { ParsedSkillFile, SkillFrontmatterMutation } from './skill-io.ts' +export type { + SkillInstallSource, + SkillInstallSpec, + SkillInvocationPatch, + SkillManagerEntry, + SkillManagerSnapshot, + SkillMutationResult, +} from './types.ts' + +/** + * Remote service exposing the local skill catalog with install/uninstall and + * invocation management. `list` reads `ctx.skills` (the layered registry) in the + * global layer — the machine-level user/bundled/custom/runtime skills — and + * marks as `managed` those that live in the writable user root (`$DSH_HOME/skills`, + * source `user-dsh`). Install/uninstall/toggle/edit all operate on that root: + * the filesystem provider's watcher picks up every change with no restart. + */ +export class SkillManagerGateway extends TypertRemoteService { + static inject = ['skills'] + + constructor(ctx: Context) { + super(ctx, 'skillManager') + } + + /** + * List the current local skills with their invocation state and whether each + * is user-manageable. The skills are enumerated directly from the machine's + * skill roots (user DSH/agents homes, the bundled dir, and the project's + * `.dsh/skills` / `.agents/skills` when a `cwd` is given), because in the web + * profile the host filesystem provider is disabled and the runtime registry + * carries no local skills in this context. + * @param cwd - workspace selector for the project skill roots; defaults to the + * process working directory. + * @returns the local skill snapshot. + */ + @Remote('list') + list(cwd?: string): SkillManagerSnapshot { + const discovered = discoverLocalSkills(cwd ?? safeCwd()) + return { + skills: discovered.map(skill => ({ + name: skill.name, + description: skill.description, + ...(skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {}), + source: skill.source, + provider: 'filesystem', + invocation: skill.invocation, + managed: skill.managed, + path: skill.path, + })), + } + } + + /** + * Install a skill from a git/npm/tarball/local source into the user root. + * Resolves the source, validates the skill, and copies its bundle to + * `$DSH_HOME/skills/`; the watcher discovers it immediately. + * @param spec - the source kind and specifier. + * @returns a confirmation naming the installed skill. + */ + @Remote('installSkill') + async installSkill(spec: SkillInstallSpec): Promise { + const workDir = mkdtempSync(join(tmpdir(), 'dsh-skill-install-')) + try { + const resolved = await resolveSkill(spec, workDir) + installSkillDir(skillRoot(), resolved.name, resolved.skillDir) + return { ok: true, name: resolved.name } + } finally { + rmSync(workDir, { recursive: true, force: true }) + } + } + + /** + * Uninstall a skill from the user root. Refuses a name that is not installed + * there, so bundled/project skills are never removed. + * @param name - the kebab-case skill name. + * @returns a confirmation. + */ + @Remote('uninstallSkill') + uninstallSkill(name: string): SkillMutationResult { + const root = skillRoot() + if (!skillExists(root, name)) { + throw new Error(`skill "${name}" is not installed in the user root`) + } + deleteSkill(root, name) + return { ok: true, name } + } + + /** + * Toggle a skill's model and/or user invocation surfaces by rewriting its + * `SKILL.md` frontmatter. + * @param name - the kebab-case skill name. + * @param patch - which surfaces to enable or disable. + * @returns a confirmation. + */ + @Remote('setEnabled') + setEnabled(name: string, patch: SkillInvocationPatch): SkillMutationResult { + const root = skillRoot() + const path = join(root, name, 'SKILL.md') + if (!existsSync(path)) throw new Error(`skill "${name}" is not installed in the user root`) + const rewritten = rewriteSkillFile(readSkillText(path), { + ...(patch.model !== undefined ? { model: patch.model } : {}), + ...(patch.user !== undefined ? { user: patch.user } : {}), + }) + writeSkillFile(root, name, rewritten) + return { ok: true, name } + } + + /** + * Edit a skill's `description` frontmatter. + * @param name - the kebab-case skill name. + * @param description - the new non-empty description. + * @returns a confirmation. + */ + @Remote('setDescription') + setDescription(name: string, description: string): SkillMutationResult { + if (description.trim().length === 0) throw new Error('skill description cannot be empty') + const root = skillRoot() + const path = join(root, name, 'SKILL.md') + if (!existsSync(path)) throw new Error(`skill "${name}" is not installed in the user root`) + const rewritten = rewriteSkillFile(readSkillText(path), { description }) + writeSkillFile(root, name, rewritten) + return { ok: true, name } + } + +} + +/** `process.cwd()`, or the DSH home when the working directory is unavailable. */ +function safeCwd(): string { + try { + return process.cwd() + } catch { + return skillRoot() + } +} + +export default SkillManagerGateway diff --git a/packages/host/skill-manager/src/install.ts b/packages/host/skill-manager/src/install.ts new file mode 100644 index 0000000000..912b99b571 --- /dev/null +++ b/packages/host/skill-manager/src/install.ts @@ -0,0 +1,145 @@ +/** + * Resolve a skill install source into a validated skill directory. + * + * A skill install materializes the `SKILL.md` bundle from a git clone, an npm + * package, a downloaded or local tarball, or a local directory into the writable + * user root (`$DSH_HOME/skills`); the filesystem provider's watcher then + * discovers it with no restart. The resolved source is unwrapped and located the + * same way the provider reads skills — a directory bundle `/SKILL.md` at + * the root or under a `skills/` subdirectory — and validated for the + * provider-mandated `name` + `description` frontmatter. The caller owns the + * scratch `workDir` and cleans it up after copying. Dependency-free of Cordis. + * @module @deepseek-ai/dsh-host-skill-manager/install + */ + +import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import type { SkillInstallSpec } from './types.ts' +import { parseSkillFile, readSkillText } from './skill-io.ts' + +/** A resolved, validated skill ready to copy into the user root. */ +export interface ResolvedSkill { + readonly name: string + readonly description: string + /** The source directory containing the skill's `SKILL.md` and resources. */ + readonly skillDir: string +} + +/** + * Resolve an install source into a validated skill. Downloads/clones/packs the + * source under `workDir` (which the caller creates and owns), unwraps any single + * top-level directory, locates the `SKILL.md` bundle, and validates it. + * @param spec - the source kind and specifier. + * @param workDir - an existing scratch directory for cloned/downloaded content. + * @returns the validated skill name, description, and source directory. + */ +export async function resolveSkill(spec: SkillInstallSpec, workDir: string): Promise { + const sourceDir = await materializeSource(spec, workDir) + const skillDir = locateSkillDir(sourceDir) + const parsed = parseSkillFile(readSkillText(join(skillDir, 'SKILL.md'))) + return { name: parsed.name, description: parsed.description, skillDir } +} + +/** Materialize an install source into a directory that (may) hold the skill. */ +async function materializeSource(spec: SkillInstallSpec, workDir: string): Promise { + switch (spec.source) { + case 'local': { + if (!existsSync(spec.spec)) throw new Error(`skill source directory not found: ${spec.spec}`) + return spec.spec + } + case 'git': { + const dest = join(workDir, 'repo') + run('git', ['clone', '--depth', '1', spec.spec, dest], gitEnv()) + return dest + } + case 'npm': { + const packDir = join(workDir, 'pack') + mkdirSync(packDir, { recursive: true }) + run('npm', ['pack', spec.spec, '--pack-destination', packDir], {}) + const tgz = findTarball(packDir) + const extractDir = join(workDir, 'pkg') + mkdirSync(extractDir, { recursive: true }) + run('tar', ['-xzf', tgz, '-C', extractDir], {}) + return unwrapSingleDir(extractDir) + } + case 'tarball': { + const extractDir = join(workDir, 'pkg') + mkdirSync(extractDir, { recursive: true }) + const tgz = /^https?:\/\//.test(spec.spec) + ? await download(spec.spec, join(workDir, 'pkg.tgz')) + : spec.spec + if (!existsSync(tgz)) throw new Error(`tarball not found: ${tgz}`) + run('tar', ['-xzf', tgz, '-C', extractDir], {}) + return unwrapSingleDir(extractDir) + } + } +} + +/** The deepest directory that is the sole wrapper around a real skill root. */ +function unwrapSingleDir(dir: string, depth = 0): string { + if (depth > 3) return dir + const entries = readdirSync(dir, { withFileTypes: true }) + const directories = entries.filter(entry => entry.isDirectory()) + if (directories.length === 1 && entries.length === directories.length) { + const only = directories[0] + return only === undefined ? dir : unwrapSingleDir(join(dir, only.name), depth + 1) + } + return dir +} + +/** Locate a skill directory under a resolved source root: `skills/` or root `SKILL.md`. */ +function locateSkillDir(root: string): string { + const skillsDir = join(root, 'skills') + if (existsSync(skillsDir)) { + for (const entry of readdirSync(skillsDir, { withFileTypes: true })) { + if (entry.isDirectory() && existsSync(join(skillsDir, entry.name, 'SKILL.md'))) { + return join(skillsDir, entry.name) + } + } + } + if (existsSync(join(root, 'SKILL.md'))) return root + throw new Error('no SKILL.md found in the resolved source') +} + +/** Find the first `.tgz` in a directory. */ +function findTarball(dir: string): string { + for (const entry of readdirSync(dir)) { + if (entry.endsWith('.tgz') || entry.endsWith('.tar.gz')) return join(dir, entry) + } + throw new Error('no tarball produced by the source') +} + +/** Download a URL to `dest` and return the local path. */ +async function download(url: string, dest: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error(`tarball HTTP ${response.status}`) + writeFileSync(dest, Buffer.from(await response.arrayBuffer())) + return dest +} + +/** Run one synchronous child; throws with its captured output on failure. */ +function run(command: string, args: readonly string[], env: Record): void { + const result = spawnSync(command, args, { + encoding: 'utf8', + env: { ...process.env, ...env }, + shell: process.platform === 'win32', + }) + if (result.status !== 0 || result.error !== undefined) { + throw new Error(`${command} ${args.join(' ')} failed: ${trim(result.stderr)}`) + } +} + +/** A git environment that never hangs on a first-time host-key or credential prompt. */ +function gitEnv(): Record { + return { + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new', + } +} + +/** Compact a child's output for an error message (first 300 chars). */ +function trim(output: string): string { + const trimmed = output.trim() + return trimmed.length <= 300 ? trimmed : `${trimmed.slice(0, 300)}…` +} diff --git a/packages/host/skill-manager/src/invariant.ts b/packages/host/skill-manager/src/invariant.ts new file mode 100644 index 0000000000..315be9b678 --- /dev/null +++ b/packages/host/skill-manager/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-skill-manager/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-skill-manager' + +/** Cordis companion plugin name. */ +export const name = 'host-skill-manager-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: mutations are filesystem effects reflected by the skill watcher. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/skill-manager/src/skill-io.ts b/packages/host/skill-manager/src/skill-io.ts new file mode 100644 index 0000000000..ea41f3fa7a --- /dev/null +++ b/packages/host/skill-manager/src/skill-io.ts @@ -0,0 +1,131 @@ +/** + * Filesystem and frontmatter helpers behind the skill-manager Remotes. + * + * A skill is a kebab-case directory bundle (`/SKILL.md`) or flat file + * (`.md`) discovered by `dsh-skill-filesystem`; install materializes one + * into the writable user root (`$DSH_HOME/skills`), uninstall removes it, and + * toggling/editing rewrites its `SKILL.md` frontmatter — all picked up by the + * provider's watcher with no restart. Frontmatter interpretation matches the + * provider exactly: `name` and `description` are required, and the invocation + * surfaces are `disable-model-invocation` / `user-invocable` (each omitted + * defaults to permitting its surface). These helpers are dependency-free of + * Cordis, so they unit-test without a context. + * @module @deepseek-ai/dsh-host-skill-manager/skill-io + */ + +import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' +import { dshHomePath } from '@deepseek-ai/dsh-home-paths' +import type { SkillInvocationPolicy } from '@deepseek-ai/dsh-skill' + +/** Kebab-case skill-name grammar, matching the registry's `isSkillName`. */ +export const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** The directory the skill provider scans at `USER_DSH_RANK`. */ +export function skillRoot(): string { + return dshHomePath('skills') +} + +/** Parsed `SKILL.md` frontmatter-derived fields plus the instruction body. */ +export interface ParsedSkillFile { + readonly name: string + readonly description: string + readonly whenToUse?: string + readonly invocation: SkillInvocationPolicy + readonly body: string +} + +/** Parse a `SKILL.md` body, validating the provider-mandated fields. */ +export function parseSkillFile(content: string): ParsedSkillFile { + const { frontmatter, body } = splitFrontmatter(content) + const data = parseYaml(frontmatter) as unknown + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('skill frontmatter is not a YAML object') + } + const record = data as Record + const name = typeof record.name === 'string' ? record.name : '' + if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`) + const description = typeof record.description === 'string' ? record.description : '' + if (description.length === 0) throw new Error(`skill "${name}" has no description`) + return { + name, + description, + invocation: { + modelInvocable: record['disable-model-invocation'] !== true, + userInvocable: record['user-invocable'] !== false, + }, + body, + ...(typeof record.whenToUse === 'string' && record.whenToUse.length > 0 + ? { whenToUse: record.whenToUse } + : {}), + } +} + +/** Frontmatter fields a management edit may change. */ +export interface SkillFrontmatterMutation { + readonly description?: string + /** Desired `modelInvocable`; written as the inverse of `disable-model-invocation`. */ + readonly model?: boolean + /** Desired `userInvocable`; written as `user-invocable`. */ + readonly user?: boolean +} + +/** + * Rewrite a `SKILL.md` with the given frontmatter mutation, preserving every + * other frontmatter key and the instruction body verbatim. + * @param content - the current `SKILL.md` text. + * @param mutation - the fields to change. + * @returns the rewritten `SKILL.md` text. + */ +export function rewriteSkillFile(content: string, mutation: SkillFrontmatterMutation): string { + const { frontmatter, body } = splitFrontmatter(content) + const parsed = parseYaml(frontmatter) as unknown + const data = (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) + ? parsed as Record + : {} + if (mutation.description !== undefined) data.description = mutation.description + if (mutation.model !== undefined) data['disable-model-invocation'] = mutation.model ? false : true + if (mutation.user !== undefined) data['user-invocable'] = mutation.user + const rendered = stringifyYaml(data, { lineWidth: 0 }).trimEnd() + return `---\n${rendered}\n---\n${body}` +} + +/** Read a skill file's text. */ +export function readSkillText(path: string): string { + return readFileSync(path, 'utf8') +} + +/** Whether a skill exists in `root` as a directory bundle or flat file. */ +export function skillExists(root: string, name: string): boolean { + return existsSync(join(root, name, 'SKILL.md')) || existsSync(join(root, `${name}.md`)) +} + +/** + * Materialize a resolved skill directory into `root/`, copying the whole + * bundle so any relative resources keep working. + */ +export function installSkillDir(root: string, name: string, sourceDir: string): void { + if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`) + cpSync(sourceDir, join(root, name), { recursive: true }) +} + +/** Remove a skill (directory bundle or flat file) from `root`. */ +export function deleteSkill(root: string, name: string): void { + if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`) + rmSync(join(root, name), { recursive: true, force: true }) + rmSync(join(root, `${name}.md`), { force: true }) +} + +/** Rewrite a skill's `SKILL.md` in place and return the new text. */ +export function writeSkillFile(root: string, name: string, content: string): string { + writeFileSync(join(root, name, 'SKILL.md'), content, 'utf8') + return content +} + +/** Split a SKILL.md into its leading YAML frontmatter block and the body after it. */ +function splitFrontmatter(content: string): { frontmatter: string; body: string } { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content) + if (match === null) throw new Error('skill file has no frontmatter') + return { frontmatter: match[1] ?? '', body: content.slice(match[0].length) } +} diff --git a/packages/host/skill-manager/src/types.ts b/packages/host/skill-manager/src/types.ts new file mode 100644 index 0000000000..e7acb5e4e6 --- /dev/null +++ b/packages/host/skill-manager/src/types.ts @@ -0,0 +1,50 @@ +import type { SkillInvocationPolicy, SkillSource } from '@deepseek-ai/dsh-skill' + +/** How a skill install source is resolved. */ +export type SkillInstallSource = 'git' | 'npm' | 'tarball' | 'local' + +/** A skill install request: the source kind plus the specifier. */ +export interface SkillInstallSpec { + readonly source: SkillInstallSource + /** + * `git`: a git clone URL/`github:user/repo`; `npm`: a package name; `tarball`: + * a `.tgz` URL or local path; `local`: a directory containing `SKILL.md`. + */ + readonly spec: string +} + +/** Which invocation surfaces a management patch enables or disables. */ +export interface SkillInvocationPatch { + /** Whether the model-invocation surface is enabled. */ + readonly model?: boolean + /** Whether the user-invocation surface is enabled. */ + readonly user?: boolean +} + +/** One local skill as exposed to the manager client. */ +export interface SkillManagerEntry { + readonly name: string + readonly description: string + readonly whenToUse?: string + /** Discovery source that produced the skill. */ + readonly source: SkillSource + /** Provider that owns the skill body. */ + readonly provider: string + readonly invocation: SkillInvocationPolicy + /** Absolute path of the skill body when known. */ + readonly path?: string + /** Whether this skill lives in the writable user root and can be managed. */ + readonly managed: boolean +} + +/** Snapshot returned by the skill-manager list Remote. */ +export interface SkillManagerSnapshot { + readonly skills: readonly SkillManagerEntry[] +} + +/** Result of a skill install/uninstall/management mutation. */ +export interface SkillMutationResult { + readonly ok: true + /** The affected skill name. */ + readonly name?: string +} diff --git a/packages/host/skill-manager/tests/discovery.spec.ts b/packages/host/skill-manager/tests/discovery.spec.ts new file mode 100644 index 0000000000..348d784764 --- /dev/null +++ b/packages/host/skill-manager/tests/discovery.spec.ts @@ -0,0 +1,102 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { discoverLocalSkills } from '../src/discovery.ts' + +const dirs: string[] = [] +const savedEnv: Record = {} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = value + } + Object.keys(savedEnv).forEach((key) => { Reflect.deleteProperty(savedEnv, key) }) +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-discovery-')) + dirs.push(dir) + return dir +} + +function withEnv(key: string, value: string): void { + savedEnv[key] = process.env[key] + process.env[key] = value +} + +function makeSkill(root: string, name: string, description = 'A skill'): void { + const dir = join(root, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`) +} + +describe('discoverLocalSkills', () => { + it('discovers skills from the user DSH and agents roots', () => { + const home = tempDir() + const agents = tempDir() + withEnv('DSH_HOME', home) + withEnv('DSH_AGENTS_HOME', agents) + makeSkill(join(home, 'skills'), 'user-skill') + makeSkill(join(agents, 'skills'), 'agent-skill') + withEnv('DSH_BUNDLED_SKILL_DIR', join(tempDir(), 'nonexistent')) + + const skills = discoverLocalSkills(join(home, 'cwd')) + expect(skills.map(s => s.name)).toEqual(['agent-skill', 'user-skill']) + const user = skills.find(s => s.name === 'user-skill')! + expect(user).toMatchObject({ source: 'user-dsh', managed: true }) + const agent = skills.find(s => s.name === 'agent-skill')! + expect(agent).toMatchObject({ source: 'user-agents', managed: false }) + }) + + it('discovers bundled and project skills, with project winning by rank', () => { + const home = tempDir() + const agents = tempDir() + const bundled = tempDir() + const project = tempDir() + withEnv('DSH_HOME', home) + withEnv('DSH_AGENTS_HOME', agents) + withEnv('DSH_BUNDLED_SKILL_DIR', bundled) + makeSkill(join(bundled), 'bundled-skill') + // Same name in the project and user roots: the project (lower rank) wins. + makeSkill(join(project, '.agents', 'skills'), 'shared', 'project version') + makeSkill(join(home, 'skills'), 'shared', 'user version') + + const skills = discoverLocalSkills(project) + expect(skills.find(s => s.name === 'bundled-skill')?.source).toBe('bundled') + const shared = skills.find(s => s.name === 'shared')! + expect(shared).toMatchObject({ source: 'project-agents', description: 'project version' }) + }) + + it('marks user-dsh skills as managed and reads invocation frontmatter', () => { + const home = tempDir() + withEnv('DSH_HOME', home) + withEnv('DSH_AGENTS_HOME', tempDir()) + const dir = join(home, 'skills', 'my-skill') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'SKILL.md'), '---\nname: my-skill\ndescription: A\ndisable-model-invocation: true\nuser-invocable: false\n---\nbody\n') + + const skill = discoverLocalSkills(home).find(s => s.name === 'my-skill')! + expect(skill).toMatchObject({ managed: true, invocation: { modelInvocable: false, userInvocable: false } }) + }) + + it('skips skills whose frontmatter name does not match the bundle, or that are malformed', () => { + const home = tempDir() + withEnv('DSH_HOME', home) + withEnv('DSH_AGENTS_HOME', tempDir()) + const root = join(home, 'skills') + mkdirSync(join(root, 'ok-skill'), { recursive: true }) + writeFileSync(join(root, 'ok-skill', 'SKILL.md'), '---\nname: ok-skill\ndescription: A\n---\nbody\n') + // name mismatch + mkdirSync(join(root, 'mismatch'), { recursive: true }) + writeFileSync(join(root, 'mismatch', 'SKILL.md'), '---\nname: different\ndescription: A\n---\nbody\n') + // no description + mkdirSync(join(root, 'nodesc'), { recursive: true }) + writeFileSync(join(root, 'nodesc', 'SKILL.md'), '---\nname: nodesc\n---\nbody\n') + + const skills = discoverLocalSkills(home) + expect(skills.map(s => s.name)).toEqual(['ok-skill']) + }) +}) diff --git a/packages/host/skill-manager/tests/install.spec.ts b/packages/host/skill-manager/tests/install.spec.ts new file mode 100644 index 0000000000..ec2f5d5210 --- /dev/null +++ b/packages/host/skill-manager/tests/install.spec.ts @@ -0,0 +1,63 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveSkill } from '../src/install.ts' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-skill-install-')) + dirs.push(dir) + return dir +} + +function makeSkill(dir: string, name: string, description = 'A skill'): void { + mkdirSync(join(dir, name), { recursive: true }) + writeFileSync(join(dir, name, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`) +} + +describe('resolveSkill (local source)', () => { + it('resolves a directory bundle at the source root', async () => { + const src = tempDir() + makeSkill(src, 'my-skill') + const work = tempDir() + const resolved = await resolveSkill({ source: 'local', spec: join(src, 'my-skill') }, work) + expect(resolved.name).toBe('my-skill') + expect(resolved.description).toBe('A skill') + expect(resolved.skillDir).toBe(join(src, 'my-skill')) + }) + + it('resolves a skills/ pack layout under the source', async () => { + const src = tempDir() + makeSkill(join(src, 'skills'), 'packed-skill') + const work = tempDir() + const resolved = await resolveSkill({ source: 'local', spec: src }, work) + expect(resolved.name).toBe('packed-skill') + expect(resolved.skillDir).toBe(join(src, 'skills', 'packed-skill')) + }) + + it('rejects a source without a SKILL.md', async () => { + const src = tempDir() + writeFileSync(join(src, 'README.md'), '# no skill\n') + const work = tempDir() + await expect(resolveSkill({ source: 'local', spec: src }, work)).rejects.toThrow(/no SKILL\.md found/) + }) + + it('rejects a source with an invalid skill name', async () => { + const src = tempDir() + writeFileSync(join(src, 'SKILL.md'), '---\nname: Bad Name\ndescription: A\n---\nbody\n') + const work = tempDir() + await expect(resolveSkill({ source: 'local', spec: src }, work)).rejects.toThrow(/invalid skill name/) + }) + + it('rejects a missing local directory', async () => { + const work = tempDir() + await expect(resolveSkill({ source: 'local', spec: join(tempDir(), 'nope') }, work)) + .rejects.toThrow(/not found/) + }) +}) diff --git a/packages/host/skill-manager/tests/skill-io.spec.ts b/packages/host/skill-manager/tests/skill-io.spec.ts new file mode 100644 index 0000000000..9244a68b90 --- /dev/null +++ b/packages/host/skill-manager/tests/skill-io.spec.ts @@ -0,0 +1,96 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + deleteSkill, installSkillDir, parseSkillFile, readSkillText, rewriteSkillFile, + skillExists, SKILL_NAME, writeSkillFile, +} from '../src/skill-io.ts' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-skill-io-')) + dirs.push(dir) + return dir +} + +const VALID = '---\nname: my-skill\ndescription: A demo skill\n---\n# My skill\nBody here.\n' + +describe('parseSkillFile', () => { + it('parses name, description, and default invocation', () => { + const parsed = parseSkillFile(VALID) + expect(parsed).toMatchObject({ + name: 'my-skill', + description: 'A demo skill', + invocation: { modelInvocable: true, userInvocable: true }, + }) + expect(parsed.body).toContain('# My skill') + }) + + it('honors invocation frontmatter and whenToUse', () => { + const content = '---\nname: my-skill\ndescription: A\ndisable-model-invocation: true\nuser-invocable: false\nwhenToUse: When X\n---\nbody\n' + const parsed = parseSkillFile(content) + expect(parsed.invocation).toEqual({ modelInvocable: false, userInvocable: false }) + expect(parsed.whenToUse).toBe('When X') + }) + + it('rejects an invalid skill name, a missing description, or no frontmatter', () => { + expect(() => parseSkillFile('---\nname: My Skill\ndescription: A\n---\nbody\n')).toThrow(/invalid skill name/) + expect(() => parseSkillFile('---\nname: my-skill\n---\nbody\n')).toThrow(/no description/) + expect(() => parseSkillFile('# no frontmatter\n')).toThrow(/no frontmatter/) + }) +}) + +describe('rewriteSkillFile', () => { + it('toggles invocation and edits description while preserving the body', () => { + const rewritten = rewriteSkillFile(VALID, { model: false, user: false, description: 'New desc' }) + expect(rewritten).toContain('disable-model-invocation: true') + expect(rewritten).toContain('user-invocable: false') + expect(rewritten).toContain('description: New desc') + expect(rewritten).toContain('# My skill') + }) + + it('re-enabling writes explicit false values', () => { + const disabled = rewriteSkillFile(VALID, { model: false }) + const enabled = rewriteSkillFile(disabled, { model: true }) + expect(enabled).toContain('disable-model-invocation: false') + }) +}) + +describe('skill filesystem helpers', () => { + it('reports existence and installs/deletes a directory bundle', () => { + const root = tempDir() + const src = tempDir() + mkdirSync(join(src, 'my-skill'), { recursive: true }) + writeFileSync(join(src, 'my-skill', 'SKILL.md'), VALID) + expect(skillExists(root, 'my-skill')).toBe(false) + installSkillDir(root, 'my-skill', join(src, 'my-skill')) + expect(skillExists(root, 'my-skill')).toBe(true) + expect(readFileSync(join(root, 'my-skill', 'SKILL.md'), 'utf8')).toContain('name: my-skill') + deleteSkill(root, 'my-skill') + expect(skillExists(root, 'my-skill')).toBe(false) + }) + + it('writeSkillFile rewrites the SKILL.md in place', () => { + const root = tempDir() + mkdirSync(join(root, 'my-skill'), { recursive: true }) + writeFileSync(join(root, 'my-skill', 'SKILL.md'), VALID) + writeSkillFile(root, 'my-skill', rewriteSkillFile(readSkillText(join(root, 'my-skill', 'SKILL.md')), { description: 'X' })) + expect(readFileSync(join(root, 'my-skill', 'SKILL.md'), 'utf8')).toContain('description: X') + }) +}) + +describe('SKILL_NAME', () => { + it('accepts kebab-case and rejects other shapes', () => { + expect(SKILL_NAME.test('my-skill')).toBe(true) + expect(SKILL_NAME.test('my')).toBe(true) + expect(SKILL_NAME.test('My-Skill')).toBe(false) + expect(SKILL_NAME.test('my_skill')).toBe(false) + expect(SKILL_NAME.test('my skill')).toBe(false) + }) +}) diff --git a/packages/host/skill-manager/tests/skill-manager.spec.ts b/packages/host/skill-manager/tests/skill-manager.spec.ts new file mode 100644 index 0000000000..42adad7aa2 --- /dev/null +++ b/packages/host/skill-manager/tests/skill-manager.spec.ts @@ -0,0 +1,163 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SkillRegistry from '@deepseek-ai/dsh-skill' +import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import SkillManagerGateway from '../src/index.ts' + +const contexts: Context[] = [] +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-skill-manager-')) + dirs.push(dir) + return dir +} + +function withUserHome(): { dir: string; restore: () => void } { + const dir = tempDir() + const savedHome = process.env.DSH_HOME + const savedAgents = process.env.DSH_AGENTS_HOME + process.env.DSH_HOME = dir + process.env.DSH_AGENTS_HOME = join(dir, 'agents') + return { + dir, + restore: () => { + if (savedHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = savedHome + if (savedAgents === undefined) delete process.env.DSH_AGENTS_HOME + else process.env.DSH_AGENTS_HOME = savedAgents + }, + } +} + +async function harness() { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SkillRegistry) + await ctx.plugin(SkillManagerGateway) + const manager = ctx.get('skillManager') as SkillManagerGateway + return { ctx, manager } +} + +/** Create a user-root skill at `$DSH_HOME/skills/` and return its SKILL.md path. */ +function createUserSkill(home: string, name: string, frontmatter = ''): string { + const dir = join(home, 'skills', name) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'SKILL.md') + writeFileSync(path, `---\nname: ${name}\ndescription: A skill\n${frontmatter}---\nbody\n`) + return path +} + +describe('SkillManagerGateway', () => { + it('publishes direct methods under the skillManager namespace', async () => { + const { manager } = await harness() + expect(manager.typertRemote).toMatchObject({ serviceKey: 'skillManager', namespace: 'skillManager' }) + expect(remoteMethods(manager)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + { method: 'installSkill', invocation: { kind: 'direct' } }, + { method: 'uninstallSkill', invocation: { kind: 'direct' } }, + { method: 'setEnabled', invocation: { kind: 'direct' } }, + { method: 'setDescription', invocation: { kind: 'direct' } }, + ]) + }) + + it('lists local skills discovered from the user root', async () => { + const { manager } = await harness() + const home = withUserHome() + try { + createUserSkill(home.dir, 'foo-bar') + createUserSkill(home.dir, 'bar-baz', 'disable-model-invocation: true\n') + const snapshot = manager.list(join(home.dir, 'cwd')) + expect(snapshot.skills).toEqual([ + { + name: 'bar-baz', + description: 'A skill', + source: 'user-dsh', + provider: 'filesystem', + invocation: { modelInvocable: false, userInvocable: true }, + managed: true, + path: join(home.dir, 'skills', 'bar-baz', 'SKILL.md'), + }, + { + name: 'foo-bar', + description: 'A skill', + source: 'user-dsh', + provider: 'filesystem', + invocation: { modelInvocable: true, userInvocable: true }, + managed: true, + path: join(home.dir, 'skills', 'foo-bar', 'SKILL.md'), + }, + ]) + } finally { + home.restore() + } + }) + + it('installs a local skill into the user root', async () => { + const { manager } = await harness() + const home = withUserHome() + try { + const src = tempDir() + mkdirSync(join(src, 'my-skill'), { recursive: true }) + writeFileSync(join(src, 'my-skill', 'SKILL.md'), '---\nname: my-skill\ndescription: A skill\n---\n# My skill\n') + await expect(manager.installSkill({ source: 'local', spec: join(src, 'my-skill') })) + .resolves.toEqual({ ok: true, name: 'my-skill' }) + const installed = join(home.dir, 'skills', 'my-skill', 'SKILL.md') + expect(readFileSync(installed, 'utf8')).toContain('name: my-skill') + } finally { + home.restore() + } + }) + + it('uninstalls a user-root skill and refuses a missing one', async () => { + const { manager } = await harness() + const home = withUserHome() + try { + createUserSkill(home.dir, 'my-skill') + expect(manager.uninstallSkill('my-skill')).toEqual({ ok: true, name: 'my-skill' }) + expect(existsSync(join(home.dir, 'skills', 'my-skill'))).toBe(false) + expect(() => manager.uninstallSkill('not-here')).toThrow(/not installed/) + } finally { + home.restore() + } + }) + + it('toggles invocation by rewriting frontmatter', async () => { + const { manager } = await harness() + const home = withUserHome() + try { + const path = createUserSkill(home.dir, 'my-skill') + manager.setEnabled('my-skill', { model: false, user: false }) + let content = readFileSync(path, 'utf8') + expect(content).toContain('disable-model-invocation: true') + expect(content).toContain('user-invocable: false') + manager.setEnabled('my-skill', { model: true, user: true }) + content = readFileSync(path, 'utf8') + expect(content).toContain('disable-model-invocation: false') + expect(content).toContain('user-invocable: true') + } finally { + home.restore() + } + }) + + it('edits the skill description', async () => { + const { manager } = await harness() + const home = withUserHome() + try { + const path = createUserSkill(home.dir, 'my-skill') + expect(manager.setDescription('my-skill', 'New description')).toEqual({ ok: true, name: 'my-skill' }) + expect(readFileSync(path, 'utf8')).toContain('description: New description') + expect(() => manager.setDescription('my-skill', ' ')).toThrow(/cannot be empty/) + } finally { + home.restore() + } + }) +}) diff --git a/packages/host/skill-manager/tsconfig.json b/packages/host/skill-manager/tsconfig.json new file mode 100644 index 0000000000..675995840f --- /dev/null +++ b/packages/host/skill-manager/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/home-paths" + }, + { + "path": "../../skill/skill" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79adb16a69..ef9df07938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -892,6 +892,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory + '@deepseek-ai/dsh-host-skill-manager': + specifier: workspace:^ + version: link:../../host/skill-manager '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -1395,6 +1398,9 @@ importers: '@deepseek-ai/dsh-client-ui-settings-plugins': specifier: workspace:^ version: link:../../client/ui-settings-plugins + '@deepseek-ai/dsh-client-ui-settings-skill-manager': + specifier: workspace:^ + version: link:../../client/ui-settings-skill-manager '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -1452,6 +1458,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory + '@deepseek-ai/dsh-host-skill-manager': + specifier: workspace:^ + version: link:../../host/skill-manager '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2679,6 +2688,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-settings-skill-manager: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-sidebar: dependencies: clsx: @@ -4981,6 +5032,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -4988,6 +5042,31 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/host/skill-manager: + dependencies: + yaml: + specifier: ^2.7.0 + version: 2.9.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': diff --git a/tsconfig.client.json b/tsconfig.client.json index e599f76f1b..a6d136dd1f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -91,6 +91,7 @@ { "path": "./packages/client/ui-settings-general" }, { "path": "./packages/client/ui-settings-models" }, { "path": "./packages/client/ui-settings-plugin-inventory" }, + { "path": "./packages/client/ui-settings-skill-manager" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } diff --git a/tsconfig.host.json b/tsconfig.host.json index 27a2684677..3811b0ac29 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -288,6 +288,7 @@ { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, { "path": "./packages/host/plugin-inventory" }, + { "path": "./packages/host/skill-manager" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, { "path": "./packages/sdk/protocol" },