Skip to content

docs

Recipe card from the charly-build plugin (Commands — runtime CLI verbs).

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.

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 on a contributor’s machine to regenerate the site 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. Build and test it with GOWORK=off:

Terminal window
cd candy/plugin-docs && GOWORK=off go build ./... && GOWORK=off go test ./...
Terminal window
task docs:sync # regenerate into docs/ (runs build:binary first)
task docs:drift # FAIL if the committed site is stale — regeneration must be a no-op
task docs:dev # local preview at http://localhost:4321
task docs:build # production build into docs/dist
# the underlying verb
charly docs generate --out docs/src/content/docs --root .
Flag Meaning
--out docs content root to write into (required)
--root repo root holding charly.yml, candy/, box/, plugins/ (default: cwd)

The generator owns three top-level trees — vision.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 hand-authored narrative (index.mdx, start/, concepts/, guides/) is never read or written.

Tree Source
vision.md VISION.md verbatim, repo-relative links rewritten for a web reader
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 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 boxes lists enabled boxes, and resolves through main’s import: closure — which pulls arch, cachyos and fedora but not debian or ubuntu. A catalog built on it omits every debian.* and ubuntu.* box (ten definitions that are checked out and have their own skills) while duplicating ten arch boxes under transitive cachyos.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 the generator walks each repo as its own project root — the superproject plus every box/<distro> submodule — and unions the results.

The skill corpus is densely self-linked in harness syntax — 3665 references across 257 distinct targets in the skill files the generator reads, /charly-check:check alone appearing 268 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.

Three guards keep real content from being mangled into links, each earned from an actual corpus case:

  1. the skill part must start with a letter — else redis://charly-redis:6379 matches;
  2. the reference must not follow / — URL authorities;
  3. 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 prose

This 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.

Invoke before running or modifying charly docs, editing candy/plugin-docs, touching the docs/ submodule’s generated trees, or changing anything the generator reads (a candy’s description:, a plugin’s providers: or CUE schema, a skill’s frontmatter or cross-references).