Merge remote-tracking branch 'origin/compact-tool-pairing' into token-meter-service

# Conflicts:
#	docs/capability-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
Hypatia May
2026-07-16 10:04:17 +08:00
280 changed files with 17955 additions and 3224 deletions
@@ -0,0 +1,126 @@
/**
* Required run-interface app feature.
*
* @module @deepseek-ai/dsh-helper/features/builtin/app
*/
import { featureId } from '../../ids.ts'
import type { ProjectProfile } from '../../project/types.ts'
import {
createAppPackageScripts,
createAppProjectArtifacts,
createProjectTemplateContext,
} from '../../templates/project-template.ts'
import {
FeatureOption,
ExclusiveOptionFeature,
} from '../feature.ts'
import { ProjectContribution, type ProjectResource } from '../resources.ts'
import {
npmCordisConfigEntry,
optionalString,
ownedTextFile,
packageScript,
requiredString,
} from './helpers.ts'
const ID = featureId('app')
function appProjectResources(
profile: ProjectProfile,
runInterface: 'acp' | 'stdio' | 'embed',
): readonly ProjectResource[] {
const context = createProjectTemplateContext(profile, runInterface)
const scripts = createAppPackageScripts(context)
return [
...createAppProjectArtifacts(context).map(document => (
ownedTextFile(ID, document.relativePath, document.serialize())
)),
packageScript(ID, 'dev', scripts.dev),
packageScript(ID, 'start', scripts.start),
]
}
class AppOption extends FeatureOption {
override readonly id: 'acp' | 'stdio' | 'embed'
override readonly label: string
constructor(id: 'acp' | 'stdio' | 'embed', label: string) {
super()
this.id = id
this.label = label
}
/** Identify options by their unique front door, not the shared interaction service. */
override markerConfigEntries(): readonly { id: string; name: string }[] {
switch (this.id) {
case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }]
case 'stdio': return [{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' }]
case 'embed': return []
}
}
/** Embed is identified by the configured loop with no external front door. */
override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean {
if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile)
return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop')
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-stdio')
}
override contribution(profile: ProjectProfile): ProjectContribution {
switch (this.id) {
case 'acp':
return new ProjectContribution([
...appProjectResources(profile, this.id),
...npmCordisConfigEntry(ID, {
id: 'user-interaction',
name: '@deepseek-ai/dsh-user-interaction',
}),
...npmCordisConfigEntry(ID, {
id: 'acp',
name: '@deepseek-ai/dsh-acp',
config: { model: profile.runtime.model },
}, ['model'], config => requiredString(config, 'model')),
])
case 'stdio':
return new ProjectContribution([
...appProjectResources(profile, this.id),
...npmCordisConfigEntry(ID, {
id: 'user-interaction',
name: '@deepseek-ai/dsh-user-interaction',
}),
...npmCordisConfigEntry(ID, {
id: 'stdio',
name: '@deepseek-ai/dsh-stdio',
config: {
welcome: 'agent REPL ready. Give it a coding task.',
agent: 'main',
},
}, ['welcome', 'agent'], config => [
...optionalString(config, 'welcome'),
...requiredString(config, 'agent'),
]),
])
case 'embed':
return new ProjectContribution(appProjectResources(profile, this.id))
}
}
}
/** Required app selection represented by acp, stdio, or embed options. */
export class AppFeature extends ExclusiveOptionFeature {
override readonly id = ID
override readonly summary = 'Run interface'
override readonly required = true
override readonly requires = [featureId('spine')]
override readonly options = [
new AppOption('acp', 'ACP server'),
new AppOption('stdio', 'Terminal REPL'),
new AppOption('embed', 'Embedded context'),
]
/** Default to the profile's already selected front door. */
override defaultOptions(profile: ProjectProfile): readonly string[] {
return [profile.runInterface]
}
}
@@ -0,0 +1,111 @@
/**
* Small resource constructors shared by builtin feature modules.
*
* @module @deepseek-ai/dsh-helper/features/builtin/helpers
*/
import type { CordisConfigEntry } from '../../documents/cordis-yaml-file.ts'
import { TextProjectFile } from '../../documents/project-file.ts'
import { resourceKey } from '../../ids.ts'
import type {
CordisConfigEntryResource,
EnvironmentResource,
OwnedFileResource,
NpmDependencyResource,
PackageScriptResource,
} from '../resources.ts'
/** Create a runtime NPM dependency resource. */
function npmDependency(_owner: string, name: string): NpmDependencyResource {
return {
kind: 'npm-dependency',
key: resourceKey(`npm-dependency:${name}`),
name,
section: 'dependencies',
}
}
/** Create a feature-owned package script that is replaceable only while unchanged. */
export function packageScript(_owner: string, name: string, command: string): PackageScriptResource {
return {
kind: 'package-script',
key: resourceKey(`package-script:${name}`),
name,
command,
removeOnlyWhenUnchanged: true,
}
}
/** Create a Cordis config entry resource with explicitly owned config keys. */
export function cordisConfigEntry(
_owner: string,
value: CordisConfigEntry,
ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}),
validateConfig?: CordisConfigEntryResource['validateConfig'],
): CordisConfigEntryResource {
return {
kind: 'cordis-config-entry',
key: resourceKey(`cordis-config-entry:${value.id}`),
entry: value,
ownedConfigKeys,
...validateConfig ? { validateConfig } : {},
}
}
/** Couple one bare-package Cordis config entry to its mandatory runtime NPM dependency. */
export function npmCordisConfigEntry(
owner: string,
value: CordisConfigEntry,
ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}),
validateConfig?: CordisConfigEntryResource['validateConfig'],
): readonly [NpmDependencyResource, CordisConfigEntryResource] {
return [
npmDependency(owner, value.name),
cordisConfigEntry(owner, value, ownedConfigKeys, validateConfig),
]
}
/** Create a secret/environment binding resource. */
export function environment(
_owner: string,
name: string,
value: string | undefined,
comment?: string,
): EnvironmentResource {
return {
kind: 'environment',
key: resourceKey(`environment:${name}`),
name,
...value === undefined ? {} : { value },
exampleValue: '',
...comment === undefined ? {} : { comment },
}
}
/** Create an owned complete-text file that is removable only while unchanged. */
export function ownedTextFile(_owner: string, path: string, text: string): OwnedFileResource {
return {
kind: 'owned-file',
key: resourceKey(`file:${path}`),
document: new TextProjectFile(path, text),
removeOnlyWhenUnchanged: true,
}
}
/** Validate a config key as a string when present. */
export function optionalString(config: Readonly<Record<string, unknown>>, key: string): string[] {
return config[key] === undefined || typeof config[key] === 'string' ? [] : [`${key} must be a string`]
}
/** Validate a config key as a non-empty string when required. */
export function requiredString(config: Readonly<Record<string, unknown>>, key: string): string[] {
return typeof config[key] === 'string' && config[key].length > 0 ? [] : [`${key} must be a non-empty string`]
}
/** Validate a config key as an array of strings. */
export function stringArray(config: Readonly<Record<string, unknown>>, key: string): string[] {
const value = config[key]
return Array.isArray(value) && value.every(item => typeof item === 'string')
? []
: [`${key} must be an array of strings`]
}
@@ -0,0 +1,363 @@
/**
* Ordered builtin feature catalog: behavior entities only where project
* context changes the contribution, typed specs everywhere else.
*
* @module @deepseek-ai/dsh-helper/features/builtin
*/
import type { Config as ClaudeHooksConfig } from '@deepseek-ai/dsh-hooks-claude'
import type { Config as CodexHooksConfig } from '@deepseek-ai/dsh-hooks-codex'
import type { Config as JsonlConfig } from '@deepseek-ai/dsh-session-persistence-jsonl'
import type { Config as SqliteConfig } from '@deepseek-ai/dsh-session-persistence-sqlite'
import type { Config as ToolSubagentConfig } from '@deepseek-ai/dsh-tool-subagent'
import type { Config as ToolWebConfig } from '@deepseek-ai/dsh-tool-web'
import type { ProjectProfile } from '../../project/types.ts'
import { defineFeatures } from '../define-feature.ts'
import { FeatureRegistry } from '../registry.ts'
import { AppFeature } from './app.ts'
import { ProviderFeature } from './provider.ts'
import { SpineFeature } from './spine.ts'
/**
* Build and definition-check the complete builtin set for one project profile.
* @param profile - project context used to validate conditional contributions.
* @returns ordered builtin feature registry.
*/
export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry {
return new FeatureRegistry(defineFeatures([
new ProviderFeature(),
new SpineFeature(),
{
id: 'bash',
summary: 'Command execution',
mode: 'exclusive',
required: true,
baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }],
options: [
{
id: 'local',
label: 'Local executor',
default: true,
resources: [{ kind: 'npm-cordis-config-entry', id: 'bash', package: '@deepseek-ai/dsh-bash-local' }],
},
{
id: 'sandbox',
label: 'Sandboxed executor',
resources: [
{ kind: 'npm-cordis-config-entry', id: 'sandbox', package: '@deepseek-ai/dsh-sandbox-local' },
{
kind: 'npm-cordis-config-entry',
id: 'bash',
package: '@deepseek-ai/dsh-bash-sandbox',
commentedExample: `Uncomment to allow writes under the project workspace.
config:
mode: workspace-write
workspaceRoot: !!js process.cwd()`,
},
],
},
],
},
new AppFeature(),
{
id: 'persistence',
summary: 'Durable session storage',
mode: 'exclusive',
required: true,
options: [
{
id: 'jsonl',
label: 'JSONL files',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'session-persistence',
package: '@deepseek-ai/dsh-session-persistence-jsonl',
config: { root: './.sessions' } satisfies JsonlConfig,
}],
},
{
id: 'sqlite',
label: 'SQLite database',
resources: [{
kind: 'npm-cordis-config-entry',
id: 'session-persistence',
package: '@deepseek-ai/dsh-session-persistence-sqlite',
config: { path: './.sessions/sessions.sqlite' } satisfies SqliteConfig,
}],
},
],
},
{
id: 'hmr',
summary: 'Hot-module reload',
mode: 'single',
options: [{
id: 'default',
label: 'Cordis HMR',
default: true,
resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@cordisjs/plugin-hmr' }],
}],
},
{
id: 'fs',
summary: 'Read, write, and edit local files',
mode: 'single',
options: [{
id: 'local',
label: 'Local filesystem',
default: true,
resources: [
{ kind: 'npm-cordis-config-entry', id: 'fs-local', package: '@deepseek-ai/dsh-fs-local' },
{ kind: 'npm-cordis-config-entry', id: 'fs-policy', package: '@deepseek-ai/dsh-fs-policy' },
{ kind: 'npm-cordis-config-entry', id: 'tool-fs', package: '@deepseek-ai/dsh-tool-fs' },
],
}],
},
{
id: 'todo',
summary: 'Model-facing task tracking',
mode: 'single',
options: [{
id: 'default',
label: 'todo_write tool',
default: true,
resources: [{ kind: 'npm-cordis-config-entry', id: 'tool-todo', package: '@deepseek-ai/dsh-tool-todo' }],
}],
},
{
id: 'skill',
summary: 'Local skill discovery',
mode: 'single',
options: [{
id: 'default',
label: 'Local skills and skill tool',
default: true,
resources: [
{ kind: 'npm-cordis-config-entry', id: 'skill', package: '@deepseek-ai/dsh-skill' },
{ kind: 'npm-cordis-config-entry', id: 'skill-local', package: '@deepseek-ai/dsh-skill-local' },
{ kind: 'npm-cordis-config-entry', id: 'tool-skill', package: '@deepseek-ai/dsh-tool-skill' },
],
}],
},
{
id: 'web',
summary: 'Web search and fetch tools',
mode: 'exclusive',
suggests: ['timeout-policy'],
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'web', package: '@deepseek-ai/dsh-web' },
{ kind: 'npm-cordis-config-entry', id: 'web-fetch-local', package: '@deepseek-ai/dsh-web-fetch-local' },
],
options: [
{
id: 'deepseek',
label: 'DeepSeek search',
default: true,
markers: [{ id: 'web-search-deepseek', name: '@deepseek-ai/dsh-web-search-deepseek' }],
resources: [
{ kind: 'npm-cordis-config-entry', id: 'web-search-deepseek', package: '@deepseek-ai/dsh-web-search-deepseek' },
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
],
},
{
id: 'exa',
label: 'Exa search',
secrets: [{ id: 'apiKey', environment: 'EXA_API_KEY', message: 'Exa API key', required: true }],
markers: [{ id: 'web-search-exa', name: '@deepseek-ai/dsh-web-search-exa' }],
resources: [
{ kind: 'npm-cordis-config-entry', id: 'web-search-exa', package: '@deepseek-ai/dsh-web-search-exa' },
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
],
},
{
id: 'perplexity',
label: 'Perplexity search',
secrets: [{
id: 'apiKey',
environment: 'PERPLEXITY_API_KEY',
message: 'Perplexity API key',
required: true,
}],
markers: [{ id: 'web-search-perplexity', name: '@deepseek-ai/dsh-web-search-perplexity' }],
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'web-search-perplexity',
package: '@deepseek-ai/dsh-web-search-perplexity',
},
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
],
},
{
id: 'fetch-only',
label: 'Fetch only',
markers: [{ id: 'tool-web', name: '@deepseek-ai/dsh-tool-web', config: { search: false } }],
resources: [{
kind: 'npm-cordis-config-entry',
id: 'tool-web',
package: '@deepseek-ai/dsh-tool-web',
config: { search: false } satisfies ToolWebConfig,
}],
},
],
},
{
id: 'subagent',
summary: 'Delegate work to child agents',
mode: 'multiple',
baseResources: [{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }],
options: [
{
id: 'spawn',
label: 'Fresh child agent',
default: true,
resources: [
{ kind: 'npm-cordis-config-entry', id: 'subagent-spawn', package: '@deepseek-ai/dsh-subagent-spawn' },
{
kind: 'npm-cordis-config-entry',
id: 'tool-subagent',
package: '@deepseek-ai/dsh-tool-subagent',
config: { provider: 'spawn' } satisfies ToolSubagentConfig,
},
],
},
{
id: 'fork',
label: 'Fork parent history',
resources: [
{ kind: 'npm-cordis-config-entry', id: 'subagent-fork', package: '@deepseek-ai/dsh-subagent-fork' },
{
kind: 'npm-cordis-config-entry',
id: 'tool-subagent-fork',
package: '@deepseek-ai/dsh-tool-subagent',
config: { provider: 'fork', toolName: 'subagent_fork' } satisfies ToolSubagentConfig,
},
],
},
],
},
{
id: 'workflow',
summary: 'Scripted multi-agent workflows',
mode: 'single',
options: [{
id: 'workerthread',
label: 'Worker thread engine',
default: true,
requires: [{ id: 'subagent', options: ['spawn'] }],
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'workflow-workerthread',
package: '@deepseek-ai/dsh-workflow-workerthread',
},
{ kind: 'npm-cordis-config-entry', id: 'tool-workflow', package: '@deepseek-ai/dsh-tool-workflow' },
],
}],
},
{
id: 'compact',
summary: 'Automatic context compaction',
mode: 'single',
options: [{
id: 'basic',
label: 'Basic compaction',
default: true,
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'token-meter',
package: '@deepseek-ai/dsh-token-meter',
},
{
kind: 'npm-cordis-config-entry',
id: 'compact-basic',
package: '@deepseek-ai/dsh-compact-basic',
},
],
}],
},
{
id: 'hooks',
summary: 'Run Claude Code or Codex hooks',
mode: 'multiple',
requires: [{ id: 'bash' }],
options: [
{
id: 'claude',
label: 'Claude Code hooks',
default: true,
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'hooks-claude',
package: '@deepseek-ai/dsh-hooks-claude',
config: { configPath: './hooks.json' } satisfies ClaudeHooksConfig,
},
{ kind: 'owned-file', path: 'hooks.json', text: '{}' },
],
},
{
id: 'codex',
label: 'Codex hooks',
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'hooks-codex',
package: '@deepseek-ai/dsh-hooks-codex',
config: { configPath: './codex-hooks.json' } satisfies CodexHooksConfig,
},
{ kind: 'owned-file', path: 'codex-hooks.json', text: '{}' },
],
},
],
},
{
id: 'guard',
summary: 'Loop-hygiene reminders',
mode: 'single',
options: [{
id: 'repeat-tool',
label: 'Repeat-tool reminders',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'repeat-tool-guard',
package: '@deepseek-ai/dsh-repeat-tool-guard',
}],
}],
},
{
id: 'timeout-policy',
summary: 'Tool timeout policy',
mode: 'single',
options: [{
id: 'default',
label: 'Timeout policy',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'timeout-policy',
package: '@deepseek-ai/dsh-timeout-policy',
}],
}],
},
{
id: 'ask-user',
summary: 'Ask the user from the model loop',
mode: 'single',
supportedInterfaces: ['acp', 'stdio'],
options: [{
id: 'default',
label: 'ask_user_question tool',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'tool-ask-user',
package: '@deepseek-ai/dsh-tool-ask-user',
}],
}],
},
]), profile)
}
@@ -0,0 +1,111 @@
/**
* Required hand-rolled DeepSeek and custom pi-ai provider behavior.
*
* @module @deepseek-ai/dsh-helper/features/builtin/provider
*/
import { JsExpression } from '../../documents/cordis-yaml-file.ts'
import { featureId } from '../../ids.ts'
import type { FeatureSelection, ProjectProfile } from '../../project/types.ts'
import {
FeatureOption,
ExclusiveOptionFeature,
type FeatureProjectView,
} from '../feature.ts'
import { ProjectContribution } from '../resources.ts'
import { npmCordisConfigEntry, environment } from './helpers.ts'
const ID = featureId('provider')
const DEFAULT_MODEL = 'deepseek-v4-flash'
const API_KEY_COMMENT = 'Required before start; an empty value makes provider startup fail.'
class DeepSeekOption extends FeatureOption {
override readonly id = 'deepseek'
override readonly label = 'DeepSeek'
override readonly secrets = [{
id: 'apiKey',
environment: 'DEEPSEEK_API_KEY',
message: 'DeepSeek API key',
required: true,
}]
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
return new ProjectContribution([
...npmCordisConfigEntry(ID, {
id: 'llm-deepseek',
name: '@deepseek-ai/dsh-llm-deepseek',
config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') },
}, ['apiKey', 'baseURL', 'models']),
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
])
}
}
class CustomOption extends FeatureOption {
override readonly id = 'custom'
override readonly label = 'Custom endpoint (pi-ai)'
override readonly secrets = [{
id: 'apiKey',
environment: 'DEEPSEEK_API_KEY',
message: 'Custom provider API key',
required: true,
}]
override readonly inputs = [{
id: 'baseURL',
message: 'Custom provider base URL',
}]
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
return new ProjectContribution([
...npmCordisConfigEntry(ID, {
id: 'llm-pi-ai',
name: '@deepseek-ai/dsh-llm-pi-ai',
config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') },
}, ['apiKey', 'baseURL', 'models']),
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
])
}
}
/** Required provider feature with DeepSeek and custom pi-ai options. */
export class ProviderFeature extends ExclusiveOptionFeature {
override readonly id = ID
override readonly summary = 'Model provider'
override readonly required = true
override readonly options = [new DeepSeekOption(), new CustomOption()]
/** Prefer the hand-rolled adapter and its public endpoint defaults. */
override defaultOptions(): readonly string[] {
return ['deepseek']
}
/** Recover literal endpoint overrides from either provider entry. */
override readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection {
const base = super.readSelection(project, selection)
const entry = project.cordisConfigEntries().find(item => item.id === 'llm-deepseek' || item.id === 'llm-pi-ai')
const baseURL = entry?.config?.baseURL
return typeof baseURL === 'string' ? { ...base, values: { baseURL } } : base
}
/** Apply explicit endpoint/model overrides while omitting provider defaults. */
override contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution {
const contribution = super.contribution(selection, profile)
const baseURL = selection.values?.baseURL
if (baseURL !== undefined && typeof baseURL !== 'string') throw new Error('provider baseURL must be a string')
return new ProjectContribution(contribution.resources.map((resource) => {
if (resource.kind !== 'cordis-config-entry' || (resource.entry.id !== 'llm-deepseek'
&& resource.entry.id !== 'llm-pi-ai')) return resource
return {
...resource,
entry: {
...resource.entry,
config: {
...resource.entry.config,
...baseURL ? { baseURL } : {},
...profile.runtime.model === DEFAULT_MODEL ? {} : { models: [profile.runtime.model] },
},
},
}
}))
}
}
@@ -0,0 +1,55 @@
/**
* Required agent-spine feature expressed as top-level Cordis config entries.
*
* @module @deepseek-ai/dsh-helper/features/builtin/spine
*/
import { featureId } from '../../ids.ts'
import type { ProjectProfile } from '../../project/types.ts'
import { loadHelperTemplate } from '../../templates/template-assets.ts'
import { FeatureOption, FixedFeature } from '../feature.ts'
import { ProjectContribution } from '../resources.ts'
import { npmCordisConfigEntry, requiredString } from './helpers.ts'
const ID = featureId('spine')
const PERSONA = loadHelperTemplate<Record<string, never>>('persona.txt.tpl').render({}).trimEnd()
function emptyAgentsDiagnostics(config: Readonly<Record<string, unknown>>): string[] {
const agents = config.agents
if (!Array.isArray(agents)) return ['agents must be an array']
return agents.length === 0 ? [] : ['agents must be empty']
}
class SpineOption extends FeatureOption {
override readonly id = 'default'
override readonly label = 'Default agent spine'
override contribution(_profile: ProjectProfile): ProjectContribution {
return new ProjectContribution([
...npmCordisConfigEntry(ID, { id: 'timer', name: '@cordisjs/plugin-timer' }),
...npmCordisConfigEntry(ID, { id: 'llm', name: '@deepseek-ai/dsh-llm' }),
...npmCordisConfigEntry(ID, { id: 'session', name: '@deepseek-ai/dsh-session' }),
...npmCordisConfigEntry(ID, {
id: 'system-prompt',
name: '@deepseek-ai/dsh-system-prompt',
config: { persona: PERSONA },
}, ['persona'], config => requiredString(config, 'persona')),
...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []),
...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }),
...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }),
...npmCordisConfigEntry(ID, {
id: 'agent-loop',
name: '@deepseek-ai/dsh-agent-loop',
config: { agents: [] },
}, ['agents'], emptyAgentsDiagnostics),
])
}
}
/** Required providerless agent spine without a composition bundle entry. */
export class SpineFeature extends FixedFeature {
override readonly id = ID
override readonly summary = 'Agent runtime spine'
override readonly required = true
override readonly options = [new SpineOption()]
}
@@ -0,0 +1,286 @@
/**
* Typed declarative definitions for features whose behavior is entirely
* the shared resource lifecycle.
*
* @module @deepseek-ai/dsh-helper/features/define-feature
*/
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import { TextProjectFile } from '../documents/project-file.ts'
import { featureId, resourceKey, type FeatureId } from '../ids.ts'
import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts'
import {
Feature,
FeatureOption,
type FeatureRequirement,
type FeatureSecret,
} from './feature.ts'
import { ProjectContribution, type ProjectResource } from './resources.ts'
/** Static NPM dependency in a declarative feature. */
interface NpmDependencySpec {
kind: 'npm-dependency'
name: string
section?: 'dependencies' | 'devDependencies'
}
/** Bare-package Cordis config entry that also contributes its NPM dependency. */
interface NpmCordisConfigEntrySpec {
kind: 'npm-cordis-config-entry'
id: string
package: string
config?: Readonly<Record<string, unknown>>
ownedConfigKeys?: readonly string[]
commentedExample?: string
}
/** Relative or absolute file Cordis config entry with no NPM dependency. */
interface FileCordisConfigEntrySpec {
kind: 'file-cordis-config-entry'
id: string
path: string
config?: Readonly<Record<string, unknown>>
ownedConfigKeys?: readonly string[]
commentedExample?: string
}
/** Static complete file owned by one feature option. */
interface OwnedFileSpec {
kind: 'owned-file'
path: string
text: string
removeOnlyWhenUnchanged?: boolean
}
/** Resource forms that require no feature-specific imperative code. */
type FeatureResourceSpec =
| NpmDependencySpec
| NpmCordisConfigEntrySpec
| FileCordisConfigEntrySpec
| OwnedFileSpec
/** Cordis config entry identity and optional plugin-config subset that identifies an option. */
interface FeatureOptionMarkerSpec {
id: string
name: string
config?: Readonly<Record<string, unknown>>
}
/** Declarative requirement converted to branded domain identity at the boundary. */
interface FeatureRequirementSpec {
id: string
options?: readonly string[]
}
/** One static option inside a typed feature definition. */
interface FeatureOptionSpec {
id: string
label: string
default?: boolean
resources: readonly FeatureResourceSpec[]
secrets?: readonly FeatureSecret[]
markers?: readonly FeatureOptionMarkerSpec[]
requires?: readonly FeatureRequirementSpec[]
}
/** Complete declarative feature definition. */
export interface FeatureSpec {
id: string
summary: string
mode: 'single' | 'exclusive' | 'multiple'
options: readonly FeatureOptionSpec[]
baseResources?: readonly FeatureResourceSpec[]
required?: boolean
requires?: readonly FeatureRequirementSpec[]
suggests?: readonly string[]
supportedInterfaces?: readonly RunInterface[]
}
function sameShape(expected: unknown, actual: unknown): boolean {
if (expected === null || actual === null) return expected === actual
if (Array.isArray(expected)) {
return Array.isArray(actual) && (expected.length === 0 || actual.every(item => sameShape(expected[0], item)))
}
if (typeof expected !== 'object') return typeof expected === typeof actual
if (typeof actual !== 'object' || Array.isArray(actual)) return false
return Object.entries(expected as Record<string, unknown>).every(
([key, value]) => sameShape(value, (actual as Record<string, unknown>)[key]),
)
}
function configDiagnostics(
expected: Readonly<Record<string, unknown>> | undefined,
): ((config: Readonly<Record<string, unknown>>) => readonly string[]) | undefined {
if (!expected || Object.keys(expected).length === 0) return undefined
return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key])
? []
: [`${key} has an incompatible value shape`])
}
function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] {
switch (spec.kind) {
case 'npm-dependency':
return [{
kind: 'npm-dependency',
key: resourceKey(`npm-dependency:${spec.name}`),
name: spec.name,
section: spec.section ?? 'dependencies',
}]
case 'npm-cordis-config-entry':
case 'file-cordis-config-entry': {
const config = spec.config ? { ...spec.config } : undefined
const validateConfig = configDiagnostics(config)
const name = spec.kind === 'npm-cordis-config-entry' ? spec.package : spec.path
return [
...spec.kind === 'npm-cordis-config-entry'
? [{
kind: 'npm-dependency' as const,
key: resourceKey(`npm-dependency:${spec.package}`),
name: spec.package,
section: 'dependencies' as const,
}]
: [],
{
kind: 'cordis-config-entry',
key: resourceKey(`cordis-config-entry:${spec.id}`),
entry: {
id: spec.id,
name,
...config ? { config } : {},
},
ownedConfigKeys: spec.ownedConfigKeys ?? Object.keys(config ?? {}),
...spec.commentedExample ? { commentedExample: spec.commentedExample } : {},
...validateConfig ? { validateConfig } : {},
},
]
}
case 'owned-file':
return [{
kind: 'owned-file',
key: resourceKey(`file:${spec.path}`),
document: new TextProjectFile(spec.path, spec.text),
removeOnlyWhenUnchanged: spec.removeOnlyWhenUnchanged ?? true,
}]
}
}
function isSubset(expected: Readonly<Record<string, unknown>>, actual: Readonly<Record<string, unknown>>): boolean {
return Object.entries(expected).every(([key, value]) => Object.is(actual[key], value))
}
class DefinedFeatureOption extends FeatureOption {
override readonly id: string
override readonly label: string
override readonly secrets: readonly FeatureSecret[]
private readonly spec: FeatureOptionSpec
constructor(spec: FeatureOptionSpec) {
super()
this.spec = spec
this.id = spec.id
this.label = spec.label
this.secrets = spec.secrets ?? []
}
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
return new ProjectContribution([
...this.spec.resources.flatMap(resourcesFromSpec),
...this.secrets.map(secret => ({
kind: 'environment' as const,
key: resourceKey(`environment:${secret.environment}`),
name: secret.environment,
...secrets[secret.id] === undefined ? {} : { value: secrets[secret.id] },
exampleValue: '',
})),
])
}
override markerConfigEntries(): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
const markers = this.spec.markers ?? this.spec.resources.flatMap((resource) => {
switch (resource.kind) {
case 'npm-cordis-config-entry': return [{ id: resource.id, name: resource.package }]
case 'file-cordis-config-entry': return [{ id: resource.id, name: resource.path }]
default: return []
}
})
return markers.map(marker => ({ id: marker.id, name: marker.name }))
}
override matchesConfigEntries(entries: readonly CordisConfigEntry[]): boolean {
const markers = this.spec.markers
if (!markers) return this.markerConfigEntries().some(marker => entries.some(
entry => entry.id === marker.id && entry.name === marker.name,
))
return markers.some(marker => entries.some(entry => entry.id === marker.id
&& entry.name === marker.name
&& (!marker.config || isSubset(marker.config, entry.config ?? {}))))
}
}
/** Feature entity backed by a typed static definition. */
class DefinedFeature extends Feature {
override readonly id: FeatureId
override readonly summary: string
override readonly mode: FeatureSpec['mode']
override readonly options: readonly FeatureOption[]
override readonly required: boolean
override readonly requires: readonly FeatureId[]
override readonly suggests: readonly FeatureId[]
override readonly supportedInterfaces: readonly RunInterface[]
private readonly spec: FeatureSpec
/** Validate and materialize one declarative definition. */
constructor(spec: FeatureSpec) {
super()
this.spec = spec
this.id = featureId(spec.id)
this.summary = spec.summary
this.mode = spec.mode
this.options = spec.options.map(option => new DefinedFeatureOption(option))
const defaultCount = spec.options.filter(option => option.default).length
if (spec.mode === 'single' && (spec.options.length !== 1 || defaultCount !== 1)) {
throw new Error(`single feature ${spec.id} requires one default option`)
}
if (spec.mode === 'exclusive' && defaultCount !== 1) {
throw new Error(`exclusive feature ${spec.id} requires exactly one default option`)
}
if (spec.mode === 'multiple' && defaultCount === 0) {
throw new Error(`multiple feature ${spec.id} requires at least one default option`)
}
this.required = spec.required ?? false
this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id))
this.suggests = (spec.suggests ?? []).map(featureId)
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'stdio', 'embed']
}
override defaultOptions(): readonly string[] {
return this.spec.options.filter(option => option.default).map(option => option.id)
}
override baseContribution(): ProjectContribution {
return new ProjectContribution((this.spec.baseResources ?? []).flatMap(resourcesFromSpec))
}
override requirements(selection: FeatureSelection): readonly FeatureRequirement[] {
const selected = new Set(selection.options)
return [
...(this.spec.requires ?? []),
...this.spec.options.filter(option => selected.has(option.id)).flatMap(option => option.requires ?? []),
].map(requirement => ({
id: featureId(requirement.id),
...requirement.options ? { options: requirement.options } : {},
}))
}
}
/** Construct the shared lifecycle entity from a typed declarative definition. */
export function defineFeature(spec: FeatureSpec): Feature {
return new DefinedFeature(spec)
}
/** Materialize one ordered catalog containing static specs and behavior entities. */
export function defineFeatures(definitions: readonly (Feature | FeatureSpec)[]): Feature[] {
return definitions.map(definition => definition instanceof Feature
? definition
: defineFeature(definition))
}
@@ -0,0 +1,104 @@
/**
* Shared option and secret question flow for create and config.
*
* @module @deepseek-ai/dsh-helper/features/feature-configurator
*/
import type { Feature } from './feature.ts'
import type { FeatureSelection, ProjectProfile } from '../project/types.ts'
import type { PromptPort } from '../questions/prompt-port.ts'
import { requireAnswer } from '../questions/prompt-port.ts'
import { MultiSelectQuestion, SecretQuestion, SelectQuestion, TextQuestion } from '../questions/question.ts'
/** Resolve one feature selection without knowing which workflow requested it. */
export class FeatureConfigurator {
private readonly port: PromptPort
/** Bind the configurator to the shared prompt boundary. */
constructor(port: PromptPort) {
this.port = port
}
/**
* Ask option and input questions, preserving current secrets on empty input.
* @param feature - feature whose options and inputs are collected.
* @param profile - target project context.
* @param current - currently installed selection, when configuring.
* @param prefilledOptions - options already chosen by a tree picker.
* @param prefilledSecrets - non-interactive secret values supplied by creation.
* @returns normalized selection with captured values and secrets.
*/
async configure(
feature: Feature,
profile: ProjectProfile,
current?: FeatureSelection,
prefilledOptions?: readonly string[],
prefilledSecrets: Readonly<Record<string, string>> = {},
): Promise<FeatureSelection> {
let options: readonly string[]
switch (feature.mode) {
case 'single':
options = feature.defaultOptions(profile)
break
case 'exclusive': {
const initialValue = current?.options[0] ?? feature.defaultOptions(profile)[0]
if (initialValue === undefined) throw new Error(`feature ${feature.id} has no default option`)
const question = new SelectQuestion({
id: `${feature.id}.option`,
message: `Choose ${feature.summary.toLowerCase()}`,
options: feature.options.map(option => ({ value: option.id, label: option.label })),
initialValue,
})
const prefilled = prefilledOptions?.[0]
options = [requireAnswer(await question.resolve(this.port, prefilled))]
break
}
case 'multiple': {
const question = new MultiSelectQuestion({
id: `${feature.id}.options`,
message: `Choose ${feature.summary.toLowerCase()}`,
options: feature.options.map(option => ({ value: option.id, label: option.label })),
initialValues: current?.options ?? feature.defaultOptions(profile),
required: true,
})
options = requireAnswer(await question.resolve(this.port, prefilledOptions))
break
}
}
const selected: FeatureSelection = {
id: feature.id,
options,
}
const values: Record<string, string> = {}
for (const input of feature.valueInputs(selected, profile)) {
const existing = current?.values?.[input.id]
if (existing !== undefined && typeof existing !== 'string') {
throw new Error(`${feature.id}.${input.id} current value must be a string`)
}
const question = new TextQuestion({
id: `${feature.id}.${input.id}`,
message: input.message,
...existing === undefined ? {} : { initialValue: existing },
validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
})
values[input.id] = requireAnswer(await question.resolve(this.port))
}
const base: FeatureSelection = Object.keys(values).length === 0
? selected
: { ...selected, values }
const secrets = { ...current?.secrets }
for (const secret of feature.secrets(base, profile)) {
const existing = secrets[secret.id]
const question = new SecretQuestion({
id: `${feature.id}.${secret.id}`,
message: existing === undefined ? secret.message : `${secret.message} (leave empty to keep current)`,
validate: value => secret.required && existing === undefined && value.length === 0
? 'A value is required'
: undefined,
})
const answer = requireAnswer(await question.resolve(this.port, prefilledSecrets[secret.id]))
if (answer.length > 0) secrets[secret.id] = answer
}
return Object.keys(secrets).length === 0 ? base : { ...base, secrets }
}
}
+345
View File
@@ -0,0 +1,345 @@
/**
* Stateful builtin feature and option domain objects.
*
* @module @deepseek-ai/dsh-helper/features/feature
*/
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import type { PackageManifest } from '../documents/package-json-file.ts'
import type { FeatureId } from '../ids.ts'
import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts'
import { ProjectContribution, type CordisConfigEntryResource, type ProjectResource } from './resources.ts'
/** Read-only project surface used by feature inspection. */
export interface FeatureProjectView {
readonly profile: ProjectProfile
cordisConfigEntries(): readonly CordisConfigEntry[]
packageManifest(): Readonly<PackageManifest>
hasDocument(path: string): boolean
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined
}
/** Installation state visible to create/config workflows. */
type FeatureInstallationState = 'absent' | 'enabled' | 'disabled' | 'inconsistent'
/** Result of round-tripping one feature from a project snapshot. */
export interface FeatureInstallation {
id: FeatureId
state: FeatureInstallationState
options: readonly string[]
selection?: FeatureSelection
diagnostics: readonly string[]
}
/** One final-state requirement on another builtin feature. */
export interface FeatureRequirement {
id: FeatureId
options?: readonly string[]
}
/** One secret captured into an environment binding rather than Cordis plugin config. */
export interface FeatureSecret {
id: string
environment: string
message: string
required: boolean
}
/** One visible string value requested only by options that own it. */
export interface FeatureValueInput {
id: string
message: string
}
/** One selectable behavior option owned by a feature. */
export abstract class FeatureOption {
abstract readonly id: string
abstract readonly label: string
readonly secrets: readonly FeatureSecret[] = []
readonly inputs: readonly FeatureValueInput[] = []
/** Contribute this option's project resources. */
abstract contribution(profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution
/** Every Cordis config entry package owned by this option during inspection. */
ownedConfigEntries(profile: ProjectProfile): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
return this.contribution(profile, {}).resources
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
.map(resource => ({ id: resource.entry.id, name: resource.entry.name }))
}
/** Cordis config entry identities that distinguish this option during inspection. */
markerConfigEntries(profile: ProjectProfile): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
return this.ownedConfigEntries(profile)
}
/** Whether current owned Cordis config entries identify this option. */
matchesConfigEntries(entries: readonly CordisConfigEntry[], profile: ProjectProfile): boolean {
return this.markerConfigEntries(profile).some(marker => entries.some(
entry => entry.id === marker.id && entry.name === marker.name,
))
}
}
/** How a feature's options compose. */
export type FeatureOptionMode = 'single' | 'exclusive' | 'multiple'
function packageNames(resources: readonly ProjectResource[]): Set<string> {
return new Set(resources
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
.map(resource => resource.entry.name))
}
function configDiagnostics(resource: CordisConfigEntryResource, entry: CordisConfigEntry): string[] {
/* v8 ignore next -- entries without validators have no diagnostics to compute */
if (!resource.validateConfig) return []
return [...resource.validateConfig(entry.config ?? {})].map(message => `${entry.id}: ${message}`)
}
/** A behavior-owning builtin feature with shallow option composition. */
export abstract class Feature {
/** Stable registry identity. */
abstract readonly id: FeatureId
/** User-facing feature summary. */
abstract readonly summary: string
/** Option-selection rule. */
abstract readonly mode: FeatureOptionMode
/** Available behavior options. */
abstract readonly options: readonly FeatureOption[]
/** Whether every valid project must enable this feature. */
readonly required: boolean = false
/** Unconditional feature requirements. */
readonly requires: readonly FeatureId[] = []
/** Features recommended during creation. */
readonly suggests: readonly FeatureId[] = []
/** Front doors under which this feature is meaningful. */
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'stdio', 'embed']
/**
* Options selected when installation has no override.
* @param profile - project context controlling applicable defaults.
* @returns selected option ids.
*/
abstract defaultOptions(profile: ProjectProfile): readonly string[]
/**
* Shared resources present for every installed option set.
* @param _profile - project context available to behavior features.
* @returns shared project contribution.
*/
baseContribution(_profile: ProjectProfile): ProjectContribution {
return new ProjectContribution([])
}
/**
* Additional final-state requirements depending on selected options.
* @param _selection - normalized feature selection.
* @returns required features and option constraints.
*/
requirements(_selection: FeatureSelection): readonly FeatureRequirement[] {
return this.requires.map(id => ({ id }))
}
/**
* Whether the feature may be selected for this project front door.
* @param profile - project context to check.
* @returns whether the feature applies.
*/
isApplicable(profile: ProjectProfile): boolean {
return this.supportedInterfaces.includes(profile.runInterface)
}
/**
* Validate and normalize one requested option set.
* @param selection - requested feature and options.
* @param profile - project context for applicability and defaults.
* @returns deduplicated, sorted selection.
*/
normalizeSelection(selection: FeatureSelection, profile: ProjectProfile): FeatureSelection {
if (selection.id !== this.id) throw new Error(`selection ${selection.id} does not belong to feature ${this.id}`)
if (!this.isApplicable(profile)) {
throw new Error(`feature ${this.id} is not available for ${profile.runInterface}`)
}
const available = new Set(this.options.map(option => option.id))
const options = [...new Set(selection.options.length > 0 ? selection.options : this.defaultOptions(profile))]
for (const option of options) {
if (!available.has(option)) throw new Error(`unknown ${this.id} option: ${option}`)
}
if (this.mode === 'single' && (options.length !== 1 || this.options.length !== 1)) {
throw new Error(`feature ${this.id} has one fixed option`)
}
if (this.mode === 'exclusive' && options.length !== 1) {
throw new Error(`feature ${this.id} requires exactly one option`)
}
if (this.mode === 'multiple' && options.length === 0) {
throw new Error(`feature ${this.id} requires at least one option`)
}
return { ...selection, options: options.sort() }
}
/**
* Build the complete selected resource contribution.
* @param selection - selected options and captured inputs.
* @param profile - target project context.
* @returns merged base and option resources.
*/
contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution {
const normalized = this.normalizeSelection(selection, profile)
const selected = this.selectedOptions(normalized)
.map(option => option.contribution(profile, normalized.secrets ?? {}))
return ProjectContribution.merge(this.baseContribution(profile), ...selected)
}
/**
* All secret definitions required by one selected option set.
* @param selection - selected options.
* @param profile - target project context.
* @returns selected secret definitions.
*/
secrets(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureSecret[] {
const normalized = this.normalizeSelection(selection, profile)
return this.selectedOptions(normalized).flatMap(option => option.secrets)
}
/**
* All visible value definitions required by one selected option set.
* @param selection - selected options.
* @param profile - target project context.
* @returns selected visible-input definitions.
*/
valueInputs(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureValueInput[] {
const normalized = this.normalizeSelection(selection, profile)
return this.selectedOptions(normalized).flatMap(option => option.inputs)
}
private selectedOptions(selection: FeatureSelection): readonly FeatureOption[] {
return selection.options.map((id) => {
const option = this.options.find(candidate => candidate.id === id)
/* v8 ignore next -- normalizeSelection already membership-checks every selected id */
if (!option) throw new Error(`unknown ${this.id} option: ${id}`)
return option
})
}
/**
* Recover input and secret values after structural inspection.
* @param project - project snapshot being inspected.
* @param selection - structurally detected selection.
* @returns selection enriched with readable values.
*/
readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection {
const secrets = Object.fromEntries(this.secrets(selection, project.profile).flatMap((secret) => {
const value = project.readEnvironment('.env', secret.environment)
return value === undefined ? [] : [[secret.id, value]]
}))
return Object.keys(secrets).length === 0 ? selection : { ...selection, secrets }
}
/**
* Inspect current files and reject any partial or ambiguous owned shape.
* @param project - project snapshot to inspect.
* @returns installation state, selection, and diagnostics.
*/
inspect(project: FeatureProjectView): FeatureInstallation {
const profile = project.profile
const allPackages = new Set<string>()
for (const option of this.options) {
for (const entry of option.ownedConfigEntries(profile)) allPackages.add(entry.name)
}
for (const name of packageNames(this.baseContribution(profile).resources)) allPackages.add(name)
const configEntries = project.cordisConfigEntries()
const ownedConfigEntries = configEntries.filter(entry => allPackages.has(entry.name))
const options = this.options
.filter(option => option.matchesConfigEntries(configEntries, profile))
.map(option => option.id)
if (ownedConfigEntries.length === 0 && options.length === 0) {
return { id: this.id, state: 'absent', options: [], diagnostics: [] }
}
let selection: FeatureSelection
try {
selection = this.normalizeSelection({ id: this.id, options }, profile)
} catch (error) {
return { id: this.id, state: 'inconsistent', options, diagnostics: [String(error)] }
}
selection = this.readSelection(project, selection)
const expected = this.contribution(selection, profile)
const expectedEntries = expected.resources
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
const diagnostics: string[] = []
for (const resource of expectedEntries) {
const actual = ownedConfigEntries.find(entry => entry.id === resource.entry.id && entry.name === resource.entry.name)
if (!actual) diagnostics.push(`missing Cordis config entry ${resource.entry.id} (${resource.entry.name})`)
else diagnostics.push(...configDiagnostics(resource, actual))
}
for (const actual of ownedConfigEntries) {
if (!expectedEntries.some(resource => resource.entry.id === actual.id && resource.entry.name === actual.name)) {
diagnostics.push(`unexpected owned Cordis config entry ${actual.id} (${actual.name})`)
}
}
const manifest = project.packageManifest()
for (const resource of expected.resources) {
switch (resource.kind) {
case 'npm-dependency':
if (!manifest[resource.section]?.[resource.name]) {
diagnostics.push(`missing package.json ${resource.section} entry ${resource.name}`)
}
break
case 'package-script':
if (!manifest.scripts?.[resource.name]) {
diagnostics.push(`missing package.json script ${resource.name}`)
}
break
case 'owned-file':
if (!project.hasDocument(resource.document.relativePath)) diagnostics.push(`missing owned file ${resource.document.relativePath}`)
break
case 'environment':
try {
if (project.readEnvironment('.env.example', resource.name) === undefined) {
diagnostics.push(`missing .env.example variable ${resource.name}`)
}
} catch (error) {
diagnostics.push(String(error))
}
break
case 'cordis-config-entry': break
}
}
const disabled = ownedConfigEntries.map(entry => entry.disabled === true)
if (disabled.some(Boolean) && disabled.some(value => !value)) {
diagnostics.push('owned Cordis config entries have mixed enabled states')
}
if (diagnostics.length > 0) {
return { id: this.id, state: 'inconsistent', options, diagnostics }
}
return {
id: this.id,
state: ownedConfigEntries.length > 0 && disabled.every(Boolean) ? 'disabled' : 'enabled',
options,
selection,
diagnostics: [],
}
}
}
/** Fixed one-option feature base. */
export abstract class FixedFeature extends Feature {
override readonly mode = 'single'
/** Select the sole option. */
override defaultOptions(): readonly string[] {
const option = this.options[0]
if (!option) throw new Error(`simple feature ${this.id} has no option`)
return [option.id]
}
}
/** Mutually exclusive option feature base. */
export abstract class ExclusiveOptionFeature extends Feature {
override readonly mode = 'exclusive'
}
/** Additive multi-option feature base. */
export abstract class MultiOptionFeature extends Feature {
override readonly mode = 'multiple'
}
@@ -0,0 +1,87 @@
/**
* Builtin feature registry and definition-time conflict checks.
*
* @module @deepseek-ai/dsh-helper/features/registry
*/
import type { FeatureId, ResourceKey } from '../ids.ts'
import type { ProjectProfile } from '../project/types.ts'
import type { Feature, FeatureProjectView } from './feature.ts'
import type { CordisConfigEntryResource } from './resources.ts'
/** Compile-time builtin feature collection. */
export class FeatureRegistry {
private readonly features = new Map<FeatureId, Feature>()
/** Register and validate a complete builtin set. */
constructor(features: readonly Feature[], validationProfile: ProjectProfile) {
const owners = new Map<ResourceKey, FeatureId>()
for (const feature of features) {
if (this.features.has(feature.id)) throw new Error(`duplicate feature id: ${feature.id}`)
this.features.set(feature.id, feature)
const validationInterface = feature.supportedInterfaces[0]
if (!validationInterface) throw new Error(`feature ${feature.id} supports no run interface`)
const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] }))
for (const selection of selections) {
const contribution = feature.contribution(selection, {
...validationProfile,
runInterface: validationInterface,
})
for (const resource of contribution.resources) {
const owner = owners.get(resource.key)
if (owner && owner !== feature.id) {
throw new Error(`resource ${resource.key} is declared by both ${owner} and ${feature.id}`)
}
owners.set(resource.key, feature.id)
}
}
}
}
/**
* Return all builtins in display order.
* @returns all registered features.
*/
all(): readonly Feature[] {
return [...this.features.values()]
}
/**
* Resolve one builtin or fail loud.
* @param id - stable feature identity.
* @returns registered feature.
*/
get(id: FeatureId): Feature {
const feature = this.features.get(id)
if (!feature) throw new Error(`unknown feature: ${id}`)
return feature
}
/**
* Inspect every applicable builtin in display order.
* @param project - project view to inspect.
* @returns installation snapshots for applicable features.
*/
inspect(project: FeatureProjectView): ReturnType<Feature['inspect']>[] {
return this.all()
.filter(feature => feature.isApplicable(project.profile))
.map(feature => feature.inspect(project))
}
/**
* Resolve the builtin that owns a Cordis package name for this profile.
* @param name - Loader package name.
* @param profile - project context controlling applicability.
* @returns owning feature, if the package is builtin-owned.
*/
ownerOfPackage(name: string, profile: ProjectProfile): Feature | undefined {
return this.all().find((feature) => {
if (!feature.isApplicable(profile)) return false
const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] }))
return selections.some(selection => feature.contribution(selection, profile).resources.some(
(resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry'
&& resource.entry.name === name,
))
})
}
}
@@ -0,0 +1,97 @@
/**
* Resource vocabulary contributed by builtin SDK features.
*
* @module @deepseek-ai/dsh-helper/features/resources
*/
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import type { ProjectFile } from '../documents/project-file.ts'
import type { ResourceKey } from '../ids.ts'
/** Runtime or development NPM dependency contribution. */
export interface NpmDependencyResource {
kind: 'npm-dependency'
key: ResourceKey
name: string
section: 'dependencies' | 'devDependencies'
}
/** Feature-owned package script. */
export interface PackageScriptResource {
kind: 'package-script'
key: ResourceKey
name: string
command: string
removeOnlyWhenUnchanged: boolean
}
/** Owned Cordis config entry plus the config keys safe to update in place. */
export interface CordisConfigEntryResource {
kind: 'cordis-config-entry'
key: ResourceKey
entry: CordisConfigEntry
ownedConfigKeys: readonly string[]
commentedExample?: string
validateConfig?: (config: Readonly<Record<string, unknown>>) => readonly string[]
}
/** Environment variable reference and dotenv material. */
export interface EnvironmentResource {
kind: 'environment'
key: ResourceKey
name: string
value?: string
exampleValue: string
comment?: string
}
/** Feature-exclusive complete file. */
export interface OwnedFileResource {
kind: 'owned-file'
key: ResourceKey
document: ProjectFile
removeOnlyWhenUnchanged: boolean
}
/** Any resource a feature can add to a project. */
export type ProjectResource =
| NpmDependencyResource
| PackageScriptResource
| CordisConfigEntryResource
| EnvironmentResource
| OwnedFileResource
/** Complete resource contribution for one selected feature state. */
export class ProjectContribution {
readonly resources: readonly ProjectResource[]
/** Validate and retain one feature-owned resource set. */
constructor(resources: readonly ProjectResource[]) {
const seen = new Set<ResourceKey>()
for (const resource of resources) {
if (seen.has(resource.key)) throw new Error(`duplicate contribution resource key: ${resource.key}`)
seen.add(resource.key)
}
this.resources = resources
}
/** Merge base and option contributions by stable key. */
static merge(...contributions: readonly ProjectContribution[]): ProjectContribution {
const resources = new Map<ResourceKey, ProjectResource>()
for (const contribution of contributions) {
for (const resource of contribution.resources) {
const previous = resources.get(resource.key)
if (previous && JSON.stringify(previous) !== JSON.stringify(resource)) {
throw new Error(`resource ${resource.key} has conflicting definitions inside one feature`)
}
resources.set(resource.key, resource)
}
}
return new ProjectContribution([...resources.values()])
}
/** Index resources by stable key. */
byKey(): ReadonlyMap<ResourceKey, ProjectResource> {
return new Map(this.resources.map(resource => [resource.key, resource]))
}
}