- build.py: vendor templates/client into mcp/templates/client with client.json manifest (16 kinds); register drone-npm as a third CI shape (types: pypi/npm/docker); root *.py stale doc numbers fixed - registry.py: registry://client resource + list_client_components / get_client_component / get_client_template tools; build prompt gains the client scaffold step (docs://modules/ui_admin); compliance prompt covers npm packages; drone docs describe all three shapes - repos.json (+mcp): doc-weaver, react-auth, react-openapi (npm Type B, package.json version source), vet (app Type C, VERSION) - docs: 90_drone_pipelines gains Type B npm section; 91_versioning covers package.json as a version source; 94_repository_inventory gains the npm package table and services/apps Type C; lib index updated Verified: bundle regen (24 modules, client 16, drone 3, repos 13); registry client readers exercised against the vendored bundle.
793 lines
33 KiB
Python
793 lines
33 KiB
Python
"""Standards MCP server.
|
|
|
|
Serves the hand-authored `standards` bundle (the application blueprint plus the
|
|
library registry and the runnable template) and adds discovery/scaffolding
|
|
helpers so an agent can find the right internal library — and pull exact
|
|
template code — before writing any infrastructure.
|
|
|
|
Resources:
|
|
docs://index, docs://nav, docs://modules/{module} (from the base server)
|
|
docs://spec (the pinned auth-server OpenAPI contract)
|
|
registry://libraries (the structured catalog)
|
|
registry://components (the app template component index)
|
|
registry://client (the client template component index)
|
|
registry://drone (the CI template index)
|
|
registry://release (the release template index)
|
|
registry://repos (the repository inventory)
|
|
|
|
Tools:
|
|
ping (from the base server)
|
|
find_library (capability -> matching libraries + MCP endpoints)
|
|
list_app_components (the template component kinds)
|
|
get_app_component (kind -> exact template file, seed tokens renamed)
|
|
get_app_template (the whole app skeleton as a path -> content map)
|
|
list_client_components (the client template component kinds)
|
|
get_client_component (kind -> exact client template file)
|
|
get_client_template (the whole client skeleton as a path -> content map)
|
|
list_drone_templates (the three CI shapes: PyPI package / npm package / Docker deployment)
|
|
get_drone_template (kind -> exact .drone.yml template)
|
|
list_release_templates (the changelog + version-file artefacts)
|
|
get_release_template (kind -> exact release template file)
|
|
list_repos (the covered repositories: type, path, version source)
|
|
|
|
Prompts:
|
|
intake (the questions to gather before building)
|
|
build (the two axes -> deterministic build prompt)
|
|
start_app (combined entry: intake, then build)
|
|
trial (self-contained trial build into a scratch dir)
|
|
compliance (remediate repos against the CI + versioning standard)
|
|
release (agent-assisted tagging after the release MR is merged)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from config import MCPBundle
|
|
from mcp_server import MCPServer
|
|
|
|
|
|
def _pascal(name: str) -> str:
|
|
"""Convert a snake/kebab name to PascalCase."""
|
|
return "".join(part.capitalize() for part in re.split(r"[_\-\s]+", name) if part)
|
|
|
|
|
|
_INTAKE_TEXT = (
|
|
"# Start a new app — intake\n\n"
|
|
"Gather the following, then call `build` (or `start_app`) with the values:\n\n"
|
|
"1. App name + one-line purpose.\n"
|
|
"2. Axis 1 — resources: each resource, its fields, and relationships\n"
|
|
" (or paste `openapi.yaml`).\n"
|
|
"3. Axis 2 — pipelines: multi-step inputs? families + formats (or `none`).\n"
|
|
"4. Auth needed now? (yes/no). If yes, the auth-server service provides it —\n"
|
|
" read `docs://spec` + `docs://modules/auth_integration` (no auth code to write).\n"
|
|
"5. Integrations: Mongo / ingestion formats / email / LLM provider.\n\n"
|
|
"Then: `build(app_name=..., resources=..., pipelines=..., auth=...)`.\n"
|
|
)
|
|
|
|
|
|
def _build_text(app_name: str, resources: str, pipelines: str, auth: str) -> str:
|
|
"""Render the deterministic build prompt from the two axes.
|
|
|
|
Args:
|
|
app_name (str): App name + one-line purpose.
|
|
resources (str): Axis 1 — resources, fields, relationships.
|
|
pipelines (str): Axis 2 — pipeline families + input formats.
|
|
auth (str): Whether auth is needed now ("yes"/"no").
|
|
|
|
Returns:
|
|
str: The build prompt.
|
|
"""
|
|
return (
|
|
f"# Build: {app_name}\n\n"
|
|
"## Spec\n"
|
|
f"- resources: {resources}\n"
|
|
f"- pipelines: {pipelines or 'none'}\n"
|
|
f"- auth: {auth}\n\n"
|
|
"## Method (deterministic)\n"
|
|
"1. Read `docs://modules/build_recipe` and follow it step by step.\n"
|
|
"2. Read `docs://modules/genericity_matrix` (Tier A/B/C) and "
|
|
"`docs://modules/layering_rules`.\n"
|
|
"3. Call `find_library(capability)` for db, di, routing, ingestion, "
|
|
"and pipeline; never reimplement a Tier A library.\n"
|
|
"4. Scaffold with `get_app_template()` (whole tree) or pull single "
|
|
"files with `list_app_components()` and "
|
|
"`get_app_component(kind, resource=..., family=...)`.\n"
|
|
"5. If a UI is needed, scaffold the client with `get_client_template()` "
|
|
"(or pull files via `list_client_components()` / "
|
|
"`get_client_component(kind)`) — the admin renders from your spec's x- "
|
|
"extensions; see `docs://modules/ui_admin`.\n"
|
|
"6. If auth is needed, read `docs://spec` (the auth-server contract) and "
|
|
"`docs://modules/auth_integration`; declare `securitySchemes.bearerAuth` "
|
|
"and `security` in your spec — do not write auth code.\n"
|
|
"7. Author only Tier C; keep Tier B as shipped.\n"
|
|
)
|
|
|
|
|
|
_COMPLIANCE_TEXT = (
|
|
"# Compliance — align repositories with the CI + versioning standard\n\n"
|
|
"Read `docs://modules/drone_pipelines` and `docs://modules/versioning_and_changelog`,\n"
|
|
"then enumerate the working set with `registry://repos` (or `list_repos()`).\n\n"
|
|
"For each repository, bring it to the standard for its `type`:\n\n"
|
|
"- **package** (Python) — `version` in `pyproject.toml`; root `CHANGELOG.md`;\n"
|
|
" Drone file from `get_drone_template(\"drone-pypi\")` (`python-lib.yaml`,\n"
|
|
" tag-triggered).\n"
|
|
"- **package** (npm) — `version` in `package.json`; root `CHANGELOG.md`;\n"
|
|
" Drone file from `get_drone_template(\"drone-npm\")` (`npm-lib.yaml`,\n"
|
|
" tag-triggered, publishes to the private npm registry).\n"
|
|
"- **service** — root `VERSION`; root `CHANGELOG.md`; Drone file from\n"
|
|
" `get_drone_template(\"drone-service\")` (`service-docker.yaml`, tag-triggered\n"
|
|
" with a `tag == VERSION` guard). The service must read its version from\n"
|
|
" `VERSION` (e.g. a build arg) — never hardcode it.\n\n"
|
|
"Steps:\n"
|
|
"1. Inspect each repo (`git -C <path> tag`, version file, `CHANGELOG.md`,\n"
|
|
" `.drone.yml`) and list the gaps — never assume.\n"
|
|
"2. Add missing files from `get_release_template(\"changelog\"/\"version-file\")`.\n"
|
|
"3. Replace `.drone.yml` with the guarded template for the repo's type.\n"
|
|
"4. Align the version file with the intended tag.\n"
|
|
"5. Commit per repo (one focused commit); **do not push or tag** without the\n"
|
|
" user's explicit go-ahead.\n"
|
|
)
|
|
|
|
_RELEASE_TEXT = (
|
|
"# Release — tag the merged commit\n\n"
|
|
"The version bump and changelog promotion live **in the release merge\n"
|
|
"request**; the tag is created on the commit that results from merging it.\n"
|
|
"See `docs://modules/versioning_and_changelog`.\n\n"
|
|
"Do this only after the user confirms the MR is merged/squashed and tells you\n"
|
|
"to proceed — never tag before the merge.\n\n"
|
|
"1. Wait for that explicit go-ahead.\n"
|
|
"2. Find the commit that introduced the version bump `x.y.z`:\n"
|
|
" - package: `git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml`\n"
|
|
" - service: `git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION`\n"
|
|
"3. Verify that commit's version file equals `x.y.z` (the Drone guard enforces\n"
|
|
" `tag == version`).\n"
|
|
"4. Tag **only** that commit, lightweight and bare: `git tag x.y.z <sha>`.\n"
|
|
"5. **Do not push.** Ask the user to run `git push origin x.y.z`, then Drone\n"
|
|
" builds the release.\n"
|
|
)
|
|
|
|
|
|
class StandardsMCPServer(MCPServer):
|
|
"""MCP server for the `standards` bundle with discovery/scaffolding tools.
|
|
|
|
Attributes:
|
|
mcp_root (Path):
|
|
Directory containing the generated bundle (index.json, nav.json,
|
|
modules/, libraries.json, components.json, and templates/).
|
|
"""
|
|
|
|
def __init__(self, bundle: MCPBundle) -> None:
|
|
super().__init__(bundle)
|
|
self._register_standards()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bundle readers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _catalog(self) -> dict[str, Any]:
|
|
"""Load the machine-readable library registry."""
|
|
path = Path(self.mcp_root) / "libraries.json"
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path), "libraries": []}
|
|
return json.loads(path.read_bytes())
|
|
|
|
def _components(self) -> dict[str, Any]:
|
|
"""Load the template component manifest."""
|
|
path = Path(self.mcp_root) / "components.json"
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path), "components": []}
|
|
return json.loads(path.read_bytes())
|
|
|
|
def _spec(self) -> dict[str, Any]:
|
|
"""Load the pinned OpenAPI contract (auth-server)."""
|
|
path = Path(self.mcp_root) / "specs" / "auth-server.json"
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path)}
|
|
return json.loads(path.read_bytes())
|
|
|
|
def _drone(self) -> dict[str, Any]:
|
|
"""Load the CI (Drone) template manifest."""
|
|
path = Path(self.mcp_root) / "drone.json"
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path), "components": []}
|
|
return json.loads(path.read_bytes())
|
|
|
|
def _release(self) -> dict[str, Any]:
|
|
"""Load the release (versioning + changelog) template manifest."""
|
|
path = Path(self.mcp_root) / "release.json"
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path), "components": []}
|
|
return json.loads(path.read_bytes())
|
|
|
|
def _repos(self) -> dict[str, Any]:
|
|
"""Load the repository inventory."""
|
|
path = Path(self.mcp_root) / "repos.json"
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path), "repos": []}
|
|
return json.loads(path.read_bytes())
|
|
|
|
def _find_release_component(self, kind: str) -> dict[str, Any] | None:
|
|
for entry in self._release().get("components", []):
|
|
if entry.get("kind") == kind:
|
|
return entry
|
|
return None
|
|
|
|
def _read_release_component(self, entry: dict[str, Any]) -> dict[str, Any]:
|
|
"""Read a vendored release standard file."""
|
|
rel = entry["path"]
|
|
path = Path(self.mcp_root) / "templates" / "release" / rel
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path)}
|
|
return {
|
|
"kind": entry["kind"],
|
|
"path": rel,
|
|
"placeholder": entry.get("placeholder"),
|
|
"content": path.read_text(encoding="utf-8"),
|
|
}
|
|
|
|
def _find_drone_component(self, kind: str) -> dict[str, Any] | None:
|
|
for entry in self._drone().get("components", []):
|
|
if entry.get("kind") == kind:
|
|
return entry
|
|
return None
|
|
|
|
def _read_drone_component(self, entry: dict[str, Any]) -> dict[str, Any]:
|
|
"""Read a vendored drone template file."""
|
|
rel = entry["path"]
|
|
path = Path(self.mcp_root) / "templates" / "drone" / rel
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path)}
|
|
return {
|
|
"kind": entry["kind"],
|
|
"path": rel,
|
|
"placeholder": entry.get("placeholder"),
|
|
"content": path.read_text(encoding="utf-8"),
|
|
}
|
|
|
|
def _find_component(self, kind: str) -> dict[str, Any] | None:
|
|
for entry in self._components().get("components", []):
|
|
if entry.get("kind") == kind:
|
|
return entry
|
|
return None
|
|
|
|
def _template_files(self, prefix: str | None = None) -> dict[str, str]:
|
|
"""Read the vendored template tree as a path -> content map.
|
|
|
|
Args:
|
|
prefix (str | None): Optional path prefix to filter by (POSIX
|
|
separators, for example ``"infra/"``).
|
|
|
|
Returns:
|
|
dict[str, str]: Relative POSIX path -> UTF-8 file content, excluding
|
|
caches and bytecode.
|
|
"""
|
|
root = Path(self.mcp_root) / "templates" / "app"
|
|
files: dict[str, str] = {}
|
|
if not root.is_dir():
|
|
return files
|
|
for path in sorted(root.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
if "__pycache__" in path.parts or path.suffix == ".pyc":
|
|
continue
|
|
rel = path.relative_to(root).as_posix()
|
|
if prefix and not rel.startswith(prefix):
|
|
continue
|
|
files[rel] = path.read_text(encoding="utf-8")
|
|
return files
|
|
|
|
def _read_component(
|
|
self,
|
|
entry: dict[str, Any],
|
|
resource: str | None,
|
|
family: str | None,
|
|
) -> dict[str, Any]:
|
|
"""Read a vendored template file, renaming seed tokens when asked."""
|
|
rel = entry["path"]
|
|
path = Path(self.mcp_root) / "templates" / "app" / rel
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path)}
|
|
text = path.read_text(encoding="utf-8")
|
|
placeholder = entry.get("placeholder")
|
|
if resource and placeholder == "resource":
|
|
text = text.replace("Item", _pascal(resource)).replace("item", resource)
|
|
if family and placeholder == "family":
|
|
text = text.replace("Example", _pascal(family)).replace("example", family)
|
|
return {
|
|
"kind": entry["kind"],
|
|
"path": rel,
|
|
"placeholder": placeholder,
|
|
"content": text,
|
|
}
|
|
|
|
def _client(self) -> dict[str, Any]:
|
|
"""Load the client template component manifest."""
|
|
path = Path(self.mcp_root) / "client.json"
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path), "components": []}
|
|
return json.loads(path.read_bytes())
|
|
|
|
def _find_client_component(self, kind: str) -> dict[str, Any] | None:
|
|
for entry in self._client().get("components", []):
|
|
if entry.get("kind") == kind:
|
|
return entry
|
|
return None
|
|
|
|
def _read_client_component(self, entry: dict[str, Any]) -> dict[str, Any]:
|
|
"""Read a vendored client template file."""
|
|
rel = entry["path"]
|
|
path = Path(self.mcp_root) / "templates" / "client" / rel
|
|
if not path.exists():
|
|
return {"error": "not_found", "path": str(path)}
|
|
return {
|
|
"kind": entry["kind"],
|
|
"path": rel,
|
|
"placeholder": entry.get("placeholder"),
|
|
"content": path.read_text(encoding="utf-8"),
|
|
}
|
|
|
|
def _client_files(self, prefix: str | None = None) -> dict[str, str]:
|
|
"""Read the vendored client template tree as a path -> content map.
|
|
|
|
Args:
|
|
prefix (str | None): Optional path prefix filter with POSIX
|
|
separators, for example ``"src/shared-theme/"``.
|
|
|
|
Returns:
|
|
dict[str, str]: Relative POSIX path -> UTF-8 file content,
|
|
excluding caches and build output.
|
|
"""
|
|
root = Path(self.mcp_root) / "templates" / "client"
|
|
files: dict[str, str] = {}
|
|
if not root.is_dir():
|
|
return files
|
|
for path in sorted(root.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
if "__pycache__" in path.parts or path.suffix == ".pyc":
|
|
continue
|
|
rel = path.relative_to(root).as_posix()
|
|
if prefix and not rel.startswith(prefix):
|
|
continue
|
|
files[rel] = path.read_text(encoding="utf-8")
|
|
return files
|
|
|
|
# ------------------------------------------------------------------
|
|
# Standards surface
|
|
# ------------------------------------------------------------------
|
|
|
|
def _register_standards(self) -> None:
|
|
@self.app.resource("registry://libraries")
|
|
def libraries() -> dict[str, Any]:
|
|
return self._catalog()
|
|
|
|
@self.app.resource("registry://components")
|
|
def components() -> dict[str, Any]:
|
|
return self._components()
|
|
|
|
@self.app.resource("registry://client")
|
|
def client() -> dict[str, Any]:
|
|
"""Return the client template component manifest.
|
|
|
|
The React client skeleton (Vite + MUI) that pairs with the app
|
|
template; the admin UI renders from the app's openapi.yaml x-
|
|
extensions via @aetoskia/react-openapi.
|
|
"""
|
|
return self._client()
|
|
|
|
@self.app.resource("registry://drone")
|
|
def drone() -> dict[str, Any]:
|
|
return self._drone()
|
|
|
|
@self.app.resource("registry://release")
|
|
def release_templates() -> dict[str, Any]:
|
|
return self._release()
|
|
|
|
@self.app.resource("registry://repos")
|
|
def repos() -> dict[str, Any]:
|
|
"""Return the repository inventory.
|
|
|
|
Use this to enumerate the repositories covered by the CI and
|
|
versioning standards, each with its type, path, version source, and
|
|
Drone shape. Paths are relative to the aetos/ workspace root.
|
|
"""
|
|
return self._repos()
|
|
|
|
@self.app.resource("docs://spec")
|
|
def spec() -> dict[str, Any]:
|
|
"""Return the pinned auth-server OpenAPI contract.
|
|
|
|
Use this (instead of a live fetch) to learn the auth-server client
|
|
contract when wiring authentication into an app.
|
|
"""
|
|
return self._spec()
|
|
|
|
@self.app.tool()
|
|
def find_library(capability: str) -> list[dict[str, Any]]:
|
|
"""Find internal libraries matching a capability keyword.
|
|
|
|
Use this before writing infrastructure: it returns the library to
|
|
use, its import name, what it replaces, and its MCP endpoint.
|
|
|
|
Args:
|
|
capability (str):
|
|
A keyword such as "database", "pdf", "auth", "pipeline",
|
|
"openapi", or "email".
|
|
|
|
Returns:
|
|
list[dict[str, Any]]:
|
|
Matching library entries (empty when nothing matches).
|
|
"""
|
|
needle = (capability or "").strip().lower()
|
|
if not needle:
|
|
return self._catalog().get("libraries", [])
|
|
|
|
matches: list[dict[str, Any]] = []
|
|
for lib in self._catalog().get("libraries", []):
|
|
haystack = " ".join(
|
|
[
|
|
str(lib.get("name", "")),
|
|
str(lib.get("import", "")),
|
|
str(lib.get("purpose", "")),
|
|
str(lib.get("use_instead_of", "")),
|
|
str(lib.get("spec", "")),
|
|
str(lib.get("recipe", "")),
|
|
" ".join(lib.get("capabilities", [])),
|
|
]
|
|
).lower()
|
|
if needle in haystack:
|
|
matches.append(lib)
|
|
return matches
|
|
|
|
@self.app.tool()
|
|
def list_app_components() -> list[dict[str, Any]]:
|
|
"""List the reusable component kinds shipped in the app template.
|
|
|
|
Returns:
|
|
list[dict[str, Any]]:
|
|
Entries with ``kind``, ``path``, ``placeholder`` and
|
|
``description``. Fetch one with :func:`get_app_component`.
|
|
"""
|
|
return self._components().get("components", [])
|
|
|
|
@self.app.tool()
|
|
def get_app_component(
|
|
kind: str,
|
|
resource: str | None = None,
|
|
family: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return the exact template file for a component kind.
|
|
|
|
Use this to copy current, app-neutral skeleton code instead of
|
|
inventing it. See :func:`list_app_components` for valid kinds.
|
|
|
|
Args:
|
|
kind (str): Component kind, e.g. "routes", "pipeline-impl".
|
|
resource (str | None): Rename the resource seed ("item"/"Item")
|
|
to this name (snake_case), for per-resource kinds.
|
|
family (str | None): Rename the family seed ("example"/"Example")
|
|
to this name (snake_case), for per-family kinds.
|
|
|
|
Returns:
|
|
dict[str, Any]: ``{kind, path, placeholder, content}`` or an
|
|
error entry when the kind is unknown.
|
|
"""
|
|
entry = self._find_component(kind)
|
|
if entry is None:
|
|
return {
|
|
"error": "unknown_kind",
|
|
"kind": kind,
|
|
"available": [c["kind"] for c in self._components().get("components", [])],
|
|
}
|
|
return self._read_component(entry, resource, family)
|
|
|
|
@self.app.tool()
|
|
def get_app_template(prefix: str = "") -> dict[str, Any]:
|
|
"""Return the whole app skeleton as a path -> content map.
|
|
|
|
Use this to scaffold in one shot instead of pulling components one
|
|
by one; combine with :func:`list_app_components` for the kind and
|
|
placeholder metadata. Seed tokens (``item`` / ``example``) are left
|
|
as-is here — fetch a renamed file with :func:`get_app_component`.
|
|
|
|
Args:
|
|
prefix (str): Optional path prefix filter with POSIX
|
|
separators, for example ``"infra/"`` or ``"pipelines/"``.
|
|
Empty returns every file.
|
|
|
|
Returns:
|
|
dict[str, Any]: ``{root, file_count, files: {relpath: content}}``.
|
|
"""
|
|
files = self._template_files(prefix or None)
|
|
return {
|
|
"root": "templates/app",
|
|
"file_count": len(files),
|
|
"files": files,
|
|
}
|
|
|
|
@self.app.tool()
|
|
def list_client_components() -> list[dict[str, Any]]:
|
|
"""List the reusable component kinds shipped in the client template.
|
|
|
|
The React client (Vite + MUI) that pairs with the app template:
|
|
the admin shell rendering the app's resources from its
|
|
``openapi.yaml``. See :func:`get_client_template` for the whole
|
|
tree and :func:`list_drone_templates` ("drone-npm") for its CI.
|
|
|
|
Returns:
|
|
list[dict[str, Any]]:
|
|
Entries with ``kind``, ``path``, ``placeholder`` and
|
|
``description``. Fetch one with :func:`get_client_component`.
|
|
"""
|
|
return self._client().get("components", [])
|
|
|
|
@self.app.tool()
|
|
def get_client_component(kind: str) -> dict[str, Any]:
|
|
"""Return the exact client template file for a component kind.
|
|
|
|
Use this to copy current, app-neutral client code instead of
|
|
inventing it. See :func:`list_client_components` for valid kinds
|
|
(e.g. "client-home", "client-header", "client-openapi-config").
|
|
|
|
Args:
|
|
kind (str): Component kind, e.g. "client-openapi-config".
|
|
|
|
Returns:
|
|
dict[str, Any]: ``{kind, path, placeholder, content}`` or an
|
|
error entry when the kind is unknown.
|
|
"""
|
|
entry = self._find_client_component(kind)
|
|
if entry is None:
|
|
return {
|
|
"error": "unknown_kind",
|
|
"kind": kind,
|
|
"available": [c["kind"] for c in self._client().get("components", [])],
|
|
}
|
|
return self._read_client_component(entry)
|
|
|
|
@self.app.tool()
|
|
def get_client_template(prefix: str = "") -> dict[str, Any]:
|
|
"""Return the whole client skeleton as a path -> content map.
|
|
|
|
Use this to scaffold the UI in one shot instead of pulling
|
|
components one by one; combine with :func:`list_client_components`
|
|
for the kind and description metadata.
|
|
|
|
Args:
|
|
prefix (str): Optional path prefix filter with POSIX
|
|
separators, for example ``"src/shared-theme/"``. Empty
|
|
returns every file.
|
|
|
|
Returns:
|
|
dict[str, Any]: ``{root, file_count, files: {relpath: content}}``.
|
|
"""
|
|
files = self._client_files(prefix or None)
|
|
return {
|
|
"root": "templates/client",
|
|
"file_count": len(files),
|
|
"files": files,
|
|
}
|
|
|
|
@self.app.tool()
|
|
def list_drone_templates() -> list[dict[str, Any]]:
|
|
"""List the CI (Drone) template shapes.
|
|
|
|
There are exactly three types of ``.drone.yml`` in the org: a
|
|
Python package published to the private PyPI index, an npm package
|
|
published to the private npm registry, and a Docker deployment
|
|
that builds, pushes, and runs a container.
|
|
|
|
Returns:
|
|
list[dict[str, Any]]:
|
|
Entries with ``kind``, ``path``, ``placeholder`` and
|
|
``description``. Fetch one with :func:`get_drone_template`.
|
|
"""
|
|
return self._drone().get("components", [])
|
|
|
|
@self.app.tool()
|
|
def get_drone_template(kind: str) -> dict[str, Any]:
|
|
"""Return the exact CI template for a component kind.
|
|
|
|
Use this to copy the current ``.drone.yml`` shape instead of
|
|
inventing one. See :func:`list_drone_templates` for valid kinds
|
|
("drone-pypi", "drone-npm", "drone-service").
|
|
|
|
Args:
|
|
kind (str): Component kind, e.g. "drone-pypi", "drone-npm" or
|
|
"drone-service".
|
|
|
|
Returns:
|
|
dict[str, Any]: ``{kind, path, placeholder, content}`` or an
|
|
error entry when the kind is unknown.
|
|
"""
|
|
entry = self._find_drone_component(kind)
|
|
if entry is None:
|
|
return {
|
|
"error": "unknown_kind",
|
|
"kind": kind,
|
|
"available": [c["kind"] for c in self._drone().get("components", [])],
|
|
}
|
|
return self._read_drone_component(entry)
|
|
|
|
@self.app.tool()
|
|
def list_release_templates() -> list[dict[str, Any]]:
|
|
"""List the release/versioning template artefacts.
|
|
|
|
These are the files every repo carries for releases: a Keep a
|
|
Changelog ``CHANGELOG.md`` and, for services, a root ``VERSION``
|
|
file. See standards "91_versioning_and_changelog".
|
|
|
|
Returns:
|
|
list[dict[str, Any]]:
|
|
Entries with ``kind``, ``path``, ``placeholder`` and
|
|
``description``. Fetch one with :func:`get_release_template`.
|
|
"""
|
|
return self._release().get("components", [])
|
|
|
|
@self.app.tool()
|
|
def get_release_template(kind: str) -> dict[str, Any]:
|
|
"""Return the exact release standard file for a component kind.
|
|
|
|
Use this to drop the canonical changelog / version file into a repo
|
|
instead of inventing one. See :func:`list_release_templates` for
|
|
valid kinds ("changelog", "version-file").
|
|
|
|
Args:
|
|
kind (str): Component kind, e.g. "changelog" or "version-file".
|
|
|
|
Returns:
|
|
dict[str, Any]: ``{kind, path, placeholder, content}`` or an
|
|
error entry when the kind is unknown.
|
|
"""
|
|
entry = self._find_release_component(kind)
|
|
if entry is None:
|
|
return {
|
|
"error": "unknown_kind",
|
|
"kind": kind,
|
|
"available": [c["kind"] for c in self._release().get("components", [])],
|
|
}
|
|
return self._read_release_component(entry)
|
|
|
|
@self.app.tool()
|
|
def list_repos(type: str | None = None) -> list[dict[str, Any]]:
|
|
"""List the repositories covered by the CI and versioning standards.
|
|
|
|
Each entry has: ``name``, ``type`` ("package" | "service" | "app"),
|
|
``path`` (relative to the aetos/ workspace root), ``version_source``
|
|
("pyproject.toml" | "package.json" | "VERSION"), ``drone`` (the
|
|
template file), ``trigger`` ("tag"), ``docs`` kinds, and ``mcp``.
|
|
|
|
Args:
|
|
type (str | None): Optional filter, e.g. "package", "service"
|
|
or "app".
|
|
|
|
Returns:
|
|
list[dict[str, Any]]: Matching repository entries.
|
|
"""
|
|
repos = self._repos().get("repos", [])
|
|
if type:
|
|
needle = type.strip().lower()
|
|
return [r for r in repos if str(r.get("type", "")).lower() == needle]
|
|
return repos
|
|
|
|
@self.app.prompt()
|
|
def intake() -> str:
|
|
"""Return the questions to gather before building an app.
|
|
|
|
Use this (or `start_app` with no arguments) to collect the two axes
|
|
and the integration list. Answer them, then call `build`.
|
|
|
|
Returns:
|
|
str: The intake questions.
|
|
"""
|
|
return _INTAKE_TEXT
|
|
|
|
@self.app.prompt()
|
|
def build(
|
|
app_name: str = "",
|
|
resources: str = "",
|
|
pipelines: str = "",
|
|
auth: str = "no",
|
|
) -> str:
|
|
"""Emit the deterministic build prompt from the two axes.
|
|
|
|
Args:
|
|
app_name (str): App name + one-line purpose.
|
|
resources (str): Axis 1 — resources, fields, relationships
|
|
(or a pasted openapi.yaml).
|
|
pipelines (str): Axis 2 — pipeline families + input formats.
|
|
auth (str): Whether auth is needed now ("yes"/"no").
|
|
|
|
Returns:
|
|
str: The build prompt, or the intake questions when the axes
|
|
are missing.
|
|
"""
|
|
if not app_name or not resources:
|
|
return _INTAKE_TEXT
|
|
return _build_text(app_name, resources, pipelines, auth)
|
|
|
|
@self.app.prompt()
|
|
def start_app(
|
|
app_name: str = "",
|
|
resources: str = "",
|
|
pipelines: str = "",
|
|
auth: str = "no",
|
|
) -> str:
|
|
"""Entry point to build a new app from the standards blueprint.
|
|
|
|
Call with no arguments to get the intake questions; call again with
|
|
the answers to get the deterministic build prompt.
|
|
|
|
Args:
|
|
app_name (str): App name + one-line purpose.
|
|
resources (str): Axis 1 — resources, fields, relationships
|
|
(or a pasted openapi.yaml).
|
|
pipelines (str): Axis 2 — pipeline families + input formats.
|
|
auth (str): Whether auth is needed now ("yes"/"no").
|
|
|
|
Returns:
|
|
str: The intake questions, or the build prompt when complete.
|
|
"""
|
|
if not app_name or not resources:
|
|
return _INTAKE_TEXT
|
|
return _build_text(app_name, resources, pipelines, auth)
|
|
|
|
@self.app.prompt()
|
|
def trial(app_name: str = "", build_path: str = "") -> str:
|
|
"""Prompt a self-contained trial build into a scratch directory.
|
|
|
|
Use this to validate the standard end-to-end without touching a real
|
|
app: generate into a throwaway path, prove it builds, and record
|
|
findings.
|
|
|
|
Args:
|
|
app_name (str): App to build for the trial.
|
|
build_path (str): Where to write the trial app.
|
|
|
|
Returns:
|
|
str: The trial prompt.
|
|
"""
|
|
target = build_path or f"apps/{app_name or '<app>'}-trial"
|
|
return (
|
|
f"# Trial: {app_name or '<app>'}\n\n"
|
|
f"Build into `{target}` (a throwaway path) and validate the "
|
|
"standard end-to-end.\n\n"
|
|
"## Steps\n"
|
|
"1. Run the `build` prompt to get the deterministic build prompt.\n"
|
|
f"2. Scaffold into `{target}` only — never an existing app.\n"
|
|
"3. Prove it: `python -c \"import main\"` and `pytest`.\n"
|
|
"4. Record every friction point as a finding: missing component, "
|
|
"wrong codegen shape, ambiguous doc, or missing prompt.\n"
|
|
"5. Do not fix the standard from inside the trial; report findings "
|
|
"back so the standard can be amended.\n\n"
|
|
f"Delete `{target}` once you are satisfied.\n"
|
|
)
|
|
|
|
@self.app.prompt()
|
|
def compliance() -> str:
|
|
"""Return the procedure to align repositories with the standard.
|
|
|
|
Use this to audit and remediate the repos listed in
|
|
`registry://repos` against the Drone and versioning standards.
|
|
|
|
Returns:
|
|
str: The compliance/remediation prompt.
|
|
"""
|
|
return _COMPLIANCE_TEXT
|
|
|
|
@self.app.prompt()
|
|
def release() -> str:
|
|
"""Return the agent-assisted tagging procedure.
|
|
|
|
Use after the user confirms the release MR is merged/squashed: find
|
|
the commit that bumped the version file, tag only that commit, and
|
|
ask the user to push the tag.
|
|
|
|
Returns:
|
|
str: The release/tagging prompt.
|
|
"""
|
|
return _RELEASE_TEXT
|