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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 17:04:36 +08:00

496 lines
20 KiB
TypeScript

/** Read-only projection of the current Cordis Loader plugin entries. */
import type { Context, FiberState } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import { fileURLToPath } from 'node:url'
import { readProfileManifest, type ProfileManifest } from '@deepseek-ai/dsh-app-boot'
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,
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,
PluginInventorySnapshot,
} from './types.ts'
export type * from './types.ts'
export { AVAILABLE_BUNDLES } from './bundles.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
* offered as offline-installable or uninstallable (they are part of the
* deployment). See `packages/boot/app-boot/src/profile.ts` `PROFILE_TEMPLATES`.
*/
const DEFAULT_BUNDLES = new Set([
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-web-app',
'@deepseek-ai/dsh-image-recognition-bundle',
])
/** Brand an existing Loader-tree entry id at the owning boundary. */
function pluginEntryId(value: string): PluginEntryId {
return value as PluginEntryId
}
/** Runtime mirror: FiberState is a cross-package const enum. */
const FIBER_STATE = {
PENDING: 0 as FiberState.PENDING,
LOADING: 1 as FiberState.LOADING,
ACTIVE: 2 as FiberState.ACTIVE,
FAILED: 3 as FiberState.FAILED,
DISPOSED: 4 as FiberState.DISPOSED,
UNLOADING: 5 as FiberState.UNLOADING,
} as const
/** Complete public projection of Cordis Fiber states. */
const FIBER_PHASE = {
[FIBER_STATE.PENDING]: 'pending',
[FIBER_STATE.LOADING]: 'loading',
[FIBER_STATE.ACTIVE]: 'active',
[FIBER_STATE.FAILED]: 'failed',
[FIBER_STATE.DISPOSED]: null,
[FIBER_STATE.UNLOADING]: 'unloading',
} as const satisfies Record<FiberState, PluginFiberPhase>
/** Remote-only service exposing the Loader's current non-group entry state. */
export class PluginInventoryGateway extends TypertRemoteService {
static inject = ['loader']
constructor(ctx: Context) {
super(ctx, 'pluginInventory')
}
/**
* Read the Loader directly on every call. Cordis's internal plugin/status
* events already maintain Entry.fiber and Fiber.state, so a second cache
* would only add another lifecycle truth to keep synchronized.
* @returns Current non-group Loader entries in Loader order.
*/
@Remote('list')
list(): PluginInventorySnapshot {
const entries: PluginInventoryEntry[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.options.group) continue
entries.push({
entryId: pluginEntryId(entry.id),
moduleName: entry.options.name,
enabled: !entry.disabled,
protected: isRequiredPlugin(entry.options.name),
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
})
}
return { entries }
}
/**
* Toggle one plugin entry on or off. Applies the change live through the
* Loader (disposing or re-starting the plugin's fiber) and persists an
* 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. 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.
*/
@Remote('setEnabled')
async setEnabled(entryId: PluginEntryId, enabled: boolean): Promise<{ ok: true }> {
// resolve() throws for an unknown id, so the entry is always defined.
const entry = this.ctx.loader.resolve(entryId)
// Disabling a required system plugin tears the process down; refuse it.
// Re-enabling a disabled plugin is what this surface is for.
if (!enabled && isRequiredPlugin(entry.options.name)) {
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 })
if (this.ctx.baseUrl !== undefined) {
persistPluginDisabled(fileURLToPath(this.ctx.baseUrl), rowId, !enabled)
}
return { ok: true }
}
/**
* List the offline-installable optional bundles, each marked installed when
* it is already composed in the profile's `dsh.profile.bundles`.
* @returns the curated bundle catalog with installed state.
*/
@Remote('availableBundles')
availableBundles(): AvailableBundlesSnapshot {
const profileDir = this.profileDir()
const installed = new Set(this.profileManifest(profileDir)?.dsh?.profile?.bundles ?? [])
// The catalog is empty until a new optional bundle ships, so the projection
// callback is unreachable in the current configuration.
/* v8 ignore next */
return {
available: AVAILABLE_BUNDLES.map(name => ({ name, installed: installed.has(name) })),
}
}
/**
* Install a plugin. A `bundle` spec composes an offline optional bundle into
* 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). 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; `pendingBuilds` pauses the install for consent,
* otherwise `restartRequired` tells the caller to restart.
*/
@Remote('installPlugin')
async installPlugin(spec: InstallSpec): Promise<InstallResult> {
const profileDir = this.profileDir()
const anchor = this.ctx.get('dshInstallAnchor') as string | undefined
if (anchor === undefined) {
throw new Error('dsh: install anchor is unavailable in this runtime')
}
if (spec.type === 'bundle') {
if (DEFAULT_BUNDLES.has(spec.name)) {
throw new Error(`dsh: bundle ${spec.name} is composed by default and is not installable`)
}
composeOfflineBundle('dsh', profileDir, anchor, spec.name)
return { ok: true, restartRequired: !(await this.reload()) }
}
if (this.ctx.get('dshAllowPluginInstall') !== true) {
throw new Error('dsh: plugin install is not permitted 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: {} }
// 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()) }
}
/**
* 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<InstallResult> {
const profileDir = this.profileDir()
if (DEFAULT_BUNDLES.has(name)) {
throw new Error(`dsh: bundle ${name} is composed by default and cannot be uninstalled`)
}
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<MarketplaceSnapshot> {
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<InstallResult> {
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<InstallResult> {
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<boolean> {
const reload = this.ctx.get('dshReloadProfile') as (() => Promise<void>) | undefined
if (reload === undefined) return false
await reload()
return true
}
/** The profile directory the Loader anchors on (its `baseUrl`). */
private profileDir(): string {
if (this.ctx.baseUrl === undefined) {
throw new Error('dsh: plugin management requires a profile directory')
}
return fileURLToPath(this.ctx.baseUrl)
}
/** Read the profile manifest, or undefined when absent. */
private profileManifest(profileDir: string): ProfileManifest | undefined {
try {
return readProfileManifest('dsh', profileDir)
} catch {
return undefined
}
}
}
export default PluginInventoryGateway