dsh-baize-rules
English | 简体中文 — 中文说明见 README.zh.md。
dsh-baize-rules (Baize) is a dsh plugin that injects user-set, durable "must-do / must-not" requirements — plain-text rules — into the model at conversation start as a sourced user/message.
The name comes from Baize (白泽) — a mythical beast said to "understand the nature of all creatures, know the names of all things, and comprehend the principles of everything." It carries the behavioral baseline that the user sets for the model.
The rules panel in the dsh web UI — the 规则 / 模板 panes, scope tabs (conversation / project / global), tag-chip filtering, per-row enable / save-as-template actions, and the active rules listed
- Rules are plain text with no
must/mustNot markers — whether something is "must-do" or "must-not" is expressed by the language of the body itself (e.g. Write comments in Chinese. = must, Do not delete the tests. = must-not).
- Injection happens at the start of a conversation: the currently active rules are injected into the model request as a persistent
user/message, wrapped in a <system-reminder> frame, with source.kind='plugin' and plugin='baize-rules'.
- No rules → no injection; if the byte budget shrinks such that all rules are cut, it returns
undefined and never injects an empty reminder shell.
Features
| Feature | Description |
|---|
| Three scopes, all injected | global (all sessions) / session (current session) / project (the session's working directory — this directory only): all three reach the model |
| Persistence | Everything is written to $DSH_HOME (default ~/.dsh); survives across sessions and restarts |
| Specificity wins | Render order project > session > global; when the byte budget is tight, the more specific rules are preserved first |
| A corrupt file never breaks a step | Rule files are read tolerantly: a broken or hand-edited file degrades that scope to empty and surfaces a problems warning, instead of failing the conversation |
| Deduplication | Suppresses duplicate injection by SHA-1 digest of the rendered text; optional injectAtEveryStep forces a refresh on every step |
| Stays present in long threads | The snapshot is republished when the text changes, after a session lifecycle change (startup / resume / clear / compact), and once the last copy is refreshAfterSteps steps old (default 20) — so the rules are never left behind only at the top of a long conversation |
| Escape protection | Literal </system-reminder> in rule bodies is escaped so user text can't close the plugin's frame |
| Command + API share the same source | The /baize-rules command and the front-end panel use the same store/core, so there is always a single source of truth |
| Tags | Rules can carry free-text tags (flow, #frontend) that the panel filters by. Panel-only by default — tags cost no model-context budget (injectTags opts in) |
| Template library | Save frequently used rules as templates (body + tags) and apply them to any scope; export/import the library as JSON to move it across machines and sessions. Applying a template makes a content copy — later template edits do not rewrite the rules you already added |
| Conflict-safe writes | Every write carries the file version it read; if another window changed the file in between, the write is refused with a retry hint instead of silently overwriting |
How this compares to AGENTS.md instructions
dsh ships @deepseek-ai/dsh-agent-instructions, which loads the workspace instruction chain (AGENTS.md / CLAUDE.md). The two are complementary — pick by where the rule has to live:
| AGENTS.md (built in) | dsh-baize-rules (this plugin) |
|---|
| Where it lives | A file inside the repository | $DSH_HOME/rules/*.json, plus the panel |
| Reach | Everyone working in that repository | Your machine: all sessions, one session, or one directory |
| Version control | Committed, reviewed and shared with the team; travels with the repository | Outside the repository; travels with DSH_HOME |
| How you change it | Edit the file; the change lands on the next file touch or session resume | Edit it in the panel or with /baize-rules; the next step injects it |
| Best for | Project conventions that belong to the project | Personal, cross-project, or per-conversation requirements |
Both inject a sourced user/message framed with <system-reminder>, and both carry the same precedence sentence, so they reinforce rather than fight each other.
Installation
dsh plugins are distributed from npm and installed into a profile via dsh plugin.
# Install from npm into the web profile (use the actual published version)
dsh plugin --profile web add dsh-baize-rules@0.2.2
pm2 restart dsh # Reload when dsh is managed by pm2
dsh --profile web
Peer dependencies (@deepseek-ai/*, react, etc.) are provided by the dsh profile; if any are missing, pnpm resolves them against peerDependencies in the profile directory.
Uninstall
# Remove the plugin from the profile
dsh plugin --profile web remove dsh-baize-rules
pm2 restart dsh # Reload when dsh is managed by pm2
If the entry lingers in the profile's dsh.profile.bundles, delete that line from
$DSH_HOME/profiles/web/package.json and restart dsh again. Your rule files under
$DSH_HOME/rules/ are not touched — delete them by hand if you want a clean slate.
Try it without touching your running setup
Install into a separate profile so your currently running dsh stays unchanged:
dsh plugin --profile smoke add dsh-baize-rules@0.2.2
dsh --profile smoke --dump-config # read & compose the config only — does not boot dsh
--dump-config only composes and prints the tree; it does not start a server, so it is safe to run
alongside your live dsh. To actually try the plugin in that profile, run dsh --profile smoke.
Note on pm2 restart dsh: it reloads the profile you are running. Install/remove only changes the
profile on disk — nothing takes effect until the next boot (or that restart).
Local development (link)
If you haven't published yet, or want to pick up source changes live, use this directory as a link dependency:
// /home/abc/.dsh/profiles/web/package.json
"dependencies": {
"dsh-baize-rules": "link:/home/abc/work/plugin/dsh-baize-rules"
}
Then run pnpm install in the profile directory and add dsh-baize-rules to dsh.profile.bundles.
Quick Start
/baize-rules # Same as /baize-rules list: show the currently active rules
/baize-rules add Write comments in Chinese. # Add to the default scope (usually session)
/baize-rules global add Don't delete or rewrite existing tests. # Explicitly add to global
/baize-rules list # Show project + session + global (abbreviated id / disabled markers)
/baize-rules list project # List one scope only
/baize-rules edit <id> Use pnpm to build only. # Edit a rule's text
/baize-rules disable <id> # Disable one (keep it, don't delete)
/baize-rules enable <id> # Re-enable
/baize-rules scope global # Subsequent commands default to global
/baize-rules clear session # Clear the current session's rules
/baize-rules global clear # Same thing: `clear <scope>` and `<scope> clear` are equivalent
/baize-rules export # Export all rules as JSON
# --- tags ---
/baize-rules tag <id> flow release # Tag a rule (idempotent, deduped)
/baize-rules untag <id> release # Drop one tag
# --- templates ---
/baize-rules save <id> # Store this rule as a template ("Save as template" in the panel)
/baize-rules tmpl list # List the template library (optionally by tag)
/baize-rules from <id|#tag> # Add rules from templates (#tag = all with that tag)
/baize-rules tmpl export ./templates.json # Export the library to a JSON file
/baize-rules tmpl import ./templates.json --yes # Import (dry run by default; --yes writes)
Scope goes before the verb. Write /baize-rules global add <text>. A scope word at the end is
no longer a modifier — in /baize-rules add Deploy after tests global the word global is part of the
rule text. See Scope syntax.
Commands
All subcommands live under /baize-rules; no argument is equivalent to list.
/baize-rules [<scope>] <command> # scope is global|session|project, written BEFORE the verb
/baize-rules [list [scope]|add <text>|remove <id>|edit <id> <text>|enable|disable <id>|tag|untag <id> <tag…>|save <id> [#tag…]|from <id|#tag>|tmpl <list|add|edit|rm|export|import>|scope <scope>|clear <scope>|export]
/baize-rules in the slash-command menu, described as "查看/增删改 会话或全局的 必须/禁止 要求,并管理可复用的规则模板"
| Subcommand | Syntax | Purpose |
|---|
| list | /baize-rules list [scope] | List the merged active rules (Project/Global/Session sections; shows No active rules. when empty). Pass a scope to list that scope only (/baize-rules list project; <scope> list is equivalent) |
| add | /baize-rules add <text> | Append a rule to the target scope (default scope); the text is the rule |
| remove | /baize-rules remove <id> | Delete a rule (id or unique prefix) |
| edit | /baize-rules edit <id> <text> | Change a rule's text |
| enable | /baize-rules enable <id> | Enable a disabled rule |
| disable | /baize-rules disable <id> | Disable a rule (keep but not active) |
| tag | /baize-rules tag <id> <tag…> | Append tags to a rule (idempotent, deduped, case-insensitive) |
| untag | /baize-rules untag <id> <tag…> | Remove the named tags |
| save | /baize-rules save <id> [#tag…] | Store the rule as a template; merges tags if a same-text template exists |
| from | /baize-rules from <id|#tag> | Add rules from templates; #tag adds every template carrying that tag. The target scope comes from the leading scope keyword (or the default), never from a second argument |
| tmpl | /baize-rules tmpl <subcommand> | Template library management (below) |
| scope | /baize-rules scope <global|session|project> | Switch the default scope for subsequent commands (persistent for the current process). The scope is an argument here, so both /baize-rules scope global and work |
tmpl subcommands
| Subcommand | Syntax | Purpose |
|---|
| list | /baize-rules tmpl list [tag] | List templates (abbreviated id, tags, use count), optionally filtered by tag |
| add | /baize-rules tmpl add <text> [#tag…] | Create a template |
| edit | /baize-rules tmpl edit <id> [<text>] [#tag…] | Change body and/or tags; omit <text> to retag only |
| rm | /baize-rules tmpl rm <id> | Delete a template |
| export | /baize-rules tmpl export [<file>] | Export the library (prints JSON when no path is given) |
| import | /baize-rules tmpl import <file> [--merge|--replace] [--dry-run] [--yes] | Import a library (dry run by default; only --yes writes) |
Argument details
<text>: the rule body, may contain spaces. Whether it's "must-do" or "must-not" is expressed by the body's language; there is no marker.
<id>: a stable rule id (crypto.randomUUID). list shows the first 8 characters as an abbreviated id for readability;
remove/edit/enable/disable/tag/untag/save all accept the full id or a unique prefix.
An ambiguous prefix reports ambiguous and lists the candidates — it never guesses. Same for tmpl rm/tmpl edit.
<tag…>: free-text tags, space separated, # prefix optional (#flow ≡ flow). At most 8 tags per item,
24 characters each; dedupe and filtering are case-insensitive, while the stored spelling is what you first typed.
from duplicate guard: a rule whose text already exists in the target scope is skipped
(Skipped N duplicate(s)), so re-adding from a template never piles up duplicates. A template that is
actually applied gets its use count +1.
Scope syntax
A scope modifier is written before the verb — this is the only form:
/baize-rules global add Write in Chinese.
/baize-rules project add Run the tests before every commit.
/baize-rules session remove <id>
BREAKING (0.2.1): the trailing scope form was removed. A scope word at the end of the line is no
longer a modifier — it is part of the argument. /baize-rules add Deploy after tests global now stores
the body Deploy after tests global in the default scope. Before 0.2.1 the same line dropped the
word global from the body and wrote the rule into global, silently truncating rule text and
mis-targeting the write, so the trailing form was removed outright rather than kept as an alias.
The verbs this applies to are add/remove/edit/enable/disable/from. A scope word anywhere
other than the first token is plain text: /baize-rules add Run global checks before release.
stores that whole sentence as the body. from lost its positional scope argument with the trailing
form: write /baize-rules project from <id> (or set the default with /baize-rules scope project)
instead of /baize-rules from <id> project.
Trailing tag metadata is unchanged. tag/untag/save/tmpl still take a trailing run of
#tag tokens as metadata, not rule text. They take the scope in the prefix form only
(/baize-rules global tag <id> frontend), so a tag literally named project/global/session
can never be swallowed as a scope.
Where the scope is an argument, not a modifier, both orders work — /baize-rules clear global ≡
/baize-rules global clear, /baize-rules scope project ≡ /baize-rules project scope, and
/baize-rules list project ≡ /baize-rules project list.
When no scope is given, the default set by /baize-rules scope is used (initially from Config.scope, usually session).
Injection behavior (how the model context changes)
- Conversation-start baseline: at the start of a session,
agent/pre-step (prepend:true) inserts the active rules as a user/message into the request, framed as <system-reminder> with source.kind='plugin', plugin='baize-rules', form='snapshot'.
- All three scopes are injected:
project > session > global, specific first. project rules are read from the session's working directory, so they join the view (and the model context) whenever the session declares a cwd; they are rendered under the header Project requirements (this directory only):.
- Specificity wins: when the budget is tight the broader
global rules are trimmed first.
- Deduplication: a SHA-1 digest is computed over the rendered text; unchanged rules aren't re-injected.
injectAtEveryStep:true forces a refresh on each step.
- A fresh copy after a session lifecycle change:
agent/session-start (startup / resume / clear / compact) drops that session's record, so the next step publishes the full rules again — a compacted or cleared session cannot be left without them, which is the one case where "inject once at the start" could lose them for good.
- A fresh copy every
refreshAfterSteps steps: once the last published copy is that many steps old it is republished even when the text is byte-identical (default 20, 0 disables the periodic refresh). A step counter that restarts after compaction counts as stale as well. Because the message is a snapshot, the model still sees a single copy — this only moves it back to where the conversation is now.
- Escape: a literal
</system-reminder> in a body is escaped via escapeReminder.
- Tags stay out of the model: tags are not injected by default (
injectTags:false), so tagging or retagging a rule neither changes the text the model sees nor triggers a redundant re-injection.
- Empty / fully trimmed: when there are no rules, or the budget cuts all of them, it returns
undefined (i.e. does not inject that message).
- A corrupt file never blocks the step: every read degrades instead of throwing — a broken file makes that scope read as empty and adds a
problems entry. The step always completes; the warning is what tells you a file needs fixing.
What the model actually sees
<system-reminder>
The following user requirements apply to every step of this conversation. Obey them.
More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
Project requirements (this directory only):
- Run the tests before every commit.
Session requirements (this conversation only):
- Always test the plugin in isolation before deploying.
Global requirements:
- Write comments in Chinese.
- Do not delete or rewrite existing tests.
</system-reminder>
(The Project requirements (this directory only): section appears only when the session has a working directory and that directory has active rules.)
Configuration (Config)
On startup the plugin validates Config with @deepseek-ai/schemastery; an invalid value makes the plugin fail to load.
| Config | Default | Description |
|---|
scope | — (schema-required) | Default scope, used when /baize-rules doesn't specify one. Any of global/session/project — project included, resolved against the session's working directory |
maxBytes | — (schema-required) | Byte cap visible to the model; trimmed with specificity-wins when exceeded |
globalRulesPath | $DSH_HOME/rules/global.json | Override the global rules file path |
injectAtEveryStep | false | Force re-render on every step (debugging); default only patches on change |
refreshAfterSteps | 20 | Republish the snapshot once the last copy is this many steps old, even when nothing changed, so a long conversation never keeps the rules only at its top. 0 disables the periodic refresh |
injectTags | false | When true, render tags into the model context (- [flow,release] body). Off by default: tags are a human-facing classifier, and injecting them spends budget and adds noise |
apiOriginCheck | true | Panel API accepts same-machine, same-origin requests only; disable when a reverse proxy fronts the web UI |
scope and maxBytes are required by the schema, not optional with an implicit fallback. Omitting
either fails the plugin load with an explicit error naming the missing field — silently defaulting an
undefined scope used to hide a broken configuration.
apiOriginCheck (default true). The panel API answers only requests that come from this machine
and, when the browser states an Origin, from the host's own origin (or a local file:// page,
which sends a null origin). Anything else is refused with 403. The check exists because the global
rule file reaches every conversation's prompt, so "anything that can reach the port" must not be
enough. Turn it off when the web UI is reached through a reverse proxy or from another host —
such a request either arrives from a non-loopback address or carries an Origin that is not the host's
own, and would be refused. With the check disabled, authentication belongs to the proxy layer.
Mount metadata (cordis.patch.yml)
The published npm package ships dsh.bundle.patch, wired up automatically by dsh when you install dsh-baize-rules@<version>:
cordis.patch.yml inserts a single plugin line with default scope: session and maxBytes: 8192. To adjust the default scope / budget, change it there.
To mount by hand instead of relying on the bundle patch, insert the same row yourself — note the whole
mounted-rows list is inserted into the tree, and config mirrors the Config schema:
# $DSH_HOME/profiles/<profile>/cordis.patch.yml (e.g. ~/.dsh/profiles/web/cordis.patch.yml)
- insert:
- id: baize-rules
name: 'dsh-baize-rules'
config:
scope: session
maxBytes: 8192
After editing the profile's cordis.patch.yml, restart dsh (pm2 restart dsh when it is pm2-managed);
the profile file is read at boot.
Data location
| Scope | Storage | When written | Persistence |
|---|
| global | $DSH_HOME/rules/global.json | On any command / API submission | ✅ across restarts |
| session | $DSH_HOME/rules/sessions/<sessionId>.json | Same | ✅ across restarts |
| project | $DSH_HOME/rules/projects/<slug>.json (slug from the session cwd; no cwd → nothing is written) | Same | ✅ across restarts |
| templates | $DSH_HOME/rules/templates.json (global, not bound to any scope) | On template create/update/delete, import, or an apply that bumps the use count | ✅ across restarts |
$DSH_HOME is resolved by @deepseek-ai/dsh-home-paths, default ~/.dsh.
Reads never throw. They go through ctx.fs (resolve/stat/readText; writes auto-create directories).
A missing file is simply an empty scope, and a corrupt or hand-edited file degrades to an empty scope
plus a problems entry — the failure is reported (command output gains a ⚠ … line, the panel shows
a warning), never fatal to the step. Writes replace a whole file (there is no partial edit on disk),
so a write is either the new complete file or nothing at all.
Concurrent writes are guarded. Every write carries the freshness token of the file it read. If the
file changed in between (another panel window, another command), the write is refused — the command
reports a conflict and asks you to retry, the HTTP API answers 409 — instead of silently overwriting
the other edit.
No empty files. Only the scopes that actually changed are rewritten, and an untouched empty scope is
not written at all. (Older versions left a 3-byte [] file per session under $DSH_HOME/rules/sessions/;
those leftovers are harmless and can be deleted by hand — new ones are no longer created.)
project rules are part of the model context. When the session declares a working directory, its
project rules are read and injected under Project requirements (this directory only):, most specific
first (project > session > global). The project file name is a slug of the session's cwd: the
characters \ / : * ? " < > | become _, leading and trailing _ are trimmed, and a cwd made entirely
of those characters falls back to the literal _. With no session or no cwd there is no project key, so
nothing is written to disk (the HTTP API refuses such a write with 400).
Client panel (optional)
The published package also exposes a dsh web client panel (lib/client.js; see the ./client entry in package.json exports), talking to the same store/core as the command through the host HTTP API /baize-rules.api.
Where the panel lives
The panel registers itself into two dsh client slots (ctx.slots.inject('sidebar.footer.action') and
ctx.slots.inject('conversation.view')), so there are three ways in and they all show the same data:
- Sidebar footer → 「规则」 button — always available, opens (or focuses) the panel.
- In-session 「规则」 tab — when a conversation is open, the panel is a tab in that conversation's view ring.
- New-chat page — with no session yet there is no tab ring, so the button opens a full-viewport overlay instead.
The panel has two panes:
- Rules: scope switch (conversation / project / global), tag-chip filtering, and per-row
Edit / Save as template / Remove. Each row shows whether the rule is enabled or disabled and toggles it in one click (no need to fall back to /baize-rules disable). New rules are created through the structured rule.add op, so the text you typed is never re-parsed as a command line and can never lose a trailing word to a scope keyword; when a submission fails the input is left as typed so nothing has to be retyped. Warnings returned in problems are shown in the panel, and the UI is keyboard- and screen-reader-accessible (tab role/aria state, modal-dialog semantics with focus management, and aria-pressed on the tag chips).
- Templates: create/edit (body + tags)/delete,
Add to rules (pick a scope and apply), Export (download JSON) and Import (choose a file → dry-run preview → confirm).
Templates are content copies. A rule row's Save as template button (「存为模板」 — this is also
referred to as 「加入模板」, "add to template"; it is the same button) stores a snapshot of that rule's body
- tags in the template library. Adding a template to a scope creates a new rule with that content: the
two are independent afterwards, so later edits to the template do not rewrite the rules you already
added, and editing a rule does not change the template it came from.
A round trip through the panel: rule row Save as template → switch to the Templates pane and edit
the body and tags there → back on the Rules pane open From template and tick the entries you want
→ choose the scope and add them.
Scope availability: the panel only offers session/project when it is attached to a conversation — opened from the new-chat page (no session yet) those two buttons are disabled with a hint and only global rules can be edited, which avoids the old behaviour where a rule looked added and then vanished.
The project directory does not come from the panel: the host resolves the session's cwd itself (resolveProject), so project rules are available whenever you are in a conversation.
API:
-
GET /baize-rules.api?sessionId=…&project=… → { global, session, project, templates, problems }
-
POST /baize-rules.api, body { sessionId, project, op, … } → { ok, text, view, templates, problems, … }
op: 'raw' (the default, and what older panels send) { raw, scope } — one command line through the same core as /baize-rules
- rules:
rule.add { scope, text }, rule.update, rule.setTags, rule.setEnabled { scope, ruleId, enabled }, rule.saveAsTemplate, rule.addFromTemplates
- templates:
template.create, template.update, template.delete, template.export, template.import
- every op returns the post-change
view, templates and problems, so one call refreshes both panes and surfaces any unreadable store
-
Failure semantics (the response body always carries text for a refusal, except 500 which carries { error }):
| Status | Meaning |
|---|
400 | The request was refused: malformed JSON body, an empty raw, a missing ruleId/text, or an edit to a scope this request has no storage key for (no session, no cwd) |
403 | Origin refused — the request is not from this machine (a non-loopback source), or the browser states an Origin that is neither the host's own nor a local page (cross-site). Behind a reverse proxy or when the UI is reached from another host, set apiOriginCheck: false and let the proxy authenticate |
405 | Method other than GET/POST |
409 | Concurrency conflict — the file changed since it was read, so nothing was written; reload and retry |
413 | Request body larger than 1 MiB |
500 |
Module structure
src/rules.ts Pure logic: Rule/RuleTemplate models + tag normalization + render/<system-reminder>/byte budget(specificity-wins)/digest/escapeReminder
src/core.ts Pure logic: parseCommand/runCommand/scope resolution/CRUD/template library ops/template import-export (zero deps, unit-testable without dsh)
src/store.ts Pure logic: global/session/project rule files + templates.json persistence; tolerant reads (empty scope + `problems`) and version-guarded writes (ctx.fs + dshHomePath)
src/command.ts Thin dsh adapter: feed view/templates/defaultScope → core, persist nextView/nextTemplates; file IO for tmpl export|import; appends `⚠ …` warnings
src/index.ts apply: agent/pre-step injection + /baize-rules command registration + API mount (inject: agents/commands/fs/webServer/sessions)
src/api.ts Host HTTP API: GET + POST(op dispatch) /baize-rules.api (origin check, 1 MiB body cap, 409 on a lost write race) for the front-end panel
src/invariant.ts dsh-invariants contract companion (name/inject/apply)
scripts/dev-render.ts Loop 0 demo
test/*.spec.ts rules/core/composition/regression tests
cordis.patch.yml Mount metadata (inserts the baize-rules plugin line + default config)
Public entry points (see package.json exports): . (index), ./invariant, ./client, ./src/*.
Development & instant feedback
pnpm dev:render # Print the <system-reminder> text the model will actually see (supports budget args)
pnpm test # Run unit + REAL-composition tests, then the client smoke test
pnpm test:watch # Re-run on save
pnpm test:client # Just the client-panel smoke test (node test/client.smoke.mjs)
pnpm build # tsc -p tsconfig.build.json → lib/
pnpm typecheck # tsc --noEmit
pnpm check:exports # Validate every path promised by package.json `exports`
Change the pure functions in src/rules.ts (rendering) or src/core.ts (command decisions), then re-run pnpm dev:render to see the change — the fastest feedback loop (sub-second, without touching dsh).
Publishing
Releases are driven from a v* tag, but the publish itself is a local command. The publish job in .github/workflows/ci.yml requires the NPM_TOKEN secret, which is not configured in this repository — so it fails with npm error code ENEEDAUTH on every tag (verified on v0.1.3, v0.1.4 and v0.1.5). Until that secret is set, npm publish --access public is the step that actually ships a version.
# 1. Bump the version: update `version` in package.json + the install example in both READMEs
# 2. Verify locally
pnpm build && pnpm typecheck && pnpm test
# 3. Commit, tag, and push both (the tag run still gives you the CI build+test gate)
git add -A && git commit -m "release: vX.Y.Z"
git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin main && git push origin vX.Y.Z
# 4. Publish — this is the step that actually ships
npm publish --access public
The publish job runs on v* tags and needs the test job to pass; it will keep reporting failure until NPM_TOKEN is added to the repository secrets.
Credentials. npm publish authenticates with your own npm credentials — either the ones already in
~/.npmrc or ones supplied through environment variables. The repository-root .npmrc is listed in
.gitignore and must stay there: never commit a token, and never paste one into an issue, a README, or
a CI log.
Changelog
See CHANGELOG.md.
License
MIT