docs
Recipe card from the charly-build plugin (Commands — runtime CLI verbs).
docs — generate the opencharly.ai site
Section titled “docs — generate the opencharly.ai site”charly docs generate emits the generated half of the documentation site published at
opencharly.ai from the sources that already exist in this repository. It is served by
candy/plugin-docs.
Placement: a RUNTIME plugin, deliberately
Section titled “Placement: a RUNTIME plugin, deliberately”plugin-docs is not listed in charly/charly.yml’s compiled_plugins:. A dev-time
documentation generator has no business inside every shipped charly binary — it is run by
the opencharly/docs repo’s deploy workflow against a charly checkout pinned by a CI-time
commit in deploy.yml (the docs repo carries no charly submodule anymore), and on a
contributor’s machine for local preview, and nowhere else.
charly prescans the declared docs word into the Kong grammar before parse and syscall.Exec’s
the plugin binary in CLI mode on the first actual charly docs invocation. Nothing about the
generator needs the host reverse channel (it reads files and writes markdown), which is exactly
what makes the out-of-process placement free here — the same property that lets charly candy
and charly migrate run in either placement.
Because it is not in compiled_plugins:, the plugin is not in go.work. Its Go tests
live in its OWN repo (opencharly/plugin-docs, candy/plugin-docs/*_test.go); the docs-site
R10 bed (docs-site-app/check-docs) lives in charly. Generation, the content-drift gate,
the Astro build and the publish all live in the opencharly/docs repo’s deploy.yml
workflow, which runs generation DIRECTLY with the plugin-docs CLI binary it builds at the
tag pinned in deploy.yml’s build step — not via the charly docs word — against the charly commit
the workflow pins (a CI-time commit in deploy.yml; the charly submodule was removed when
it leaked the whole repo into consumer fetch trees, and .gitmodules now carries only the
marketplace).
charly docs generate \ --out /absolute/path/to/site-content \ --root /absolute/path/to/opencharly| Flag | Meaning |
|---|---|
--out |
docs content root to write into (required) |
--root |
repo root holding charly.yml, candy/, box/ (default: cwd) |
--plugins |
the opencharly/marketplace checkout the skill corpus is read from (default: <root>/plugins) — the docs deploy passes its marketplace submodule |
What it emits
Section titled “What it emits”The generator owns six top-level entries — index.md, grievances.md, vision.md,
liberation.md, reference/ and recipes/ — and rewrites them wholesale each run, so a deleted
source entity disappears from the site instead of lingering as an orphan page. (The table below
expands reference/ into its sub-trees plus the single file reference/providers.md, so it
runs to many rows.) What stays hand-authored is the teaching narrative this repository has no
equivalent of: start/, concepts/ and guides/.
Hand-authored pages are never WRITTEN — but they are read. The boundary is the DO-NOT-EDIT
header, not the path: the prune pass reads every .md/.mdx under --out and removes only those
carrying it, so a page is matched by its content, never inferred from where it sits. The
whole-site link gate then reads hand-authored pages too, deliberately — a dead site-absolute
link in a concepts/ or guides/ page fails generation, exactly as it would in a generated one.
Treating “hand-authored” as “the generator ignores it” is the trap; it will not overwrite your
page, and it will refuse to build if you link somewhere that does not exist.
The home page is the newest arrival and the one people get wrong: it used to be a hand-authored
index.mdx restating README.md, and roughly two thirds of it was README prose maintained twice
across a submodule boundary. It is now index.md, projected from README.md. Edit the README,
not the page.
| Tree | Source |
|---|---|
index.md |
README.md, H1 and tagline dropped (the frontmatter title and hero render them), https://opencharly.ai/… links rewritten site-relative, and the repo-relative targets AGENTS.md, the marketplace repo’s README and CHANGELOG/README.md repointed at GitHub or their site equivalent |
grievances.md |
GRIEVANCES.md, H1 dropped, repo-relative links rewritten for a web reader |
vision.md |
VISION.md, H1 dropped, repo-relative links rewritten for a web reader |
liberation.md |
LIBERATION.md, H1 dropped, repo-relative links rewritten — the deliberate easter-egg page, entered last in the sidebar as “Liberation” |
reference/cli/ |
one page per command: provider word |
reference/candy/ |
every defined candy: packages, services, and its plan: as an acceptance spec |
reference/box/ |
every defined box |
reference/plugin/ |
providers, placement, rendered schema/*.cue |
reference/providers.md |
the inverted index: every reserved word → its owning plugin |
recipes/ |
every skill plus its references/*.md detail pages |
Every emitted file carries a DO-NOT-EDIT header, and regeneration on a clean tree is a
no-op — the same drift gate SDD applies to generated Go.
The docs: node — ONE generation config
Section titled “The docs: node — ONE generation config”Everything the assembly above needs is declared in ONE docs: kind entity in the charly
repo’s TOP-LEVEL charly.yml (<root>/charly.yml — the project root generate is pointed
at, never a module-level manifest). It is CUE-contract-sourced: the closed #DocsConfig
contract lives in spec/schema/docs.cue (the spec module) and the generator decodes the
node into the GENERATED spec.DocsConfig types (cue:gen), CUE defaults applied. The node
is discovered by the same name-first walk as skill:/hook:/marketplace: entities — the
docs kind discriminator nested under any entity name — and the contract is explicit:
- ONE docs-site generation config per repo. More than one
docs:node in the rootcharly.ymlis a configuration error; an ABSENT node decodes to the schema defaults, which reproduce the pre-config generator exactly (compiled corpus read fromcharly/charly.yml+charly/go.mod, no release/extra repos) — an un-annotated project still generates, identically. - The node names
sources.compiled(thecompiled_plugins_pathmanifest and thego_mod_pathpins),sources.release_repos/sources.extra_repos, themarketplace.path, theprojectionstoggles (recipes/cli/providers/candy/box/plugin/ landing), the hand-authoredoutput.hand_authoredtree, thelanding.readmesource and thegates(site_links/sidebar_links/prune). No generation knob is hardcoded; the node — or its schema defaults — answers every one.
Deployment: the drift-proof recipe
Section titled “Deployment: the drift-proof recipe”Generation, the drift gate, the Astro build and the publish are the SOLE domain of the
opencharly/docs repo’s deploy.yml (Cloudflare Pages, project opencharly-docs). A local
regeneration only ever mirrors it; the recipe that keeps the drift gate — a byte-no-op
regeneration diff on src/content/docs — green:
- charly is pinned by a CI-time commit in
deploy.yml(thecheckout <sha>in its charly-clone step), never by a submodule — the docs checkout’s.gitmodulescarries ONLY the marketplace. - The marketplace submodule must sit at the docs-repo-RECORDED gitlink, not at
mainHEAD. The corpus moves independently of the site; the recorded gitlink is the exact corpus the committed pages were generated against, whilemainHEAD is wherever the corpus PRs happened to land. Checking outmain’s moving tip and the drift gate disagree by construction. - The corpus wiring candy is written BEFORE generate. The deploy runs
python3 .github/docs-corpus/build_wiring.py .github/docs-corpus/pins.tsvintocandy/docs-corpus/charly.ymlinside the charly checkout (--root). The pins file is the release/extra-repo list in tag form (plugin-name<TAB>v2026.DDD.CCCC); the wiring candy turns it into the@github.com/opencharly/<name>/candy/<name>:<tag>refs the release/extra assembly resolves (pod-*repos carry the ref at their repo root). A run that skips this step regenerates a catalog WITHOUT those repos and drifts. --outsits under the docs checkout with the Astro config present — the sidebar gate (verifySidebarLinks) resolves every sidebarlink:target against the emitted routes and walks UP from the content root (src/content/docs) to findastro.config.mjs; an absent config is a HARD error, never a skipped check.- The refs cache must be fresh/CI-equivalent. Release/extra repos resolve at go.mod
pins or the NEWEST CalVer tag at generation time through
refs.DownloadRepo, whose fetched trees are cached (refs.RepoCacheDir,CHARLY_REPO_CACHEoverride). CI’s fresh cache resolves newer tags/content than a stale local one, so a stale local cache regenerates a site that differs from BOTH the committed pages and CI’s — the drift gate stays red locally despite a correctpins.tsvand correct sources. When the gate is red, regenerate from a warm, CI-equivalent cache and confirm the diff is exactly the intended projection change.
The deploy invokes generation DIRECTLY — /tmp/plugin-docs-bin generate --root <charly> --plugins <marketplace> --out <docs>/src/content/docs with the CLI binary built at the
pinned plugin tag — so the drift gate never depends on a local charly install or its
out-of-process word resolution. The docs landing is therefore: bump the charly CI-time
commit when the pin must advance, carry the regenerated pages, and let the drift gate
prove the two agree.
Editing a source is a docs landing
Section titled “Editing a source is a docs landing”Everything in that table is a PROJECTION, and every one of its sources is an ordinary
file people edit for reasons that have nothing to do with the site — a candy’s
description:, a plan step, a skill’s prose. Each such edit re-projects its page, and
from that moment the committed site no longer matches its sources, so the docs repo’s
drift gate (its deploy workflow regenerates and fails on any diff) stays red until a docs
PR lands the regenerated pages. A one-line prose fix is therefore never a one-repo
change. Plan it as a multi-repo landing before starting, not after a gate says so.
Two chains reach the site, and they are not the same length.
Candy and box prose — ONE hop. reference/candy/ and reference/box/ are read
straight off disk, walking each repo as its own project root. Editing a
candy/<name>/charly.yml description: or plan: re-projects
reference/candy/<name>.md in the same tree, immediately. The docs PR (regenerating at
the pinned charly) belongs to the same cutover as the candy edit.
Skill prose — TWO hops, with a direct complement. recipes/ is NOT read from
candy/*/charly.yml — but the generator DOES project candy skill: entities directly
from its own remote-aware walk (collectCandySkills), the complement that keeps a moved
candy’s recipe references resolving on the site even before the marketplace regeneration
lands (the stale-marketplace gap /charly-tools:crabbox measures — see the tests
section). The long path still holds: a skill is authored as a skill: entity in its
owning candy; charly marketplace generate projects that into the
opencharly/marketplace repo (<family>/skills/<name>/SKILL.md), and
charly docs generate reads that projection (via --plugins — the marketplace
checkout the docs repo pins as a submodule) first. Editing a skill:
entity therefore changes nothing on the site until charly marketplace generate has
run and the marketplace landing merges; the docs repo pins the marketplace in its own
.gitmodules (branch main), so the change reaches readers once the marketplace main
advances and a docs PR regenerates the site at the moved corpus. Advancing the corpus
without regenerating docs in the same change is what leaves the published site stale,
and nothing except the docs repo’s drift gate will say so — which is precisely how the
mirror has fallen behind before.
The ordering this forces — regenerate the projection, land the producer repo, then the
consumer (docs) and its charly pin — is /charly-internals:git-workflow B6’s
producer→consumer rule applied to documentation. That skill owns the landing sequence;
the mechanism above is what makes the sequence necessary, and is all this page needs to
carry.
Defined, not default-active
Section titled “Defined, not default-active”The catalog enumerates what is defined, never what happens to be switched on. This is not a stylistic preference; the obvious surfaces are actively wrong for the purpose:
charly box list boxeslists enabled boxes, and resolves through main’simport:closure — which pulls arch, cachyos and fedora but not debian or ubuntu. A catalog built on it omits everydebian.*andubuntu.*box (ten definitions that are checked out and have their own skills) while duplicating ten arch boxes under transitivecachyos.arch.*aliases.- A plugin absent from
compiled_plugins:still loads out-of-process when a plan references its word, so “in the binary” is not the same set as “exists”.
So discovery is not the walked trees alone: the catalog is ONE assembly (R3/R5,
assembleCatalog) unioning three sets —
- the walked closure — remote-ref-aware (
candywalk.CollectEntitiesRemote): the superproject plus everybox/<distro>submodule, resolving any@github.com/opencharly/…ref in a walkedrequire:/candy:list through the standalone fetch (refs.DownloadRepo); - the compiled corpus — every name in the
docs:node’scompiled_plugins_pathmanifest (compiled_plugins:list), each pinned by a require in thego_mod_pathgo.mod (modulegithub.com/opencharly/<name>/candy/<name>); the Go tag formv0.YYYYDDD.Cis mapped onto the repo’s CalVer tagv2026.DDD.CCCC(the counter re-zero-padded to four —compiledPluginRef), the repo fetched at that tag and walked as a remote entity. A compiled-in plugin is therefore documented by construction, even when no walked tree declares it — the compiled blind spot is closed; - the release/extra repos from the
docs:config lists — bare names resolved through the sameDownloadReposeam, at the go.mod require pin when the compiled corpus go.mod requires the repo, else the repo’s NEWEST CalVer tag at generation time (refs.GitLatestTag).
Every set is the DEFINED set, never the switched-on set — the two bullets above stay the reason the enabled surfaces cannot be trusted.
Cross-reference rewriting
Section titled “Cross-reference rewriting”The skill corpus is densely self-linked in harness syntax — references across many distinct
targets in the skill files the generator reads, /charly-check:check alone appearing many
times. (Candy, box and VISION.md sources carry references too; those go through other emitters
and are not counted here.)
3654 of those are rewritten into site links — every reference in a skill BODY. The remaining
11 sit in SKILL.md frontmatter description: fields and never reach the published page at all:
splitFrontmatter removes the frontmatter before the body is rewritten, and firstLine then
truncates the description to its first sentence for the page subtitle, which drops all 11. They
resolve, so nothing breaks — but that is why, not because they are published unlinked.
An unresolvable reference in a body fails generation rather than emitting a dead link; a
reference in a description: is never gated, because it is never published.
One sanitizer: page path == served URL == link target
Section titled “One sanitizer: page path == served URL == link target”Every emitted page path AND every link target run through ONE sanitizer
(sanitizeSegment): lowercase ASCII letters and digits survive, every other byte renders
as -. That is not cosmetic — Astro strips . and : when it derives a route slug from
a file name, so a segment built from an entity identity (a repo path, a CalVer tag, a
namespaced name) would be served only at a mangled URL (githubcom/…/plugin-checkv20262…)
while every generated natural link to it 404s — 851 published URLs, measured on the live
site. Entity pages are additionally namespaced by DIRECTORY (a slash, not a dot — a
dotted filename would also be slug-mangled), so fedora/check-pod survives where
fedora.check-pod would not. Because the sanitizer is the one transform behind both file
names and link targets, the page path, the served route and the link target are the same
string BY CONSTRUCTION — a raw identity can never leak a dot or a colon into a page name
while its links disagree.
Three guards keep real content from being mangled into links, each earned from an actual corpus case:
- the skill part must start with a letter — else
redis://charly-redis:6379matches; - the reference must not follow
/— URL authorities; - it must not follow a word character — image tags like
localhost/charly-selkies-kde:latest.
references/<file>.md pointers (backticked paths, never markdown links) resolve to the skill’s
own child page.
Authoring consequence: you cannot write a fake reference
Section titled “Authoring consequence: you cannot write a fake reference”Because the gate fails closed, a well-formed reference in a skill BODY must resolve — there is
no escape syntax. A skill therefore cannot write /charly- followed by two real-looking lowercase
words as a throwaway example: that shape matches, resolves to nothing, and fails the docs build.
Write generic forms with angle-bracket placeholders instead — < is not a letter, so the
pattern never matches:
/charly-<plugin>:<skill> ✅ safe in proseThis is not hypothetical, and it bit twice in one sitting. The first draft of
/charly-tools:docs-site used a made-up two-word reference as its example of an unresolvable
one, and the generator dutifully aborted — the skill documenting the gate tripped the gate. The
fix’s own “don’t do this” example then tripped it a second time. Failing closed is still the
right behaviour (a typo’d real reference is far likelier than a deliberate fake one), so
describe the bad shape rather than spelling it.
Why declarative sources, not the CLI’s help output
Section titled “Why declarative sources, not the CLI’s help output”The host renders every dynamic command word with a generic stub description, intercepts the
depth-1 --help itself, and plugin-served help arrives in at least three mutually incompatible
formats with no machine-readable dump. Parsing that would be the fragile shim R4 forbids. Every
fact the site needs is already declared: plugin.providers, the per-plugin schema/*.cue, and
each candy’s description:.
The trade-off is stated rather than hidden: command PARENTHOOD is a Go method
(CommandParent()), not a manifest field, so CLI pages name the word and its owning plugin
without asserting where it nests. The narrative CLI guide covers the nesting and the three
core-spine words (box, version, reap-orphans) that are not command: providers.
Tests: hermetic by construction
Section titled “Tests: hermetic by construction”The generator-wiring tests are HERMETIC: they run against self-contained fixtures, never
against the live corpus or the network. testdata/project is a minimal charly project
whose docs: node DISABLES the compiled corpus and declares no release/extra repos — so
the catalog assembly never fetches — plus the README/VISION/GRIEVANCES/LIBERATION
narratives the root pages project; testdata/marketplace is a one-plugin corpus that is
internally consistent BY CONSTRUCTION; testdata/site seeds the hand-authored pages the
generated landing links to, and the fixture writes the astro.config.mjs the sidebar gate
reads. The fetch seams — the repoResolver pair, download (refs.DownloadRepo) and
latestTag (refs.GitLatestTag) — are INJECTABLE into assembleCatalog, so the
assembly itself runs on fixtures without any network.
Bare-clone tests use fixtures so they perform zero network fetches: the
repoResolver pair, download (refs.DownloadRepo) and latestTag
(refs.GitLatestTag) are injectable, and the fixture tree carries the minimal
project, corpus and astro.config.mjs the passes read. A test that fetched the
real corpus would instead depend on whatever the pinned marketplace checkout
happens to hold, so the fixtures pin the input. The CHARLY_DOCS_MARKETPLACE
environment override wins over the fixture corpus when set — the seam the RDD bed uses to
pass a freshly regenerated corpus. The ONE deliberate network test is
TestCollectEntitiesRemote_ResolvesMovedCandy, which fetches the real layer-ripgrep
pilot repo at its newest tag to prove the actual refs.DownloadRepo fetch path (the
Phase-3 R10 gate) — a single test, not the wiring suite.
Cross-References
Section titled “Cross-References”/charly-tools:docs-site— the candy +check-docsbed that builds and proves the site./charly-internals:plugin— placement, the provider model, the CUE-schema contract./charly-internals:skills— the skill corpus this site publishes.
When to Use This Skill
Section titled “When to Use This Skill”Invoke before running or modifying charly docs, editing candy/plugin-docs, touching the
generated trees in the opencharly/docs repo, or changing anything the generator reads (a candy’s
description:, a plugin’s providers: or CUE schema, a skill’s frontmatter or cross-references).