diff --git a/framework/cli/pyproject.toml b/framework/cli/pyproject.toml index 4bd02d2a..95d3a0f7 100644 --- a/framework/cli/pyproject.toml +++ b/framework/cli/pyproject.toml @@ -39,6 +39,16 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["simple_module_cli"] +# The skills directory lives at repo root (so `npx skills add` from +# vercel-labs/skills picks it up) and is symlinked into +# ``simple_module_cli/skills`` for editable installs. Exclude the symlink +# from the package so Hatch doesn't pull it in, then ``force-include`` the +# real source path for the wheel — that gives the wheel real files at +# ``simple_module_cli/skills/`` without double-packaging. +exclude = ["simple_module_cli/skills"] [tool.hatch.build.targets.wheel.shared-data] "simple_module_cli/templates" = "simple_module_cli/templates" + +[tool.hatch.build.targets.wheel.force-include] +"../../skills" = "simple_module_cli/skills" diff --git a/framework/cli/simple_module_cli/cli.py b/framework/cli/simple_module_cli/cli.py index 4792dad7..54272fbb 100644 --- a/framework/cli/simple_module_cli/cli.py +++ b/framework/cli/simple_module_cli/cli.py @@ -4,6 +4,7 @@ sm new sm create-host sm create-module + sm skills add / list / update Plugins discovered via the ``simple_module_cli.cli_plugins`` entry-point group are mounted as named subgroups (e.g. ``sm host gen-pages``). @@ -21,6 +22,7 @@ from simple_module_cli.plugins import discover_and_mount from simple_module_cli.scaffolding import create_host as _create_host from simple_module_cli.scaffolding import create_module as _create_module +from simple_module_cli.skills_cmd import app as skills_app app = typer.Typer( help="SimpleModule developer CLI.", @@ -29,6 +31,7 @@ ) app.command("new")(new_project) +app.add_typer(skills_app, name="skills") @app.command("create-host") diff --git a/framework/cli/simple_module_cli/skills b/framework/cli/simple_module_cli/skills new file mode 120000 index 00000000..82c57311 --- /dev/null +++ b/framework/cli/simple_module_cli/skills @@ -0,0 +1 @@ +../../../skills \ No newline at end of file diff --git a/framework/cli/simple_module_cli/skills_cmd.py b/framework/cli/simple_module_cli/skills_cmd.py new file mode 100644 index 00000000..4e1b5323 --- /dev/null +++ b/framework/cli/simple_module_cli/skills_cmd.py @@ -0,0 +1,249 @@ +"""``sm skills`` — install or update agent skill packs in a target project. + +Bundles the SKILL.md packs shipped under ``simple_module_cli/skills/`` and +materialises them into a project directory (default ``.claude/skills``) so any +agent that reads the [Agent Skills format](https://agentskills.io/specification) +can pick them up. + +Three subcommands: + +* ``sm skills list`` — show every bundled skill and its description. +* ``sm skills add`` — copy (or symlink) skills into the destination. +* ``sm skills update`` — re-copy skills that are already installed at the + destination, overwriting them. +""" + +from __future__ import annotations + +import importlib.resources +import re +import shutil +from collections.abc import Iterable +from enum import StrEnum +from pathlib import Path +from typing import Annotated + +import typer + +__all__ = ["Action", "app", "install_skill", "iter_bundled_skills"] + +app = typer.Typer( + help="Install agent skills (Claude Code / Agent Skills format) into a project.", + no_args_is_help=True, +) + +_DEFAULT_PROJECT_DIR = Path(".claude") / "skills" +_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL) + + +class Action(StrEnum): + WROTE = "wrote" + UPDATED = "updated" + SKIPPED = "skipped" + + +def _bundled_skills_root() -> Path: + """Path to the ``skills/`` directory shipped inside the wheel. + + Resolved via ``importlib.resources`` so editable installs and wheels both + work; tests can monkey-patch this to point at a fixture directory. + """ + return Path(str(importlib.resources.files("simple_module_cli") / "skills")) + + +def iter_bundled_skills(root: Path | None = None) -> list[Path]: + """Return every bundled skill directory (one with a SKILL.md), sorted.""" + base = root if root is not None else _bundled_skills_root() + if not base.is_dir(): + return [] + return sorted(p for p in base.iterdir() if p.is_dir() and (p / "SKILL.md").is_file()) + + +def _read_description(skill_dir: Path) -> str: + """Pull the ``description:`` field out of a SKILL.md's YAML frontmatter. + + Supports YAML's plain-scalar continuation: lines that begin with whitespace + after the ``description:`` line are folded into the value. The previous + "any line containing a colon ends the value" heuristic mis-fired on + prose like ``Note: see docs``. + """ + text = (skill_dir / "SKILL.md").read_text(encoding="utf-8") + match = _FRONTMATTER_RE.match(text) + if not match: + return "" + parts: list[str] = [] + for line in match.group(1).splitlines(): + if line.startswith("description:"): + parts.append(line.split(":", 1)[1].strip()) + elif parts and line[:1].isspace(): + parts.append(line.strip()) + elif parts: + break + return " ".join(p for p in parts if p) + + +def _resolve_dest(dest: Path | None, global_: bool) -> Path: + if dest is not None: + return dest + if global_: + # Resolve home lazily so HOME overrides (CI, sudo, Docker entrypoints) + # take effect for every invocation, not just the import-time one. + return Path.home() / ".claude" / "skills" + return Path.cwd() / _DEFAULT_PROJECT_DIR + + +def _select(names: Iterable[str], available: list[Path]) -> list[Path]: + by_name = {p.name: p for p in available} + requested = [n for n in names if n] + if not requested: + return list(available) + unknown = [n for n in requested if n not in by_name] + if unknown: + typer.echo(f"ERROR: unknown skill(s): {', '.join(unknown)}", err=True) + typer.echo(f"Available: {', '.join(sorted(by_name))}", err=True) + raise typer.Exit(code=1) + return [by_name[n] for n in requested] + + +def install_skill( + src: Path, + dest_root: Path, + *, + force: bool, + symlink: bool, +) -> tuple[Action, Path]: + """Copy or symlink one skill directory into ``dest_root//``.""" + target = dest_root / src.name + if target.exists() or target.is_symlink(): + if not force: + return (Action.SKIPPED, target) + if target.is_symlink() or target.is_file(): + target.unlink() + else: + shutil.rmtree(target) + action = Action.UPDATED + else: + action = Action.WROTE + target.parent.mkdir(parents=True, exist_ok=True) + if symlink: + target.symlink_to(src.resolve(), target_is_directory=True) + else: + shutil.copytree(src, target) + return (action, target) + + +@app.command("list") +def list_skills() -> None: + """List every bundled skill and its trigger description.""" + skills = iter_bundled_skills() + if not skills: + typer.echo("(no skills bundled)") + return + width = max(len(s.name) for s in skills) + for skill in skills: + desc = _read_description(skill) + typer.echo(f" {skill.name.ljust(width)} {desc}") + + +@app.command("add") +def add_skills( + names: Annotated[ + list[str] | None, + typer.Argument(help="Skill names to install. Empty = install every bundled skill."), + ] = None, + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Target directory. Defaults to ./.claude/skills."), + ] = None, + global_: Annotated[ + bool, + typer.Option("--global", "-g", help="Install into ~/.claude/skills (machine-wide)."), + ] = False, + force: Annotated[ + bool, + typer.Option("--force", "-f", help="Overwrite existing skill directories."), + ] = False, + symlink: Annotated[ + bool, + typer.Option( + "--symlink", + help="Symlink to the bundled source instead of copying. " + "Useful when developing the skills in-tree.", + ), + ] = False, +) -> None: + """Install bundled simple_module skills into a project.""" + available = iter_bundled_skills() + if not available: + typer.echo("ERROR: no bundled skills found in this CLI install.", err=True) + raise typer.Exit(code=1) + + selected = _select(names or [], available) + target_root = _resolve_dest(dest, global_) + target_root.mkdir(parents=True, exist_ok=True) + + counts: dict[Action, int] = dict.fromkeys(Action, 0) + for src in selected: + action, target = install_skill(src, target_root, force=force, symlink=symlink) + counts[action] += 1 + typer.echo(f" {action.value:8} {src.name} -> {target}") + + typer.echo( + f"\nDone. wrote={counts[Action.WROTE]} updated={counts[Action.UPDATED]} " + f"skipped={counts[Action.SKIPPED]} (target: {target_root})" + ) + if counts[Action.SKIPPED]: + typer.echo("Pass --force to overwrite skipped skills.") + + +@app.command("update") +def update_skills( + names: Annotated[ + list[str] | None, + typer.Argument(help="Skill names to update. Empty = update every skill already installed."), + ] = None, + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Target directory. Defaults to ./.claude/skills."), + ] = None, + global_: Annotated[ + bool, + typer.Option("--global", "-g", help="Update ~/.claude/skills (machine-wide)."), + ] = False, + symlink: Annotated[ + bool, + typer.Option( + "--symlink", + help="Symlink to the bundled source instead of copying.", + ), + ] = False, +) -> None: + """Re-copy bundled skills, overwriting existing targets. + + With no arguments: only updates skills already present in the destination + (so you can re-pull the latest copies without re-deciding which ones you want). + """ + available = iter_bundled_skills() + if not available: + typer.echo("ERROR: no bundled skills found in this CLI install.", err=True) + raise typer.Exit(code=1) + + target_root = _resolve_dest(dest, global_) + requested = list(names or []) + if requested: + selected = _select(requested, available) + else: + if not target_root.is_dir(): + typer.echo(f"Nothing to update — {target_root} does not exist.") + return + installed = {p.name for p in target_root.iterdir() if p.is_dir() or p.is_symlink()} + selected = [s for s in available if s.name in installed] + if not selected: + typer.echo(f"Nothing to update — no installed skills found at {target_root}.") + return + + target_root.mkdir(parents=True, exist_ok=True) + for src in selected: + action, target = install_skill(src, target_root, force=True, symlink=symlink) + typer.echo(f" {action.value:8} {src.name} -> {target}") + typer.echo(f"\nDone. Updated {len(selected)} skill(s) at {target_root}.") diff --git a/framework/cli/tests/test_skills_cmd.py b/framework/cli/tests/test_skills_cmd.py new file mode 100644 index 00000000..58659533 --- /dev/null +++ b/framework/cli/tests/test_skills_cmd.py @@ -0,0 +1,254 @@ +"""Tests for the ``sm skills`` subcommand group.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import typer +from simple_module_cli import skills_cmd +from simple_module_cli.cli import app as root_app +from typer.testing import CliRunner + + +@pytest.fixture +def fake_skills_root(tmp_path, monkeypatch): + """Create a tiny bundled-skills directory and route the CLI at it.""" + root = tmp_path / "bundled" + root.mkdir() + + (root / "alpha").mkdir() + (root / "alpha" / "SKILL.md").write_text( + "---\nname: alpha\ndescription: First fake skill for testing.\n---\n\n# Alpha\n", + encoding="utf-8", + ) + + (root / "beta").mkdir() + (root / "beta" / "SKILL.md").write_text( + "---\n" + "name: beta\n" + "description: Second fake skill, with a multi-line\n" + " description that spans lines.\n" + "---\n\n" + "# Beta\n", + encoding="utf-8", + ) + (root / "beta" / "scripts").mkdir() + (root / "beta" / "scripts" / "helper.py").write_text("print('hi')\n", encoding="utf-8") + + # A junk dir without a SKILL.md must be ignored. + (root / "not-a-skill").mkdir() + + monkeypatch.setattr(skills_cmd, "_bundled_skills_root", lambda: root) + return root + + +class TestIterBundledSkills: + def test_lists_only_dirs_with_skill_md(self, fake_skills_root): + skills = skills_cmd.iter_bundled_skills() + names = [p.name for p in skills] + assert names == ["alpha", "beta"] + + def test_returns_empty_when_root_missing(self, tmp_path, monkeypatch): + missing = tmp_path / "nope" + monkeypatch.setattr(skills_cmd, "_bundled_skills_root", lambda: missing) + assert skills_cmd.iter_bundled_skills() == [] + + +class TestList: + def test_prints_each_bundled_skill(self, fake_skills_root): + runner = CliRunner() + result = runner.invoke(root_app, ["skills", "list"]) + assert result.exit_code == 0, result.output + assert "alpha" in result.output + assert "First fake skill" in result.output + assert "beta" in result.output + + def test_empty_bundle_is_handled(self, tmp_path, monkeypatch): + empty = tmp_path / "empty" + empty.mkdir() + monkeypatch.setattr(skills_cmd, "_bundled_skills_root", lambda: empty) + runner = CliRunner() + result = runner.invoke(root_app, ["skills", "list"]) + assert result.exit_code == 0 + assert "no skills bundled" in result.output + + def test_description_continuation_lines_are_folded(self, fake_skills_root): + """Multi-line descriptions (YAML plain-scalar continuation) fold on whitespace.""" + runner = CliRunner() + result = runner.invoke(root_app, ["skills", "list"]) + assert result.exit_code == 0, result.output + # The beta fixture's description continues onto an indented line; both halves + # must appear in the rendered output. + assert "Second fake skill" in result.output + assert "spans lines" in result.output + + def test_description_with_internal_colon_does_not_truncate(self, tmp_path, monkeypatch): + """Prose containing a colon must not be mistaken for a new YAML key.""" + root = tmp_path / "bundled" + root.mkdir() + skill = root / "gamma" + skill.mkdir() + (skill / "SKILL.md").write_text( + "---\nname: gamma\ndescription: Triggers on Foo: bar baz quux.\n---\n", + encoding="utf-8", + ) + monkeypatch.setattr(skills_cmd, "_bundled_skills_root", lambda: root) + + runner = CliRunner() + result = runner.invoke(root_app, ["skills", "list"]) + assert result.exit_code == 0, result.output + assert "Triggers on Foo: bar baz quux." in result.output + + +class TestAdd: + def test_installs_all_skills_when_no_args(self, fake_skills_root, tmp_path): + runner = CliRunner() + target = tmp_path / "project" + target.mkdir() + result = runner.invoke( + root_app, + ["skills", "add", "--dest", str(target / ".claude" / "skills")], + ) + assert result.exit_code == 0, result.output + assert (target / ".claude" / "skills" / "alpha" / "SKILL.md").is_file() + assert (target / ".claude" / "skills" / "beta" / "SKILL.md").is_file() + # Subdirectories of the source skill copy through too. + assert (target / ".claude" / "skills" / "beta" / "scripts" / "helper.py").is_file() + + def test_installs_only_named_skills(self, fake_skills_root, tmp_path): + runner = CliRunner() + dest = tmp_path / "x" + result = runner.invoke(root_app, ["skills", "add", "alpha", "--dest", str(dest)]) + assert result.exit_code == 0, result.output + assert (dest / "alpha" / "SKILL.md").is_file() + assert not (dest / "beta").exists() + + def test_unknown_skill_errors_with_listing(self, fake_skills_root, tmp_path): + runner = CliRunner() + result = runner.invoke( + root_app, + ["skills", "add", "ghost", "--dest", str(tmp_path / "x")], + ) + assert result.exit_code == 1 + assert "unknown skill" in result.output.lower() + assert "alpha" in result.output and "beta" in result.output + + def test_skips_existing_without_force(self, fake_skills_root, tmp_path): + runner = CliRunner() + dest = tmp_path / "x" + runner.invoke(root_app, ["skills", "add", "alpha", "--dest", str(dest)]) + # Sentinel file inside the existing skill must survive the second run. + sentinel = dest / "alpha" / "user-edit.md" + sentinel.write_text("hand-edited", encoding="utf-8") + + result = runner.invoke(root_app, ["skills", "add", "alpha", "--dest", str(dest)]) + assert result.exit_code == 0, result.output + assert "skipped" in result.output + assert sentinel.read_text(encoding="utf-8") == "hand-edited" + + def test_force_overwrites(self, fake_skills_root, tmp_path): + runner = CliRunner() + dest = tmp_path / "x" + runner.invoke(root_app, ["skills", "add", "alpha", "--dest", str(dest)]) + sentinel = dest / "alpha" / "user-edit.md" + sentinel.write_text("hand-edited", encoding="utf-8") + + result = runner.invoke(root_app, ["skills", "add", "alpha", "--dest", str(dest), "--force"]) + assert result.exit_code == 0, result.output + assert "updated" in result.output + # --force replaces the directory wholesale, so the sentinel goes away. + assert not sentinel.exists() + assert (dest / "alpha" / "SKILL.md").is_file() + + def test_symlink_creates_link(self, fake_skills_root, tmp_path): + runner = CliRunner() + dest = tmp_path / "x" + result = runner.invoke( + root_app, + ["skills", "add", "alpha", "--dest", str(dest), "--symlink"], + ) + assert result.exit_code == 0, result.output + installed = dest / "alpha" + assert installed.is_symlink() + assert installed.resolve() == (fake_skills_root / "alpha").resolve() + + def test_global_flag_writes_under_home_claude(self, fake_skills_root, tmp_path, monkeypatch): + fake_home = tmp_path / "home" + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + + runner = CliRunner() + result = runner.invoke(root_app, ["skills", "add", "alpha", "-g"]) + assert result.exit_code == 0, result.output + assert (fake_home / ".claude" / "skills" / "alpha" / "SKILL.md").is_file() + + +class TestUpdate: + def test_updates_only_already_installed_when_no_names(self, fake_skills_root, tmp_path): + runner = CliRunner() + dest = tmp_path / "x" + # Pre-install just alpha, then call update with no names — beta must + # stay un-installed because it wasn't there before. + runner.invoke(root_app, ["skills", "add", "alpha", "--dest", str(dest)]) + + # Hand-edit alpha so we can prove the update overwrites. + (dest / "alpha" / "edit.md").write_text("e", encoding="utf-8") + + result = runner.invoke(root_app, ["skills", "update", "--dest", str(dest)]) + assert result.exit_code == 0, result.output + assert "alpha" in result.output + assert "beta" not in result.output + assert not (dest / "beta").exists() + # alpha was force-replaced, so the hand-edited file is gone. + assert not (dest / "alpha" / "edit.md").exists() + + def test_explicit_names_force_install_even_if_missing(self, fake_skills_root, tmp_path): + runner = CliRunner() + dest = tmp_path / "x" + # beta isn't installed yet; update with explicit name should still install it. + result = runner.invoke(root_app, ["skills", "update", "beta", "--dest", str(dest)]) + assert result.exit_code == 0, result.output + assert (dest / "beta" / "SKILL.md").is_file() + + def test_no_dest_dir_yields_friendly_message(self, fake_skills_root, tmp_path): + runner = CliRunner() + missing = tmp_path / "never-created" + result = runner.invoke(root_app, ["skills", "update", "--dest", str(missing)]) + assert result.exit_code == 0 + assert "Nothing to update" in result.output + + +class TestSkillsRegisteredOnRootApp: + def test_skills_subcommand_visible_in_help(self): + runner = CliRunner() + result = runner.invoke(root_app, ["--help"]) + assert result.exit_code == 0, result.output + assert "skills" in result.output + + +class TestRealBundle: + """Smoke test: the actual skills shipped with this CLI are discoverable.""" + + def test_bundled_skills_root_exists(self): + # Editable install: simple_module_cli/skills is a symlink into repo /skills. + # Wheel install: shared-data force-include copies the dir verbatim. + # Either way, the root must exist and contain at least the two original skills. + root = skills_cmd._bundled_skills_root() + assert root.is_dir(), f"bundled skills root missing: {root}" + names = {p.name for p in skills_cmd.iter_bundled_skills(root)} + assert "simple-module-creating" in names + assert "simple-module-cli" in names + + def test_every_bundled_skill_has_valid_frontmatter(self): + for skill in skills_cmd.iter_bundled_skills(): + text = (skill / "SKILL.md").read_text(encoding="utf-8") + assert text.startswith("---\n"), f"{skill.name}: missing frontmatter" + # Frontmatter must declare both name + description. + head = text.split("---", 2)[1] + assert "name:" in head, f"{skill.name}: frontmatter has no name" + assert "description:" in head, f"{skill.name}: frontmatter has no description" + + +def test_typer_app_exports() -> None: + """The skills subcommand exports a Typer instance — required for add_typer.""" + assert isinstance(skills_cmd.app, typer.Typer) diff --git a/skills/README.md b/skills/README.md index 615d58df..ff37f612 100644 --- a/skills/README.md +++ b/skills/README.md @@ -4,19 +4,31 @@ Agent skills for working in a [simple_module_python](https://github.com/antosuba ## Install -Pick your scope and run one command: +There are two install paths — pick whichever fits your project. + +### Option A — `sm skills` (recommended for `simple_module_cli` users) + +Every project produced by `sm new` already depends on `simple_module_cli`, which ships these skills inside its wheel. From the project root: ```bash -# All skills in this repo, into the current project -npx skills add antosubash/simple_module_python +sm skills list # see what's available +sm skills add # install ALL skills into ./.claude/skills/ +sm skills add simple-module-creating # install just one +sm skills add -g # install into ~/.claude/skills (machine-wide) +sm skills add --dest agents/skills # custom target dir +sm skills add --symlink # symlink to the bundled source (good for skill devs) +sm skills update # re-pull updates for skills already installed +sm skills update simple-module-doctor # explicit re-pull (force-overwrites) +``` -# Globally (available in every project on your machine) -npx skills add antosubash/simple_module_python -g +`sm skills` resolves the bundled set against whatever version of `simple_module_cli` is installed, so upgrading the CLI ships skill updates the next time you run `sm skills update`. -# Just one skill, into a specific agent -npx skills add antosubash/simple_module_python --skill simple-module-creating -a claude-code +### Option B — `npx skills` (no Python install needed) -# List what's available without installing +```bash +npx skills add antosubash/simple_module_python # all skills, current project +npx skills add antosubash/simple_module_python -g # globally +npx skills add antosubash/simple_module_python --skill simple-module-creating -a claude-code npx skills add antosubash/simple_module_python --list ``` @@ -32,6 +44,9 @@ The CLI is [vercel-labs/skills](https://github.com/vercel-labs/skills); see its | [simple-module-database](./simple-module-database/SKILL.md) | Adding SQLModel tables, picking a mixin, or debugging session/transaction behavior | | [simple-module-migrations](./simple-module-migrations/SKILL.md) | Generating, applying, or reviewing Alembic migrations after installing or changing a module | | [simple-module-inertia-pages](./simple-module-inertia-pages/SKILL.md) | Adding or debugging an Inertia page in a module — render keys, shared props, common pitfalls | +| [simple-module-locales](./simple-module-locales/SKILL.md) | Adding or debugging i18n in a module — `locale_dirs()`, namespaces, CLDR plurals, the Zod-in-hook rule | +| [simple-module-registries](./simple-module-registries/SKILL.md) | Contributing menu items, permissions, feature flags, or events from a module | +| [simple-module-testing](./simple-module-testing/SKILL.md) | Writing pytest tests — picking the right fixture (`db_session` / `app` / `authenticated_client`), single-test runs, e2e | | [simple-module-doctor](./simple-module-doctor/SKILL.md) | Interpreting a diagnostic code (`SM001`–`SM018`) printed at boot | The skills are designed to stand alone — install them into any host or module-package project and they'll work without needing access to the framework's source repo. diff --git a/skills/simple-module-cli/SKILL.md b/skills/simple-module-cli/SKILL.md index 6b5afdef..d3e8e107 100644 --- a/skills/simple-module-cli/SKILL.md +++ b/skills/simple-module-cli/SKILL.md @@ -1,11 +1,11 @@ --- name: simple-module-cli -description: Use when invoking the `sm` 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, or creating an admin user. Triggers on "sm new", "sm create-host", "sm create-module", "sm host gen-pages", "sm users create-admin", or any unfamiliar `sm` subcommand. +description: Use when invoking the `sm` 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 "sm new", "sm create-host", "sm create-module", "sm host gen-pages", "sm users create-admin", "sm skills add", or any unfamiliar `sm` subcommand. --- # simple_module_python: the `sm` CLI -The `sm` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`). It groups four kinds of operations: scaffolding new things, project-time helpers for the host, and admin shortcuts for the bundled modules. +The `sm` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`). It groups four kinds of operations: scaffolding new things, project-time helpers for the host, admin shortcuts for the bundled modules, and installing the bundled agent skills. ## Top-level commands @@ -14,6 +14,7 @@ The `sm` command is provided by `simple_module_cli` (installed as a dep of `simp | `sm new ` | Greenfield: scaffold a complete app (host + selected modules) in one shot, with an interactive wizard for DB / tenancy / module preset | | `sm create-host ` | You want just a bare host project; you'll add modules later by `pip install`-ing them | | `sm create-module ` | You're authoring a publishable module package (separate repo, distributed via PyPI) | +| `sm skills …` | Install / update the bundled agent-skill packs into a project (`add`, `list`, `update`) | | `sm host …` | Project-time helpers run from inside a host directory (page manifest, JS dep sync) | | `sm settings …` | Settings-module admin — currently `import-from-env` | | `sm users …` | Users-module admin — currently `create-admin` | @@ -85,6 +86,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**. +## `sm 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. + +```bash +sm skills list # see what's available +sm skills add # install ALL skills into ./.claude/skills/ +sm skills add simple-module-creating simple-module-cli # specific ones only +sm skills add -g # ~/.claude/skills (machine-wide) +sm skills add --dest agents/skills # explicit target dir +sm skills add --symlink # symlink to bundled source (good when iterating on the skills themselves) +sm skills update # re-pull whatever is already installed at the dest +sm skills update simple-module-doctor # explicitly re-pull one (always force-overwrites) +``` + +**Without `--force`, `sm skills add` skips skills that already exist at the destination** — so re-running it is safe. Use `--force` (or `sm skills update`) to overwrite. + +The bundle resolves against your installed `simple_module_cli`. To get newer skills, upgrade the CLI (`uv sync` or `pip install -U simple_module_cli`) and re-run `sm skills update`. + ## `sm host gen-pages` — regenerate the Inertia manifest Run from a host project. Scans every installed module's `pages/*.tsx`, writes `client_app/modules.{manifest.json,generated.ts,generated.css}`, and extends Vite's `server.fs.allow`. diff --git a/skills/simple-module-locales/SKILL.md b/skills/simple-module-locales/SKILL.md new file mode 100644 index 00000000..179e3670 --- /dev/null +++ b/skills/simple-module-locales/SKILL.md @@ -0,0 +1,125 @@ +--- +name: simple-module-locales +description: Use when adding or debugging i18n in a simple_module_python module — declaring `locale_dirs()`, naming JSON files, picking a namespace, writing CLDR-pluralized keys, or fixing SM013–SM016. Triggers on "translations", "locales", "useT", "i18n", "missing locale", "non-string leaf", "first-render-locale freeze", or any edit under `/locales/`. +--- + +# simple_module_python: locales (i18n) + +## File layout + +Each module ships JSON files under its `locales/` directory and points the framework at it from `ModuleBase.locale_dirs()`: + +``` +modules/orders/orders/ +├── module.py +└── locales/ + ├── en.json + └── es.json +``` + +```python +# orders/module.py +import importlib.resources +from pathlib import Path + +class OrdersModule(ModuleBase): + def locale_dirs(self) -> dict[str, Path]: + # key = namespace, value = directory holding .json files + return {"orders": Path(str(importlib.resources.files(__package__) / "locales"))} +``` + +The map's **key** is the i18n namespace (use the lowercase module package name); the **value** is the directory containing one JSON file per supported locale. + +## Key-flattening + namespace prefix + +Nested objects flatten with dotted keys, then the namespace prefixes the result: + +```json +// orders/locales/en.json +{ + "browse": { "title": "Orders", "empty": "No orders yet." }, + "errors": { "not_found": "Order {id} not found" } +} +``` + +At runtime the keys become `orders.browse.title`, `orders.browse.empty`, `orders.errors.not_found`. Interpolation uses `{name}` placeholders, not `%(name)s` or `${name}`. + +## CLDR pluralization + +Use suffixes on the key — never branch in TypeScript: + +```json +{ + "items_count_zero": "No items", + "items_count_one": "{count} item", + "items_count_other": "{count} items" +} +``` + +Supported suffixes: `_zero`, `_one`, `_two`, `_few`, `_many`, `_other`. **Only `_other` is required** — the rest are optional; the runtime falls back to `_other` for plural forms not declared. English needs `_one` and `_other`; Russian needs `_one`, `_few`, `_other`; Arabic uses all six. + +## Frontend usage — `useT()` + +```tsx +import { keys, useT } from '@simple-module-py/i18n'; + +export default function Browse({ orders }: Props) { + const { t } = useT(); + return

{t(keys.orders.browse.title)}

; + // or with interpolation + //

{t('orders.errors.not_found', { id: 42 })}

+} +``` + +The shared prop `i18n` (set by `InertiaLayoutDataMiddleware`) carries the active locale + flattened bundle, so `useT()` doesn't fetch. + +## The Zod-in-hook rule + +**Translated Zod schemas must be constructed inside a hook**, never at module scope: + +```tsx +// ❌ wrong — `t()` resolves once when the schema is built. The schema then +// carries strings from whichever locale was active at first render — +// forever, even after the user switches languages. +const schema = z.object({ + name: z.string().min(1, t('orders.validation.name_required')), +}); + +// ✅ right — schema is rebuilt per render, picking up the current locale. +export function useOrderSchema() { + const { t } = useT(); + return z.object({ + name: z.string().min(1, t('orders.validation.name_required')), + }); +} +``` + +## Backend usage + +Server-side, translated strings come from the same bundle via `request.state.locale` resolution. Avoid hard-coding English in error responses that surface to users — pull the message through the locale system so other languages get it for free. + +## Diagnostic codes + +| Code | Level | Cause | Fix | +|---|---|---|---| +| **SM013** | WARNING | `locale_dirs()` declares a namespace, but a file is missing for one of `SM_I18N_SUPPORTED_LOCALES` | Create the file (even an empty `{}`) or trim the supported-locales list | +| **SM014** | WARNING | A non-default locale is missing keys that exist in the default (en) | Add the keys, or accept that the runtime falls back to default | +| **SM015** | WARNING | A non-default locale has keys **not** in the default | Either remove the dead keys or add them to the default file | +| **SM016** | ERROR | A locale JSON file is invalid or has non-string leaves | Fix the JSON. Only string leaves are allowed — interpolation is `{placeholder}` strings, not nested objects | + +SM016 is fatal in production. SM013–SM015 are warnings, but they're the kind of warnings that turn into "Spanish users see English in production" if ignored. + +## Pitfalls + +- **Used a non-string leaf** (`"count": 5`, an array, or an object instead of a string with placeholders). SM016. Only string leaves; interpolate via `{name}` placeholders. +- **Skipped `locale_dirs()` even though `locales/` exists.** The framework only loads what's declared. The directory alone does nothing. +- **Used the module's PascalCase `meta.name` as the namespace key.** It must be the lowercase package name (typically the directory under `modules/`). Mixing cases breaks `keys..…` autocompletion in TS. +- **Constructed a translated Zod schema at module scope.** First-render-locale freeze — see the rule above. Always rebuild inside a hook. +- **Hand-edited the flattened key in JS instead of going through `keys..…`.** The `keys` proxy is type-safe; raw string keys silently rot when the JSON renames. +- **Pluralized with `if (n === 1)` in TSX.** Use CLDR suffixes — they handle locales English doesn't (Russian `_few`, Arabic `_zero`/`_two`/`_many`) without per-page branching. + +## Related skills + +- **simple-module-conventions** — the Zod-in-hook rule lives in the convention list +- **simple-module-doctor** — full reference for `SM013`–`SM016` +- **simple-module-inertia-pages** — how `i18n` lands in shared props diff --git a/skills/simple-module-registries/SKILL.md b/skills/simple-module-registries/SKILL.md new file mode 100644 index 00000000..37e3f122 --- /dev/null +++ b/skills/simple-module-registries/SKILL.md @@ -0,0 +1,144 @@ +--- +name: simple-module-registries +description: Use when a module needs to contribute menu items, permissions, feature flags, or event handlers in a simple_module_python codebase — the four cross-cutting registries the framework gives every module. Triggers on "register_menu_items", "register_permissions", "register_feature_flags", "register_event_handlers", "MenuRegistry", "PermissionRegistry", "FeatureFlagRegistry", "EventBus", "feature_flag decorator", or "publish event". +--- + +# simple_module_python: cross-module registries + +Four registries are populated during boot from each module's `register_*` hook. They turn the modular monolith into something more than a bag of routers: navigation aggregates, permission checks expand consistently, features can be toggled per tenant, and modules emit/consume events without importing each other. + +## Menu — `register_menu_items(registry: MenuRegistry)` + +```python +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection + +class OrdersModule(ModuleBase): + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Orders", + url="/orders/", + icon="shopping-cart", + order=10, + section=MenuSection.SIDEBAR, + roles=["admin", "staff"], # empty list = all authenticated users + ) + ) +``` + +**Sections:** `SIDEBAR`, `ADMIN_SIDEBAR`, `NAVBAR`, `USER_DROPDOWN`. The `menus` shared prop on every Inertia response contains all four — the React layout chooses which to render where. `order` controls intra-section sorting (lower = first). `method="post"` is for items that need to be a form submission (logout) rather than a link. + +## Permissions — `register_permissions(registry: PermissionRegistry)` + +```python +from simple_module_core.permissions import PermissionRegistry + +class OrdersModule(ModuleBase): + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group("Orders", [ + "orders.view", + "orders.create", + "orders.delete", + ]) + registry.map_role("staff", ["orders.view", "orders.create"]) +``` + +**Convention:** permission names are `.` (lowercase, dot-separated). Group name is human-readable — it surfaces in the admin UI as a section header. The built-in `admin` role gets the wildcard `"*"` and skips per-permission checks. + +The runtime expansion (role → permissions) is cached. `register_permissions` is called once at boot; mutating the registry afterwards bypasses the cache and invalidates user sessions until the cache TTL elapses. Don't mutate at request time. + +To check inside an endpoint, depend on the `RequireAnyPermissionDep` / `RequireAllPermissionsDep` dependencies (see auth/users), not by reading the registry by hand. + +## Feature flags — `register_feature_flags(registry: FeatureFlagRegistry)` + +```python +from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry + +class OrdersModule(ModuleBase): + def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: + registry.add(FeatureFlagDefinition( + name="orders.bulk_import", + description="Enables the CSV bulk-import UI on /orders/import", + default_enabled=False, + )) +``` + +**Resolution order at request time:** tenant override > system override > `default_enabled`. Per-tenant overrides come from the multi-tenant context (`request.state.tenant_id` from `TenantMiddleware`); system overrides come from the settings module's persisted overrides table. + +**Checking a flag** (in an endpoint) — use the helper, not raw registry access: + +```python +from simple_module_core.feature_flags import is_flag_enabled, require_flag, feature_flag + +@router.post("/import") +async def bulk_import(request: Request): + if not is_flag_enabled(request, "orders.bulk_import"): + raise HTTPException(404) + ... + +# Or as a decorator — 404 when off: +@router.post("/import") +@feature_flag("orders.bulk_import") +async def bulk_import(...): ... +``` + +All helpers read `request.state.tenant_id`, so the per-tenant override Just Works. + +## Events — `register_event_handlers(bus: EventBus)` + +The event bus is async and in-process. Modules emit + consume domain events without importing each other. + +```python +# orders/contracts/events.py +from dataclasses import dataclass +from simple_module_core.events import Event + +@dataclass +class OrderPlaced(Event): + order_id: int + user_id: str + total_cents: int +``` + +```python +# notifications/module.py +from simple_module_core.events import EventBus +from orders.contracts.events import OrderPlaced + +class NotificationsModule(ModuleBase): + def register_event_handlers(self, bus: EventBus) -> None: + bus.subscribe(OrderPlaced, self._send_receipt) + + async def _send_receipt(self, event: OrderPlaced) -> None: + ... +``` + +```python +# orders/service.py +async def place_order(self, ...): + order = ... + await self._bus.publish(OrderPlaced(order_id=order.id, ...)) + return order +``` + +**`publish`** awaits every handler concurrently via `asyncio.gather` and isolates handler failures (logged, not propagated). **`publish_nowait`** schedules dispatch on the running loop and returns immediately — use when the publisher must not be blocked or rolled back by handler failure. + +The event bus has no persistence and no retry. If the host crashes between `publish` and the handler completing, the event is lost. For durable workflows use `background_tasks` (Celery) instead. + +## Inter-module convention: contracts only + +Module A consuming Module B's events should import only from `b.contracts.events`. Importing `b.service` or `b.models` couples them tightly and breaks the framework→plugin direction (`SM009`) when a framework piece accidentally pulls one of those imports along with it. + +## Pitfalls + +- **Mutated a registry after boot.** Boot-phase only. Cached views (menus, role→permission map) aren't invalidated for live requests; mutations look fine in dev with auto-reload and silently rot in prod. +- **Raw permission strings in endpoints (`request.state.user.permissions`).** Use `RequireAnyPermissionDep` / `RequireAllPermissionsDep`. The dependency handles wildcard expansion and 401 vs 403 distinction. +- **Forgot a feature flag's `default_enabled=False`.** A flag added with `default_enabled=True` is on for every tenant on first deploy — defeats the point of gating a rollout. Default to `False`; flip via override after the rollout window. +- **Subscribed to an event in `register_settings` instead of `register_event_handlers`.** `register_settings` runs **before** the event bus is constructed; the subscription silently no-ops. +- **Used `publish_nowait` inside a request handler that needs the listener to commit a DB row in the same transaction.** It returns immediately — the handler runs after the request has already committed/rolled back. For "in this request, do X then Y", just call Y directly. + +## Related skills + +- **simple-module-creating** — where these hooks live in the lifecycle order +- **simple-module-conventions** — `SM009` (framework→plugin direction) applies to inter-module imports too +- **simple-module-doctor** — `SM007` fires when a module overrides no hooks at all diff --git a/skills/simple-module-testing/SKILL.md b/skills/simple-module-testing/SKILL.md new file mode 100644 index 00000000..1962cb64 --- /dev/null +++ b/skills/simple-module-testing/SKILL.md @@ -0,0 +1,102 @@ +--- +name: simple-module-testing +description: Use when writing or running pytest tests in a simple_module_python project — picking the right fixture, understanding why a test session sees pre-stamped alembic revisions, getting an `authenticated_client`, or running a single test / e2e suite. Triggers on "how do I test", "fixture", "authenticated_client", "asyncio_mode", "make test", "make test-e2e", "playwright", or any new file under `tests/` or `/tests/`. +--- + +# simple_module_python: testing + +## What's already wired up + +Root `conftest.py` provides app-level fixtures that **every** test directory inherits — module tests don't redeclare them. `pyproject.toml` sets `asyncio_mode = "auto"` and `-m 'not e2e'`, so: + +- `async def test_*` works without `@pytest.mark.asyncio`. +- `make test` excludes the `e2e` marker by default; only `make test-e2e` runs it. + +| Fixture | What you get | +|---|---| +| `settings` | `Settings` configured for in-memory SQLite (`sqlite+aiosqlite:///:memory:`), `multi_tenant=True`, `tenant_header="X-Tenant-ID"`. | +| `db_state` / `engine` | Fresh `DatabaseState` per test with the framework's SQLAlchemy listeners (`AuditMixin`, soft-delete filter, tenant scoping) registered. | +| `db_session` | `AsyncSession` against in-memory SQLite with **all** module tables created and `alembic_version` stamped at head. The stamp matters: without it the boot-time `check_migrations` raises SM010 and the `app` fixture can't start. | +| `app` | A live FastAPI app — `create_app(settings)` plus lifespan started/stopped. Tables are pre-created the same way as `db_session`. | +| `client` | Unauthenticated `httpx.AsyncClient` against `app` via `ASGITransport`. | +| `authenticated_client` | Same client with a forged session cookie carrying a seeded admin (`admin@test` / `test-password`). The seed runs `users.bootstrap.create_admin`, so the users tables must be in scope. | + +## Standard test patterns + +```python +# Unit test — no app, no HTTP +async def test_service_creates_order(db_session): + order = await OrdersService(db_session).create(name="x") + assert order.id is not None + +# API test — JSON endpoint +async def test_api_lists_orders(authenticated_client): + resp = await authenticated_client.get("/api/orders") + assert resp.status_code == 200 + +# View test — Inertia endpoint (X-Inertia header) +async def test_view_renders_index(authenticated_client): + resp = await authenticated_client.get("/orders/", headers={"X-Inertia": "true"}) + assert resp.status_code == 200 + assert resp.json()["component"] == "Orders/Index" +``` + +`authenticated_client` already carries the cookie — don't manually pass `cookies=`. + +## Single-test / focused runs + +```bash +# One file +uv run pytest modules/orders/tests/test_service.py + +# One test +uv run pytest modules/orders/tests/test_service.py::test_creates_order + +# One JS test +npx vitest run modules/orders/orders/pages/__tests__/Index.test.tsx +``` + +`make test` runs `test-py` then `test-js`. `make test-py` and `make test-js` run only one suite each. + +## E2E tests (Playwright) + +E2E tests live in `tests/e2e/` behind the `e2e` marker. They drive a real Chromium browser against a running stack: + +```bash +make dev # in one terminal: docker up + API + Vite +uv run playwright install chromium # one-time, per machine +make test-e2e # in another terminal +``` + +Env vars they read (defaults in parens): `E2E_BASE_URL` (`http://localhost:8000`), `E2E_USERNAME` (`admin@example.com`), `E2E_PASSWORD` (`admin`), `E2E_USER_ID` (optional — enables the password-reset test). + +Token-minting helpers in `tests/e2e/conftest.py` use the same `SM_USERS_VERIFICATION_TOKEN_SECRET` as the server, so you don't need to scrape `ConsoleMailer` stdout to grab a verify link. + +## Module-test layout + +Each module ships its own `tests/` next to its package: + +``` +modules/orders/ +├── orders/ # package +└── tests/ + ├── __init__.py + └── test_service.py # imports orders.service +``` + +The directory must be listed in the root `pyproject.toml` under `[tool.pytest.ini_options].testpaths` for `make test` to pick it up. `sm create-module` adds this entry; if you scaffolded a module by hand, add it. + +## Pitfalls + +- **Forgot to use the `db_session` / `app` fixture and called your service with a hand-rolled engine.** The `AuditMixin` / soft-delete / tenant listeners aren't registered on a bare engine — your test passes locally and breaks on the next person's clone. Always go through the fixtures. +- **Reused the same `db_session` across tests via `module`/`session` scope.** The fixture is function-scoped on purpose: in-memory SQLite is per-connection, and shared state across tests masks ordering bugs. Don't widen the scope. +- **Asserted on `auth.user` in a test that uses `client` (not `authenticated_client`).** Without the seeded admin and signed cookie, `auth.user` is `None`. Use `authenticated_client`. +- **Marked an async test with `@pytest.mark.asyncio`.** Redundant under `asyncio_mode = "auto"`; remove it. +- **Ran `make test` to validate an e2e change.** The default suite excludes the `e2e` marker. Use `make test-e2e` (with `make dev` running) for those. +- **CI green but `make test-e2e` fails locally.** E2E isn't part of `make test` or PR CI by default — verify Playwright tests against a live `make dev` before relying on them. + +## Related skills + +- **simple-module-database** — what `db_session` actually wires up (mixins, listeners) +- **simple-module-doctor** — why `db_session` stamps `alembic_version` (avoids SM010 at app startup) +- **simple-module-creating** — module scaffolding adds the `tests/` entry to `testpaths`