Scaffold a new FoundryVTT v13 module repo (Vite + TS + bun + biome, CI, release-please) with basic/app/libwrapper variants. Use when bootstrapping or init-ing a foundry module.
Installs into .claude/skills of the current project.
Are you the author of Foundryvtt Module Scaffold?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/laurigates-foundryvtt-module-scaffold)
---
created: 2026-06-26
modified: 2026-09-23
reviewed: 2026-07-30
name: foundryvtt-module-scaffold
description: >-
Scaffold a new FoundryVTT v13 module repo (Vite + TS + bun + biome, CI,
release-please) with basic/app/libwrapper variants. Use when bootstrapping or
init-ing a foundry module.
allowed-tools: Bash, Read, Write, Edit, Grep, Glob, TodoWrite
args: "[--name <repo>] [--display <title>] [--desc <text>] [--variant <basic|app|libwrapper>]"
argument-hint: --name foundryvtt-x --display "X" --desc "…" --variant basic
---
# foundryvtt-module-scaffold
Bootstrap a new FoundryVTT v13 **module** repo built with **Vite + TypeScript +
bun + biome**, leaving only the actual module logic to implement. The generated
repo passes `just check` (typecheck + build + lint + test) from the first commit
and distributes via the GitHub-release manifest-URL convention.
## When to Use This Skill
| Use this skill when... | Use the alternative when... |
|---|---|
| Starting a new FoundryVTT module repo — CI-green TS toolchain, release-please, and a basic/app/libwrapper skeleton before writing module logic | You want the full pipeline (repo created + seeded + gitops-adopted) → `foundryvtt-module` |
| Spinning up a FoundryVTT module backlog idea | Adding a feature to an *existing* module — this creates a **new** repo |
## The architecture it scaffolds
**TypeScript source in `src/` (entry `src/module.ts`), built to `dist/<id>.mjs`
via Vite library mode.** `vite-plugin-static-copy` places `module.json`, `lang/`,
`styles/` (and `templates/` for the app variant) into `dist/`, which Foundry
serves as the module root. `tsc --noEmit` type-checks; Vite emits — decoupled.
- **Type gate**: `bun run typecheck` → `tsc --noEmit`. Foundry globals are typed
by loose local ambient shims (`src/foundry-shims.d.ts`), so the build is
self-contained and CI-green without the (beta, git-only) `fvtt-types`. Verify
the real Foundry API before relying on a shape — the shims are deliberately
loose. Opt into `fvtt-types` later if you want richer types.
- **Build**: `bun run build` → `vite build` → `dist/<id>.mjs` + copied assets.
- **Dev**: `bun run dev` → Vite dev server on `:30001` proxying everything to
Foundry on `:30000` except the module's own files (served with HMR).
- **Distribute via GitHub release**: `module.json` `manifest` →
`releases/latest/download/module.json`, `download` →
`releases/latest/download/<id>.zip`. release-please bumps `$.version` in both
`package.json` and `module.json`; the release job builds, zips `dist/`, and
attaches the assets. No foundryvtt.com submission is needed to install by URL
(only to be *listed* in the in-app package browser).
## Three variants
| Variant | Use when | Adds on top of basic |
|---------|----------|----------------------|
| `basic` (default) | Settings + lifecycle behavior — the minimal well-formed module. | `init`/`ready` hooks, a registered `world` setting, i18n, scoped CSS. |
| `app` | The module has a UI panel. | An `ApplicationV2`/`HandlebarsApplicationMixin` window (`src/app.ts` + `templates/app.hbs`) and a `game.settings.registerMenu` button that opens it. |
| `libwrapper` | The module patches a core/system method. | `src/patches.ts` with a `libWrapper.register(...)` call **and** a manual monkey-patch fallback when lib-wrapper is absent; a `relationships.recommends` entry for `lib-wrapper`. |
**Decision rule:** `basic` for behavior driven by hooks/settings; `app` when the
module surfaces a window or dialog; `libwrapper` when it overrides a core method
(the conflict-safe way to do that on Foundry). Variants compose conceptually —
start from the closest one and add the rest by hand.
## How to run
`scaffold.py` is stdlib-only. Run from the workspace where the module should
land (e.g. `repos/laurigates/foundryvtt-dev/`).
Basic settings+hooks module:
```sh
python3 ${CLAUDE_SKILL_DIR}/scaffold.py --name foundryvtt-initiative-tweaks --display "Initiative Tweaks" --desc "Small quality-of-life tweaks to the combat initiative tracker."
```
Module with an ApplicationV2 UI panel:
```sh
python3 ${CLAUDE_SKILL_DIR}/scaffold.py --name foundryvtt-party-overview --display "Party Overview" --desc "A dockable party status panel for GMs." --variant app
```
Module that patches a core method via libWrapper:
```sh
python3 ${CLAUDE_SKILL_DIR}/scaffold.py --name foundryvtt-token-vision-tweak --display "Token Vision Tweak" --desc "Adjusts token vision drawing via a libWrapper-guarded patch." --variant libwrapper
```
Flags: `--name` (GitHub repo, e.g. `foundryvtt-x`), `--id` (Foundry module id;
default = `--name` minus the leading `foundryvtt-`), `--display` (title),
`--desc`, `--variant {basic,app,libwrapper}`, `--fvtt-min` / `--fvtt-verified`
(compatibility, default `12` / `13`), `--publisher` (default `laurigates`),
`--author`, `--dir` (parent dir, default cwd).
It refuses to overwrite an existing directory.
### Verifying a module (`--verify`)
```sh
python3 ${CLAUDE_SKILL_DIR}/scaffold.py --verify foundryvtt-initiative-tweaks
```
Re-runs the finishing-pass audit against an **existing** module and emits a
machine verdict (`MODULE_ID_MATCH=`, `ESMODULES=`, `MANIFEST_URL=`,
`DOWNLOAD_URL=`, `RELEASE_ZIP=`, `DECLARED_ASSETS=`, `LOCKFILE=`, then
`ISSUE_COUNT=` and `STATUS=`). Exit 1 on ERROR.
| `STATUS=` | Meaning |
|-----------|---------|
| `ERROR` | Foundry cannot install, load, or update the published module — the id disagrees across `module.json` / `vite.config.ts` / `src/constants.ts`, `esmodules` names a file the build never emits, or an install URL resolves to a release asset nobody uploads. |
| `WARN` | It loads, but something is unfinished (a declared asset that 404s, no `bun.lock` yet). A fresh scaffold is `WARN` until `bun install` runs. |
| `OK` | Nothing outstanding. |
The generated repo asserts the same invariants in `tests/manifest.test.ts`, which
its own `test` CI job runs on every PR — so a rename that drifts one of the five
places the module id appears fails a PR, not a user's install.
### Alternative: the cargo-generate template (pilot)
`templates/foundryvtt-module/` is a cargo-generate port of `scaffold.py` whose
emitted files are real files (so `tsc`/`biome`/`actionlint` can check them)
rather than Python strings. Output is byte-identical, enforced by
`scripts/tests/test-template-parity.sh`.
**`scaffold.py` remains the default.** Reach for the template to edit the
scaffold itself, or to try the flow before it is promoted:
```sh
cargo generate --path ${CLAUDE_SKILL_DIR}/../../templates/foundryvtt-module --name foundryvtt-initiative-tweaks --vcs none --define 'display_name=Initiative Tweaks' --define 'description=…' --define variant=basic
```
Needs `cargo-generate` locally — not in the base image, and the main cost of the
port. CI installs it from the release tarball so the parity gate actually runs
(#2221). See [`templates/README.md`](../../templates/README.md)
for the comparison, the one deliberate divergence (a non-kebab-case name), the
Liquid brace-collision fixes, and what promoting the template would take.
## What you get
A repo where `just check` passes from the first commit: a real `module.json`
manifest, `package.json` (bun scripts), `vite.config.ts`, strict `tsconfig.json`,
`biome.json`, `vitest.config.ts` + a green Vitest smoke test (Foundry globals
stubbed in `tests/setup.ts`) and `tests/manifest.test.ts` (the manifest-vs-build
gate above), `.github/workflows/` (`ci.yml`,
`release-please.yml`), `release-please-config.json` + manifest,
`renovate.json`, a `justfile`, `src/module.ts` + `src/settings.ts` +
`src/constants.ts` + `src/foundry-shims.d.ts`, `lang/en.json`,
`styles/<id>.css`, `CLAUDE.md`, `README.md`, `LICENSE`, and an ADR recording the
toolchain decision. The `app` variant adds `src/app.ts` + `templates/app.hbs`;
`libwrapper` adds `src/patches.ts`.
## After scaffolding
The generator prints the exact next steps. In order:
```sh
cd foundryvtt-<name>
git init -b main
bun install
just check
```
Seed `main` directly (the repo is unprotected until gitops adopts it) — pushing
a feature branch first would leave `main` missing on origin and force a rename +
default-branch fixup later. `bun install` writes `bun.lock`, which the seed
commit must include (CI uses `--frozen-lockfile`).
Then implement, and wire up infra:
1. **Implement the module** — for `basic`, `src/module.ts` + `src/settings.ts`;
for `app`, `src/app.ts` + `templates/app.hbs`; for `libwrapper`, replace the
`Token._draw` example in `src/patches.ts` with the real target.
2. **Add the repo to `gitops/repositories.tf`** with `release_please = true` and
a `foundryvtt` topic (mirror the `foundryvtt-mcp` entry). On apply, gitops
pushes the release-please App credentials.
**Or skip steps entirely:** run the **`/foundryvtt-module`** orchestrator, which
chains scaffold → `gh repo create` → seed `main` → the gitops PR.
## Hard rules baked into the output
- **`id` is the single source of truth.** `module.json` `id`, the install
folder, and the release zip name all derive from `--id`. Lowercase kebab-case
only.
- **ESM-only, paths byte-match the manifest.** `esmodules` references
`<id>.mjs`; the Vite output filename is pinned to match. A mismatch is a silent
load failure — `tests/manifest.test.ts` fails the PR that introduces it.
- **Target the harness-pinned Foundry version.** Keep `module.json`
`compatibility.{minimum,verified}` in sync with what you test against. Verify
the Foundry API against <https://foundryvtt.com/api/> or the live console — not
memory.
- **Do not commit `dist/`.** It is git-ignored and rebuilt; CI builds it for
releases.
- **Scoped CSS.** Every selector is prefixed with the module id — keep it that
way so styles never clobber core or other modules.
- **Never hand-edit `CHANGELOG.md` or the `version` fields** — release-please
owns them (it bumps both `package.json` and `module.json`).
## Agentic Optimizations
| Context | Command |
|---------|---------|
| Scaffold a basic module | `python3 ${CLAUDE_SKILL_DIR}/scaffold.py --name foundryvtt-X --display "X" --desc "…"` |
| Scaffold an app module | `python3 ${CLAUDE_SKILL_DIR}/scaffold.py --name foundryvtt-X --display "X" --desc "…" --variant app` |
| Verify a generated module | `cd foundryvtt-X && bun install && just check` |
| Gate the finishing pass (machine verdict) | `python3 ${CLAUDE_SKILL_DIR}/scaffold.py --verify foundryvtt-X` |
| Check the pilot still matches scaffold.py | `bash ${CLAUDE_SKILL_DIR}/scripts/tests/test-template-parity.sh` |
| Prove the finishing-pass gate still fires | `bash ${CLAUDE_SKILL_DIR}/scripts/tests/test-manifest-invariants.sh` |
## Notes & deferrals
- The biome pin is single-sourced in `scaffold.py`'s `BIOME_VERSION` constant so
`biome.json` and the CI `setup-biome` step never drift.
- Action/tool versions in the generated workflows are current as of scaffolding;
the account-wide gitops Renovate App reads the emitted `renovate.json` and
bumps them. No repo-local `renovate.yml` is emitted: a second runner kept a
second dependency dashboard under a second bot identity (#2708).
- The generated module uses **local ambient shims**, not `fvtt-types`. This keeps
the build green and self-contained; switch `tsconfig` `types` to `fvtt-types`
(`github:League-of-Foundry-Developers/foundry-vtt-types#main`) for full API
types once you need them.
- Quench (in-Foundry Mocha runner) and Playwright integration tests against the
harness are **not** scaffolded — add them when the module warrants runtime
coverage.