--- name: add-service description: Use when adding a new service (a business capability like auth, AI, blog, or finance) to the Aegis Stack framework itself. Covers the plugin-spec registry, migrations, CLI, template, dashboard, test, docs, and i18n files that must change together. --- # Add service Adds a new service (business logic, e.g. blog, finance, auth) to the framework so generated projects can select it via `aegis init --services` or add it later via `aegis add-service`. Traced from the two most recent services, `blog` and `finance`. ## When to use Use when the task is "add a new service to Aegis Stack" (a business capability under `app/services//`, selected via `include_` in `copier.yml`). Do NOT use for adding a **component** (infrastructure: worker, scheduler, database, redis, ingress, observability) - components live in `aegis/core/components.py` and share the same `PluginSpec` shape but a different file footprint. Do NOT use for changing behavior inside an existing service; that is ordinary feature work, not this skill. ## Files that change Registry (`ServiceSpec` is a thin alias of `PluginSpec` pinned to `kind=SERVICE`; see `aegis/core/plugins/spec.py`): - `aegis/core/services.py`: add a `ServiceSpec` entry to the `SERVICES` dict. Set `type` (add a new `ServiceType` member only if none fit), `description`/`long_description`, `required_components`, `recommended_components`, `pyproject_deps`, `template_files` (display list), `marker_path` (the path `aegis update` uses to detect the service on disk), and `docs_path` (leave `""` until the docs page ships - a non-empty value is pinned by `tests/core/test_spec_docs_paths.py`). - `aegis/core/services.py`: `wiring=PluginWiring(...)` on the spec - declares `routers` (`RouterWiring`), `dashboard_cards`/`dashboard_modals` (`FrontendWidgetWiring`), and `deps_providers` (`SymbolWiring`). This metadata mirrors, and must stay in sync with, the hand-written Jinja conditionals in the template files below - it does not currently drive their rendering for in-tree services. - `aegis/core/services.py`: `files=FileManifest(primary=[...], extras={...})` - every path the service owns. `primary` is the add/init base; `compute_file_mapping()` derives `post_gen_tasks.get_component_file_mapping()` from this, so there is no separate hand-maintained mapping to edit. - `aegis/core/migration_generator.py`: only if the service owns tables, add a `ServiceMigrationSpec` and reference it from the spec's `migrations=[...]` list in `services.py`. The spec has no tables in it: a revision's contents are derived from the SQLModel classes by the generated project's `app/cli/migrate_gen.py` (run by init/add inside the project venv), so a column is declared in exactly one place, the model. The spec carries only what models cannot express - `service_name`, `description`, the Postgres `schema` its tables live in, and `data_sql` for a row a schema change makes mandatory. `MIGRATION_SPECS` is derived lazily from every spec's `.migrations` via `collect_migrations()` - no dict entry to hand-maintain. - `aegis/constants.py`: `AnswerKeys` - add ` = "include_"` and `SERVICE_ = ""`. Add any sub-flag keys the service needs (see `FINANCE_PLAID`, `FINANCE_SNAPTRADE`, `FINANCE_IMPORT`). - `copier.yml` (repo root, not under `templates/`): add the `include_` bool question, plus any per-service config questions gated with `when: "{{ include_ }}"` (model on `finance_plaid`). Extend `get_services_needing_migrations` (`aegis/core/migration_generator.py`) if the service owns tables, and the two template mirrors of that rule (`pyproject.toml.jinja`'s alembic dependency, `database_init.py.jinja`); `tests/core/test_alembic_dependency_matches_migrations.py` fails until they agree. - `aegis/core/_service_parser.py`: only if the service takes bracket syntax like `[option]` (model on `ai_service_parser.py`, `auth_service_parser.py`, `insights_service_parser.py`; blog and finance need none of this - they have no `options=` on their spec). Wire it into `aegis/core/service_resolver.py` if it introduces a cross-service dependency check (see the `insights[per_user]` requires `auth[org]` block). - `aegis/cli/interactive.py`: `interactive__service_config()` only if the service needs an interactive config prompt during `aegis add-service --interactive` (model on `interactive_ai_service_config`; not needed for blog/finance). Generation and update plumbing (thread the new `AnswerKeys.` answer key through the generate and update flows; a table-owning service that skips these generates fine but breaks migrations on `aegis update`): - `aegis/core/template_generator.py`: add an `AnswerKeys.: "yes"/"no"` entry to the answer dict built from `selected_services` (model on the existing per-service entries using `extract_base_service_name`). - `aegis/core/copier_manager.py`: add `AnswerKeys.` to the template context booleans, and if the service owns tables include it in the `needs_migration_files` detection. - `aegis/core/copier_updater.py` and `aegis/commands/update.py`: add `include_ = answers.get(AnswerKeys., False)` and OR it into the aggregate migration/needs check used during update. - `aegis/core/manual_updater.py`: add `AnswerKeys.` to the answer-keys list it threads through add/remove. CLI (framework commands, not the generated project's CLI): - `aegis/commands/add_service.py`: resolves service deps to components, prompts for config, calls `ManualUpdater.add_component`, generates migrations via `generate_migration`/`bootstrap_alembic`. Only add a service-specific branch here if the service needs bracket-syntax parsing or extra answer keys threaded through (auth, ai, insights do; blog and finance need none). - `aegis/commands/remove_service.py`: calls `ManualUpdater.remove_component`; no per-service branch needed unless the service needs a custom warning (see the auth-specific data-loss warning block). - `aegis/commands/services.py`: the `aegis services` list command reads `SERVICES`/`ServiceType` automatically; no edit needed unless a new `ServiceType` header is required. Template (`aegis/templates/copier-aegis-project/{{ project_slug }}/`): - `app/services//`: the service package (business logic, `deps.py` for the FastAPI dependency provider referenced by `wiring.deps_providers`). - `app/components/backend/api//`: routes, registered in `app/components/backend/api/routing.py.jinja` behind a full-body `{%- if include_ %}` import + `app.include_router(...)` block. - `app/cli/.py.jinja`: the project's `` subcommand. Register it in `app/cli/main.py.jinja` with the existing `try: importlib.import_module(...) / except ImportError: pass` pattern. - Dashboard card: `app/components/frontend/dashboard/cards/_card.py`, exported from `cards/__init__.py.jinja` behind `{%- if include_ %}`, and added to the aggregate `{%- if include_auth or ... or include_ or _plugins %}` gate in that same file (guards `ServicesCard`'s import). - Dashboard modal: `app/components/frontend/dashboard/modals/_modal.py`, exported from `modals/__init__.py.jinja`, and added to the `modal_map` and import block in `dashboard/cards/card_utils.py.jinja` (key convention: `"service_"`, matching `wiring.dashboard_cards[0].modal_id`). - The card/modal also need registering in the surrounding frontend wiring, each behind `{%- if include_ %}`: `dashboard/status_overview.py.jinja` (the status grid), `app/components/frontend/main.py.jinja` (the frontend app that mounts the card), `app/services/system/ui.py.jinja` (dashboard registry), and `app/components/backend/api/deps.py.jinja` if the service's routes need a shared dependency. Trace how `blog` appears in each before adding ``. - `app/components/backend/startup/component_health.py.jinja`: register the service's health check behind `{%- if include_ %}` with `register_service_health_check("", check__service_health)`. - `app/components/backend/startup/database_init.py.jinja` (table-owning services only): import the service's SQLModel models behind `{%- if include_ %}` (with `# noqa: F401`), add `include_` to the `needs_migrations` set expression, and add a `"": ("table", "")` entry to the existing-table stamp map. Skipping this means the service's tables are never created or stamped. - `alembic/env.py.jinja` (table-owning services only): import the models behind `{%- if include_ %}` so Alembic autogenerate sees them. - `pyproject.toml.jinja`: gate service-specific dependencies behind `{%- if include_ %}`. If the service owns tables, add it to the shared `alembic==1.16.5` gate condition (the big `{%- if include_auth or ... or include_ %}` line). - `docs/services//` under the template tree is a separate, optional convention (ships docs inside the generated project itself); only `comms` has it today. Do not treat it as required. Framework docs (this repo's own `/docs`, published via the root `mkdocs.yml` - distinct from the template docs above, and what `ServiceSpec.docs_path` points at): - `docs/services//index.md` (+ `cli.md`, `api.md`, `dashboard.md`, `examples.md`, `setup.md` as applicable - see `docs/services/blog/` and `docs/services/payment/` for the current shape). - `mkdocs.yml` (repo root): add a `Services: - :` nav block pointing at the new pages (see the `blog`/`payment`/`insights` entries). - `aegis/core/services.py`: set `docs_path="services/"` on the spec once the pages exist (leave `""` until then, per above). Tests (generated-project template tests, under `aegis/templates/copier-aegis-project/{{ project_slug }}/`): - `tests/services//test__service.py` (or `tests/services/test__service.py` for a small service): unit tests for the service's business logic (write first, TDD). - `tests/api/test__endpoints.py`: only if the service exposes routes. - Template-render test (framework side, optional): if the service's template has non-trivial cross-service Jinja branching (e.g. blog's router renders differently with auth on/off/rbac), add a `tests/core/test__template_render.py` that renders the `.jinja` file directly via `Environment(loader=FileSystemLoader(get_template_path()))` and asserts on the rendered source (see `tests/core/test_blog_template_render.py`). Tests (this repo's own CLI/core test suite): - `tests/core/test_services.py`: add a spec-shape test class (name, type, required/recommended components, `pyproject_deps`) and a `test__service_wiring` test asserting `spec.wiring.routers[0].module`, `.alias`, and the dashboard `modal_id`s (mirrors `test_blog_service_wiring`). - `tests/cli/test_services_cli.py`: extend the `aegis services` list-command coverage tables with the new service's expected inclusion/exclusion flags. - `tests/cli/conftest.py`: add a `_with_database` (or similarly named) entry to `NAMED_PROJECT_SPECS` so downstream tests copy a cached project instead of regenerating one (`tests/CLAUDE.md` explains the cache; skipping this entry costs 10+ minutes of CI time). Also add the new stack to `tests/cli/test_stack_generation.py`'s `STACK_COMBINATIONS` and to the `everything` entry in `NAMED_PROJECT_SPECS`. - `tests/cli/test_add_service_importable.py`: add the service name (with bracket options if it would otherwise prompt interactively) to `SERVICE_INVOCATIONS`. This is the regen-gap guard: it runs `aegis add-service ` against a cached base project and imports the webserver/CLI/routing entry chains in the generated project's own venv, catching files that render fine at `init` time but are missing from the add/remove file mapping. - A migration test in the style of `tests/cli/test_add_service_migrations.py` if the service owns tables (asserts `aegis add-service ` bootstraps alembic and writes the migration file). - Collateral: adding a service to `SERVICES` shifts the service list that several existing tests assert against. Expect to update `tests/cli/test_guided.py`, `tests/cli/test_interactive_project_selection.py`, `tests/cli/test_interactive_scheduler.py`, `tests/cli/test_ai_configuration.py`, and `tests/cli/test_scheduler_persistence.py` when they fail on counts or expected-option lists. These are not new files; run the gates and fix what goes red. i18n: - `aegis/i18n/locales/en.py`: add `"service."` (short description, used by `aegis services`) and `"projectmap."` (label used by the project map renderer) with the real English strings. - `aegis/i18n/locales/{de,es,fr,ja,ko,ru,zh,zh_hant}.py`: add the SAME two keys to every other locale, using the English string as a placeholder. This is required for parity: `tests/core/test_i18n.py` asserts every locale carries the same keys as English and fails naming the missing key otherwise. Real translation of the placeholders is a separate translator-reviewed pass; do not attempt it here, but the keys must exist now. Cross-cutting: - Shared files need **no registration**. A shared file is any template path no component/service `FileManifest` claims; `aegis add`/`remove` finds it by rendering the tree at the old and new answers and diffing. Add `{% if include_ %}` conditionals to `routing.py.jinja`, `component_health.py.jinja`, `cards/__init__.py.jinja` — or to a brand-new shared file — and it is handled automatically. There is no list to add it to. `tests/core/test_shared_scope_completeness.py` fails, naming the file, if a stack-dependent file somehow ends up handled by nothing. ## Procedure 1. Write the failing tests first: `tests/services//test__service.py` (template side) for business logic, and `tests/api/test__endpoints.py` if the service exposes routes. Confirm they fail for the right reason. 2. Add `AnswerKeys.` / `AnswerKeys.SERVICE_` in `aegis/constants.py`. 3. If the service owns tables, add its `ServiceMigrationSpec` (name, schema; no table bodies) to `aegis/core/migration_generator.py`. 4. Add the `ServiceSpec` entry in `aegis/core/services.py`: identity fields, `required_components`, `pyproject_deps`, `wiring=PluginWiring(...)`, `migrations=[...]`, and `files=FileManifest(primary=[...])`. 5. Add the `include_` question (and any config questions) to `copier.yml`; table-owning services go in `get_services_needing_migrations` and its template mirrors (see above). Thread the new `AnswerKeys.` through the generation/update plumbing: `template_generator.py`, `copier_manager.py`, `copier_updater.py`, `manual_updater.py`, and `commands/update.py` (see Files that change). 6. Build the template package: `app/services//`, the API router plus `routing.py.jinja` registration, the CLI module plus `main.py.jinja` registration, and the health check registration in `component_health.py.jinja`. 7. Wire the dashboard and frontend: card, modal, both `__init__.py.jinja` files, the `modal_map`/import block in `card_utils.py.jinja`, plus `status_overview.py.jinja`, `frontend/main.py.jinja`, and `system/ui.py.jinja`. If the service owns tables, also register its models in `database_init.py.jinja` (import + `needs_migrations` + stamp map) and `alembic/env.py.jinja`. 8. Gate `pyproject.toml.jinja` deps (and the shared alembic condition if the service owns tables). 9. Add the i18n keys `service.` and `projectmap.` to `en.py` with real strings, and stub the same two keys into every other locale for parity (see i18n above); `tests/core/test_i18n.py` fails otherwise. 10. Add the `tests/core/test_services.py` spec-shape + wiring tests, the `tests/cli/conftest.py` cache entry, and the `STACK_COMBINATIONS` / `everything` entries. 11. Add the service to `SERVICE_INVOCATIONS` in `tests/cli/test_add_service_importable.py` and, if it owns tables, add a migration test in the style of `test_add_service_migrations.py`. This verifies the add path on an EXISTING project (`aegis add-service ` against a cached base), not just fresh `aegis init` - the only way to catch files that render at init time but are missing from the FileManifest. 12. If any generated-project fix happened in a scratch/prototype project rather than directly in the template, backport it into `aegis/templates/` before finishing - templates are the source of truth. 13. Once the docs page exists, add `docs/services//` pages, wire them into the root `mkdocs.yml` nav, and set `docs_path` on the spec. 14. Run the gates below and fix anything red. ## Gates - `make check` (lint, typecheck, test) must pass. - `make test-stacks-quick` while iterating (fast feedback: runs the full validation phase against a representative subset - base, everything, insights). - `make test-stacks-full` before calling the work done (runs `test-stacks` generation-only pass, the slow `test-stacks-build` validation pass, and the kitchen-sink `everything` stack across every combination). ## Pitfalls - Template changes render from the committed git state unless you pass `aegis init --dev` (working tree) or commit first; testing a template edit with plain `aegis init` on an uncommitted change silently uses stale content. - `FileManifest.primary` on the spec is the single source `get_component_file_mapping()` derives from (via `compute_file_mapping()`). A file present in the template but missing from `primary` renders correctly on fresh `init` (Copier just includes it) but is silently skipped by `add-service`/`remove-service` on an existing project - the gap only shows up when `tests/cli/test_add_service_importable.py` exercises the add path, which is why that test's `SERVICE_INVOCATIONS` list must include the new service. - A file must be in **exactly one** place: either the spec's `FileManifest` (the service owns it outright) or nowhere (it is shared, and the render-diff engine handles it). Putting an owned file's path in a shared file's conditionals, or vice versa, is the mistake to watch for — a file the manifest claims is invisible to the engine by design, so a `{% if include_ %}` block inside it will never be re-rendered on add/remove of a *different* component. - The SQLModel classes are the single source of the schema. The generated project's `tests/test_model_registry.py` replays the revisions onto a scratch database and asserts they rebuild the models exactly, and `tests/cli/test_migrations_match_models.py` does the same on Postgres. - `ServiceSpec.wiring` (routers, dashboard cards/modals, deps providers) is metadata that currently only *mirrors* what's hand-wired into the shared Jinja template files - it does not yet drive their rendering for in-tree services. Editing one without the other leaves the spec's self-description (used by `aegis services`, tests, and future plugin tooling) inconsistent with what the generated project actually does. - `docs_path` on the spec is pinned by `tests/core/test_spec_docs_paths.py` to a real file under this repo's root `/docs` - setting it before the docs page exists (or pointing it at the template's `docs/services//` instead of the root one) fails that test. - A table-owning service keeps its models under `app/services//models` (module or package). `app/core/model_registry.py` imports that path for alembic, the tests, startup and `migrate-fix`; a table defined anywhere else is invisible to all four, and `tests/core/test_model_registry_is_the_only_registrar.py` fails on it. - Threading `AnswerKeys.` into `services.py` and `copier.yml` is not enough: the generate/update plumbing (`template_generator.py`, `copier_manager.py`, `copier_updater.py`, `manual_updater.py`, `commands/update.py`) also reads it, and `copier_manager.py` gates `needs_migration_files` on it, so a table-owning service that skips them breaks migrations on `aegis update` while passing a fresh `aegis init`. - Adding an i18n key to `en.py` only fails `tests/core/test_i18n.py`, which requires every locale to carry the same keys; stub the key into all locales now and leave real translation to the separate translator pass. - Full-body `{% if %}` gating only inside a shared file; never use Copier conditional filenames (breaks on Windows).