AGENTS.md
August 24, 2026 · View on GitHub
@rdmu/create-dsh-plugin — the scaffold that generates DeepSeek Harness plugin
projects. Read README.md for what it does; this file is how to work
on it.
Four READMEs, two documents: the repository root carries README.md and
README.zh.md, and packages/cli/create/ carries a copy of each because npm
publishes the package's own directory. The copies are the root files minus the
Contributing / 参与开发 section — change one, mirror it, and keep the Chinese
in step with the English. files in the CLI manifest lists both, following the
dsh convention that a tarball ships English and Chinese side by side.
The one contract that governs everything
This package's version IS the dsh version generated projects depend on, plus a
.rev.N suffix naming which of our releases against that dsh it is.
0.1.1-rc.2.rev.2 → generated projects pin @deepseek-ai/dsh-*@0.1.1-rc.2
└── dsh ──┘ └─┬─┘
our second release targeting it
There is no --dsh-version flag and there must never be one. A release of this
scaffold targets exactly one dsh release, pins it exactly (no caret), and is
tested against it. dshRange() cuts the suffix off at REVISION_MARKER, which is
a literal .rev. rather than a positional rule, so dsh changing the shape of its
own prerelease cannot break the parse.
Three places state the version and versions.spec.ts asserts they agree:
- the root
package.json packages/cli/create/package.jsonpackages/example/plugin-hello/package.json
The template manifests (templates/bundle/, templates/layout/*/files/) state
0.0.0 plus the dsh version in their pins, which versions.spec.ts also
asserts; rewriteManifest replaces both at generation time.
Two kinds of release, both bumping those three manifests:
- A new dsh release: set the version to
<dsh>.rev.1, updateFRAMEWORK_VERSIONSinpackages/cli/create/src/versions.tsif Cordis or schemastery moved, update the dsh pins in every manifest, reinstall, runpnpm peers check, and runpnpm run scaffold:smoke. - A scaffold-only fix: bump
.rev.Nalone. npm never allows republishing a version, so even a typo fix needs a new number; nothing else changes.
Releasing
pnpm run verify-releasable is the local preflight; the same script runs first in
CI so a duplicate version or an unownable name fails in seconds rather than after
the smoke test. .github/workflows/release.yml is the one button
(workflow_dispatch, with a dry_run input) and runs, in order:
verify-releasable → check → coverage → scaffold:smoke → pnpm publish --provenance → tag.
The npm name is @rdmu/create-dsh-plugin; the unscoped create-dsh-plugin was
already taken on npm by an unrelated account. The bin stays
create-dsh-plugin, which is what makes pnpm create @rdmu/dsh-plugin work —
npm create drops the create- segment when resolving the package name.
Both workflows filter the package by path (--filter ./packages/cli/create),
never by name, so a future rename touches only manifests and prose.
Publishing needs the NPM_TOKEN repository secret. Everything else in the release
path is reproducible from a clean checkout.
Layout, and why the template is split
packages/
cli/create/ the scaffold CLI (published as `@rdmu/create-dsh-plugin`)
example/plugin-hello/ the example plugin — a REAL workspace package
templates/
root/ generated-project root files, shared by both layouts
layout/{single,workspace}/ per-layout `files/` overlay and `fragments/` passages
bundle/ the generated bundle package
docs/ authoring guides, copied into generated projects
scripts/ repository tooling
The template lives in three places for three distinct reasons. Do not "simplify" this by merging them:
packages/example/plugin-hello/is a real workspace package so this repository's own typecheck, lint, and tests cover the code the scaffold hands out. A template that only gets copied is a template nobody compiles.templates/root/andtemplates/bundle/hold files whose real names would be picked up by this repository's tooling. Apackage.jsonthere would join the pnpm workspace; atsconfig.jsonwould be compiled; an.oxlintrc.jsonwould be read as a nested lint config. Hence the prefixes:__name→name, and_name→.name(seetargetNameinsrc/copy.ts).docs/is shared between this repository's own links and every generated project.
prepack (scripts/prepare-cli-package.ts) collapses all three into the single
template/ tree a published tarball carries, which is why
resolveTemplateRoots() has a published branch and a source branch.
The two layouts
--layout decides the generated project's shape: single (the default) puts the
plugin at the project root with the bundle in bundle/, workspace puts both
under packages/. The templates are written in the workspace shape, because
that is the shape this repository itself has and therefore the one that stays
honest under review; the single layout is derived from it.
Three mechanisms do that deriving, and adding a template file means deciding which one it belongs to:
- Path rewriting (
layoutRewritesinsrc/copy.ts) handles every file whose only difference is path depth — globs,paths,--filtertargets, relativeextends. Prefer this: it keeps one copy of the file. Rules are literal strings and ordered, and the relative-depth rules name whole paths rather than blanket../../../, because.claude/skills/dsh-source/does not move between layouts. - A per-layout
files/overlay is for files whose shape differs, where no rewrite would help: the root manifest, the root tsconfig, andtsdown.config.ts. The overlay is materialized last, so it wins over whatever the shared trees wrote at the same path. <!-- include: name.md -->fragments are for the passages ofAGENTS.md,README.md, and the plugin's own README that genuinely differ. Includes resolve before naming substitution, so a fragment is template text like any other, and a layout that needs nothing from a passage provides an empty fragment. Two full copies of those documents would drift; five short fragments do not.
In the single layout the plugin's README becomes the project's, because that file
is what npm publishes for a one-package repository; the project-level pointers it
would otherwise lack arrive through the where-to-start.md fragment, which is
empty in the workspace layout.
A file that needs none of the three simply lives in templates/root/.
scaffold.spec.ts asserts both layouts produce their expected trees, leave no
template token behind, and leave no unresolved include marker.
Commands
pnpm install
pnpm run check # typecheck + lint + test + build
pnpm run test:coverage # per-file 100%; every `v8 ignore` states why
pnpm run scaffold:smoke # THE release gate — see below
pnpm run trace <pkg> # where an installed dsh package's contract can be read
pnpm run dsh:graph # fetch the pinned dsh source and index it (see below)
scaffold:smoke is not optional before a release
pnpm run test generates from the SOURCE layout. Only scaffold:smoke builds,
packs a real tarball, installs it, generates from the PUBLISHED layout, and then
runs the generated project's own pnpm run check. It is the only thing that
catches a missing files entry, a broken prepack, or a stale lib/. It has
already caught one such bug; it exists because that class of failure is invisible
to every other check.
Conventions
The toolchain and code style are deepseek-harness's, deliberately: a plugin developed in a generated project should build under the same rules as dsh's own packages, and this repository is the reference for what those rules are.
- No semicolons, single quotes, 2-space indent, trailing commas on multiline,
140-column limit. Enforced by
pnpm run lint. - Every module and exported symbol carries JSDoc stating its non-obvious
contract; function-like exports document
@paramand non-void@returns. - Comments state contracts and consequences. Do not restate the code, narrate changes, or preserve review history.
- Per-file 100% coverage. Each
/* v8 ignore */names why the branch is unreachable — an unexplained one is a bug, not a waiver. - Tests describe behavior. When behavior should change, change the test with it.
- Docs state the common path and name their edge. Where a guide stops it points at
docs/tracing-dsh.md, because a generated project's audience is an agent that would otherwise invent a dsh API.
Template-specific rules
- Every lowercase
helloin the template must name this plugin. Substitution is a word-bounded, case-sensitive replace, so prose may sayHellobut an identifier-shapedhellothat means something else would be corrupted on rename.scaffold.spec.tsasserts no template token survives a rename. - The template's tool is
hello_greet, replaced with<snake_role>_greet. Longest token first — seesubstituteinsrc/copy.ts. - Anything added to
templates/root/needs a prefix decision. If its real name would be seen by pnpm, tsc, oxlint, vitest, or npm, prefix it. - Bundle packages ship no JavaScript. They are excluded from the generated
tsdown.config.tsworkspace glob; tsdown fails on a package whose entry glob matches nothing.
The dsh source graph
A generated project fetches its pinned dsh release's full source into
.dsh-source/dsh-v<version>/ and indexes it with
codegraph, from postinstall, so an
agent can read implementation instead of guessing it. Three rules keep that
defensible:
- Neither the snapshot nor any
.codegraph/is ever committed — here or in a generated project. Remote and tag are derived from the installed manifest (src/dsh-source.ts), so the recipe reproduces the artifact exactly; the index alone is ~256 MB of machine-local SQLite. - The step may never fail an install, and may never hang one.
templates/root/scripts/postinstall.mjsspawns and forgives;dsh-graph.tsexits 0 under--quietwhatever went wrong, disables git's credential prompts, and caps each step with a timeout — an invisible prompt on /dev/tty would otherwise blockpnpm installforever. Nogit, no network, nocodegraphbinary, and a--prodinstall withouttsxare all expected states, each with a printed reason. - A snapshot counts as fetched only once a marker says so.
git clonecreates.gitbefore it checks anything out and keeps it on failure, so a killedpostinstallwould otherwise leave a partial tree that every later run treats as complete and indexes forever. DSH_GRAPH=0is the CI contract, and--dry-runoutranks it. A dry run has to work with the graph disabled, orscaffold:smoke's assertion that the remote and tag resolve offline would silently stop asserting. Never let the release gate clone anything.
The split across three files is the coverage boundary: dsh-source.ts decides,
graph-runner.ts sequences against an injected GraphIo, and dsh-graph.ts is
argv plus real node: calls. Only the last is excluded from coverage; anything in
it that deserves a test belongs in one of the other two.
This repository carries the dsh:graph script but deliberately no postinstall
hook for it: a maintainer installing here is not the audience for a dsh source
graph, and slowing every install would be a bad trade.
Dependencies
The root devDependencies carry four dsh packages nothing here imports directly —
dsh-attachment, dsh-brand, dsh-timeout, dsh-typert-protocol. They are
transitive peers of the dsh packages the example plugin does use, and with
autoInstallPeers: false they must be declared or tsc cannot resolve the types.
This mirrors how a real dsh profile supplies them through its installation
fallback, so the example plugin's own manifest stays honest about what it imports.
Run pnpm peers check after changing dsh versions; a new unmet dsh peer belongs
in the root devDependencies and in templates/root/__package.json, not in the
example plugin's manifest.