In the deployment practice of DeepSeek Harness (hereafter dsh), the most frequently asked question is not "how do I write a plugin," but rather "I clearly configured it, so why is the value taking effect not the one I configured." Behind this question actually lie three chains: how a plugin exposes injectable fields through the Config interface + a Schemastery schema of the same name; how the two manifests—the bundle and the profile—each play their own role; and when patches from multiple layers are stacked, in what order the final effective configuration is determined. This article is the first of 1/2 segments of "Plugin Configuration and Bundle / Profile Layering: Where Does dsh's Effective Configuration Actually Come From," and it first explains the first half of "where configuration comes from" thoroughly: the field mapping from cordis.yml to the plugin apply, why default values are written on schema fields, where the boundary between configuration and code separation lies, what questions the dsh keys of bundle and profile each answer, and why profile is placed outside the installation directory. The second half will continue with installation into profile, load-order layering, pnpm forwarding, and the complete chain of automatically maintaining bundles. Only by understanding this segment can you, when "I changed the configuration and it didn't take effect," have your first reaction be to locate the layer rather than repeatedly changing code.
From cordis.yml to Plugin apply: How the Config Interface Defines Injectable Fields
dsh's plugin model is built on top of cordis's dependency injection container. When a plugin is loaded, the container calls the apply function it exports and passes in two parameters: the first is the Context (the context visible to the current plugin, used to access services, register commands, mount child plugins, etc.), and the second is the protagonist of this article—the validated configuration object. In other words, the keys you write in cordis.yml will ultimately be parsed, validated, filled with default values, and then handed to you as the second parameter of apply. The endpoints of this chain are very clear: keys passed in by cordis.yml → the Config interface exported by the plugin → the second parameter of apply.
For this chain to hold, the plugin must export two things, and their names must be related: a Config interface and a Config schema of the same name. The former is the type contract for the TypeScript compiler to see, while the latter is the source of validation and default values for the runtime to see. Many people wonder the first time they write a plugin: why must the interface and the schema have the same name? Because dsh's loader needs to find the schema by name at runtime, while the TypeScript side needs to find the type by name; having the same name makes "type" and "runtime schema" two forms of the same symbol—the loader gets the schema for validation, and the editor gets the interface for completion, without conflict.
Let's look at a concrete Config interface. Suppose our plugin is called my-plugin, and it accepts three fields: greeting, maxRetries, and verbose. The interface can be written like this:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'my-plugin'
// Config interface: defines which configuration options the plugin accepts
export interface Config {
greeting: string // greeting message
maxRetries: number // maximum number of retries
verbose?: boolean // whether to output verbose logs (optional)
}
Note the difference in optionality among the three fields—this is the part of this section that is most easily overlooked and most prone to pitfalls. greeting and maxRetries are required fields (no question mark), meaning that at the type level, any value of type Config must provide them; verbose is an optional field (with a question mark), meaning the type allows it to be absent. But this "optional" does not mean "you can ignore it" at runtime—it only means the TS compiler won't throw an error just because you didn't write it. What actually determines "what happens when the user doesn't provide it" is the schema default value covered in the next section. Type-level optionality and runtime default-value filling are two separate things and must be understood independently.
There is also a mapping detail to note: the key names passed in via cordis.yml map directly to the field names of the Config interface. If you write greeting in the yml, the schema must have a greeting key; if you write max_retries (underscore style), it will not map to maxRetries, and the result will either be a validation failure or the field failing to get a value and falling back to the default. dsh does not perform automatic conversion between camelCase and underscores—this differs from some frameworks, and configuration keys must align exactly with field names.
So how do you use these configs in apply? Very directly:
export function apply(ctx: Context, config: Config) {
// Prints the value passed in by the user or the schema default
console.log(config.greeting)
}
Here config is already the "merged result": if the user explicitly passed greeting, it is the user's value; if the user did not pass it, it is the default in the schema; if the user passed it but with the wrong type (for example, writing maxRetries as the string "3"), it will be intercepted by the schema before entering apply. In other words, what apply receives is always a fully populated, already validated, structurally complete configuration. Plugin authors do not need to write fallback logic like "if it wasn't passed, then..." inside apply—fallback handling is the schema's responsibility.
Why does this design matter from an engineering standpoint? Because it completely separates "the validity of configuration" from "business logic." Imagine an era without schema: plugin authors would write a pile of code like config.maxRetries ?? 3 and typeof config.verbose === 'boolean' ? config.verbose : false inside apply. Every plugin would do its own thing, error-handling styles would vary wildly, and behavior would be unpredictable when users passed the wrong types. Now all of this converges into a single place—the schema—and plugin authors only need to care about "what to do once they have valid configuration."
Once you understand this chain, there's another point that advanced readers often care about: the second argument of apply is configuration, so where did the "application arguments" go? This will be made clear later when we discuss loading order—application arguments are not another layer of patch; they are resolved through the application's own services. In other words, command-line arguments and configuration layering are two separate mechanisms, and you shouldn't conflate them. For now, you only need to remember: the config in apply(ctx, config) comes from the configuration layering system, not from something stuffed in directly by the command line.
Schemastery's same-name schema: why default values are written on fields rather than in apply
The previous section repeatedly mentioned "default values"; this section clarifies where they belong. The default values for dsh plugin configuration are written on the fields of the Config schema exported under the same name, not inside the apply function. The conventional way to write this is: export const Config: Schema<Config> = Schema.object({ ... }). Note the clever part here: the interface is named Config, and the constant is also named Config—the former is a type, the latter is a value, and TypeScript allows this kind of same-name type/value dual declaration. What the loader retrieves by name is that constant (the schema), while what the type system uses is that interface.
A complete schema looks roughly like this:
// 同名的 Config schema:默认值写在这里
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
maxRetries: Schema.number().default(3),
verbose: Schema.boolean().default(false),
})
Breaking down the information in this schema line by line:
- Schema.object({...}) declares that this is an object-type schema, and its set of keys is exactly the set of configuration fields the plugin accepts. Any key not in this object will not make it into the final config.
- Schema.string().default('Hello') declares that greeting is a string with a default value of 'Hello'. When the user does not specify greeting, config.greeting is 'Hello'; if the user provides a non-string (such as a number), validation will fail.
- Schema.number().default(3) declares that maxRetries is a number with a default value of 3. This is the concrete value given in the material, and it is also a very natural default setting for a retry-related field.
- Schema.boolean().default(false) declares that verbose is a boolean with a default value of false. Even though verbose is optional in the interface, the schema still gives it an explicit default value, so at runtime config.verbose is always true or false, never undefined.
Why must default values be written on the fields rather than in apply? There are several progressively deeper reasons, and the further you go, the more they become engineering-level concerns:
- Single source of truth. If default values are written in apply, then the question of "which configurations this plugin supports and what each one defaults to" gets scattered throughout the code logic, and users reading the docs, reading the schema, and reading the code have to piece together information from three places. When written on the schema, the schema is the sole authority, documentation can be generated automatically, and types can be inferred.
- Validation and filling must be completed before entering apply. apply is business logic, and once it starts executing, it means the configuration has already been deemed valid. If default value filling is placed in apply, then any code inside apply before the filling must operate under the assumption that "the configuration is incomplete," which would force plugin authors to write a large amount of defensive code.
- The semantics of optional fields must be backstopped by the schema. Back to verbose: in the interface it is optional, but in business code you want it to always be a boolean. The schema's .default(false) precisely completes this transformation—at the type level it allows absence, while at the runtime level it guarantees presence. This is the correct way to implement the design intent of "optional": the interface marks it optional, the schema supplies the default value, with the two sides cooperating rather than depending on each other.
- In layered overrides, the location of default values determines behavior. Default values belong to the layer where the schema resides; the user layer only writes the keys it cares about, and keys that are not overridden naturally fall back to that layer's default values. If default values are hardcoded in apply, then layered override discussions turn into "if branches in the code," which is completely out of control.
To reiterate the criterion given by the source material: default values go directly on the schema fields. This is not a stylistic preference but part of the dsh configuration mechanism. A common beginner mistake is writing Schema.string() without a default, then wondering, "What is config.greeting when the user doesn't specify it?" The answer: it depends on how the validator handles that field—it might be undefined, or it might throw an error outright—but either way, you should never rely on that uncertainty. For any field declared optional in the interface, or any field for which you want a definite initial value, the schema should explicitly provide .default().
Here we can draw a comparison to help advanced readers build intuition. Schema validation for plugin configuration differs from the common "environment variables + manual parse" approach as follows:
| Dimension | Schemastery schema of the same name | Environment variables + manual parse |
|---|---|---|
| Source of type declarations | Config interface shares the same name as the schema; types match runtime | Relies on documentation conventions; runtime relies on hand-written typeof checks |
| Location of default values | .default() on schema fields | Scattered || and ?? throughout |
| Validation timing | Unified validation before entering apply | Validated only when used, timing varies |
| Error feedback | Blocked immediately on validation failure, pinpointing the specific field | Often crashes only when execution reaches a certain line; stack doesn't point to configuration |
| Layered overrides | Replaced line by line via patch layers; schema provides defaults | No concept of layers; later writes override earlier ones purely by order |
| Documentation sync | Schema is the documentation—readable and inferable | Documentation and code easily drift apart |
The core conclusion of this table is: schema turns "what the configuration looks like" into an executable contract, whereas manual parse merely spreads the same information out in code and cannot be handled uniformly within a layered system. In a system like dsh that emphasizes stacking multiple patch layers, without schema as a unified entry point, layered overrides would be impossible to achieve.
Finally, a practical detail to keep in mind: the field sets of the schema and the interface should stay consistent. If you add a field to the interface but forget to add it to the schema, users passing that key won't get it into config; if you add a field to the schema but not to the interface, the TS side can't see it, and you'll need a cast to use it in apply. The two must evolve in sync—this is also the engineering rationale for placing the interface and schema in the same file, written right next to each other.
Boundaries of Configuration-Code Separation: Which Values Belong in the Schema and Which Should Stay in the Plugin
The material opens with a very typical counterexample: the greet tool hardcodes the greeting message in code, so different deployments must modify code to change it. This single sentence actually summarizes the entire motivation for "separating configuration from code"—anything that may differ across deployments and should not require code changes to swap out should be externalized as configuration; anything that is inherent behavior of the plugin itself and should not affect users regardless of whether it is changed should stay in code.
To determine whether a value should go into the schema or stay within the plugin, you can go through the following criteria one by one:
- Does it vary by deployment? The greeting message is a textbook example: the test environment wants to say 'Hello', the production environment wants to say '您好', and different customer deployments may each need their own copy. It changes, so it goes into the schema (greeting). Conversely, some constant inside the plugin used for string prefix concatenation is the same across all deployments, so there is no need to externalize it.
- Does it vary by environment/load? maxRetries falls into this category: environments with network jitter want to increase the retry count, while stable environments want to lower it to save time. This kind of parameter—"same code, different values under different runtime conditions"—is naturally suited for the schema.
- Is it a troubleshooting/observability switch? verbose is a typical debug switch: normally off to save logs, turned on during troubleshooting to see details. Such boolean switches should almost always be externalized, and the default value is usually the "quiet" setting—the default given in the material is false.
- Does it involve secrets, paths, or endpoints? Anything involving credentials, file paths, or service addresses almost inevitably varies by deployment and must be externalized, never hardcoded into the repository.
- Is it an algorithmic invariant? The number of states in some internal state machine, a fixed magic number in a protocol, or the choice of hash algorithm—changing these means changing behavior and requires code and test changes, so they are not suitable for being made "configurable." Externalizing them instead enlarges the configuration surface, causes combinatorial explosion, and makes maintenance difficult.
Using this set of criteria to review the three fields in the material, the boundaries become very clear: greeting is copy that "varies by deployment," so it is externalized; maxRetries is a tuning parameter that "varies by environment," so it is externalized with 3 as the default; verbose is a "troubleshooting switch," so it is externalized with a default of false. All three fields are externalized, and each has its own default value in the schema. In the example code, apply does only one thing: console.log(config.greeting)—it consumes configuration rather than deciding configuration. This is exactly the state that "separating configuration from code" aims to achieve.
Regarding optional boolean items like verbose, there is also an easily overlooked issue of how to handle them. Since the interface already declares verbose?: boolean, some authors think they can omit it from the schema or not write a default. Neither approach is recommended:
- Do not omit optional fields from the schema. Omitting them means that even if the user passes verbose, it will not be recognized or included in the config, rendering the switch effectively useless. The schema's field set is a whitelist; keys not listed in it will not pass through.
- Do not rely on undefined to express "off." Writing
if (config.verbose)in business code may appear to work, but if the logging logic needs to explicitly distinguish between "not set" and "explicitly false," undefined introduces ambiguity. Once the schema provides .default(false), the semantics are deterministic: either true or false. - Default values should choose the "safe" side. For a debug switch, the safe side is off (false); for retries, the safe side is a finite number (3), not unlimited. The choice of default value is itself an engineering decision, and the schema is where it lands.
Here is a comparison table that grounds the judgment of "should be externalized" versus "should be inlined" in concrete types, making it easy to reference in actual projects:
| Type of value | Should be externalized into schema | Typical fields and default values | Rationale |
|---|---|---|---|
| User-facing copy | Yes | greeting, default 'Hello' | May differ across deployments/customers; changing copy should not require code changes |
| Tuning parameters | Yes | maxRetries, default 3 | Varies with environment and load; needs to be adjustable on site |
| Troubleshooting switches | Yes | verbose, default false | Quiet during normal operation, enabled for troubleshooting; default to the safe side |
| Secrets/endpoints/paths | Yes | Injected per deployment, usually no safe default | Necessarily differ across deployments and must not enter the repository |
| Protocol constants/magic numbers | No | Keep inside the plugin | Changing them changes behavior and requires code and test changes |
| Algorithm/state machine internal logic | No | Keep inside the plugin | Belongs to implementation invariants; externalizing causes combinatorial explosion |
To summarize in one sentence: The size of the configuration surface is a design tradeoff; the criterion for externalization is "variable at deployment time and should not require code changes," not "configure everything that can be configured." Turning things that should not be externalized into configuration may look flexible in the short term, but over time it causes configuration combinations to spiral out of control, default value semantics to become ambiguous, and troubleshooting costs to rise. dsh's schema mechanism gives you the ability to externalize, but whether and how much to use it remains the plugin author's judgment. The three fields in the material happen to cover the three typical externalization scenarios of "copy, tuning, and switches," making it a very reasonable minimal configuration surface.
bundle and profile: the division of labor between two kinds of manifests under the dsh key
Having covered the configuration of a single plugin, we now need to zoom out to "how these plugins are packaged, installed, and launched." dsh's installation mechanism is built on two concepts: bundle and profile. Both are described by a package.json, but they carry different kinds of manifests under the dsh key and answer different questions. This is the key prerequisite for understanding the entire layered system.
Let's lay out the definitions of the two concepts first:
- A bundle is an npm package that comes with a configuration layer. Its manifest declares dsh.bundle, and it answers the question "what does this package contribute"—specifically, it contributes a patch file that inserts or overrides plugin lines.
- A profile is a directory located under $DSH_HOME/profiles/<name> that describes a launchable composition. Its manifest declares dsh.profile, and it answers the question "which bundles make up this configuration, and in what order."
The division of roles between the two can be remembered like this: a bundle is what you author and distribute; a profile is what the user launches with dsh --profile <name>. This sentence is worth reading over and over. Plugin authors write bundles, publishing the patch file together with the code to npm (or a local checkout); users don't need to manually assemble bundles—instead, they launch a profile, which records "which bundles to load and in what order." The material contains an unequivocal conclusion: nothing is both at the same time. A package is either a bundle or a profile; the identities are mutually exclusive. Even though both descriptions are hidden under the dsh key of package.json, dsh.bundle and dsh.profile are two different kinds of manifests.
Let's nail down the differences between the two with a comparison table:
| Concept | manifest key | Question answered | Who authors / who uses |
|---|---|---|---|
| bundle | dsh.bundle | What this package contributes (a patch file) | Authored by plugin authors, distributed with the package |
| profile | dsh.profile | Which bundles make up this configuration, and in what order | Automatically created and maintained by dsh plugin, launched by the user |
There are three points in this table worth expanding on. First, the manifest keys differ: a bundle uses dsh.bundle, while a profile uses dsh.profile, and the loader uses this to determine what role the package plays in the system. Second, the questions they answer differ: one is "what does it contribute" (the content perspective), and the other is "who is it composed of" (the orchestration perspective). Third, the authors and users differ: a bundle is written by plugin authors and distributed with the package, making it a release artifact; a profile is automatically created and maintained by the dsh plugin command, making it a user-side runtime configuration.
There is also a detail that is easy to confuse: the material uses one sentence to point out that "surface-level composition packages can resolve them through the application's own services." What this means is that the plugin lines inserted or overridden by the patch provided by a bundle can themselves participate normally in the resolution of application services; there is no such thing as "plugins inside a bundle are a special species." For advanced readers, understanding this helps build the correct mental model: a bundle is not a new runtime; it simply adds a configuration layer on top of existing plugin lines.
Now let's look at the overall picture of the installation mechanism. The installation mechanism is built on two concepts, both of which are described by a single package.json. This means that when you run dsh plugin add on a package, dsh reads that package's package.json, checks whether it declares dsh.bundle or dsh.profile, and then decides where to place it in the system. If it declares dsh.bundle, dsh appends the package to the target profile's dsh.profile.bundles list; the profile itself is initialized by dsh plugin on first use. This process of "reading the manifest to determine the role" is exactly the concrete embodiment of the division of labor between the two manifests.
Finally, let's correct a common misunderstanding: some readers think that bundle and profile are in a "containment relationship," that is, a profile contains bundles, so a bundle is also a kind of profile. This is not the case. When a profile's bundles list references a bundle, it is a reference relationship, not identity inheritance. A bundle referenced by multiple profiles is still just a bundle; a profile that references several bundles is still just a profile. The material's statement that "nothing is both at the same time" is precisely cutting off this confusion of identity. Keep this firmly in mind, and later, when discussing the order of the bundles array and override relationships, you will not mix up "who contains whom" with "who overrides whom."
What dsh.bundle Declares: A Patch File That Inserts or Overrides Plugin Lines
The previous section said that a bundle answers "what does this package contribute." This section makes that "what" concrete: the core artifact of a bundle is a patch file, whose job is to insert or override plugin lines. This is the most information-dense sentence in dsh's composition-package mechanism, and it's worth unpacking word by word.
Let's start with "patch file." In dsh's configuration layering system, a patch is the basic unit that participates in "layer-by-layer composition." The effective configuration is not a hard-coded yml file, but the result of starting from an empty root and stacking several patch layers in a fixed order. The patch file carried by a bundle is precisely its credential for participating in composition as one layer. Many of the plugin lines users see in cordis.yml or a profile actually originate from some bundle's patch being applied.
Now let's look at "insert or override plugin lines." This reveals the two basic actions of a patch:
- Insert: the configuration originally had no such plugin line, and the bundle's patch adds it. This corresponds to the scenario of "adding a new capability to the system"—for example, installing a hello-plugin, whose patch inserts the hello-related plugin line into the configuration.
- Override: the configuration already had this plugin line, and the bundle's patch replaces its configuration. This corresponds to the scenario of "customizing existing behavior"—for example, a bundle wanting to adjust the parameters of a plugin in the base package.
Here we must introduce a key mechanism. When discussing load order, the source material gives a clear conclusion: a patch replaces the entire config value of the target line, rather than deep-merging individual keys. This point is extremely important for understanding bundle behavior. Suppose a plugin line in the base configuration is { greeting: 'Hello', maxRetries: 3 }, and your bundle patch only writes { greeting: '您好' }. Then after the override, this line's config is { greeting: '您好' }, and maxRetries will not be "inherited" from the base configuration—unless it is re-added in a higher-priority layer, or backfilled by the plugin's own schema defaults. This is a very common pitfall: many people assume a patch is "a patch-style change to just one key," but in reality it is a whole-line replacement. There are usually two solutions: either write out all keys of the line in the patch, or confirm that the omitted keys can be restored to the correct semantics by schema defaults.
Why is a bundle's artifact a patch file rather than some other form? Because dsh's layering model needs a configuration unit that is "stackable, orderable, and line-by-line winning." A patch file fits exactly: it is stackable (multiple bundles' patches are applied in sequence), orderable (the order of the bundles array is the application order), and line-by-line winning (later-applied layers override earlier-applied layers). If a bundle directly produced a "final configuration," it would lose the ability to layer and stack, and multiple bundles would not be able to coordinate with one another.
From a distribution perspective, the material explicitly states that a bundle is written by the plugin author and distributed with the package. This is a clear line of responsibility:
- The plugin author writes the plugin code (exporting the Config interface and the schema of the same name).
- The plugin author writes the patch file, declaring which plugin entries this package should insert or override.
- The plugin author declares dsh.bundle under the dsh key in package.json, pointing to this patch.
- The package is published or checked out locally.
- The user installs it into a profile via dsh plugin.
Of these five steps, the first four are on the bundle side, and the fifth enters the profile side. The bundle author does not need to care which profile the user will launch with, nor should they assume how they will be composed; the bundle is only responsible for answering "what do I contribute." This sense of boundaries is the foundation of ecosystem composability: precisely because a bundle does not overstep, the same bundle can be reused by any number of profiles.
There is another point that advanced readers will raise: are a bundle's patch and the user's own patches (such as the profile-level cordis.patch.yml, the home-level patch, and the command-line --patch overlay) mechanically the same thing? The answer is that they are all "layers," and they all follow the rule that "later-applied layers win on a per-line basis"; the only difference is their position and priority. A bundle's patch enters the system through the dsh.profile.bundles list and sits in the first tier of the layering order; the user's patches come after it, so the user can always override the bundle's settings. This design—"the bundle provides defaults, the user has the final say"—is a very pragmatic choice in dsh's layering system.
$DSH_HOME/profiles/<name>: Why profiles live outside the installation directory
Now let's shift our perspective to profiles. The material gives a very precise definition: a profile is a directory located under $DSH_HOME/profiles/<name> that describes a launchable composition, along with an equally precise path description: a profile lives outside the installation directory, with the path template $DSH_HOME/profiles/<name>. This "outside the installation directory" is not an arbitrary choice, but a direct consequence of what a profile is.
First, understand what a profile is. It describes a launchable composition—that is, a profile itself is not executable code, nor an installation artifact, but a "launch recipe": which bundles are needed, in what order, and which user patches are layered on top. Its manifest declares dsh.profile, and its core content is the dsh.profile.bundles array. When the user launches with dsh --profile <name>, dsh reads the profile description from this path, assembles the effective configuration accordingly, and then starts up.
Why should such a "recipe" live outside the installation directory? This can be understood from several angles:
- The installation directory is replaceable and upgradeable. The dsh core and its dependencies may be reinstalled, upgraded, or switched between versions; if the profile were written inside the installation directory, a single upgrade could erase or overwrite the user's startup recipe. By placing the profile under $DSH_HOME, no matter how the installation directory changes, the user's configuration assets remain unaffected.
- A profile is a user-side, machine-local asset, not a distribution artifact. Bundles are distributed by plugin authors, whereas profiles belong to the user/machine. A user may create different profiles on different machines, and these should not be packaged into distribution artifacts, nor should they travel with the package.
- A single profile can reference bundles from multiple sources. The profile's bundles list may contain both official base packages like @deepseek-ai/dsh-base and third-party packages from a local checkout. This requires the profile to sit in a "neutral" position that is not monopolized by any single package, and $DSH_HOME/profiles provides exactly such a neutral path.
- The need for multiple coexisting profiles. The <name> in the path template means multiple profiles (demo, prod, debug, …) can coexist on the same machine, each describing a different startup combination while sharing the same installation. This is only easy to do when profiles are independent of the installation directory.
Placing profiles in $DSH_HOME has another implicit benefit: it is the natural home for machine-local preferences and shared configuration. When discussing load order, the material lists $DSH_HOME/cordis.patch.yml as a home-level layer, indicating that $DSH_HOME is not just a container for profiles but also the place where "machine-local preferences shared across profiles" are stored. The profile residing at $DSH_HOME/profiles/<name> is a natural extension of this system.
Here we can compare the difference between "placing the profile inside the installation directory" and "placing it outside the installation directory" to help build judgment:
| Dimension | Inside the installation directory | In $DSH_HOME/profiles/<name> |
|---|---|---|
| When upgrading dsh | May be overwritten or lost | Unaffected, assets preserved |
| Multiple coexisting profiles | Requires extra isolation, prone to interference | Naturally isolated by <name> |
| Relationship with the distribution package | Easily packaged into distribution artifacts by mistake | Clearly user-side, not distributed with the package |
| Relationship with home-level patches | Difficult to establish a shared layer | Both reside in $DSH_HOME, sharing preferences follows naturally |
| Startup referencing bundles | Paths coupled to the installation location | Neutral references, sources can be diverse |
A common point of confusion needs to be clarified: the profile's directory name <name> is exactly the <name> in dsh --profile <name> at startup. So the path for the demo profile is $DSH_HOME/profiles/demo, and you start it with dsh --profile demo. The name is the unique identifier, and dsh relies on it to locate the directory under $DSH_HOME/profiles. This is also why the material says a profile is "automatically created and maintained by the dsh plugin"—the first time you use a given profile, the dsh plugin initializes it under $DSH_HOME/profiles/<name>, and users typically don't need to manually create the directory or write a package.json.
There's another point directly relevant to the subsequent installation steps: the material mentions that "the first use initializes the profile" and gives a very specific detail—@deepseek-ai/dsh-base becomes its first bundle. This sentence carries a lot of information: it means that in a brand-new profile's dsh.profile.bundles list, the first entry is always @deepseek-ai/dsh-base. It's the first tier in the layering order, and all user-installed bundles come after it. This also explains why later-installed bundles can override the base package's behavior—order determines the winner. The profile lives outside the installation directory, yet the first bundle it references comes from the official base package. This "decoupled location, coupled reference" is precisely the design intent of the layering system.
The dsh.profile.bundles list: which bundles make up this configuration, and in what order
The core content of a profile is the dsh.profile.bundles list, and the question it answers is stated clearly in the material: which bundles make up this configuration, and in what order. Note that there are two elements here—which (membership) and what order (sequencing). Together these two determine the final effective configuration; neither can be omitted.
Let's look at the membership dimension first. Each entry in the bundles array is a bundle name. The material gives a real list in the installation steps: first dsh-base, then each installed bundle in the order it was added. In other words, an initialized profile's bundles roughly takes the form ['@deepseek-ai/dsh-base', '...user-installed packages...']. The official base package always sits in the first position, with user-installed packages following in sequence.
Now the ordering dimension. Why does order matter? Because the layering system follows the later-applied layer wins per line. The order of the bundles array is the patch application order: bundles earlier in the array are applied first, and those later are applied afterward; when two bundles both touch the same line of plugin configuration, the one applied later wins. So the bundles array isn't just a "list"—it's a priority sequence. Placing a bundle further down the list gives it higher override priority. This is the most practical adjustment lever at the profile level.
Combined with the earlier point that "a patch replaces the entire config line rather than performing a deep merge," we can walk through a concrete scenario: the patch in the base bundle dsh-base inserts a line for some plugin, configured as { greeting: 'Hello', maxRetries: 3, verbose: false }; the patch of the hello-plugin that the user subsequently installs also wants to configure this line, but only writes { greeting: '您好' }. Since hello-plugin comes after dsh-base in bundles, its patch is applied later, and this line ultimately becomes { greeting: '您好' }—note that maxRetries and verbose do not retain the base bundle's values; they are either restored by the plugin schema's default values (3 and false) or simply missing. This example ties together the three mechanisms of bundle order, line-by-line precedence, and whole-line replacement, and is a key deduction for understanding profiles.
The material provides a package.json example of a profile, which is well worth analyzing word by word:
{
"name": "dsh-profile-demo",
"private": true,
"dependencies": {
"dsh-hello-plugin": "link:/path/to/hello-plugin"
},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"dsh-hello-plugin"
]
}
}
}
There are several details to note in this profile's package.json:
- The name is dsh-profile-demo, corresponding to the profile name demo, following the naming convention dsh-profile-<name>.
- private: true, indicating that this is a private, unpublished package—it is inherently a user-side configuration directory and should not be published.
- In dependencies it is link:/path/to/hello-plugin, indicating that this hello-plugin comes from a local checkout and is wired in via link. This also explains why the material recommends running the install command in the directory containing hello-plugin—local path links do not need to be published to npm.
- The dsh.profile.bundles array has @deepseek-ai/dsh-base as its first item and dsh-hello-plugin as its second, fully consistent with our earlier deduction.
Let me re-emphasize the responsibility of "automatically created and maintained by the dsh plugin". Users do not manually edit this bundles array—at least not on the normal path. The user runs dsh plugin --profile <name> add <package>, and dsh forwards it to pnpm within the profile directory to complete the dependency installation; at the same time, because the package declares dsh.bundle, it is appended to dsh.profile.bundles. In other words, the bundles array is a byproduct of the installation behavior, and its order is determined by "insertion order". This leads to a practical corollary: if you want a certain bundle to have higher override priority, reinstalling it will not necessarily move it to the end of the list (depending on the implementation details of dsh). A more reliable approach is to understand the ordering semantics and then adjust it through the mechanism when needed. But in any case, in normal usage you should trust the automatic maintenance performed by dsh plugin.
Placing the bundles list within the overall layering order makes its position clearer. The complete loading order given by the source material is:
- The profile's dsh.profile.bundles list—each bundle patch in list order, first dsh-base, then each installed bundle in the order it was added;
- The profile's own cordis.patch.yml—the user profile-level patch layer;
- The home-level $DSH_HOME/cordis.patch.yml—machine-local preferences shared by all profiles;
- Each --patch <path> overlay—in argv order.
As you can see, the bundles list sits at level 1 of the layering order, the base layer before all user patches. This means: bundles provide the base composition, profile-level patches can override it, home-level patches can override it again, and command-line overlays have the final say. Comparing the four layers clearly in a table—who writes them, their sharing scope, and their priority—is the best preparation for understanding what follows:
| Order | Layer | Who writes it / sharing scope | Relative priority |
|---|---|---|---|
| 1 | dsh.profile.bundles list | Plugin authors provide patches, dsh plugin maintains the list; within a single profile | Lowest (applied first) |
| 2 | The profile's own cordis.patch.yml | Written by the user for that profile; within a single profile | Higher than bundles |
| 3 | $DSH_HOME/cordis.patch.yml | Written by the user for the entire machine; shared by all profiles | Higher than profile-level |
| 4 | --patch <path> overlay | Passed in via argv on the command line; for a single launch | Highest (applied last) |
This table also clarifies two easily confused points. First, home-level patches take precedence over profile-level patches—even though profiles appear more "dedicated," home-level patches are loaded after them, so when it comes down to row-by-row precedence, home-level wins. The design intent is that machine-local preferences can uniformly override the settings of an individual profile. Second, application arguments are not another layer of patches. Application arguments on the command line are resolved through the application's own services and do not participate in the four-layer stacking; the command-line input that actually participates in stacking is --patch overlay, and it follows argv order, comes last, and has the highest priority. Once you distinguish these two points, you can give an accurate answer to the question of "which layer actually wins."
mkdir -p hello-plugin: the minimal starting action for creating a bundle
With the theory covered, let's get hands-on. The first step given in the official tutorial is very simple:
mkdir -p hello-plugin
It just creates the package directory. This action itself has little technical substance, but its role in the overall narrative is worth explaining, because it is the physical starting point for all subsequent installation and layering discussions.
Why start by creating a directory? Because a bundle is first and foremost an npm package, and the minimal form of an npm package is a directory plus a package.json. Later in the material, plugin code, patch files, and package.json (which declares dsh.bundle) will be placed in this directory, turning it into a truly installable bundle. hello-plugin serves as a running example throughout the tutorial: it will be installed into a profile, become the second member of the dsh.profile.bundles list, and participate in the layering reasoning of "who overrides whom." So the directory created now is the first link in this complete chain.
Put this action into the correct sequence:
- Create the package directory (the mkdir -p hello-plugin in this section).
- Write the plugin code inside the directory, exporting the Config interface and the schema of the same name (corresponding to the earlier sections of this article).
- Write the patch file, declaring which plugin rows this package inserts or overrides.
- Declare dsh.bundle under the dsh key in package.json.
- In the directory containing hello-plugin, run
dsh plugin --profile demo add ./hello-pluginto install it into the profile. - dsh initializes the profile (on first use, @deepseek-ai/dsh-base becomes the first bundle), pnpm links that checkout, and dsh appends this package to dsh.profile.bundles because it declares dsh.bundle.
Note the command form in step 5: dsh plugin --profile <name> <args...>. The source material explicitly states that it forwards to pnpm within the profile directory, so all pnpm subcommands are available. This means pnpm operations like add, remove, install, and update can all be invoked through dsh plugin as the entry point, executing within the context of the specified profile. This is dsh's practical design of merging "profile management" and "dependency management" into one.
There's another detail worth noting for advanced readers: the source material says "in the directory containing hello-plugin, install the checkout of that package," using the command add ./hello-plugin. This relative path will be recorded by pnpm as a link dependency (reflected in the profile's package.json as link:/path/to/hello-plugin). In other words, bundle development can be fully localized—no need to publish to npm first before it can be referenced by a profile. This is extremely friendly for debugging composite packages and iterating on patches: after modifying code and patches, the profile takes effect directly through the link. Once you understand this, you can verify bundle behavior in your own deployment environment the same way, without detouring through the publishing process.
The -p in mkdir -p is also worth mentioning: it ensures no error is raised when the parent directory doesn't exist, and no error when it already exists. This may seem trivial, but it aligns with scripting best practices—installation scripts or Makefiles can safely re-execute it. For a tutorial oriented toward deployment and operations, this kind of "repeatable execution" detail is often more important than the syntax itself.
At this point, we've covered the first half of "where the configuration comes from": from cordis.yml to the field mapping of apply, the default value ownership of same-named Schemastery schemas, the criteria for separating configuration from code, the division of labor between the two manifests—bundle and profile, the patch artifacts of bundles, the rationale for placing profiles in $DSH_HOME, the ordering semantics of the bundles list, and the initial steps for hello-plugin. The second half will continue with the complete execution details of the install command, how the four layers of loading order combine layer by layer, the specific pitfalls introduced by whole-line replacement in patches and how to avoid them, and the complete behavior of dsh automatically maintaining bundles—bringing the thread of "where the effective configuration actually comes from" to a full close.
In the previous section, we thoroughly covered the Config interface on the plugin side and the same-named Schemastery schema, and also brought the division of labor between the two manifests—bundle and profile—to the table. Now let's take the next step: actually install hello-plugin into a profile, and then figure out exactly which layer dsh's effective configuration emerges from.
dsh plugin --profile <name> <args...>: The mechanism for forwarding to pnpm within the profile directory
Many readers, upon first seeing the command dsh plugin --profile demo add ./hello-plugin, mistakenly assume that dsh implements its own package manager. The truth is quite the opposite: dsh merely acts as a courier here—it forwards the remaining arguments after --profile verbatim to pnpm, and it executes this forwarding within the profile directory. Understanding this point grants you, in one stroke, the authority to explain the entire plugin installation pipeline.
Let's first break the command down. The command takes the form:
dsh plugin --profile <name> <args...>
Here, --profile <name> is dsh's own argument, used to locate which profile to operate on; while <args...> is the entire set of arguments passed through to pnpm, i.e., pnpm's command line. This means that virtually all the pnpm subcommands you're familiar with work here: add to install dependencies, remove to uninstall dependencies, install to restore from the lockfile, update to bump versions, list to view what's installed, why to trace dependency origins, link to link a local directory, run to execute scripts. Because the forwarding is verbatim, the argument syntax you've learned on pnpm, switches like --save-dev, and even usages like --filter require no additional support from dsh. dsh performs no syntax translation—it simply switches to a different working directory and then hands control over to pnpm.
Why must it be executed within the profile directory? Because a profile itself is a directory located at $DSH_HOME/profiles/<name>, with its own package.json and dependency tree. Every bundle you install ultimately lands in this directory's node_modules and is written into this directory's package.json. If dsh invoked pnpm in your current working directory, the installed packages would end up in the wrong place—dependencies would land in the project root, and the profile wouldn't be able to read them; or worse, two profiles might end up sharing dependencies they shouldn't. Therefore, "forwarding within the profile directory" is not an implementation detail but part of the installation semantics: a profile's dependency closure belongs to the profile's own directory.
Now let's talk about first-time use. When you first run dsh plugin --profile demo ... against a profile name that doesn't yet exist, dsh won't report "profile not found" and exit. Instead, it initializes the profile: it creates the $DSH_HOME/profiles/demo directory, generates a minimal profile skeleton (including package.json), and registers @deepseek-ai/dsh-base as its first bundle. In other words, the act of initialization inherently carries a "baseline" with it. This design is crucial: from the very first second of its existence, a profile is not an empty configuration—it has a dsh-base layer backing it, ensuring that even if you haven't installed a single third-party plugin, the profile is still a bootable composition.
From an engineering standpoint, there are two easy pitfalls here. First, don't manually modify node_modules inside the profile or hand-write dependencies. A profile's dependencies should be maintained through the dsh plugin path, so that dsh can continue to perform its own registration work after pnpm finishes installing (more on this later). Second, the profile name becomes part of the path, so using lowercase English names like demo, prod, and staging is less hassle, avoiding path issues caused by case sensitivity and platform differences. Third, if you're doing a one-off install in CI, first confirm that $DSH_HOME is writable and lands on the expected persistent volume—a profile is runtime state, and putting it in a temporary directory means the bundles list will be lost on the next build.
There's one more concept to nail down here: --profile selects "which set of configuration to assemble." It has nothing to do with the plugin itself, nor with patches. The same plugin package can be installed by both the demo and prod profiles simultaneously; each profile maintains its own dsh.profile.bundles list, independent of the other. This sets the stage for the later topic of "environment-differentiated configuration."
dsh plugin --profile demo add ./hello-plugin: the triple change triggered by a single install
Now let's actually install the hello-plugin from the previous section. The command is:
dsh plugin --profile demo add ./hello-plugin
This command looks unremarkable, but in the demo profile it actually triggers three things at once. Once you understand the order of these three things and the boundaries of their responsibilities, you'll be able to quickly pinpoint which layer is failing whenever an installation goes wrong.
First change: pnpm linked this checkout. Because the argument ./hello-plugin is a local directory rather than a package name on npm, pnpm treats it as a local path dependency, creates a link under the profile's node_modules pointing to that directory, and writes this dependency into the profile's package.json under dependencies, in a form like "dsh-hello-plugin": "link:/path/to/hello-plugin". Note what link semantics mean: when you change code in the source directory, the contents at the install point change immediately, with no need to re-add. This is extremely convenient during plugin development, but be careful before shipping—a link dependency ties "the installed version" to "the state of the source directory," so if the source directory is cleaned up or switched to another branch, the profile changes along with it. To pin a version, you should publish to a registry and install by version number.
Second change: dsh appends it to dsh.profile.bundles based on the dsh.bundle declaration. This is the step unique to dsh in the entire installation flow. After pnpm finishes installing dependencies, dsh reads this package's package.json and checks whether dsh.bundle is declared under its dsh key. If it is, dsh appends this package to the profile's dsh.profile.bundles array. This step is the key difference between "installed" and "just a dependency": a package can be installed into a profile by pnpm, but if it doesn't declare dsh.bundle, it won't become a bundle, and therefore won't contribute a configuration layer. A bundle is essentially an npm package with an attached configuration layer, and that configuration layer is the patch file it provides; the dsh.bundle manifest key answers exactly the question "what does this package contribute." So the two-part action can be remembered like this: pnpm is responsible for making the package "exist," and dsh is responsible for making the package "take effect."
Third change: @deepseek-ai/dsh-base becomes the first bundle. Because demo is a profile initialized on first use, the boss slot is always reserved for dsh-base—it is the first entry in the bundles list, and it is also the base layer. Every subsequently appended installed bundle is placed after it in "order of addition." This order is not meaningless: it directly determines the stacking order during later loading, with dsh-base laying the foundation first and third-party packages stacking on top of it. This is also why, when troubleshooting override issues, the first thing you should look at is the arrangement of the bundles array.
Chain the three changes together in execution order: pnpm links the checkout → the dependency enters package.json → dsh reads the manifest and appends the package to bundles → dsh-base stays firmly in first place. A problem at any step shows up as a different symptom: if the pnpm step fails (for example, there's no valid package.json in the directory), you'll see the package manager throw an error; if the dsh.bundle step doesn't take effect (for example, the package name doesn't match the directory name, or the manifest is written in the wrong place), you'll see the dependency installed but the configuration completely unchanged—this is the most insidious class of failure, because the install "looks like it succeeded." When this happens, first go check whether that package name actually appears in the profile's dsh.profile.bundles.
Below is an example you can paste and run directly, covering the full loop from creating a package to installing it to verifying it. Replace the package name and directory name in the example with those from your actual project.
# 1) Prepare a minimal bundle package source in any working directory
mkdir -p hello-plugin/src
cat > hello-plugin/package.json <<'EOF'
{
"name": "dsh-hello-plugin",
"version": "0.0.1",
"private": true,
"main": "src/index.js",
"dsh": {
"bundle": {
"patch": "cordis.patch.yml"
}
}
}
EOF
# 2) The patch file that the bundle must provide
cat > hello-plugin/cordis.patch.yml <<'EOF'
# This patch declares which plugin lines this bundle inserts into / overrides in the config
# Fill in the actual line contents according to your own plugin id and config
EOF
# 3) Install into the demo profile (the profile is initialized automatically on first use)
dsh plugin --profile demo add ./hello-plugin
# 4) Verify: check whether the package appears in the bundles list
cat "$DSH_HOME/profiles/demo/package.json"
# 5) While you're at it, see what pnpm installed
dsh plugin --profile demo list --depth 0
There are two things in this example that you need to replace: the actual path of the patch file under dsh.bundle, and the plugin lines that the patch file actually needs to insert or override. Space doesn't allow me to go into the patch line format here, but keep its role firmly in mind: the bundle's manifest answers "what does this package contribute," and the answer is a single patch file—it inserts or overrides plugin lines.
What the generated profile package.json looks like: dependencies vs. dsh.profile.bundles
After installation completes, the generated profile's package.json looks roughly like this (the shape comes from the source material; replace the paths with your actual checkout):
{
"name": "dsh-profile-demo",
"private": true,
"dependencies": {
"dsh-hello-plugin": "link:/path/to/hello-plugin"
},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"dsh-hello-plugin"
]
}
}
}
Reading it field by field, this file is essentially a two-column table of "who installed what, and who contributed what."
- name: dsh-profile-demo. This is the profile's own package name; note that it is not the same thing as the name of the plugin package you installed. A profile is a standalone directory, and it needs its own identifier so it can be recognized in the pnpm workspace and in logs. It is usually generated by dsh at initialization time based on the profile name, and you don't need to maintain it by hand.
- private: true. A profile should never be published to a registry. It describes "this bootable combination on this machine"—it is runtime assembly information, not a distributable artifact. The private flag also serves as a reminder: the profile lives outside the installation directory, with the path template $DSH_HOME/profiles/<name>; it belongs to the environment, not to the code repository.
- dependencies: { "dsh-hello-plugin": "link:/path/to/hello-plugin" }. This is pnpm's territory, recording "which packages are in this profile's dependency tree and where they come from." The link prefix indicates it points to a local checkout. This section answers "is the package there."
- dsh.profile.bundles. This is dsh's territory, recording "which bundle packages make up this configuration and in what order." The first entry in the array, @deepseek-ai/dsh-base, is the base layer added automatically at initialization, with the rest arranged in the order they were added. This section answers "does the package take effect, and in what order."
Putting the two fields side by side gives you the most important mental model in this section: dependencies determine whether something is installed, while bundles determine whether it is used and how it stacks. The two are not automatically synchronized—pnpm can install a package that does not declare dsh.bundle (it goes into dependencies only, not into bundles); and in engineering practice, the order of bundles is what ultimately determines the override relationships. So when you suspect a plugin is not taking effect, your troubleshooting chain should be: first confirm in dependencies that the package is installed, then confirm in bundles that the package is registered, and only then suspect the patch content and load order.
There are also two abstract distinguishing questions worth committing to memory, and they stem from the positional difference between a bundle and a profile: a bundle is what you author and distribute, while a profile is what the user starts with dsh --profile <name>; both are described by a package.json, but they carry different kinds of manifests. Nothing is both at the same time—a package either declares dsh.bundle (it contributes a patch file) or exists as a profile (it describes which bundles compose it and in what order); there is no package that is both a bundle and a profile. Keep this rule of exclusion in mind, and many conceptual confusions will dissolve on their own. In addition, a profile's manifest is usually created and maintained automatically by dsh plugin, so you rarely need to write it by hand; a bundle's manifest, by contrast, is written by the plugin author and is part of what you are responsible for. The table below lays out the key differences between these two kinds of manifests side by side for quick reference.
| Dimension | bundle | profile |
|---|---|---|
| manifest key | dsh.bundle | dsh.profile |
| Question it answers | What this package contributes (a patch file) | Which bundles compose this configuration and in what order |
| Who writes it | Written by the plugin author and distributed with the package | Created and maintained automatically by dsh plugin |
| Who uses it | Installed as a dependency into a profile | Started by the user with dsh --profile <name> |
| Location | Distributed with the npm package, residing in the profile's dependency tree | Outside the installation directory, with the path template $DSH_HOME/profiles/<name> |
| Typical content | A patch file that inserts or overrides plugin lines | A list of bundles that determines the stacking order |
| Can it be edited manually | Yes, it is part of your codebase | Recommended to leave it to dsh plugin for maintenance |
| Can it also be the other | No. Nothing is both at the same time. | |
Four Layers of Effective Configuration Loading: Composing from an Empty Root to the argv Overlay
The plugins are installed and the bundles are registered, but the "final effective configuration" has yet to take shape. dsh's approach is: on top of an empty root, compose layer by layer in a fixed order, with later-applied layers winning line by line. The complete order given by the source material is as follows:
- The profile's dsh.profile.bundles list. The patches of each bundle are applied in list order—first dsh-base, then each installed bundle in the order it was added. This is the primary source of the configuration.
- The profile's own cordis.patch.yml. A user profile-level patch layer located within the profile directory, used for personalized adjustments to this particular environment.
- The home-level $DSH_HOME/cordis.patch.yml. Located at the DSH_HOME level, this is a machine-local preference shared across profiles, affecting all profiles on this machine.
- Each --patch <path> overlay. Applied one by one in argv order, used for temporary stacking at startup.
This ordering diagram is worth printing out and sticking on your desk, because it simultaneously determines two things: who can override whom, and where changes should go. The granularity of the layers goes from "package" to "profile" to "machine" to "this process"—a ladder from broad to narrow, from shared to temporary. To change the configuration of a particular environment, put it in layers 1 or 2; to change preferences for all profiles on this machine, put it in layer 3; to experiment only for this one startup, use layer 4.
There is one extremely critical conclusion here that is also the easiest to misread, and the source material states it in a single sentence: application arguments are not another patch layer. Many people instinctively treat the various command-line arguments as a "fifth layer," but they are not part of the patch layering system and do not participate in the "win line by line" override comparison. If you expect some key setting to be "overridden by a command-line argument over the config file," when in v1 it is actually an ordinary application argument resolved by the service, you will end up with an effective value that doesn't match your expectations—and you won't be able to find where the "fifth layer" is at all. When troubleshooting this kind of problem, first remove application arguments from your mental model of patch layering.
Another detail that's easy to overlook is the "empty root": layering doesn't start from a default configuration, but from an empty root, building up layer by layer. This means which plugin rows and which configs exist in the final configuration is entirely determined by these four layers together, with no hidden "implicit defaults." This is actually advantageous for troubleshooting—every line can be traced back to a specific layer, as long as you know how to read the layers.
Here's a commonly asked diagnostic question worth addressing: the profile's bundles list is ordered "by the order in which they were added," and this order is determined by the call history of dsh plugin add. If you repeatedly add / remove the same set of packages, the order may not match the directory order you see during code review. Order is semantics, so when you're investigating override relationships, don't assume that the bundles arrangement equals the recommended order in the README—actually read the profile's package.json.
Later-applied layers win by row: patch replaces the entire row's config rather than deep-merging
Now let's get into the most error-prone point in this section, and the one semantic rule from this entire article that you most need to memorize: later-applied layers win by row, and a patch replaces the target row's entire config value rather than deep-merging individual keys.
"Winning by row" is coarse-grained: the unit of comparison is the plugin row, not an individual key within the config. If a plugin row from a lower layer takes effect, and the same plugin row appears in a higher layer, then the higher layer's row wins, and the lower layer's row no longer participates at all. There's no such thing as "the lower layer provides greeting, and the higher layer only supplements maxRetries"—that kind of key-level stitching默契 doesn't happen.
"Replacing the entire config value" is the sharpest edge of this semantic rule. Suppose a plugin row in a lower layer carries a complete configuration:
- greeting: "Hello"
- maxRetries: 3
- verbose: false
And a higher layer only wants to change one field, say changing maxRetries to 5, so it writes a patch row containing only that key. The result is not "the other keys are preserved and maxRetries becomes 5," but rather the entire config is replaced by this new value: if the new value contains only maxRetries, then greeting and verbose lose their source, and the values of these two keys in the final configuration won't match your intuition (depending on whether the plugin's schema provides default values and its behavior when they're absent). This is a pitfall that actually happens in production, and the symptom is often "I only changed one parameter, why did another parameter change too?"—and it's very hard to realize that replacement semantics caused it.
The countermeasure is very clear: if you want the higher layer to win while preserving the other keys, you must write the entire config in full in that higher-layer patch. Treat "whole-row replacement" as a contract: whoever provides the winning row is responsible for all keys in that row. To reduce maintenance cost, a common practice is to keep the lower layer's config keys as few as possible and concentrate volatile items in the higher layer; or simply write each environment's complete config as a full block, trading readability for determinism.
One more detail directly related to "winning by line" comes from the previous section: on the plugin side, in the Config schema, default values are written on the schema fields (such as Schema.string().default('Hello'), Schema.number().default(3), Schema.boolean().default(false)), and the second argument to apply is the validated config. So when a higher layer replaces a key and causes that key to be missing, whether the final config still has a value for that key depends on whether the schema provides a default and on the behavior of default validation. This ties two things together: "schema design" and "layered overrides." Optional keys (such as verbose?: boolean in TypeScript) and keys with default values do not behave the same under replacement semantics. When writing a patch, if you don't want to be forced to write everything out, relying on schema defaults is one approach—but first confirm that the default matches the expectations of that environment, rather than silently steering the value "back" in a direction you don't want.
Now for the semantic consequences of the third layer: the home-level $DSH_HOME/cordis.patch.yml sits after the profile's own patch layer, so it overrides the profile-level settings of every profile. This fits its role as "machine-local preferences" very well (such as local paths, proxies, and logging switches), but it is also a layer that is easy to apply by mistake: if you write a home-level patch on a shared machine, it will override the intent of every profile. When troubleshooting "why doesn't a given profile's config take effect on this machine," first check whether $DSH_HOME/cordis.patch.yml has a plugin line with the same name. Likewise, the argv overlay comes last; it overrides all preceding layers and is very well suited for temporary diagnostics, but don't stuff critical differences into the startup command long-term—it isn't persisted to disk, isn't reviewable, and under the rule that "applying arguments is not another patch layer," the override path you think you're taking may not be reached at all.
The following example demonstrates a typical whole-line replacement scenario: a higher layer only wants to change one key, but because of replacement semantics it takes all the other keys from the lower layer with it. The example uses two pseudo-patches to express the layer relationship, helping you reproduce and verify this in your own project.
# Lower layer: a patch contributed by a bundle in the profile's dsh.profile.bundles (illustrative)
- id: my-plugin
config:
greeting: "Hello"
maxRetries: 3
verbose: false
# Higher layer: the profile's own cordis.patch.yml, which only wants to bump maxRetries to 5
- id: my-plugin
config:
maxRetries: 5
# Result (winning by line + whole-line replacement, not per-key merge):
# greeting —— no longer comes from that lower-layer line; its value is now determined by this layer's config and the schema default
# maxRetries —— 5
# verbose —— likewise, no longer comes from that lower-layer line
# Correct approach: the higher layer must write out the entire config
- id: my-plugin
config:
greeting: "Hello"
maxRetries: 5
verbose: false
To verify whether this kind of override happens as expected, the most useful action is not to guess, but to add patches layer by layer and observe how the final configuration changes: first record a baseline with no patches at all; then add a profile-level patch and observe which keys are modified; then add a home-level patch and observe which keys get pushed back; finally add a --patch overlay to the startup command and observe whether it overrides all the previous layers as you intended. Adding only one layer at a time is the only reliable way to troubleshoot override semantics.
How surface-layer bundles resolve their own services: how an ordinary application obtains dependencies
Now that the layering is covered, there is another class of questions that must be answered: after a plugin has been patched layer by layer, how does it access other services? The material gives a very concise but highly informative sentence—surface-layer bundles can resolve them through the ordinary application's own services.
Translated into engineering language: although a bundle is assembled by the "configuration layer," it is not merely a static declaration that can only be rewritten by configuration; as an npm package with code, it is still an ordinary cordis application component, and at runtime it can obtain the services it depends on through the regular service resolution mechanism. In other words, "being managed by patches" and "being able to resolve services" are two different things: the former determines where the config for this entry comes from, while the latter determines where it gets capabilities from at runtime.
This distinction resolves a common conceptual anxiety: some people worry that "since my plugin was inserted by a patch, does that mean it cannot get context or depend on other plugins?" The answer is no. A bundle lives within the cordis application system, and service resolution follows the application's own path, regardless of whether you use a bundle or which layer patched it. Config is an input that can be overridden; services are dependency resolution at runtime.
In practice, this leads to two useful corollaries. First, what belongs in config and what should be obtained from services must be clearly separated: anything that operations should be allowed to adjust by environment (switches, thresholds, paths, greetings, and the like) should go into the Config schema so the patch layers can override it; anything that is a code-level capability dependency (logging, storage, services provided by other plugins) should go through service resolution, and you should not try to use config to "switch" a problem that should be solved at the dependency level. Second, when troubleshooting "a plugin cannot obtain dependencies," do not look into patch layering. Layering only explains the source of configuration values; service resolution failures are usually issues with dependency installation order, service registration timing, or how context is obtained—these are different problem domains. Separating these two problem domains can save a great deal of time spent searching in the wrong direction.
September 2026 practice: a troubleshooting checklist for dsh layered configuration
Bring the mechanisms above onto the troubleshooting bench. For advanced readers, the checklist below can serve directly as a starting point for dsh layered configuration issues. Proceeding in the order of "layering first, semantics second" can cover the vast majority of failures of the kind "the configuration did not take effect / it took effect but it is not what I wanted."
- First, confirm you picked the right profile. Check whether the --profile <name> in your launch command points to the profile you think it does. Profiles live under $DSH_HOME/profiles/<name>, and each profile has its own package.json and bundles — pick the wrong one and you've picked the wrong entire configuration tree.
- Read the bundle order instead of guessing. Open the profile's package.json and look at dsh.profile.bundles: is dsh-base first, does the ordering of third-party packages match your expectations, and is there a package you thought was installed but that isn't actually in the list (which means it doesn't declare dsh.bundle, or the install step never finished).
- Locate the two kinds of patch layers. Look at cordis.patch.yml inside the profile directory (profile level) and $DSH_HOME/cordis.patch.yml (home level). Pay special attention to the fact that the home level is shared across profiles; it applies after the profile level and overrides it. The answer to "a profile on this machine isn't taking effect" is often hiding in this layer.
- Verify the argv order of overlays. Each --patch <path> overlay is applied in argv order, with the last one overriding all the ones before it. If your launch script assembles multiple overlays, or generates them dynamically from environment variables, that order is the direct determinant of the resulting overrides. Unstable order means unstable behavior.
- Apply whole-line replacement semantics, not per-key merge. When you hit "I changed one key and other keys changed too," first assume replacement semantics are in play: is the config in the higher-level patch written out in full? Do you need to carry along the remaining keys from the lower level? Don't expect keys omitted at the higher level to be inherited from the lower level.
- Take application arguments out of the layering model. Remember that application arguments are not another patch layer. If a setting you expect to participate in "last line wins" is passed in as an application argument, then it doesn't go down that comparison path. Check whether the override you expect to happen isn't in this layering scheme at all.
- Separate config problems from service problems. Determine whether "the plugin can't get something" is a wrong config value or a failed service resolution. For the former, look in the patch layering; for the latter, look toward dependencies and service registration. Don't conflate the two.
- Reproduce with a single-layer incremental approach. Record the baseline with no patches → add the profile level → add the home level → add overlays, adding only one layer at a time and diffing the final config. This is far faster than suspecting all three layers at once, and it's the only way to truly confirm the override order.
If you had to pair this checklist with a single troubleshooting philosophy, it would be: first ask "where does this line come from," then ask "why is it this value." The layered model answers the first question, replacement semantics answer the second, and application arguments and service resolution remind you that not everything fits inside this layering scheme.
Summary and Best Practices
Condense the entire article into an actionable checklist. Hopefully, the next time you face the question "where does the effective configuration actually come from," you can walk through it item by item instead of starting from scratch and guessing.
- Establish the contract on the plugin side first: Export a Config interface and a same-named Schemastery schema, write default values on the schema fields (e.g., Schema.string().default('Hello'), Schema.number().default(3), Schema.boolean().default(false)), and remember that the second argument to apply is the validated configuration. This is the starting point for "separating configuration from code."
- Distinguish the two manifests: dsh.bundle answers "what does this package contribute" — a patch file that inserts or overrides plugin lines; dsh.profile answers "which bundles make up this configuration and in what order." A bundle is what a plugin author writes and distributes; a profile is what a user launches with dsh --profile <name>. Nothing is both at the same time.
- Understand the essence of dsh plugin: dsh plugin --profile <name> <args...> forwards arguments to pnpm inside the profile directory, so all pnpm subcommands are available. On first use, it initializes the profile and makes @deepseek-ai/dsh-base its first composition package.
- One install, three changes: pnpm links the checkout → dsh appends the package into dsh.profile.bundles according to the dsh.bundle declaration → dsh-base stays firmly in first place. Remember: dependencies determine "whether it's installed," bundles determine "whether it's used and how it's layered."
- Memorize the four-layer load order: ① the profile's dsh.profile.bundles (including dsh-base and each installed composition package, in the order they were added) → ② the profile's own cordis.patch.yml → ③ $DSH_HOME/cordis.patch.yml (machine-local preferences shared across profiles) → ④ each --patch <path> overlay (in argv order). Application arguments are not another patch layer.
- There is only one rule of override semantics: later-applied layers win on a per-line basis, and a patch replaces the entire config value of the target line rather than deep-merging individual keys. If you want to change one key while preserving the rest, write the full config at the higher layer; don't expect to inherit keys from a lower layer.
- Service resolution is orthogonal to configuration layering: surface-level composition packages can resolve them through ordinary application-owned services. Use config to carry inputs that can be overridden by patches, and use services to resolve runtime capability dependencies. Don't conflate the two into a single problem.
- Engineering discipline: Don't let hand-written dependencies and hand-written bundles bypass dsh plugin; use link dependencies during development for convenience, and pin versions before release; profiles belong to the environment and should live under a persistent, writable $DSH_HOME; treat overlays in launch commands as temporary diagnostic tools, not hiding places for long-term configuration differences.
- Troubleshooting mantra: First confirm the right profile is selected → read the bundles order → locate the two types of patch layers → verify the argv order of overlays → apply whole-line replacement semantics → pull application arguments out of the layering → separate config issues from service issues → reproduce using the single-layer incremental method. First ask "where does this line come from," then ask "why is it this value."
Once you have a solid grasp of this layering and manifest mechanism, you'll have three things in hand: a configuration contract that can be overridden by patches (Config and schema), a set of installable and reusable distribution units (bundles), and a predictable loading tree (four-layer ordering plus line-by-line precedence). Combined, these are exactly the complete answer to the question of where dsh's effective configuration comes from.