diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..57bdc60
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[alias]
+molt = "run --package fuz_template --"
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 19440b0..5b8e111 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -45,3 +45,18 @@ jobs:
cache: npm
- run: npm ci
- run: npx @fuzdev/gro check --workspace --build
+
+ rust:
+ # molt anchors this job (crates/fuz_template/src/anchors.rs) so stripping
+ # the rust feature can remove it — update the anchor when editing.
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ # rustup auto-installs the toolchain pinned in rust-toolchain.toml
+ - run: cargo fmt --check
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+ - run: cargo test --workspace
diff --git a/CLAUDE.md b/CLAUDE.md
index 24fe082..f6af6d0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,39 +57,142 @@ fuz_template is a **SvelteKit starter template**:
## Using the template
-Clone with degit or use GitHub's "Use this template" button:
+Use GitHub's "Use this template" button or clone directly:
```bash
-npx degit fuzdev/fuz_template myproject
+git clone https://github.com/fuzdev/fuz_template.git myproject
cd myproject
npm i
```
-**Files to customize:**
+Then transform it into your own project with molt (see below):
-- `package.json` - name, version, description, homepage, repository
-- `svelte.config.js` - update origin URL
-- `src/routes/+layout.svelte` - update `
`
+```bash
+cargo molt
+```
+
+**Files molt customizes (or do it by hand):**
+
+- `package.json` - name, description, homepage, repository, glyph/logo fields
+- `src/routes/+layout.svelte` - ``, template logo
- `src/routes/+page.svelte` - replace demo content
+- `src/routes/about/+page.svelte` - heading
+- `src/lib/Mreows.svelte` and `src/lib/Positioned.svelte` - deleted (demo components)
+- `LICENSE` and the `license` fields - deleted (the MIT license is the
+ template's; your project chooses its own)
- `static/CNAME` - update or delete for your domain
-- `.github/FUNDING.yml` - update or delete
+- `.github/FUNDING.yml` and `.github/ISSUE_TEMPLATE/` - update or delete
+- `README.md` and `CLAUDE.md` - regenerate for the new project
+
+Not customized: `static/logo.svg` and `static/favicon.png` keep the template's
+spider (molt prints a reminder), and `package-lock.json` keeps the old name
+until you run `npm i`.
+
+## molt (self-eject CLI)
+
+`crates/fuz_template` is molt — a one-shot wizard that personalizes the
+clone and then deletes itself (like create-react-app's eject, or a spider
+shedding its skin). Invoked via the cargo alias in `.cargo/config.toml`:
+
+```bash
+cargo molt # interactive wizard: prompts, prints the plan, confirms
+cargo molt check # verify molt's anchors still match the template
+cargo molt --help # all flags, for non-interactive use
+```
+
+Key behaviors:
+
+- **Requires a git repo with a clean tree** (exit 2 otherwise; `--force`
+ overrides dirty, nothing overrides no-git); an applied plan is undone with
+ `git reset --hard && git clean -fd` (the tree was clean, so `git clean`
+ removes only files molt created).
+ Applying to a dirty tree (only reachable via `--force`) always demands the
+ dirty-specific in-the-moment confirmation — on the `--wetrun` path and the
+ wizard path alike; `--wetrun` alone never skips it, and without a terminal
+ the dirty apply is refused (exit 2). The only ungated write path is
+ `--wetrun` on a clean tree.
+- **Plan-then-apply**: every file edit is anchored on exact current content;
+ one unmatched anchor aborts before any write, and anchors re-verify after
+ a confirm prompt, immediately before writing. Non-interactive runs write
+ nothing without `--wetrun`.
+- **Feature registry**: every optional feature is a keep/strip choice —
+ `rust` (the whole workspace: `Cargo.toml`, `Cargo.lock`, `crates/`,
+ `rust-toolchain.toml`, `clippy.toml`, and the `rust` CI job), `cli` (the
+ starter crate `crates/app_cli`, renamed to `crates/{name}` with every
+ `app_cli` occurrence substituted), `docs` (`src/routes/docs/` +
+ `src/routes/library.ts` + the starter page's docs link + the
+ svelte-docinfo tooling: devDependency, Vite plugin, ambient types), and
+ `github-extras` (FUNDING.yml + issue templates; the only default-strip —
+ kept copies are personalized with funding placeholders and discussion
+ links pointed at your repo url).
+ One prompt each in the wizard — except `cli`, which rides with the `rust`
+ prompt while it's the only crate feature (a kept workspace needs a member
+ crate, so there is no separate decision) — or `--keep`/`--strip` id lists
+ (comma-separated or repeated). Stripping `rust` cascades to `cli`;
+ stripping `cli` while keeping `rust` is rejected (cargo can't load an
+ empty workspace), and a prompt whose answer explicit flags already force
+ (`--keep cli` forces the workspace, `--strip cli` forces stripping rust)
+ is skipped with a note.
+ `.cargo/` (which holds only the `cargo molt` alias) and the MIT `LICENSE`
+ + `license` fields are deleted unconditionally — the license is fuz.dev's,
+ not the new project's, so keeping it is never right (same reasoning as the
+ personalized github-extras). The registry lives in
+ `crates/fuz_template/src/features.rs` — new features are one entry + a
+ plan fragment there (the check sample configs derive from the registry).
+- **Identity fields**: name (required), npm name, description, domain,
+ repo url — derived from the git origin when it isn't the template's.
+- **Self-verifying**: `cargo molt check` and the crate's tests verify every
+ anchor against the working tree — and that the embedded workspace manifest
+ template stays byte-identical to the live root `Cargo.toml` apart from the
+ members line — so a template edit that would break ejection fails
+ `cargo test` (and CI) at the same commit. When you edit an anchored file,
+ update `crates/fuz_template/src/anchors.rs` (and the embedded templates in
+ `crates/fuz_template/templates/`) in the same change.
+
+## Rust workspace
+
+The repo doubles as a Rust workspace following the fuz ecosystem's
+conventions: root `Cargo.toml` with the canonical lint block
+(`unsafe_code = "forbid"`, clippy pedantic/nursery at warn) and release
+profile, `clippy.toml` test allowances, and the toolchain pinned in
+`rust-toolchain.toml`. Do not use Gro for the Rust side — run cargo
+directly:
+
+```bash
+cargo test --workspace
+cargo clippy --workspace --all-targets -- -D warnings
+cargo fmt --check
+```
+
+CI runs these in the `rust` job of `.github/workflows/check.yml`. Errors
+follow thiserror enums with `.exit_code()`/`.hint()` helpers and
+`fn main() -> ExitCode`; exit codes: `0` success, `2` caller-must-fix
+(usage, preconditions, anchor drift), `1` everything else. Arg parsing uses
+argh with an explicit `from_args` so usage errors exit `2`.
## Architecture
### Directory structure
```
+Cargo.toml # Rust workspace (lints, profile, deps)
+crates/
+├── app_cli/ # starter CLI crate — molt renames it to yours
+└── fuz_template/ # molt — the self-eject CLI (deletes itself)
+ ├── src/ # plan/verify/apply, wizard, features, anchors
+ └── templates/ # embedded output templates (*.in)
src/
├── app.html # HTML entry with theme detection
├── lib/ # your library code
│ ├── Mreows.svelte # example component (replace me)
│ └── Positioned.svelte # example component (replace me)
+├── test/
+│ └── example.test.ts # test file example
└── routes/
├── +layout.svelte # root layout with fuz_css imports
├── +layout.ts # prerender: true, ssr: true
├── +page.svelte # home page
├── style.css # custom global styles
- ├── example.test.ts # test file example
├── about/+page.svelte
└── docs/ # documentation pages
├── +layout.svelte # wraps docs in Docs component
@@ -114,8 +217,8 @@ Replace these with your actual components.
- `+layout.ts` exports `prerender = true` and `ssr = true` for full static
generation
-- `svelte.config.js` enables runes mode and configures CSP via
- `create_csp_directives()` from fuz_ui
+- `svelte.config.js` enables runes mode and includes a commented-out example
+ CSP config using `create_csp_directives()` from fuz_ui
- Uses `@sveltejs/adapter-static` for static output
### Theme detection
@@ -178,15 +281,14 @@ Deploy with `gro deploy` (builds and pushes to deploy branch).
production use
- **Minimal test coverage** - Only one example test file included
- **Static only** - No dynamic server-side content
-- **Tests colocated** - Tests in routes (`example.test.ts`) rather than
- `src/test/` directory
## Project standards
- TypeScript strict mode
- Svelte 5 with runes API
- Prettier with tabs, 100 char width
-- Node >= 22.15
+- Node >= 24.14
+- Rust pinned via `rust-toolchain.toml` (edition 2024)
- Private package (not published to npm)
## Related projects
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..f2f1da9
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,135 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "app_cli"
+version = "0.0.1"
+dependencies = [
+ "argh",
+ "thiserror",
+]
+
+[[package]]
+name = "argh"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033"
+dependencies = [
+ "argh_derive",
+ "argh_shared",
+]
+
+[[package]]
+name = "argh_derive"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d"
+dependencies = [
+ "argh_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "argh_shared"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "fuz_template"
+version = "0.0.1"
+dependencies = [
+ "argh",
+ "thiserror",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..802d217
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,55 @@
+[workspace]
+resolver = "2"
+members = ["crates/app_cli", "crates/fuz_template"]
+
+[workspace.package]
+version = "0.0.1"
+edition = "2024"
+license = "MIT"
+publish = false
+
+[workspace.dependencies]
+argh = "0.1"
+thiserror = "2"
+
+[workspace.lints.rust]
+unsafe_code = "forbid"
+missing_debug_implementations = "warn"
+trivial_casts = "warn"
+trivial_numeric_casts = "warn"
+unused_lifetimes = "warn"
+unused_qualifications = "warn"
+
+[workspace.lints.clippy]
+# Enable lint groups (priority -1 so individual lints can override)
+all = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+cargo = { level = "warn", priority = -1 }
+
+# Pedantic overrides
+module_name_repetitions = "allow"
+must_use_candidate = "allow"
+similar_names = "allow"
+too_many_lines = "allow"
+
+# Nursery overrides
+significant_drop_tightening = "allow"
+
+# Cargo overrides (private repos)
+cargo_common_metadata = "allow"
+multiple_crate_versions = "allow"
+
+# Restriction lints (panic points need explicit #[allow] with justification)
+clone_on_ref_ptr = "warn"
+dbg_macro = "warn"
+expect_used = "warn"
+panic = "warn"
+todo = "warn"
+unwrap_used = "warn"
+
+[profile.release]
+lto = true
+codegen-units = 1
+panic = "abort"
+strip = true
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6a6c15d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) fuz.dev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 2bb3705..9860292 100644
--- a/README.md
+++ b/README.md
@@ -45,11 +45,10 @@ so the normal commands like `vite dev` work as expected.
It also uses [Gro](https://github.com/fuzdev/gro)
for tasks like deploying and more.
-If you're logged into GitHub, click "Use this template" above or clone with
-[`degit`](https://github.com/Rich-Harris/degit):
+If you're logged into GitHub, click "Use this template" above, or clone directly:
```bash
-npx degit fuzdev/fuz_template cooltoy
+git clone https://github.com/fuzdev/fuz_template.git cooltoy
cd cooltoy
npm i
# then
@@ -65,7 +64,7 @@ gro sync # called by `gro dev`, refreshes generated files and calls `svelte-kit
> [Vite](https://github.com/vitejs/vite), [Gro](https://github.com/fuzdev/gro),
> and [fuz_ui](https://github.com/fuzdev/fuz_ui)
-> [Windows will not be suported](https://github.com/fuzdev/fuz_template/issues/4) because
+> [Windows will not be supported](https://github.com/fuzdev/fuz_template/issues/4) because
> I chose Bash instead - Fuz recommends [WSL](https://docs.microsoft.com/en-us/windows/wsl/about)
The template includes
@@ -75,20 +74,78 @@ with no further configuration.
To learn how to swap it out for another deployment target, see
[the SvelteKit adapter docs](https://svelte.dev/docs/kit/adapters).
-To make it your own, change `@fuzdev/fuz_template` and `template.fuz.dev`
-to your project name in the following files:
+To make it your own, run the molt wizard (requires [Rust](https://rustup.rs/) and
+a clean git tree — commit or stash first):
-- [`package.json`](package.json)
-- [`svelte.config.js`](svelte.config.js)
+```bash
+cargo molt
+```
+
+It renames the project, strips the demo components, and deletes itself — see
+[molt](#molt) below.
+
+Prefer to do it by hand (or skip installing Rust)? Change `@fuzdev/fuz_template`
+and `template.fuz.dev` to your project name in the following files:
+
+- [`package.json`](package.json) — also remove or replace the `glyph`,
+ `logo`, and `logo_alt` fields
- [`src/routes/+layout.svelte`](src/routes/+layout.svelte)
- [`src/routes/+page.svelte`](src/routes/+page.svelte)
-- update or delete [`src/static/CNAME`](src/static/CNAME)
- and ./.github/FUNDING.yml
+- [`src/routes/about/+page.svelte`](src/routes/about/+page.svelte)
+- update or delete [`static/CNAME`](static/CNAME),
+ ./.github/FUNDING.yml, and ./.github/ISSUE_TEMPLATE/
+
+And to remove the Rust workspace, delete `Cargo.toml`, `Cargo.lock`, `crates/`,
+`.cargo/`, `rust-toolchain.toml`, `clippy.toml`, and the `rust` job in
+[`.github/workflows/check.yml`](.github/workflows/check.yml).
+
+To remove the docs system, delete `src/routes/docs/` and
+[`src/routes/library.ts`](src/routes/library.ts), remove the docs link in
+[`src/routes/+page.svelte`](src/routes/+page.svelte), and drop the
+`svelte-docinfo` devDependency along with its wiring in
+[`vite.config.ts`](vite.config.ts) and [`src/app.d.ts`](src/app.d.ts).
Then run `npm i` to update `package-lock.json`.
-Optionally add a [license file](https://choosealicense.com/)
-and [`package.json` value](https://spdx.org/licenses/), like `"license": "MIT"`.
+The template is MIT-licensed ([`LICENSE`](LICENSE), plus `license` fields in
+`package.json` and `Cargo.toml`), copyright fuz.dev — to keep MIT for your
+project, replace the copyright holder with your own; or swap in
+[another license](https://choosealicense.com/).
+
+## molt
+
+The template doubles as a Rust workspace, and `crates/fuz_template` is
+`molt` — a one-shot wizard that transforms this clone into your own project,
+then deletes itself. Like a spider shedding its template skin. 🕷
+
+```bash
+cargo molt # interactive wizard: prompts, prints the full plan, confirms
+cargo molt check # verify molt's file anchors still match the template
+```
+
+What it does, driven by your answers:
+
+- renames the project everywhere (`package.json`, page titles, headings)
+- updates or removes `static/CNAME`, `homepage`, and `repository`
+- deletes the demo components and writes a minimal starting page
+- regenerates `README.md` and `CLAUDE.md` for your project
+- deletes the template's MIT `LICENSE` and `license` fields — your project
+ chooses its own license
+- keeps or strips each optional feature — `rust` (the whole workspace),
+ `cli` (the starter crate at [`crates/app_cli`](crates/app_cli), renamed to
+ your project name), `docs` (the docs system), and `github-extras`
+ (funding + issue templates, personalized when kept) — via prompts or
+ `--keep`/`--strip` lists (e.g. `--strip rust` or `--keep github-extras --strip docs`);
+ stripping `cli` alone is rejected — a kept workspace needs a crate
+- deletes its own crate
+
+It refuses to run without a clean git tree, so an applied plan can be undone
+with `git reset --hard && git clean -fd` (the tree was clean, so `git clean`
+removes only files molt created). `--force` lets a dirty tree through for _planning_,
+but applying to a dirty tree always requires an interactive confirmation —
+without a terminal it refuses (exit 2), because there'd be no clean undo
+point. Run with flags instead of prompts for non-interactive use
+(`cargo molt --help`); in that mode nothing is written without `--wetrun`.
## build
@@ -111,7 +168,7 @@ gro test -- --forwarded-args 'to vitest'
```
See [Vitest](https://github.com/vitest-dev/vitest),
-[`src/lib/example.test.ts`](src/lib/example.test.ts),
+[`src/test/example.test.ts`](src/test/example.test.ts),
and [Gro's test docs](https://github.com/fuzdev/gro/blob/main/src/docs/test.md) for more.
## deploy
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 0000000..7bada54
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,3 @@
+allow-unwrap-in-tests = true
+allow-expect-in-tests = true
+allow-panic-in-tests = true
diff --git a/crates/app_cli/Cargo.toml b/crates/app_cli/Cargo.toml
new file mode 100644
index 0000000..920d95b
--- /dev/null
+++ b/crates/app_cli/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "app_cli"
+description = "a CLI scaffolded by fuz_template's molt"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[dependencies]
+argh.workspace = true
+thiserror.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/app_cli/src/error.rs b/crates/app_cli/src/error.rs
new file mode 100644
index 0000000..a6c53d7
--- /dev/null
+++ b/crates/app_cli/src/error.rs
@@ -0,0 +1,38 @@
+use thiserror::Error;
+
+/// Errors surfaced by the `app_cli` CLI.
+///
+/// Exit-code dialect: `0` success; `2` = the caller must change something
+/// local (arguments, config) before retrying; `1` = everything else.
+#[derive(Debug, Error)]
+pub enum CliError {
+ /// The invocation itself is wrong.
+ #[error("{0}")]
+ Usage(String),
+}
+
+impl CliError {
+ /// Maps the error to its stable exit code.
+ pub const fn exit_code(&self) -> u8 {
+ match self {
+ Self::Usage(_) => 2,
+ }
+ }
+
+ /// An optional remediation hint printed under the error.
+ pub const fn hint(&self) -> Option<&'static str> {
+ match self {
+ Self::Usage(_) => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn exit_codes_are_stable() {
+ assert_eq!(CliError::Usage(String::new()).exit_code(), 2);
+ }
+}
diff --git a/crates/app_cli/src/main.rs b/crates/app_cli/src/main.rs
new file mode 100644
index 0000000..8e92bcc
--- /dev/null
+++ b/crates/app_cli/src/main.rs
@@ -0,0 +1,64 @@
+//! The starter CLI: replace this with your program.
+
+mod error;
+
+use std::process::ExitCode;
+
+use argh::FromArgs;
+
+use crate::error::CliError;
+
+/// The `app_cli` CLI.
+#[derive(FromArgs, Debug)]
+struct TopLevel {
+ /// print the version and exit
+ #[argh(switch)]
+ version: bool,
+
+ /// who to greet
+ #[argh(positional)]
+ who: Option,
+}
+
+fn main() -> ExitCode {
+ let args: Vec = std::env::args().collect();
+ let bin = args.first().map_or("app_cli", String::as_str);
+ let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
+ // Parse explicitly rather than `argh::from_env`, which hard-exits `1` on any
+ // parse failure — a usage error is `2` by shell convention.
+ let top = match TopLevel::from_args(&[bin], &rest) {
+ Ok(top) => top,
+ Err(early_exit) => {
+ return if early_exit.status.is_ok() {
+ println!("{}", early_exit.output);
+ ExitCode::SUCCESS
+ } else {
+ eprintln!("{}", early_exit.output);
+ ExitCode::from(2)
+ };
+ }
+ };
+ match run(&top) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("error: {e}");
+ if let Some(hint) = e.hint() {
+ eprintln!("hint: {hint}");
+ }
+ ExitCode::from(e.exit_code())
+ }
+ }
+}
+
+fn run(top: &TopLevel) -> Result<(), CliError> {
+ if top.version {
+ println!("app_cli {}", env!("CARGO_PKG_VERSION"));
+ return Ok(());
+ }
+ let who = top.who.as_deref().unwrap_or("world");
+ if who.is_empty() {
+ return Err(CliError::Usage("who must not be empty".to_owned()));
+ }
+ println!("hello {who}, from app_cli");
+ Ok(())
+}
diff --git a/crates/fuz_template/Cargo.toml b/crates/fuz_template/Cargo.toml
new file mode 100644
index 0000000..e60aa99
--- /dev/null
+++ b/crates/fuz_template/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "fuz_template"
+description = "molt — transforms this template clone into your own project, then deletes itself"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[dependencies]
+argh.workspace = true
+thiserror.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/fuz_template/src/anchors.rs b/crates/fuz_template/src/anchors.rs
new file mode 100644
index 0000000..016afe9
--- /dev/null
+++ b/crates/fuz_template/src/anchors.rs
@@ -0,0 +1,74 @@
+//! Exact-content anchors into the template's files.
+//!
+//! molt and the template travel in the same repo at the same commit, so these
+//! can be exact strings. `cargo molt check` (and this crate's tests) verify
+//! every anchor still matches — when you edit an anchored spot in the
+//! template, update the anchor here in the same change.
+
+pub const PACKAGE_JSON_NAME: &str = " \"name\": \"@fuzdev/fuz_template\",\n";
+pub const PACKAGE_JSON_DESCRIPTION: &str = " \"description\": \"a static web app template built with the fuz stack: TypeScript, Svelte, SvelteKit, and Gro\",\n";
+pub const PACKAGE_JSON_GLYPH: &str = " \"glyph\": \"\u{2744}\",\n";
+pub const PACKAGE_JSON_LOGO: &str = " \"logo\": \"logo.svg\",\n";
+pub const PACKAGE_JSON_LOGO_ALT: &str =
+ " \"logo_alt\": \"a friendly pixelated spider facing you\",\n";
+pub const PACKAGE_JSON_LICENSE: &str = " \"license\": \"MIT\",\n";
+pub const PACKAGE_JSON_HOMEPAGE: &str = " \"homepage\": \"https://template.fuz.dev/\",\n";
+pub const PACKAGE_JSON_REPOSITORY: &str =
+ " \"repository\": \"https://github.com/fuzdev/fuz_template\",\n";
+
+pub const LAYOUT_LOGO_IMPORT: &str =
+ "\timport {logo_fuz_template} from '@fuzdev/fuz_ui/logos.ts';\n";
+pub const LAYOUT_SITE_STATE: &str = "\t// `glyph` and `repo_url` derive from `pkg_json`; `icon` stays explicit (structured `SvgData`).\n\tsite_context.set(new SiteState({icon: logo_fuz_template, pkg_json}));";
+pub const LAYOUT_SITE_STATE_REPLACEMENT: &str = "\t// `glyph` and `repo_url` derive from `pkg_json`.\n\tsite_context.set(new SiteState({pkg_json}));";
+pub const LAYOUT_TITLE: &str = "@fuzdev/fuz_template";
+
+pub const PAGE_MREOWS_IMPORT: &str = "import Mreows, {mreow_items} from '$lib/Mreows.svelte';";
+pub const H1_FUZ_TEMPLATE: &str = "
fuz_template
";
+
+// the docs system's tooling, stripped with the `docs` feature
+pub const PACKAGE_JSON_SVELTE_DOCINFO: &str = " \"svelte-docinfo\": \"^0.5.3\",\n";
+pub const VITE_DOCINFO_IMPORT: &str = "import svelte_docinfo from 'svelte-docinfo/vite.js';\n";
+pub const VITE_DOCINFO_PLUGIN: &str = "svelte_docinfo(), ";
+pub const APP_D_TS_DOCINFO: &str = "// Registers ambient types for the `virtual:svelte-docinfo` module (Vite plugin).\n// eslint-disable-next-line @typescript-eslint/triple-slash-reference\n/// \n";
+
+// the github extras, personalized when kept
+pub const FUNDING_GITHUB: &str = "github: ryanatkn";
+/// The template's repo url as it appears in the issue-template discussion
+/// links — replaced with the molted project's repo url when derivable.
+pub const TEMPLATE_REPO_URL: &str = "https://github.com/fuzdev/fuz_template";
+
+pub const README_H1: &str = "# @fuzdev/fuz_template \u{2744}";
+pub const CLAUDE_H1: &str = "# fuz_template\n";
+pub const CNAME_CONTENT: &str = "template.fuz.dev";
+pub const WORKSPACE_MEMBERS: &str = "members = [\"crates/app_cli\", \"crates/fuz_template\"]";
+/// The workspace manifest's license line — stripped on eject along with the
+/// `LICENSE` file (a molted project chooses its own license).
+pub const WORKSPACE_LICENSE: &str = "license = \"MIT\"\n";
+
+/// The starter CLI crate's name — molt renames every occurrence (and the
+/// crate directory) to the chosen project name.
+pub const APP_CLI_TOKEN: &str = "app_cli";
+/// The starter CLI crate's placeholder description line.
+pub const APP_CLI_DESCRIPTION: &str = "description = \"a CLI scaffolded by fuz_template's molt\"\n";
+/// The starter CLI crate's license inheritance line, stripped on eject
+/// (the workspace's license line goes with it).
+pub const APP_CLI_LICENSE: &str = "license.workspace = true\n";
+
+/// The `rust` job appended to `.github/workflows/check.yml` — kept here as an
+/// exact-match anchor so stripping the `rust` feature can remove it.
+pub const CI_RUST_JOB: &str = r"
+ rust:
+ # molt anchors this job (crates/fuz_template/src/anchors.rs) so stripping
+ # the rust feature can remove it — update the anchor when editing.
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ # rustup auto-installs the toolchain pinned in rust-toolchain.toml
+ - run: cargo fmt --check
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+ - run: cargo test --workspace
+";
diff --git a/crates/fuz_template/src/apply.rs b/crates/fuz_template/src/apply.rs
new file mode 100644
index 0000000..4fa8038
--- /dev/null
+++ b/crates/fuz_template/src/apply.rs
@@ -0,0 +1,260 @@
+use std::fs;
+use std::path::Path;
+
+use crate::error::CliError;
+use crate::plan::Action;
+
+/// Applies a verified plan at `root`. Callers must run `plan::verify` first —
+/// apply assumes anchors match and targets exist.
+pub fn apply(root: &Path, plan: &[Action]) -> Result<(), CliError> {
+ for action in plan {
+ match action {
+ Action::ReplaceOnce {
+ path,
+ anchor,
+ replacement,
+ ..
+ } => {
+ let full = root.join(path);
+ let content = fs::read_to_string(&full).map_err(|source| CliError::Io {
+ path: full.clone(),
+ source,
+ })?;
+ let updated = content.replacen(anchor.as_str(), replacement, 1);
+ fs::write(&full, updated).map_err(|source| CliError::Io { path: full, source })?;
+ }
+ Action::ReplaceAll { path, from, to, .. } => {
+ let full = root.join(path);
+ let content = fs::read_to_string(&full).map_err(|source| CliError::Io {
+ path: full.clone(),
+ source,
+ })?;
+ let updated = content.replace(from.as_str(), to);
+ fs::write(&full, updated).map_err(|source| CliError::Io { path: full, source })?;
+ }
+ Action::ReplaceFile { path, content, .. } => {
+ let full = root.join(path);
+ fs::write(&full, content).map_err(|source| CliError::Io { path: full, source })?;
+ }
+ Action::RenameDir { from, to } => {
+ let from_full = root.join(from);
+ let to_full = root.join(to);
+ fs::rename(&from_full, &to_full).map_err(|source| CliError::Io {
+ path: from_full,
+ source,
+ })?;
+ }
+ Action::DeleteFile { path } => {
+ let full = root.join(path);
+ fs::remove_file(&full).map_err(|source| CliError::Io { path: full, source })?;
+ }
+ Action::DeleteDir { path } => {
+ let full = root.join(path);
+ fs::remove_dir_all(&full).map_err(|source| CliError::Io { path: full, source })?;
+ }
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use super::*;
+ use crate::check::sample_configs;
+ use crate::plan::{build_plan, verify};
+
+ fn repo_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .unwrap()
+ .parent()
+ .unwrap()
+ .to_path_buf()
+ }
+
+ /// Copies the parts of the repo that molt touches into a scratch dir.
+ fn copy_template(destination: &Path) {
+ let root = repo_root();
+ for dir in ["src", "static", ".github", ".cargo", "crates"] {
+ copy_dir(&root.join(dir), &destination.join(dir));
+ }
+ for file in [
+ "package.json",
+ "README.md",
+ "CLAUDE.md",
+ "LICENSE",
+ "vite.config.ts",
+ "Cargo.toml",
+ "Cargo.lock",
+ "rust-toolchain.toml",
+ "clippy.toml",
+ ] {
+ fs::copy(root.join(file), destination.join(file)).unwrap();
+ }
+ }
+
+ fn copy_dir(source: &Path, destination: &Path) {
+ fs::create_dir_all(destination).unwrap();
+ for entry in fs::read_dir(source).unwrap() {
+ let entry = entry.unwrap();
+ let target = destination.join(entry.file_name());
+ let file_type = entry.file_type().unwrap();
+ if file_type.is_dir() {
+ copy_dir(&entry.path(), &target);
+ } else if file_type.is_file() {
+ fs::copy(entry.path(), &target).unwrap();
+ }
+ // symlinks (e.g. AGENTS.md at the root) aren't under the copied dirs
+ }
+ }
+
+ fn scratch_dir(label: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "fuz_template_molt_test_{label}_{}",
+ std::process::id()
+ ));
+ if dir.exists() {
+ fs::remove_dir_all(&dir).unwrap();
+ }
+ fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ fn read(root: &Path, path: &str) -> String {
+ fs::read_to_string(root.join(path)).unwrap()
+ }
+
+ #[test]
+ fn apply_keep_rust_sample() {
+ let [config, _] = sample_configs();
+ let dir = scratch_dir("keep_rust");
+ copy_template(&dir);
+
+ let plan = build_plan(&config);
+ let issues = verify(&dir, &plan).unwrap();
+ assert!(issues.is_empty(), "verify issues: {issues:#?}");
+ apply(&dir, &plan).unwrap();
+
+ let package_json = read(&dir, "package.json");
+ assert!(package_json.contains("\"name\": \"@sample/sample_app\""));
+ assert!(!package_json.contains("glyph"));
+ assert!(package_json.contains("\"homepage\": \"https://sample.example.com/\""));
+ assert_eq!(read(&dir, "static/CNAME"), "sample.example.com\n");
+
+ // the template's MIT license never ships in a molted project
+ assert!(!dir.join("LICENSE").exists());
+ assert!(!package_json.contains("\"license\""));
+
+ let layout = read(&dir, "src/routes/+layout.svelte");
+ assert!(!layout.contains("logo_fuz_template"));
+ assert!(layout.contains("@sample/sample_app"));
+
+ let page = read(&dir, "src/routes/+page.svelte");
+ assert!(!page.contains("Mreows"));
+ assert!(page.contains("
sample_app
"));
+ assert!(page.contains("resolve('/docs')"));
+ assert!(!dir.join("src/lib/Mreows.svelte").exists());
+ assert!(!dir.join("src/lib/Positioned.svelte").exists());
+
+ // docs kept
+ assert!(dir.join("src/routes/docs").is_dir());
+ assert!(dir.join("src/routes/library.ts").is_file());
+
+ assert!(read(&dir, "README.md").starts_with("# @sample/sample_app"));
+ let claude = read(&dir, "CLAUDE.md");
+ assert!(claude.starts_with("# sample_app"));
+ assert!(claude.contains("## Rust workspace"));
+ assert!(claude.contains("src/routes/docs"));
+
+ // github-extras kept: personalized, never the template author's links
+ let funding = read(&dir, ".github/FUNDING.yml");
+ assert!(!funding.contains("ryanatkn"));
+ assert!(funding.contains("your-github-username"));
+ let issue_config = read(&dir, ".github/ISSUE_TEMPLATE/config.yml");
+ assert!(
+ issue_config.contains("https://github.com/sample/sample_app/discussions/new/choose")
+ );
+ assert!(!issue_config.contains("fuzdev/fuz_template"));
+ assert!(
+ !read(&dir, ".github/ISSUE_TEMPLATE/preapproved.md").contains("fuzdev/fuz_template")
+ );
+ assert!(read(&dir, ".github/workflows/check.yml").contains("cargo clippy"));
+
+ // docs kept: the svelte-docinfo tooling stays
+ assert!(package_json.contains("svelte-docinfo"));
+ assert!(read(&dir, "vite.config.ts").contains("svelte_docinfo()"));
+
+ let workspace = read(&dir, "Cargo.toml");
+ assert!(workspace.contains("members = [\"crates/sample_app\"]"));
+ assert!(!workspace.contains("license"));
+ assert!(!dir.join("crates/fuz_template").exists());
+ assert!(!dir.join("crates/app_cli").exists());
+ assert!(!dir.join(".cargo").exists());
+ let crate_manifest = read(&dir, "crates/sample_app/Cargo.toml");
+ assert!(crate_manifest.contains("name = \"sample_app\""));
+ // the user's description survives verbatim — the app_cli token rename
+ // runs before the description insert
+ assert!(crate_manifest.contains("description = \"a sample app that replaces app_cli\""));
+ assert!(!crate_manifest.contains("license"));
+ let main_rs = read(&dir, "crates/sample_app/src/main.rs");
+ assert!(main_rs.contains("hello {who}, from sample_app"));
+ assert!(!main_rs.contains("app_cli"));
+ assert!(!read(&dir, "crates/sample_app/src/error.rs").contains("app_cli"));
+
+ fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[test]
+ fn apply_strip_rust_sample() {
+ let [_, config] = sample_configs();
+ let dir = scratch_dir("strip_rust");
+ copy_template(&dir);
+
+ let plan = build_plan(&config);
+ let issues = verify(&dir, &plan).unwrap();
+ assert!(issues.is_empty(), "verify issues: {issues:#?}");
+ apply(&dir, &plan).unwrap();
+
+ let package_json = read(&dir, "package.json");
+ assert!(package_json.contains("\"name\": \"plain_app\""));
+ assert!(!package_json.contains("homepage"));
+ assert!(!package_json.contains("repository"));
+ assert!(!package_json.contains("\"license\""));
+ assert!(!dir.join("static/CNAME").exists());
+ assert!(!dir.join("LICENSE").exists());
+
+ assert!(!dir.join("Cargo.toml").exists());
+ assert!(!dir.join("Cargo.lock").exists());
+ assert!(!dir.join("crates").exists());
+ assert!(!dir.join(".cargo").exists());
+ assert!(!dir.join("rust-toolchain.toml").exists());
+ assert!(!dir.join("clippy.toml").exists());
+
+ let workflow = read(&dir, ".github/workflows/check.yml");
+ assert!(!workflow.contains("cargo"));
+ assert!(workflow.contains("npx @fuzdev/gro check"));
+
+ // docs stripped, along with the svelte-docinfo tooling
+ assert!(!dir.join("src/routes/docs").exists());
+ assert!(!dir.join("src/routes/library.ts").exists());
+ let page = read(&dir, "src/routes/+page.svelte");
+ assert!(!page.contains("resolve('/docs')"));
+ assert!(page.contains("resolve('/about')"));
+ assert!(!package_json.contains("svelte-docinfo"));
+ assert!(!read(&dir, "vite.config.ts").contains("svelte_docinfo"));
+ assert!(!read(&dir, "src/app.d.ts").contains("svelte-docinfo"));
+
+ // extras stripped in this sample
+ assert!(!dir.join(".github/FUNDING.yml").exists());
+ assert!(!dir.join(".github/ISSUE_TEMPLATE").exists());
+
+ let claude = read(&dir, "CLAUDE.md");
+ assert!(!read(&dir, "README.md").contains("## rust"));
+ assert!(!claude.contains("## Rust workspace"));
+ assert!(!claude.contains("src/routes/docs"));
+
+ fs::remove_dir_all(&dir).unwrap();
+ }
+}
diff --git a/crates/fuz_template/src/check.rs b/crates/fuz_template/src/check.rs
new file mode 100644
index 0000000..5ae89c2
--- /dev/null
+++ b/crates/fuz_template/src/check.rs
@@ -0,0 +1,115 @@
+use std::collections::BTreeSet;
+use std::fs;
+use std::path::Path;
+use std::process::ExitCode;
+
+use crate::anchors;
+use crate::config::MoltConfig;
+use crate::error::CliError;
+use crate::features;
+use crate::plan::{build_plan, verify};
+use crate::templates;
+
+/// Runs `molt check`: verifies every anchor and embedded-template invariant
+/// against the tree at `root`.
+pub fn run(root: &Path) -> Result {
+ let issues = check_all(root)?;
+ if issues.is_empty() {
+ println!("molt check passed: all anchors and embedded templates match");
+ Ok(ExitCode::SUCCESS)
+ } else {
+ eprintln!(
+ "molt check failed — the template drifted from molt's anchors or embedded templates:"
+ );
+ for issue in &issues {
+ eprintln!(" {issue}");
+ }
+ eprintln!("(update crates/fuz_template/src/anchors.rs or templates/ in the same change)");
+ // drift is caller-must-fix, same dialect as `CliError::Drift`
+ Ok(ExitCode::from(2))
+ }
+}
+
+/// Verifies the plans for both sample configs, covering every anchor molt can
+/// touch (each feature exercised kept in one config and stripped in the other),
+/// plus the embedded-template invariants that anchors alone can't see.
+pub fn check_all(root: &Path) -> Result, CliError> {
+ let mut issues = Vec::new();
+ for config in sample_configs() {
+ issues.extend(verify(root, &build_plan(&config))?);
+ }
+ // the embedded workspace template must stay byte-identical to the live
+ // root Cargo.toml apart from the members and license lines — otherwise
+ // an edit to the live lints/profile/deps would silently ship a stale
+ // workspace to every molted project while the members anchor still matched
+ let live_path = root.join("Cargo.toml");
+ let live = fs::read_to_string(&live_path).map_err(|source| CliError::Io {
+ path: live_path,
+ source,
+ })?;
+ let rendered = templates::render(
+ templates::WORKSPACE_CARGO_TOML,
+ &[
+ ("members = [__MEMBERS__]", anchors::WORKSPACE_MEMBERS),
+ ("__LICENSE__", anchors::WORKSPACE_LICENSE),
+ ],
+ );
+ if live != rendered {
+ issues.push(
+ "Cargo.toml: drifted from crates/fuz_template/templates/workspace_cargo.toml.in (only the members and license lines may differ)"
+ .to_owned(),
+ );
+ }
+ issues.sort();
+ issues.dedup();
+ Ok(issues)
+}
+
+/// Two configs that together exercise every plan branch: one keeps every
+/// registry feature (derived from `features::FEATURES`, so a new feature is
+/// covered without touching this) and sets every optional value, one strips
+/// every feature and clears the optional values.
+pub fn sample_configs() -> [MoltConfig; 2] {
+ [
+ MoltConfig {
+ name: "sample_app".to_owned(),
+ npm_name: "@sample/sample_app".to_owned(),
+ // contains "app_cli" to prove the crate rename can't corrupt it
+ description: "a sample app that replaces app_cli".to_owned(),
+ domain: Some("sample.example.com".to_owned()),
+ repo_url: Some("https://github.com/sample/sample_app".to_owned()),
+ kept: features::FEATURES.iter().map(|f| f.id).collect(),
+ },
+ MoltConfig {
+ name: "plain_app".to_owned(),
+ npm_name: "plain_app".to_owned(),
+ description: String::new(),
+ domain: None,
+ repo_url: None,
+ kept: BTreeSet::new(),
+ },
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use super::*;
+
+ #[test]
+ fn anchors_match_the_template() {
+ let root: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .unwrap()
+ .parent()
+ .unwrap()
+ .to_path_buf();
+ let issues = check_all(&root).unwrap();
+ assert!(
+ issues.is_empty(),
+ "template drifted from molt's anchors:\n{}",
+ issues.join("\n")
+ );
+ }
+}
diff --git a/crates/fuz_template/src/cli.rs b/crates/fuz_template/src/cli.rs
new file mode 100644
index 0000000..1db0b43
--- /dev/null
+++ b/crates/fuz_template/src/cli.rs
@@ -0,0 +1,80 @@
+use argh::FromArgs;
+
+/// molt — transform this `fuz_template` clone into your own project, then
+/// molt deletes itself. Run with no arguments for the interactive wizard.
+/// Without a terminal, `--name` is required and nothing is written unless
+/// `--wetrun` is passed.
+// when adding a flag, include it in `has_molt_flags` below
+#[derive(FromArgs, Debug)]
+pub struct TopLevel {
+ /// project name (`snake_case`; used for crate names, headings, and defaults)
+ #[argh(option)]
+ pub name: Option,
+
+ /// npm package name (defaults to the project name; may be scoped like @you/name)
+ #[argh(option)]
+ pub npm_name: Option,
+
+ /// one-line project description
+ #[argh(option)]
+ pub description: Option,
+
+ /// custom domain written to static/CNAME (omit to delete CNAME and homepage)
+ #[argh(option)]
+ pub domain: Option,
+
+ /// repository url (defaults to the git origin remote when it isn't the template's)
+ #[argh(option)]
+ pub repo: Option,
+
+ /// features to keep, comma-separated or repeated
+ /// (rust, cli, docs, github-extras)
+ #[argh(option)]
+ pub keep: Vec,
+
+ /// features to strip, comma-separated or repeated
+ /// (rust, cli, docs, github-extras)
+ #[argh(option)]
+ pub strip: Vec,
+
+ /// apply the plan (without it, non-interactive runs write nothing and
+ /// the wizard asks before applying)
+ #[argh(switch)]
+ pub wetrun: bool,
+
+ /// proceed even if the git tree is dirty
+ #[argh(switch)]
+ pub force: bool,
+
+ #[argh(subcommand)]
+ pub subcommand: Option,
+}
+
+impl TopLevel {
+ /// Whether any molt-run flag was passed — they're meaningless combined
+ /// with the `check` subcommand, so the caller rejects that instead of
+ /// silently ignoring them.
+ pub const fn has_molt_flags(&self) -> bool {
+ self.name.is_some()
+ || self.npm_name.is_some()
+ || self.description.is_some()
+ || self.domain.is_some()
+ || self.repo.is_some()
+ || !self.keep.is_empty()
+ || !self.strip.is_empty()
+ || self.wetrun
+ || self.force
+ }
+}
+
+#[derive(FromArgs, Debug)]
+#[argh(subcommand)]
+pub enum Subcommand {
+ Check(CheckCommand),
+}
+
+/// Verify molt's anchors and embedded templates still match the template
+/// (used by CI and tests).
+#[derive(FromArgs, Debug)]
+#[argh(subcommand, name = "check")]
+pub struct CheckCommand {}
diff --git a/crates/fuz_template/src/config.rs b/crates/fuz_template/src/config.rs
new file mode 100644
index 0000000..bf08ece
--- /dev/null
+++ b/crates/fuz_template/src/config.rs
@@ -0,0 +1,174 @@
+use std::collections::BTreeSet;
+use std::fmt::Write as _;
+
+use crate::error::CliError;
+
+/// Fully-resolved molt choices, assembled from flags and wizard answers.
+/// `kept` holds the feature ids (from `features::FEATURES`) to keep.
+#[derive(Debug)]
+pub struct MoltConfig {
+ pub name: String,
+ pub npm_name: String,
+ pub description: String,
+ pub domain: Option,
+ pub repo_url: Option,
+ pub kept: BTreeSet<&'static str>,
+}
+
+impl MoltConfig {
+ pub fn keeps(&self, feature_id: &str) -> bool {
+ self.kept.contains(feature_id)
+ }
+}
+
+/// Names cargo refuses as package names: Rust keywords (strict + reserved,
+/// including 2024's `gen`) plus `test`, which conflicts with the built-in
+/// test library. The name becomes the starter crate's name, so a keyword
+/// would leave the molted workspace unable to build.
+const RESERVED_CRATE_NAMES: &[&str] = &[
+ "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate",
+ "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl",
+ "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
+ "return", "self", "static", "struct", "super", "test", "trait", "true", "try", "type",
+ "typeof", "unsafe", "unsized", "use", "virtual", "where", "while", "yield",
+];
+
+/// Validates a project name: `snake_case`, usable as a crate name.
+pub fn validate_name(name: &str) -> Result<(), CliError> {
+ let valid_first = name.chars().next().is_some_and(|c| c.is_ascii_lowercase());
+ let valid_rest = name
+ .chars()
+ .skip(1)
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
+ if !valid_first || !valid_rest {
+ return Err(CliError::Usage(format!(
+ "invalid name {name:?}: use snake_case starting with a letter (e.g. my_app)"
+ )));
+ }
+ if RESERVED_CRATE_NAMES.contains(&name) {
+ return Err(CliError::Usage(format!(
+ "name {name:?} can't be used as a crate name (Rust keyword or built-in) — pick another"
+ )));
+ }
+ if matches!(name, "fuz_template" | "app_cli" | "xtask") {
+ return Err(CliError::Usage(format!(
+ "name {name:?} is reserved — pick your own project name"
+ )));
+ }
+ Ok(())
+}
+
+/// Validates a project description: a single line, no control characters
+/// (it lands in `package.json`, TOML, and markdown blockquotes).
+pub fn validate_description(description: &str) -> Result<(), CliError> {
+ if description.chars().any(char::is_control) {
+ return Err(CliError::Usage(
+ "description must be a single line without control characters".to_owned(),
+ ));
+ }
+ Ok(())
+}
+
+/// Validates an npm package name loosely (scoped names like `@you/name` allowed).
+pub fn validate_npm_name(name: &str) -> Result<(), CliError> {
+ let valid = !name.is_empty()
+ && name.chars().all(|c| {
+ c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.' | '/' | '@')
+ });
+ if valid {
+ Ok(())
+ } else {
+ Err(CliError::Usage(format!(
+ "invalid npm package name {name:?}"
+ )))
+ }
+}
+
+/// Validates a bare domain like `example.com` (no scheme, no path).
+pub fn validate_domain(domain: &str) -> Result<(), CliError> {
+ let valid = !domain.is_empty()
+ && domain.contains('.')
+ && domain
+ .chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '.'));
+ if valid {
+ Ok(())
+ } else {
+ Err(CliError::Usage(format!(
+ "invalid domain {domain:?}: expected a bare domain like example.com"
+ )))
+ }
+}
+
+/// Escapes a string for embedding in a JSON string literal (also valid for
+/// TOML basic strings, which share the `\"`/`\\`/`\n` escapes).
+pub fn json_escape(value: &str) -> String {
+ let mut out = String::with_capacity(value.len());
+ for c in value.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\t' => out.push_str("\\t"),
+ c if u32::from(c) < 0x20 => {
+ let _ = write!(out, "\\u{:04x}", u32::from(c));
+ }
+ c => out.push(c),
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn name_validation() {
+ assert!(validate_name("my_app").is_ok());
+ assert!(validate_name("app2").is_ok());
+ assert!(validate_name("My_App").is_err());
+ assert!(validate_name("2app").is_err());
+ assert!(validate_name("my-app").is_err());
+ assert!(validate_name("").is_err());
+ assert!(validate_name("fuz_template").is_err());
+ assert!(validate_name("app_cli").is_err());
+ // Rust keywords and `test` can't be crate names
+ assert!(validate_name("match").is_err());
+ assert!(validate_name("loop").is_err());
+ assert!(validate_name("test").is_err());
+ assert!(validate_name("gen").is_err());
+ assert!(validate_name("matcher").is_ok());
+ }
+
+ #[test]
+ fn description_validation() {
+ assert!(validate_description("").is_ok());
+ assert!(validate_description("a fine one-liner").is_ok());
+ assert!(validate_description("line\nbreak").is_err());
+ assert!(validate_description("tab\there").is_err());
+ }
+
+ #[test]
+ fn npm_name_validation() {
+ assert!(validate_npm_name("my_app").is_ok());
+ assert!(validate_npm_name("@you/my-app").is_ok());
+ assert!(validate_npm_name("").is_err());
+ assert!(validate_npm_name("My App").is_err());
+ }
+
+ #[test]
+ fn domain_validation() {
+ assert!(validate_domain("example.com").is_ok());
+ assert!(validate_domain("sub.example.co.uk").is_ok());
+ assert!(validate_domain("https://example.com").is_err());
+ assert!(validate_domain("nodots").is_err());
+ }
+
+ #[test]
+ fn json_escaping() {
+ assert_eq!(json_escape("plain"), "plain");
+ assert_eq!(json_escape("a \"b\" c"), "a \\\"b\\\" c");
+ assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
+ }
+}
diff --git a/crates/fuz_template/src/error.rs b/crates/fuz_template/src/error.rs
new file mode 100644
index 0000000..dbe82b8
--- /dev/null
+++ b/crates/fuz_template/src/error.rs
@@ -0,0 +1,90 @@
+use std::path::PathBuf;
+
+use thiserror::Error;
+
+/// Errors surfaced by molt.
+///
+/// Exit-code dialect: `0` success; `2` = the caller must change something
+/// local (arguments, git state, modified files) before retrying; `1` =
+/// everything else.
+#[derive(Debug, Error)]
+pub enum CliError {
+ /// The invocation itself is wrong (bad flag values, missing required args).
+ #[error("{0}")]
+ Usage(String),
+
+ /// The environment isn't ready (not a git repo, dirty tree, wrong directory).
+ #[error("{message}")]
+ Precondition {
+ message: String,
+ hint: Option<&'static str>,
+ },
+
+ /// The tree no longer matches molt's anchors — usually the clone was
+ /// hand-edited (restorable by the caller), else the template itself
+ /// drifted (`molt check` reports that as its own failure).
+ #[error("template drift detected:\n{0}")]
+ Drift(String),
+
+ /// Filesystem failure while planning or applying.
+ #[error("io error at {path}: {source}")]
+ Io {
+ path: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+
+ /// Terminal input/output failed mid-wizard. Deliberately not `#[from]` —
+ /// only the wizard maps into this, so a stray `?` on a filesystem error
+ /// can't get mislabeled as a terminal failure.
+ #[error("terminal io failed: {0}")]
+ Terminal(std::io::Error),
+}
+
+impl CliError {
+ pub fn precondition(message: impl Into, hint: Option<&'static str>) -> Self {
+ Self::Precondition {
+ message: message.into(),
+ hint,
+ }
+ }
+
+ /// Maps the error to its stable exit code.
+ pub const fn exit_code(&self) -> u8 {
+ match self {
+ Self::Usage(_) | Self::Precondition { .. } | Self::Drift(_) => 2,
+ Self::Io { .. } | Self::Terminal(_) => 1,
+ }
+ }
+
+ /// An optional remediation hint printed under the error.
+ pub const fn hint(&self) -> Option<&'static str> {
+ match self {
+ Self::Precondition { hint, .. } => *hint,
+ Self::Drift(_) => {
+ Some("restore the listed files (e.g. git checkout) or run from a fresh clone")
+ }
+ _ => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn exit_codes_are_stable() {
+ assert_eq!(CliError::Usage(String::new()).exit_code(), 2);
+ assert_eq!(CliError::precondition("x", None).exit_code(), 2);
+ assert_eq!(CliError::Drift(String::new()).exit_code(), 2);
+ assert_eq!(
+ CliError::Io {
+ path: PathBuf::new(),
+ source: std::io::Error::other("x"),
+ }
+ .exit_code(),
+ 1
+ );
+ }
+}
diff --git a/crates/fuz_template/src/features.rs b/crates/fuz_template/src/features.rs
new file mode 100644
index 0000000..ae05e80
--- /dev/null
+++ b/crates/fuz_template/src/features.rs
@@ -0,0 +1,196 @@
+//! The keep/strip feature registry.
+//!
+//! Every molt-selectable feature is one entry here: a wizard prompt, a
+//! `--keep`/`--strip` id, and a plan fragment in `plan.rs` keyed off the
+//! resolved set. Adding a feature means adding a registry entry and its plan
+//! fragment (`check.rs`'s keep-all sample config derives from the registry,
+//! so anchor coverage follows automatically) — and joining `CRATE_FEATURES`
+//! if it contributes a workspace member crate.
+
+use std::collections::BTreeSet;
+
+use crate::error::CliError;
+
+/// A molt-selectable feature of the template.
+#[derive(Debug)]
+pub struct Feature {
+ /// Stable id used by `--keep`/`--strip` (kebab-case).
+ pub id: &'static str,
+ /// Wizard prompt, phrased as "keep X?".
+ pub prompt: &'static str,
+ /// Whether the feature is kept when unspecified.
+ pub default_keep: bool,
+ /// A feature this one is part of — stripping the parent strips this too.
+ pub requires: Option<&'static str>,
+}
+
+pub const RUST: &str = "rust";
+pub const CLI: &str = "cli";
+pub const DOCS: &str = "docs";
+pub const GITHUB_EXTRAS: &str = "github-extras";
+
+pub const FEATURES: [Feature; 4] = [
+ Feature {
+ id: RUST,
+ prompt: "keep the Rust workspace? (includes the starter CLI crate, renamed to crates/)",
+ default_keep: true,
+ requires: None,
+ },
+ Feature {
+ // the wizard skips this prompt while `cli` is the only crate feature —
+ // a kept workspace forces it, so the rust prompt covers the pair
+ id: CLI,
+ prompt: "keep the starter CLI crate? (renamed to crates/)",
+ default_keep: true,
+ requires: Some(RUST),
+ },
+ Feature {
+ id: DOCS,
+ prompt: "keep the docs system? (src/routes/docs, auto-generated API docs)",
+ default_keep: true,
+ requires: None,
+ },
+ Feature {
+ id: GITHUB_EXTRAS,
+ prompt: "keep .github/FUNDING.yml and the issue templates?",
+ default_keep: false,
+ requires: None,
+ },
+];
+
+/// Splits repeatable/CSV flag values into feature ids, validating each.
+pub fn parse_ids(values: &[String]) -> Result, CliError> {
+ let mut ids = Vec::new();
+ for value in values {
+ for raw in value.split(',') {
+ let raw = raw.trim();
+ if raw.is_empty() {
+ continue;
+ }
+ let Some(feature) = FEATURES.iter().find(|f| f.id == raw) else {
+ return Err(CliError::Usage(format!(
+ "unknown feature {raw:?} — valid: {}",
+ FEATURES.map(|f| f.id).join(", ")
+ )));
+ };
+ ids.push(feature.id);
+ }
+ }
+ Ok(ids)
+}
+
+/// Resolves the kept-feature set from `--keep`/`--strip` flags, applying
+/// defaults and the `requires` cascade. `explicit` returns which features the
+/// flags decided (the wizard prompts only for the rest).
+pub fn resolve(
+ keep: &[String],
+ strip: &[String],
+) -> Result<(BTreeSet<&'static str>, BTreeSet<&'static str>), CliError> {
+ let keep_ids = parse_ids(keep)?;
+ let strip_ids = parse_ids(strip)?;
+ if let Some(id) = keep_ids.iter().find(|id| strip_ids.contains(id)) {
+ return Err(CliError::Usage(format!(
+ "feature {id:?} passed to both --keep and --strip"
+ )));
+ }
+ for id in &keep_ids {
+ if let Some(feature) = FEATURES.iter().find(|f| f.id == *id)
+ && let Some(parent) = feature.requires
+ && strip_ids.contains(&parent)
+ {
+ return Err(CliError::Usage(format!(
+ "--keep {id} conflicts with --strip {parent} ({id} is part of {parent})"
+ )));
+ }
+ }
+ let mut kept = BTreeSet::new();
+ let mut explicit = BTreeSet::new();
+ for feature in &FEATURES {
+ let choice = if keep_ids.contains(&feature.id) {
+ explicit.insert(feature.id);
+ true
+ } else if strip_ids.contains(&feature.id) {
+ explicit.insert(feature.id);
+ false
+ } else {
+ feature.default_keep
+ };
+ if choice {
+ kept.insert(feature.id);
+ }
+ }
+ cascade(&mut kept);
+ Ok((kept, explicit))
+}
+
+/// Strips any feature whose `requires` parent is stripped.
+pub fn cascade(kept: &mut BTreeSet<&'static str>) {
+ for feature in &FEATURES {
+ if let Some(parent) = feature.requires
+ && !kept.contains(parent)
+ {
+ kept.remove(feature.id);
+ }
+ }
+}
+
+/// Features that contribute workspace member crates. A kept `rust` needs at
+/// least one — cargo refuses to load a virtual manifest with no members.
+pub const CRATE_FEATURES: [&str; 1] = [CLI];
+
+/// Whether `kept` keeps the Rust workspace with no member crates — an invalid
+/// combination the caller must reject (or repair by stripping `rust` too).
+pub fn rust_without_crates(kept: &BTreeSet<&'static str>) -> bool {
+ kept.contains(RUST) && !CRATE_FEATURES.iter().any(|id| kept.contains(id))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn strings(values: &[&str]) -> Vec {
+ values.iter().map(|v| (*v).to_owned()).collect()
+ }
+
+ #[test]
+ fn defaults() {
+ let (kept, explicit) = resolve(&[], &[]).unwrap();
+ assert_eq!(kept.into_iter().collect::>(), vec![CLI, DOCS, RUST]);
+ assert!(explicit.is_empty());
+ }
+
+ #[test]
+ fn csv_and_repeats() {
+ let (kept, _) = resolve(&strings(&["github-extras,docs"]), &strings(&["cli"])).unwrap();
+ assert!(kept.contains(GITHUB_EXTRAS));
+ assert!(kept.contains(DOCS));
+ assert!(kept.contains(RUST));
+ assert!(!kept.contains(CLI));
+ }
+
+ #[test]
+ fn strip_rust_cascades_to_cli() {
+ let (kept, _) = resolve(&[], &strings(&["rust"])).unwrap();
+ assert!(!kept.contains(RUST));
+ assert!(!kept.contains(CLI));
+ }
+
+ #[test]
+ fn conflicts_error() {
+ assert!(resolve(&strings(&["rust"]), &strings(&["rust"])).is_err());
+ assert!(resolve(&strings(&["cli"]), &strings(&["rust"])).is_err());
+ assert!(resolve(&strings(&["nope"]), &[]).is_err());
+ }
+
+ #[test]
+ fn rust_without_crates_detection() {
+ let (kept, _) = resolve(&[], &[]).unwrap();
+ assert!(!rust_without_crates(&kept));
+ let (kept, _) = resolve(&[], &strings(&["rust"])).unwrap();
+ assert!(!rust_without_crates(&kept));
+ // stripping the last crate feature while keeping rust is the invalid
+ // combination `resolve_config` rejects
+ let (kept, _) = resolve(&[], &strings(&["cli"])).unwrap();
+ assert!(rust_without_crates(&kept));
+ }
+}
diff --git a/crates/fuz_template/src/git.rs b/crates/fuz_template/src/git.rs
new file mode 100644
index 0000000..40e99e0
--- /dev/null
+++ b/crates/fuz_template/src/git.rs
@@ -0,0 +1,80 @@
+use std::path::Path;
+use std::process::Command;
+
+use crate::error::CliError;
+
+/// Runs a git command at `root`, returning its stdout on success and `None`
+/// on a nonzero exit (e.g. no `origin` remote configured).
+pub fn output(root: &Path, args: &[&str]) -> Result