Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 51 additions & 15 deletions skills/simple-module-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
---
name: simple-module-cli
description: Use when invoking the `smpy` CLI for a simple_module_python project — starting a new app, scaffolding a host or a publishable module, regenerating the Inertia page manifest, importing settings overrides from env, creating an admin user, or installing the bundled agent skills. Triggers on "smpy new", "smpy create-host", "smpy create-module", "smpy host gen-pages", "smpy users create-admin", "smpy skills add", or any unfamiliar `smpy` subcommand.
description: Use when invoking the `smpy` CLI for a simple_module_python project — starting a new app, scaffolding a host or a publishable module, bumping simple_module_* dependencies to their latest PyPI versions, regenerating the Inertia page manifest, importing settings overrides from env, creating an admin user, or installing the bundled agent skills. Triggers on "smpy new", "smpy create-host", "smpy create-module", "smpy package-update", "smpy host gen-pages", "smpy users create-admin", "smpy skills add", or any unfamiliar `smpy` subcommand.
---

# simple_module_python: the `smpy` CLI

The `smpy` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`). It groups several kinds of operations: scaffolding new things, project-time helpers for the host, admin shortcuts for the bundled modules, installing the bundled agent skills, and bumping `simple_module_*` deps.
The `smpy` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`); `simple-module` is an identical alias for the same entry point. It groups several kinds of operations: scaffolding new things, project-time helpers for the host, admin shortcuts for the bundled modules, installing the bundled agent skills, and bumping `simple_module_*` deps.

`new`, `create-host`, `create-module`, `package-update`, and `skills` are built into `simple_module_cli` and always present. `host` / `settings` / `users` are **plugin subgroups** contributed by installed packages (`simple_module_hosting` / `simple_module_settings` / `simple_module_users`) through the `simple_module_cli.cli_plugins` entry-point group — they only show up when that package is installed, which is why a fresh bare host won't list `smpy users` until the users module is added.

## Top-level commands

Expand All @@ -24,19 +26,24 @@ The `smpy` command is provided by `simple_module_cli` (installed as a dep of `si

The fastest way from zero to a working app. It calls `create-host` under the hood, then installs and wires up the modules you pick.

App names must be **lowercase** with a single separator style (`my_app`, `my-app`, or `myapp`) — `smpy new` rejects mixed case / spaces / mixed separators up front (`MyApp`, `foo_bar-baz` error out). `create-host` and `create-module` are lenient and accept any case.

```bash
# Interactive (asks for DB, tenancy, preset, module list)
smpy new MyApp
smpy new my_app

# Non-interactive: take all defaults (sqlite, no tenancy, standard preset)
smpy new MyApp --yes
smpy new my_app --yes

# Pick a preset and add extras
smpy new MyApp --preset full --tenancy --db postgres
smpy new MyApp --preset minimal --with background_tasks,file_storage --yes
smpy new my_app --preset full --tenancy --db postgres
smpy new my_app --preset minimal --with background_tasks,file_storage --yes

# Scaffold only — skip uv sync / npm install / alembic upgrade head
smpy new MyApp --no-install
# Consume-only host: no modules/ dir, no sample module to author against
smpy new my_app --preset standard --flat --yes

# Scaffold only — skip uv sync / npm install / the initial migration
smpy new my_app --no-install
```

**Module presets:**
Expand All @@ -45,10 +52,14 @@ smpy new MyApp --no-install
|---|---|
| `minimal` | `users` (and `auth` as a dep) |
| `standard` (default) | `users`, `dashboard`, `permissions` (+ deps) |
| `full` | every module in the catalog |
| `custom` | interactive — pick each module yes/no |
| `full` | every module **in the scaffolder catalog** (the 8 keys below) — *not* every module in the monorepo |
| `custom` | interactive — pick each catalog module yes/no |

`--preset` only accepts `minimal`, `standard`, or `full`. **`custom` is wizard-only** — there's no `--preset custom`; to hand-pick modules non-interactively, start from any preset and add the rest with `--with`.

`--with` accepts a comma-separated list of catalog keys (`auth, users, permissions, dashboard, settings, feature_flags, file_storage, background_tasks`). Transitive `requires` are auto-added; the wizard prints `Added X (required by Y)` so you can see what got pulled in.
`--with` accepts a comma-separated list of catalog keys (`auth, users, permissions, dashboard, settings, feature_flags, file_storage, background_tasks`). Transitive `requires` are auto-added; the wizard prints `Added X (required by Y)` so you can see what got pulled in. An unknown key fails fast with the available list (so `--with=does_not_exist` is self-correcting). Note the catalog is a curated subset — repo modules like `products`, `keycloak`, and `audit_log` are **not** catalog keys; add those to an existing host by installing the package (see the pitfall below).

Selecting `background_tasks` also runs its post-scaffold **recipe**: it drops `docker-compose.yml`, `scripts/run_worker.py`, host/worker Dockerfiles, a `SM_BG_TASKS_BROKER_URL` entry in `.env.example`, and Celery `make` targets. The closing "Next steps" then reminds you to run `docker compose up -d redis worker beat` for the worker/beat processes.

**Options summary:**

Expand All @@ -57,10 +68,13 @@ smpy new MyApp --no-install
| `--dest <PATH>` | `./<name>` | Where to write the project |
| `--db sqlite\|postgres` | `sqlite` | Backend configured in `.env.example` |
| `--tenancy / --no-tenancy` | `--no-tenancy` | Enable the multi-tenant middleware |
| `--preset minimal\|standard\|full` | wizard asks | Module bundle |
| `--preset minimal\|standard\|full` | wizard asks | Module bundle (no `custom` — that's wizard-only) |
| `--with <names>` | none | Extra catalog keys beyond the preset |
| `--flat` | off | Skip the `modules/` directory and the sample module — for a host that only ever consumes published modules and never authors its own |
| `--yes / -y` | off | Skip prompts; accept defaults |
| `--no-install` | off | Skip `uv sync` / `npm install` / `alembic upgrade head` |
| `--no-install` | off | Skip `uv sync` / `npm install` / the autogenerated initial migration; the printed next steps use `make migration` / `make migrate` / `make dev` instead |

Passing `--preset` or any `--with` value switches `smpy new` into flag-driven mode (no wizard) even without `--yes`; DB and tenancy then come from `--db` / `--tenancy` defaults. The validated name is also normalized to a PyPI-safe kebab-case slug — `smpy new my_app` prints `Normalizing PyPI name to 'my-app'.` and uses that for the package metadata.

## `smpy create-host <name>` — bare host

Expand All @@ -70,7 +84,7 @@ smpy create-host MyApp --with Auth,Products # declare module deps in pyproject.
smpy create-host MyApp --dest ./apps/myapp # custom destination
```

`--with` takes **PascalCase module names** (matching `ModuleMeta.name`), not catalog keys. Use this when you want to drive the build yourself rather than via `smpy new`'s wizard.
`--with` takes **PascalCase module names** (matching `ModuleMeta.name`), not catalog keys — each is lowercased into a `simple_module_<name>` dependency in `pyproject.toml`. Unlike `smpy new`, the names are **not** checked against the scaffolder catalog, so any published module works (e.g. `--with Products` → `simple_module_products`). No deps are installed and no transitive `requires` are resolved; you drive `uv sync` and migrations yourself. Use this when you want to wire the build by hand rather than via `smpy new`'s wizard.

## `smpy create-module <name>` — module package

Expand All @@ -87,6 +101,25 @@ The result is a complete package: `pyproject.toml` with the entry point declared

For the post-scaffold steps (entry point, Inertia namespace, etc.) see **simple-module-creating**.

## `smpy package-update` — bump `simple_module_*` deps

Walks the project's `pyproject.toml` (plus every `[tool.uv.workspace]` member), finds each dependency whose distribution name matches `simple_module_*` / `simple-module-*`, queries PyPI for the latest non-yanked release, and rewrites the constraint to `name>=<latest>`. PyPI lookups run in parallel, so it's quick even across a multi-package workspace.

```bash
smpy package-update # rewrite constraints in ./pyproject.toml (+ workspace members)
smpy package-update --dry-run # show "old → new" per file, write nothing
smpy package-update --include-pre # also consider pre-releases (a/b/rc/dev/post)
smpy package-update --path apps/web # point at another project root or a pyproject.toml
```

| Flag | Default | Meaning |
|---|---|---|
| `--path <DIR\|FILE>` | cwd | Project root or an explicit `pyproject.toml` |
| `--dry-run` | off | Print planned changes only; don't write |
| `--include-pre` | off | Allow pre-release versions as the "latest" |

**Deps it deliberately skips:** anything whose `[tool.uv.sources]` entry points at a workspace member, a local `path`, a `git` ref, or a `url` — those don't come from PyPI, so their version lives elsewhere. Skipped deps are listed with a reason (`workspace/local source`, `not found on PyPI`). The command only edits constraints; **run `uv sync` afterward** to actually install the new versions (it reminds you to).

## `smpy skills` — install the bundled agent skills

`simple_module_cli` ships a set of [SKILL.md](https://agentskills.io/specification) packs (the ones in this directory). Drop them into any project so Claude Code / Cursor / Codex / etc. find them automatically.
Expand Down Expand Up @@ -124,6 +157,7 @@ Wheel-installed modules ship `package.json` declarations that need to land in th
```bash
smpy host sync-js-deps # uses ./client_app
smpy host sync-js-deps --host-client-app=apps/web/client_app
smpy host sync-js-deps --dry-run # print the npm install command without running it
```

Run after `pip install <module>` if the new module ships frontend code.
Expand Down Expand Up @@ -163,9 +197,11 @@ Don't bake `--password` literals into a script you commit; use a secrets store a
- **Forgot `smpy host sync-js-deps` after `pip install`-ing a module with pages.** Vite resolves module imports against `client_app/node_modules`; the new module's JS deps won't land until you sync.
- **Used `smpy create-module` to add a module to an existing host.** That command is for **publishable** packages, intended to live in their own repo. To add a module to an existing host: install it (`pip install simple_module_<name>` or add to `pyproject.toml` and `uv sync`), then autogenerate a migration. See **simple-module-creating** + **simple-module-migrations**.
- **Calling `smpy create-admin` before migrations have run.** The users tables don't exist yet; the command will error. Run `alembic upgrade head` first (or use `smpy new` which does it for you when `--no-install` isn't set).
- **Expecting `--with products` (or `keycloak` / `audit_log`) to work on `smpy new`.** Those modules exist in the monorepo but aren't scaffolder-catalog keys, so `smpy new --with products` errors. Either scaffold without them and `pip install simple_module_products` into the host afterward, or use `smpy create-host --with Products` (which doesn't validate against the catalog).
- **`smpy package-update` "did nothing" for a workspace dep.** Deps sourced from a `[tool.uv.sources]` workspace/path/git/URL entry are intentionally skipped (their version isn't on PyPI) — they show up in the output as `skipped`, not updated. Also remember to run `uv sync` after; `package-update` only rewrites the constraint strings.

## Related skills

- **simple-module-creating** — what `smpy create-module` produces and the post-scaffold contract
- **simple-module-inertia-pages** — what `smpy host gen-pages` regenerates and why
- **simple-module-migrations** — the `alembic upgrade head` step `smpy new` runs
- **simple-module-migrations** — the migration step `smpy new` runs (`make migration` + `make migrate`, or `alembic upgrade head` directly)
2 changes: 1 addition & 1 deletion skills/simple-module-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Locale-related diagnostics: SM013 (missing file), SM014 (missing keys vs default

### 9. Don't re-enable the silenced ty rules

Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, and `invalid-argument-type` in `pyproject.toml`. SQLModel runtime-instruments fields as SQLAlchemy attributes, so the type checker can't see what's there. Real bugs surface in tests.
Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type`, and `invalid-assignment` in `pyproject.toml`. SQLModel runtime-instruments fields as SQLAlchemy attributes, so the type checker can't see what's there (and `model_config = ConfigDict(...)` clashes with ty's internal `SQLModelConfig` type, which `invalid-assignment` covers). Real bugs surface in tests.

## Related skills

Expand Down
50 changes: 32 additions & 18 deletions skills/simple-module-creating/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,49 @@ description: Use when adding a new feature package to a simple_module_python app
## Quick path

```bash
# scaffold a publishable module package in ./orders (run in a fresh repo or
# inside a host's modules/ directory)
smpy create-module orders

# install the new package into the host environment
uv sync # or: pip install -e ./orders
# scaffold a module into this repo's modules/orders/ — also wires the package
# into host/ + root pyproject.toml and runs `uv sync --all-packages`
make new-module name=orders

# if you added a frontend page, regenerate the Inertia manifest
smpy host gen-pages --host-dir=client_app
make gen-pages

# if you added SQLModel tables, autogenerate + apply a migration
uv run alembic revision --autogenerate -m "add orders module"
uv run alembic upgrade head
make migration msg="add orders module"
make migrate
```

`make new-module` (→ `scripts/new_module.py`) is the in-repo path and produces
the full tree below. For a **standalone publishable package** (its own repo,
with CI + PyPI `publish.yml`), use `smpy create-module orders` instead — a
leaner scaffold; pass `--standalone` to force the `.github/` workflows, then
`uv sync` (or `pip install -e ./orders`) to install it into the host.

## What scaffolding produces

```
orders/
modules/orders/
├── pyproject.toml # entry point declared here
├── package.json # per-module JS deps
├── tsconfig.json
├── tests/test_orders.py
└── orders/
├── module.py # OrdersModule(ModuleBase) with ModuleMeta
├── models.py # Base = create_module_base("orders")
├── contracts/ # SQLModel DTOs — public surface
├── contracts/schemas.py # SQLModel DTOs — public surface
├── service.py # business logic
├── services.py # app.state.orders state container (settings)
├── settings.py # SM_ORDERS_* env → dataclass
├── deps.py # FastAPI dependencies
├── endpoints/{api,views}.py
├── pages/ # *.tsx, auto-discovered by Vite
├── pages/{Browse,Create,Edit}.tsx # *.tsx, auto-discovered by Vite
└── locales/en.json
```

`make new-module` also edits `host/pyproject.toml` (adds the workspace dep) and
the root `pyproject.toml` (type-check + test paths), so the module is picked up
by the host and by `make lint`/`make test` without further wiring.

## The contract: pyproject.toml + module.py

The host discovers modules via a single entry point. If this is wrong, the module silently does nothing in dev and **fails boot in production** (strict discovery).
Expand All @@ -56,21 +70,20 @@ orders = "orders.module:OrdersModule"

```python
# modules/orders/orders/module.py
from simple_module_core import ModuleBase, ModuleMeta
from simple_module_core.module import ModuleBase, ModuleMeta

class OrdersModule(ModuleBase):
meta = ModuleMeta(
name="Orders", # PascalCase, must be unique
route_prefix="/api/orders",
view_prefix="/orders",
depends_on=[], # other module names (PascalCase)
version="0.1.0",
)
```

`ModuleMeta.name` is load-bearing in two places: the `__tablename__` prefix you author and the PascalCase Inertia component namespace. So directory `blog_posts` → `name="BlogPosts"` → `inertia.render("BlogPosts/Index", ...)` → `pages/Index.tsx`. Mismatches fire diagnostic codes `SM003` (orphan page) / `SM004` (phantom render).

For modules you intend to publish, also add `version=` (your module's semver) and `requires_framework=` (a PEP 440 spec for the framework API range, e.g. `">=1.0,<2.0"`) so the host can reject incompatible installs at boot.
`ModuleMeta` also accepts `version=` (defaults to `"1.0.0"`) and `requires_framework=` (a PEP 440 spec for the framework API range, e.g. `">=1.0,<2.0"`) so the host can reject incompatible installs at boot — set these on modules you intend to publish.

## Lifecycle hooks (override only what you need)

Expand All @@ -84,21 +97,22 @@ In execution order — all no-op by default:
| `register_feature_flags(registry)` | `FeatureFlagDefinition` constants |
| `register_event_handlers(bus)` | `bus.subscribe(EventCls, handler)` |
| `register_health_checks(registry)` | Module-owned health probes |
| `register_public_routes(registry)` | Exempt routes from auth via `add_prefix` / `add_regex` (method-aware) |
| `register_exception_handlers(app)` | Module-specific error mapping |
| `register_middleware(app)` | LIFO — module middleware sorted last wraps outermost |
| `register_routes(api_router, view_router)` | `include_router(...)` your two routers |
| `on_startup(app)` / `on_shutdown(app)` | Async; shutdown runs in reverse order |

## Verify after scaffolding

Boot the host. Diagnostics run automatically: in development, warnings/errors land in the boot logs; in production (`SM_ENVIRONMENT != development`), errors fail the boot. Codes `SM001`/`SM008`/`SM009` are blocking; `SM007` (module overrides no hooks) is info-only.
Boot the host. The diagnostics pass runs **only in development** — warnings/errors print to the boot logs and any ERROR raises `SystemExit`. In **production** the diagnostics pass is skipped; the one thing that fails a production boot is strict module *discovery* (a bad entry point — broken import, missing `meta`, non-`ModuleBase` class), surfaced as `SM001`. So `SM008`/`SM009` are dev-boot / `make doctor` errors, not production-boot blockers; `SM007` (module overrides no hooks) is info-only.

The new module should appear in the registered-modules log line. If it has a view endpoint, visit `<view_prefix>/`.
The new module should appear in the registered-modules log line. If it has a view endpoint, visit `<view_prefix>/`. Run `make doctor` for the same diagnostics out of band (orphan pages, coupling, migration drift, locale checks) — see the **simple-module-doctor** skill for the full code list.

## Pitfalls

- **Forgot the entry point.** Package installs, module silently doesn't load (production strict mode raises `InvalidModuleError`). Verify `[project.entry-points.simple_module]` exists in `pyproject.toml`.
- **`name=` collides.** Two modules with the same `ModuleMeta.name` raise `SM008` at boot.
- **`name=` collides.** Two modules with the same `ModuleMeta.name` raise `SM008` at dev boot / `make doctor` (their lowercased names would also collide as table prefixes in the shared schema).
- **Registered the module by hand in host code.** Don't — discovery is entry-point-only; host code never imports module code.

For framework-wide rules that apply once the module exists (SQLModel-everywhere, file-size cap, settings layout, no `session.commit()` in services), see the **simple-module-conventions** skill. For migration mechanics see **simple-module-migrations**.
Loading
Loading