feat: configure repository plugins from DSH home

This commit is contained in:
Tianyi Cui
2026-07-30 06:00:53 +08:00
parent 0664b25cd9
commit 2448496803
40 changed files with 783 additions and 76 deletions

View File

@@ -2,5 +2,5 @@
# 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 packages/cordis/repository-plugin/README.md
README.md: 80744eb489d1714f59ba6e53207476a8ce222e24
README.zh.md: d297b44e4a065fa99865e3a42d2c823c7b7c5848
README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7
README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797

View File

@@ -28,6 +28,23 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
## Standalone app configuration
The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`):
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
```
Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root.
The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
## Preparation
`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point.

View File

@@ -28,6 +28,23 @@
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。
## 独立应用配置
已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation:
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
```
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。
TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
## 准备阶段
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。

View File

@@ -34,6 +34,7 @@
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-mcp-client": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -44,6 +45,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -8,8 +8,10 @@ import { dirname, isAbsolute, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type {} from '@cordisjs/plugin-loader'
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
import { z } from 'zod'
import {
REPOSITORY_PLUGIN_BUILTIN,
isOutside,
@@ -17,6 +19,11 @@ import {
type PreparedPluginConfig,
} from './format.ts'
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
import {
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
} from './source.ts'
export {
PREPARED_ASSET_DIRECTORY,
@@ -31,6 +38,19 @@ export const name = 'repository-plugin'
/** Loader service required to register the fixed prepared-wrapper builtin. */
export const inject = ['loader']
/** Repository Plugin runtime and source-list configuration. */
export interface Config {
/** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */
repositories?: string[]
/** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */
cacheDir?: string
}
export const Config = z.object({
repositories: z.array(z.string().min(1)).default([]),
cacheDir: z.string().min(1).optional(),
}).strict().default({ repositories: [] })
function preparedPath(baseUrl: string, configured: string): string {
if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`)
const directory = dirname(fileURLToPath(baseUrl))
@@ -101,16 +121,25 @@ const preparedRuntime = {
* Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers.
* @param ctx - plugin context carrying the Loader service.
*/
export function apply(ctx: Context): void {
export async function apply(ctx: Context, config: Config = {}): Promise<void> {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
}
ctx.effect(function* () {
const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier)
if (new Set(repositories).size !== repositories.length) {
throw new Error('repository sources must resolve to unique exact specifiers')
}
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir))
await ctx.effect(async function* () {
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
yield () => {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
}
}
}, 'repository-plugin Loader builtin')
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
}

View File

@@ -0,0 +1,74 @@
/**
* GitHub repository source validation and prepared-wrapper loading.
* @module
*/
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Context, Fiber, Plugin } from 'cordis'
import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { PREPARED_ENTRY_FILENAME } from './format.ts'
/** Directory under the Harness home containing immutable repository generations. */
export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s&]+)(?:&path:(\/[^\s&]+))?$/
function validPluginPath(path: string): boolean {
const segments = path.split('/').slice(1)
return segments.length > 0
&& segments.at(-1) === '.dsh-plugin'
&& segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..')
}
/**
* Normalize one user-facing GitHub source to the exact pnpm dependency specifier.
* @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`.
* @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted.
* @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid.
*/
export function resolveRepositorySpecifier(configured: string): string {
const match = GITHUB_SOURCE_PATTERN.exec(configured)
if (match === null) {
throw new Error(`repository source must use github:owner/repo#<ref> with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`)
}
const path = match[4]
if (path !== undefined && !validPluginPath(path)) {
throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`)
}
return path === undefined ? `${configured}&path:/.dsh-plugin` : configured
}
/**
* Resolve the persistent repository cache root.
* @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`.
* @returns an absolute cache directory.
*/
export function resolveRepositoryCacheDirectory(configured: string | undefined): string {
return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY))
}
/**
* Load one exact repository generation's generated wrapper as a child Cordis fiber.
* @param ctx - repository runtime context that owns the child.
* @param cache - package-manager-native immutable repository cache.
* @param specifier - normalized exact pnpm dependency specifier.
* @returns the settled prepared-wrapper fiber.
* @throws when installation, wrapper import, manifest validation, or child registration fails.
*/
export async function loadPreparedRepository(
ctx: Context,
cache: Pick<RepositoryCache, 'resolve'>,
specifier: string,
): Promise<Fiber> {
const directory = await cache.resolve(specifier)
const filename = join(directory, PREPARED_ENTRY_FILENAME)
try {
const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
const fiber = ctx.plugin(plugin)
return await fiber
} catch (cause) {
throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause })
}
}

View File

@@ -2,9 +2,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
import SkillService from '@deepseek-ai/dsh-skill'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -12,6 +13,11 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin'
import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant'
import { parsePreparedPluginConfig } from '../src/format.ts'
import {
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
} from '../src/source.ts'
const roots: string[] = []
@@ -35,6 +41,8 @@ async function writeSkill(root: string, name: string): Promise<void> {
}
afterEach(async () => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
@@ -253,7 +261,7 @@ describe('prepared repository plugin Loader composition', () => {
await ctx.plugin(Loader)
const registrar = ctx.plugin(RepositoryPlugin)
await registrar
expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered')
await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered')
const replacement = { name: 'replacement', apply() {} }
ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement
@@ -263,6 +271,113 @@ describe('prepared repository plugin Loader composition', () => {
})
})
describe('configured GitHub repository sources', () => {
it('defaults an omitted source list and rejects unknown configuration fields', () => {
expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] })
expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false)
})
it('accepts an empty direct-apply config', async () => {
const ctx = new Context()
await ctx.plugin(Loader)
await RepositoryPlugin.apply(ctx, {})
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined()
await ctx.fiber.dispose()
})
it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => {
expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0'))
.toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin')
expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin'))
.toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')
})
it('rejects absent refs and invalid plugin subpaths', () => {
for (const source of [
'github:owner/repository',
'github:owner/repository#',
'https://github.com/owner/repository#ref',
'github:owner/repository#ref&path:relative/.dsh-plugin',
]) {
expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#<ref>')
}
for (const path of [
'/plugins//.dsh-plugin',
'/plugins/../.dsh-plugin',
'/plugins/./.dsh-plugin',
'/plugins/not-a-plugin',
]) {
expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`))
.toThrow('path must be an absolute repository subpath')
}
})
it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => {
const root = await temporaryDirectory('cache-root')
vi.stubEnv('DSH_HOME', root)
expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins'))
expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit'))
})
it('loads a configured source through the immutable cache and removes its skill on teardown', async () => {
const root = await temporaryDirectory('configured-source')
await writeSkill(join(root, 'skills'), 'configured-repository-skill')
const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] })
await RepositoryPlugin.prepareDshPlugin(directory)
const resolved: string[] = []
const cacheDirectory = join(root, 'cache')
vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) {
expect(this.directory).toBe(cacheDirectory)
resolved.push(specifier)
return directory
})
const ctx = new Context()
await ctx.plugin(Loader)
await ctx.plugin(SkillService)
const registrar = ctx.plugin(RepositoryPlugin, {
repositories: ['github:owner/repository#fixed-ref'],
cacheDir: cacheDirectory,
})
await registrar
expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin'])
await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({
provider: 'repository:configured-source-fixture',
})
await registrar.dispose()
await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined()
await ctx.fiber.dispose()
})
it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => {
const ctx = new Context()
await ctx.plugin(Loader)
await expect(RepositoryPlugin.apply(ctx, {
repositories: [
'github:owner/repository#ref',
'github:owner/repository#ref',
],
})).rejects.toThrow('must resolve to unique exact specifiers')
vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed'))
await expect(RepositoryPlugin.apply(ctx, {
repositories: ['github:owner/repository#other'],
})).rejects.toThrow('prepare failed')
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined()
await ctx.fiber.dispose()
})
it('labels a missing prepared wrapper with its exact source and path', async () => {
const root = await temporaryDirectory('missing-wrapper')
const ctx = new Context()
const specifier = 'github:owner/repository#missing&path:/.dsh-plugin'
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier))
.rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`)
await ctx.fiber.dispose()
})
})
describe('repository plugin invariant companion', () => {
it('registers its explained empty invariant', async () => {
const ctx = new Context()

View File

@@ -23,6 +23,9 @@
{
"path": "../../mcp/mcp-client"
},
{
"path": "../../util/paths"
},
{
"path": "../../support/invariants"
}