description: "The user-settings service for plugin authors and maintainers registering configurable namespaces, reading resolved values, or wiring configuration surfaces."
English | 中文
dsh-settings lets plugins expose configuration that users can change at runtime: a plugin registers a namespace with a schema, and the resolved value honors schema defaults, the deployment's own composition base, and the user-edited document section — with user overrides winning. Consumers read a snapshot of the resolved value and are notified of every committed change; configuration surfaces get one descriptor per namespace — schema, current value, which layer each field came from, effect timing — without touching storage directly. Writes change only the user overrides, run one at a time per namespace, and can carry an expected revision so a stale writer is refused instead of silently overwriting a newer one. A provider must be mounted to store the document; without one, nothing changes and configuration stays exactly as composed.
Plugins and configuration surfaces use ctx.settings to read and change configuration at runtime. The common path: mount a provider, register a namespace with a schema, read and watch the resolved value, and write through the owner scope.
Choose settings when a plugin's configuration should be changeable at runtime — by the user editing a document or by a configuration UI — without restarting or re-reading cordis.yml. It fits when several plugins each own one configuration namespace, and when a configuration surface must render schemas, mark user-overridden fields, and persist edits. It is unnecessary when configuration is fixed at load time: without a provider mounted, nothing changes and configuration stays exactly as composed.
The service stores nothing by itself; mount a provider such as the shipped file-backed one:
- name: '@deepseek-ai/dsh-settings-file'
config:
path: /absolute/path/to/settings.yaml
ctx.settings appears once the provider is live. The provider README owns the full configuration surface; the generated configuration catalog lists every accepted field.
A plugin registers its own namespace with a schemastery schema, optionally supplying the composition entry as the base layer so the resolved value starts from what the deployment already configured:
const scope = ctx.settings.register('ui-theme', ThemeSchema, {
base: config, // composition entry config; the user layer resolves above it
})
const theme = scope.get() // deep-frozen resolved snapshot
scope.update({ density: 'compact' }) // merges into the user section and persists
Literal namespace arguments are checked by TypeScript against the lowercase letter, digit, and hyphen grammar; dynamically supplied strings receive the same validation at runtime. ctx.settings.installSection(owner, ns, schema, entry, hooks) packages the optional-service wiring for a consumer plugin: while a settings service exists it registers the namespace with the plugin's composition entry as base; when the service goes away the plugin falls back to its entry config and keeps working exactly as composed.
get(ns) returns the resolved value as a deep-frozen snapshot, undefined while the namespace is unregistered. watch(callback) invokes the callback after each committed change with (next, prev): invocations of one callback run one at a time in commit order, and failures are contained and logged, so a slow or throwing observer never blocks or breaks other observers.
update(ns, patch) deep-merges a plain-object patch into the user section only — never into base — validates the resolved candidate, persists through the provider, then commits. replace(ns, section) sets the user section wholesale, which is the removal/reset path: replace({}) re-inherits base and schema defaults. mutate(ns, ops) applies ordered { op: 'set' | 'unset', path } edits to the section as it stands when the write reaches the front of the queue — the removal path for a caller holding an incomplete (for example redacted) view, because rebuilding a section from what a wire surface returned and replacing it wholesale would delete every field the wire never sent back.
Every write rejects non-JSON-compatible data (a Date, Map, BigInt, non-finite number, or circular reference fails with its $-rooted path before anything persists), rejects on a read-only provider, and accepts an optional expectedRevision: pass back the revision from a descriptor, and a namespace that moved past it refuses the write with SettingsConflictError instead of overwriting the writer that landed first.
describe() returns one descriptor per registered namespace: the serialized schema, the resolved value, the detached base and user layers (a field's presence in user marks it user-overridden), the effect timing, and the namespace's revision. Pass redactSecrets: true on every wire surface: it strips role('secret') fields from every layer and enumerates them as { path, set } slots so a page can render write-only inputs without ever receiving a secret. documentPath and prepareDocument() expose the provider's user-editable file to a native editor when one exists.
settings/updated (ns, next, prev, source) fires after each committed change — an in-process write (source: 'update') or an externally observed edit (source: 'provider') — and never when the resolved value is deep-equal. settings/document-updated (ns, revision) fires whenever the raw user section changed, even when the resolved value did not, which is what an open editor needs to learn that a field went from inherited to overridden. A stored section the schema rejects keeps the namespace's last good value and warns on reload; at registration the same failure rejects the registration itself.
Read these pages when the service-level contract is not enough. They move from the shared subsystem vocabulary to the shipped provider and the capability architecture.
Indirectly, through consumer plugins, which own any model-facing content fed by a settings value; the service only stores and resolves user settings and registers nothing model-facing itself.
No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.
These limits define when the service is a poor fit or needs special care. They are current package constraints, not a task backlog.
base, and one user document; it does not record which layer supplied each resolved value.redactSecrets is not a proven wire boundary — the walker follows object/dict/array containers, so a role('secret') field reachable only through a union, intersection, or transform is returned verbatim with an empty secrets list, and the serialized schema carries a secret field's default to every client. Neither case is rejected; a schema whose secrets are not reachable through the walked containers must not be registered on a wire-exposed namespace. A fail-closed describeForWire() — one that refuses a schema it cannot prove safe and sanitizes the serialized envelope and error text — is the deferred answer.