stkit 17.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. stk/__init__.py +19 -0
  2. stk/__main__.py +6 -0
  3. stk/agent_login.py +78 -0
  4. stk/app.py +323 -0
  5. stk/checks.py +634 -0
  6. stk/cli/__init__.py +49 -0
  7. stk/cli/agent.py +267 -0
  8. stk/cli/base.py +79 -0
  9. stk/cli/database.py +199 -0
  10. stk/cli/develop.py +63 -0
  11. stk/cli/doctor.py +262 -0
  12. stk/cli/main.py +101 -0
  13. stk/cli/reports.py +296 -0
  14. stk/cli/smoke.py +399 -0
  15. stk/cli/users.py +259 -0
  16. stk/cli/watch.py +68 -0
  17. stk/extensions.py +29 -0
  18. stk/migrations/__init__.py +130 -0
  19. stk/migrations/env.py +58 -0
  20. stk/migrations/script.py.mako +17 -0
  21. stk/migrations/versions/stk_0001_initial_schema.py +213 -0
  22. stk/portal/views.py +34 -0
  23. stk/public/__init__.py +0 -0
  24. stk/public/models.py +0 -0
  25. stk/public/views.py +344 -0
  26. stk/scaffold/__init__.py +1 -0
  27. stk/scaffold/app/CLAUDE.md +65 -0
  28. stk/scaffold/app/Dockerfile +41 -0
  29. stk/scaffold/app/README.md +16 -0
  30. stk/scaffold/app/__app__/__init__.py +1 -0
  31. stk/scaffold/app/__app__/app.py +8 -0
  32. stk/scaffold/app/__app__/settings.py +7 -0
  33. stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
  34. stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
  35. stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
  36. stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
  37. stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
  38. stk/scaffold/app/docker-compose.yml +88 -0
  39. stk/scaffold/app/dockerignore +13 -0
  40. stk/scaffold/app/env-sample +78 -0
  41. stk/scaffold/app/gitignore +8 -0
  42. stk/scaffold/app/nginx/nginx.conf +46 -0
  43. stk/scaffold/app/nginx/stk.conf +36 -0
  44. stk/scaffold/app/postgres/init-db.sh +8 -0
  45. stk/scaffold/app/pyproject.toml +23 -0
  46. stk/scaffold/app/tests/__init__.py +0 -0
  47. stk/scaffold/app/tests/test_app.py +23 -0
  48. stk/scaffold/generator.py +209 -0
  49. stk/scaffold/templates.py +437 -0
  50. stk/security.py +145 -0
  51. stk/settings.py +147 -0
  52. stk/shell.py +284 -0
  53. stk/static/VERSIONS.txt +4 -0
  54. stk/static/css/app.css +229 -0
  55. stk/static/css/layout.css +330 -0
  56. stk/static/css/vuetify.min.css +6 -0
  57. stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  58. stk/static/icons/tabler-icons.min.css +4 -0
  59. stk/static/img/auth-bg.webp +0 -0
  60. stk/static/img/favicon.ico +0 -0
  61. stk/static/img/stk.svg +1664 -0
  62. stk/static/js/axios.min.js +5 -0
  63. stk/static/js/components/NotificationDropdown.js +156 -0
  64. stk/static/js/components/ThemeSwitcher.js +68 -0
  65. stk/static/js/components/TransitionExpand.js +64 -0
  66. stk/static/js/components/VerticalNavGroup.js +119 -0
  67. stk/static/js/components/VerticalNavLink.js +36 -0
  68. stk/static/js/components/VerticalNavSectionTitle.js +21 -0
  69. stk/static/js/components/index.js +47 -0
  70. stk/static/js/config.js +150 -0
  71. stk/static/js/vue.min.js +13 -0
  72. stk/static/js/vuetify.min.js +3148 -0
  73. stk/static/robots.txt +1 -0
  74. stk/tasks/__init__.py +78 -0
  75. stk/templates/401.html +14 -0
  76. stk/templates/404.html +12 -0
  77. stk/templates/500.html +13 -0
  78. stk/templates/_brand.html +1 -0
  79. stk/templates/auth_layout.html +26 -0
  80. stk/templates/cms/activities.html +135 -0
  81. stk/templates/cms/roles.html +215 -0
  82. stk/templates/cms/users.html +312 -0
  83. stk/templates/dashboard.html +132 -0
  84. stk/templates/index.html +52 -0
  85. stk/templates/layout.html +328 -0
  86. stk/templates/login-layout.html +54 -0
  87. stk/templates/security/_macros.html +39 -0
  88. stk/templates/security/_messages.html +12 -0
  89. stk/templates/security/change_password.html +100 -0
  90. stk/templates/security/confirm_email.html +44 -0
  91. stk/templates/security/email/change_notice.html +4 -0
  92. stk/templates/security/email/change_notice.txt +5 -0
  93. stk/templates/security/email/confirmation_instructions.html +3 -0
  94. stk/templates/security/email/confirmation_instructions.txt +3 -0
  95. stk/templates/security/email/login_instructions.html +5 -0
  96. stk/templates/security/email/login_instructions.txt +5 -0
  97. stk/templates/security/email/reset_instructions.html +1 -0
  98. stk/templates/security/email/reset_instructions.txt +3 -0
  99. stk/templates/security/email/reset_notice.html +1 -0
  100. stk/templates/security/email/reset_notice.txt +1 -0
  101. stk/templates/security/email/welcome.html +7 -0
  102. stk/templates/security/email/welcome.txt +7 -0
  103. stk/templates/security/forgot_password.html +63 -0
  104. stk/templates/security/login_user.html +206 -0
  105. stk/templates/security/mf_recovery.html +67 -0
  106. stk/templates/security/mf_recovery_codes.html +119 -0
  107. stk/templates/security/register_user.html +263 -0
  108. stk/templates/security/reset_password.html +53 -0
  109. stk/templates/security/send_confirmation.html +48 -0
  110. stk/templates/security/send_login.html +39 -0
  111. stk/templates/security/two_factor_select.html +78 -0
  112. stk/templates/security/two_factor_setup.html +166 -0
  113. stk/templates/security/two_factor_verify_code.html +71 -0
  114. stk/templates/security/wan_register.html +179 -0
  115. stk/templates/security/wan_signin.html +123 -0
  116. stk/templates/security/wan_verify.html +109 -0
  117. stk/user/__init__.py +0 -0
  118. stk/user/forms.py +10 -0
  119. stk/user/models.py +308 -0
  120. stk/user/views.py +339 -0
  121. stk/utils/email.py +32 -0
  122. stk/utils/ratelimit.py +67 -0
  123. stk/websocket.py +133 -0
  124. stkit-17.0.0.dist-info/METADATA +135 -0
  125. stkit-17.0.0.dist-info/RECORD +128 -0
  126. stkit-17.0.0.dist-info/WHEEL +4 -0
  127. stkit-17.0.0.dist-info/entry_points.txt +2 -0
  128. stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/cli/doctor.py ADDED
@@ -0,0 +1,262 @@
1
+ """`stk doctor`: the state of this checkout, and the next action for anything wrong.
2
+
3
+ Answers the questions you would otherwise discover one failed command at a time:
4
+ is the environment configured, is the database migrated, is there an admin, are
5
+ the vendored assets present, is the test-only login accidentally exposed.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from sqlalchemy import func, select
11
+
12
+ import stk
13
+ import stk.extensions as ext
14
+ from stk.cli.base import app_spec, cli_app, project_root, run_async
15
+ from stk.migrations import build_alembic_config, sync_url
16
+
17
+ CORE_STATIC = Path(stk.__file__).parent / "static"
18
+
19
+ OK = "ok"
20
+ WARN = "warn"
21
+ FAIL = "fail"
22
+
23
+
24
+ def _finding(name, status, detail, remedy=None):
25
+ return {"name": name, "status": status, "detail": detail, "remedy": remedy}
26
+
27
+
28
+ def _check_env_file(root):
29
+ path = root / ".env"
30
+ if not path.exists():
31
+ return _finding(
32
+ "env file",
33
+ FAIL,
34
+ ".env is missing",
35
+ "copy .env-sample to .env, fill secrets",
36
+ )
37
+ return _finding("env file", OK, str(path.relative_to(root)))
38
+
39
+
40
+ def _check_secrets(config):
41
+ weak = [
42
+ name
43
+ for name in ("SECRET_KEY", "SECURITY_PASSWORD_SALT")
44
+ if len(str(config.get(name) or "")) < 32
45
+ ]
46
+ if weak:
47
+ return _finding(
48
+ "secrets",
49
+ FAIL,
50
+ f"weak or missing: {', '.join(weak)}",
51
+ "set 32+ random characters in .env",
52
+ )
53
+ return _finding("secrets", OK, "SECRET_KEY and password salt look random")
54
+
55
+
56
+ def _check_database(config):
57
+ url = config["SQLALCHEMY_DATABASE_URI"]
58
+ if url.startswith("sqlite"):
59
+ path = Path(url.split("///")[-1])
60
+ if not path.exists():
61
+ return _finding(
62
+ "database", FAIL, f"{path} does not exist", "uv run stk create-db"
63
+ )
64
+ size = path.stat().st_size // 1024
65
+ return _finding("database", OK, f"sqlite {path.name} ({size} KiB)")
66
+ return _finding("database", OK, url.split("@")[-1])
67
+
68
+
69
+ def _as_tuple(value):
70
+ return () if value is None else (value,) if isinstance(value, str) else value
71
+
72
+
73
+ def _applied(script, current):
74
+ """Every revision the database already has: its heads and their ancestry.
75
+
76
+ The version table holds only heads, and an app revision that depends on
77
+ stk's head stands in for it.
78
+ """
79
+ applied, pending = set(), list(current)
80
+ while pending:
81
+ revision = script.get_revision(pending.pop())
82
+ if revision.revision not in applied:
83
+ applied.add(revision.revision)
84
+ pending += [
85
+ *_as_tuple(revision.down_revision),
86
+ *_as_tuple(revision.dependencies),
87
+ ]
88
+ return applied
89
+
90
+
91
+ def _defined(script, revision):
92
+ try:
93
+ script.get_revision(revision)
94
+ except Exception: # alembic raises CommandError for a revision it cannot find
95
+ return False
96
+ return True
97
+
98
+
99
+ def _check_migrations(app):
100
+ """Compare the database revisions against the migration heads on disk."""
101
+ from alembic.runtime.migration import MigrationContext
102
+ from alembic.script import ScriptDirectory
103
+ from sqlalchemy import create_engine
104
+
105
+ script = ScriptDirectory.from_config(build_alembic_config(app))
106
+ heads = set(script.get_heads())
107
+ engine = create_engine(sync_url(app.config["SQLALCHEMY_DATABASE_URI"]))
108
+ try:
109
+ with engine.connect() as connection:
110
+ current = set(MigrationContext.configure(connection).get_current_heads())
111
+ except Exception as exc: # unreachable database is its own diagnosis
112
+ return _finding("migrations", FAIL, str(exc)[:80], "uv run stk db upgrade")
113
+ finally:
114
+ engine.dispose()
115
+
116
+ shown = ", ".join(sorted(heads))
117
+ if not current:
118
+ return _finding(
119
+ "migrations", FAIL, "database has no revision", "uv run stk db upgrade"
120
+ )
121
+ unknown = sorted(rev for rev in current if not _defined(script, rev))
122
+ if unknown:
123
+ return _finding(
124
+ "migrations",
125
+ FAIL,
126
+ f"database is stamped at {', '.join(unknown)}, which no migration defines",
127
+ "uv run stk db stamp heads (after checking the schema matches)",
128
+ )
129
+ if missing := sorted(heads - _applied(script, current)):
130
+ return _finding(
131
+ "migrations",
132
+ WARN,
133
+ f"database is behind: {', '.join(missing)} not applied",
134
+ "uv run stk db upgrade",
135
+ )
136
+ return _finding("migrations", OK, f"at heads ({shown})")
137
+
138
+
139
+ def _check_admin():
140
+ from stk.user.models import Role, User
141
+
142
+ async def _count():
143
+ async with ext.async_session_factory() as session:
144
+ statement = (
145
+ select(func.count())
146
+ .select_from(User)
147
+ .where(User.roles.any(Role.name == "admin"))
148
+ )
149
+ return (await session.execute(statement)).scalar_one()
150
+
151
+ try:
152
+ admins = run_async(_count())
153
+ except Exception as exc:
154
+ return _finding("admin user", FAIL, str(exc)[:80], "uv run stk db upgrade")
155
+ if admins:
156
+ return _finding("admin user", OK, f"{admins} admin(s)")
157
+ return _finding("admin user", WARN, "no admin user", "uv run stk install")
158
+
159
+
160
+ def _check_assets():
161
+ versions = CORE_STATIC / "VERSIONS.txt"
162
+ if not versions.exists():
163
+ return _finding("assets", WARN, "VERSIONS.txt missing", "./vendor.sh")
164
+ expected = [
165
+ CORE_STATIC / name
166
+ for name in (
167
+ "js/vue.min.js",
168
+ "js/vuetify.min.js",
169
+ "css/vuetify.min.css",
170
+ "icons/tabler-icons.min.css",
171
+ "icons/fonts/tabler-icons.woff2",
172
+ )
173
+ ]
174
+ missing = [path.name for path in expected if not path.exists()]
175
+ if missing:
176
+ return _finding("assets", FAIL, f"missing: {', '.join(missing)}", "./vendor.sh")
177
+ pinned = " ".join(versions.read_text().split("\n")[0:2]).strip()
178
+ return _finding("assets", OK, pinned)
179
+
180
+
181
+ def _check_agent_login(config):
182
+ enabled = config.get("STK_ENABLE_AGENT_LOGIN", False)
183
+ env = config.get("STK_ENV", "production")
184
+ if enabled and env != "development":
185
+ return _finding(
186
+ "agent login",
187
+ FAIL,
188
+ f"test-only login enabled with STK_ENV={env}",
189
+ "unset STK_ENABLE_AGENT_LOGIN, or set STK_ENV=development",
190
+ )
191
+ return _finding(
192
+ "agent login", OK, "enabled in development only" if enabled else "disabled"
193
+ )
194
+
195
+
196
+ def _check_browser():
197
+ try:
198
+ from playwright.sync_api import sync_playwright
199
+ except ImportError:
200
+ return _finding(
201
+ "browser", WARN, "playwright not installed", "uv sync --extra dev"
202
+ )
203
+ try:
204
+ with sync_playwright() as playwright:
205
+ path = Path(playwright.chromium.executable_path)
206
+ except Exception:
207
+ return _finding(
208
+ "browser",
209
+ WARN,
210
+ "chromium not installed",
211
+ "uv run playwright install chromium",
212
+ )
213
+ if not path.exists():
214
+ return _finding(
215
+ "browser",
216
+ WARN,
217
+ "chromium not installed",
218
+ "uv run playwright install chromium",
219
+ )
220
+ return _finding("browser", OK, "chromium ready for stk smoke")
221
+
222
+
223
+ def _check_repl():
224
+ try:
225
+ import ptpython # noqa: F401
226
+ except ImportError:
227
+ return _finding(
228
+ "shell",
229
+ WARN,
230
+ "ptpython missing, stk shell falls back to the plain console",
231
+ "uv sync --extra dev",
232
+ )
233
+ return _finding("shell", OK, "ptpython: highlighting and completion")
234
+
235
+
236
+ def build_doctor_report():
237
+ """Return every finding, plus the worst status seen."""
238
+ findings = [_check_env_file(project_root())]
239
+ try:
240
+ app = cli_app()
241
+ except Exception as exc: # an app that cannot load is the diagnosis
242
+ findings.append(
243
+ _finding("app", FAIL, str(exc)[:120], "read the error, then rerun")
244
+ )
245
+ app = None
246
+ if app is not None:
247
+ findings += [
248
+ _finding("app", OK, app_spec()),
249
+ _check_secrets(app.config),
250
+ _check_database(app.config),
251
+ _check_migrations(app),
252
+ _check_admin(),
253
+ _check_agent_login(app.config),
254
+ ]
255
+ findings += [_check_assets(), _check_browser(), _check_repl()]
256
+ if any(item["status"] == FAIL for item in findings):
257
+ status = FAIL
258
+ elif any(item["status"] == WARN for item in findings):
259
+ status = WARN
260
+ else:
261
+ status = OK
262
+ return {"status": status, "findings": findings}
stk/cli/main.py ADDED
@@ -0,0 +1,101 @@
1
+ """The `stk` command: one entry point for the whole framework.
2
+
3
+ Wraps Quart's own CLI group. Every command in `stk/cli/__init__.py` lives on
4
+ this group, so commands that need no app (`stk init`) run anywhere. The app is
5
+ found from `[tool.stk] app` in the nearest pyproject.toml, or from STK_APP, and
6
+ commands it registers (`app.cli.add_command`) appear here too.
7
+ """
8
+
9
+ import inspect
10
+ from importlib.metadata import version
11
+
12
+ import click
13
+ from quart.cli import QuartGroup
14
+
15
+ import stk.cli as cli
16
+ from stk.cli.base import load_app
17
+
18
+ SECTIONS = [
19
+ ("Develop", ["init", "run", "shell", "new", "routes"]),
20
+ ("Database", ["db", "create-db"]),
21
+ ("Verify", ["doctor", "verify", "smoke", "inspect", "report"]),
22
+ (
23
+ "Accounts",
24
+ [
25
+ "install",
26
+ "create",
27
+ "add-role",
28
+ "reset",
29
+ "cleanup-sessions",
30
+ "browser-token",
31
+ "protect-mfa",
32
+ ],
33
+ ),
34
+ ]
35
+
36
+
37
+ class StkGroup(QuartGroup):
38
+ """Quart's CLI group, listing commands by what they are for."""
39
+
40
+ def format_commands(self, ctx, formatter):
41
+ commands = {}
42
+ for name in self.list_commands(ctx):
43
+ command = self.get_command(ctx, name)
44
+ if command is not None and not command.hidden:
45
+ commands[name] = command
46
+
47
+ for title, names in SECTIONS:
48
+ rows = [
49
+ (name, commands.pop(name).get_short_help_str(limit=60))
50
+ for name in names
51
+ if name in commands
52
+ ]
53
+ if rows:
54
+ with formatter.section(title):
55
+ formatter.write_dl(rows)
56
+
57
+ if commands:
58
+ with formatter.section("Other"):
59
+ formatter.write_dl(
60
+ [
61
+ (name, command.get_short_help_str(limit=60))
62
+ for name, command in sorted(commands.items())
63
+ ]
64
+ )
65
+
66
+
67
+ def _create_app(**_kwargs):
68
+ return load_app()
69
+
70
+
71
+ def _print_version(ctx, _param, value):
72
+ if not value or ctx.resilient_parsing:
73
+ return
74
+ # Apps rename the distribution, so report the framework package's own version.
75
+ import stk
76
+
77
+ click.echo(f"stk {stk.__version__} (quart {version('quart')})")
78
+ ctx.exit()
79
+
80
+
81
+ main = StkGroup(
82
+ name="stk",
83
+ create_app=_create_app,
84
+ add_version_option=False,
85
+ help="stk: async Quart framework with a Vue frontend and no build step.",
86
+ params=[
87
+ click.Option(
88
+ ["-v", "--version"],
89
+ is_flag=True,
90
+ expose_value=False,
91
+ is_eager=True,
92
+ callback=_print_version,
93
+ help="Show the stk version and exit.",
94
+ )
95
+ ],
96
+ context_settings={"help_option_names": ["-h", "--help"]},
97
+ )
98
+
99
+ for _name, _command in inspect.getmembers(cli):
100
+ if isinstance(_command, click.Command):
101
+ main.add_command(_command)
stk/cli/reports.py ADDED
@@ -0,0 +1,296 @@
1
+ """Machine-readable reports behind `stk inspect`, `stk verify`, and `stk report`."""
2
+
3
+ import html
4
+ import inspect as pyinspect
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from stk.migrations import import_model_modules
10
+
11
+ VERIFY_COMMANDS = [
12
+ ("ruff", ["ruff", "check", "."]),
13
+ ("checks", [sys.executable, "-m", "stk.checks"]),
14
+ ("migration-drift", [sys.executable, "-m", "stk", "db", "check"]),
15
+ ]
16
+
17
+ # A failing check that does not say how to fix it makes the reader do the work
18
+ # twice. Every check names its remedy, in the report and in the JSON an agent reads.
19
+ REMEDIES = {
20
+ "ruff": "uv run ruff check --fix . && uv run ruff format .",
21
+ "checks": "read the failed assertion above; each check names what it expected",
22
+ "migration-drift": 'uv run stk db revision -m "describe change" && uv run stk db upgrade',
23
+ }
24
+
25
+
26
+ def _command_runner(command):
27
+ try:
28
+ result = subprocess.run(
29
+ command,
30
+ capture_output=True,
31
+ text=True,
32
+ check=False,
33
+ )
34
+ except FileNotFoundError:
35
+ if command[0] == "ruff":
36
+ return None, "", "ruff not installed"
37
+ raise
38
+ return result.returncode, result.stdout, result.stderr
39
+
40
+
41
+ def _route_source(view_func):
42
+ if view_func is None:
43
+ return None
44
+ source_file = pyinspect.getsourcefile(view_func)
45
+ if source_file is None:
46
+ return None
47
+ try:
48
+ _, line = pyinspect.getsourcelines(view_func)
49
+ except OSError:
50
+ line = None
51
+ source = {"file": str(Path(source_file).resolve())}
52
+ if line is not None:
53
+ source["line"] = line
54
+ return source
55
+
56
+
57
+ def _guards(func):
58
+ """Return the quart-security guards wrapping `func`, outermost first.
59
+
60
+ ponytail: identifies the guards by the decorator's closure qualname because
61
+ quart-security exposes no marker. `checks.py` asserts the result against real
62
+ requests, so a rename upstream fails the gate instead of silently reporting
63
+ every route as open.
64
+ """
65
+ guards = []
66
+ while func is not None:
67
+ qualname = getattr(getattr(func, "__code__", None), "co_qualname", "")
68
+ for guard in ("auth_required", "roles_required"):
69
+ if qualname.startswith(f"{guard}."):
70
+ guards.append(guard)
71
+ func = getattr(func, "__wrapped__", None)
72
+ return guards
73
+
74
+
75
+ def _route_auth(app, rule):
76
+ """Report whether a route is guarded, and where the guard is declared."""
77
+ if rule.rule.startswith("/_test/"):
78
+ return {"required": False, "source": "test-only", "scheme": "agent-token"}
79
+
80
+ blueprint = rule.endpoint.rsplit(".", 1)[0] if "." in rule.endpoint else None
81
+ if _guards(app.view_functions.get(rule.endpoint)):
82
+ return {"required": True, "source": "route", "scheme": "session"}
83
+
84
+ for func in app.before_request_funcs.get(blueprint, []):
85
+ if _guards(func):
86
+ return {"required": True, "source": "blueprint", "scheme": "session"}
87
+
88
+ return {"required": False, "source": "unguarded", "scheme": "public"}
89
+
90
+
91
+ def build_routes_report(app):
92
+ """Return machine-readable route facts for agents and tooling."""
93
+ routes = []
94
+ for rule in sorted(app.url_map.iter_rules(), key=lambda item: item.rule):
95
+ methods = sorted((rule.methods or set()) - {"HEAD", "OPTIONS"})
96
+ view_func = app.view_functions.get(rule.endpoint)
97
+ blueprint = rule.endpoint.rsplit(".", 1)[0] if "." in rule.endpoint else None
98
+ routes.append(
99
+ {
100
+ "rule": rule.rule,
101
+ "endpoint": rule.endpoint,
102
+ "blueprint": blueprint,
103
+ "methods": methods,
104
+ "arguments": sorted(rule.arguments),
105
+ "auth": _route_auth(app, rule),
106
+ "source": _route_source(view_func),
107
+ }
108
+ )
109
+ return routes
110
+
111
+
112
+ def build_models_report():
113
+ """Return model metadata in a compact, agent-readable shape."""
114
+ from stk.extensions import Base
115
+
116
+ import_model_modules()
117
+ models = {}
118
+ for table_name, table in sorted(Base.metadata.tables.items()):
119
+ models[table_name] = {
120
+ "columns": [
121
+ {
122
+ "name": column.name,
123
+ "type": str(column.type),
124
+ "nullable": column.nullable,
125
+ "primary_key": column.primary_key,
126
+ }
127
+ for column in table.columns
128
+ ],
129
+ "indexes": sorted(index.name for index in table.indexes if index.name),
130
+ }
131
+ return models
132
+
133
+
134
+ def build_context_report(app):
135
+ """Return the project context contract used by agents and tooling."""
136
+ return {
137
+ "routes": build_routes_report(app),
138
+ "models": build_models_report(),
139
+ }
140
+
141
+
142
+ def build_commands_report(root):
143
+ """Return every stk command, walked from the live click tree under `root`."""
144
+ import click
145
+
146
+ from stk.cli.main import SECTIONS
147
+
148
+ section_of = {name: title for title, names in SECTIONS for name in names}
149
+ commands = []
150
+
151
+ def walk(ctx, path):
152
+ command = ctx.command
153
+ if isinstance(command, click.Group):
154
+ for name in command.list_commands(ctx):
155
+ child = command.get_command(ctx, name)
156
+ if child is not None and not child.hidden:
157
+ walk(
158
+ click.Context(child, info_name=name, parent=ctx), [*path, name]
159
+ )
160
+ return
161
+ help_text = pyinspect.cleandoc(command.help or "").split("\n\n")[0]
162
+ commands.append(
163
+ {
164
+ "command": " ".join(["stk", *path]),
165
+ "section": section_of.get(path[0], "Other"),
166
+ "usage": " ".join(["stk", *path, *command.collect_usage_pieces(ctx)]),
167
+ "help": " ".join(help_text.split()),
168
+ "options": [
169
+ {
170
+ "flags": ", ".join(param.opts + param.secondary_opts),
171
+ "help": " ".join((param.help or "").split()),
172
+ }
173
+ for param in command.params
174
+ if isinstance(param, click.Option)
175
+ ],
176
+ }
177
+ )
178
+
179
+ walk(root, [])
180
+ order = [title for title, _ in SECTIONS] + ["Other"]
181
+ return sorted(commands, key=lambda c: (order.index(c["section"]), c["command"]))
182
+
183
+
184
+ def render_commands_markdown(commands):
185
+ """Render the commands report as the docs CLI reference page."""
186
+ lines = [
187
+ "---",
188
+ 'title: "CLI Reference"',
189
+ 'description: "Every stk command and option, generated from the code"',
190
+ "---",
191
+ "",
192
+ "Generated from the live command tree by `uv run stk inspect commands --markdown`.",
193
+ "Do not edit it by hand: `stk verify` fails when this page and the code disagree.",
194
+ "Agents can read the same data as JSON with `uv run stk inspect commands --json`.",
195
+ ]
196
+ section = None
197
+ for command in commands:
198
+ if command["section"] != section:
199
+ section = command["section"]
200
+ lines += ["", f"## {section}"]
201
+ lines += ["", f"### `{command['command']}`", "", f"`{command['usage']}`"]
202
+ if command["help"]:
203
+ lines += ["", command["help"]]
204
+ if command["options"]:
205
+ lines += ["", "| Option | Description |", "|---|---|"]
206
+ for option in command["options"]:
207
+ description = option["help"].replace("|", r"\|")
208
+ lines.append(f"| `{option['flags']}` | {description} |")
209
+ return "\n".join(lines) + "\n"
210
+
211
+
212
+ def build_verify_report(commands=None, runner=_command_runner):
213
+ """Run verification commands and return a compact report."""
214
+ checks = []
215
+ for name, command_args in commands or VERIFY_COMMANDS:
216
+ returncode, stdout, stderr = runner(command_args)
217
+ checks.append(
218
+ {
219
+ "name": name,
220
+ "command": command_args,
221
+ "returncode": returncode,
222
+ "status": "skipped"
223
+ if returncode is None
224
+ else "passed"
225
+ if returncode == 0
226
+ else "failed",
227
+ "stdout": stdout.strip(),
228
+ "stderr": stderr.strip(),
229
+ "remedy": REMEDIES.get(name),
230
+ }
231
+ )
232
+ status = (
233
+ "passed"
234
+ if all(check["returncode"] in (0, None) for check in checks)
235
+ else "failed"
236
+ )
237
+ return {"status": status, "checks": checks}
238
+
239
+
240
+ def build_project_report_html(routes, verify_report):
241
+ """Render a static project review artifact."""
242
+ rows = "\n".join(
243
+ "<tr>"
244
+ f"<td>{html.escape(route['rule'])}</td>"
245
+ f"<td>{html.escape(', '.join(route['methods']))}</td>"
246
+ f"<td>{html.escape(str(route['blueprint']))}</td>"
247
+ f"<td>{html.escape(route['auth']['scheme'])}</td>"
248
+ f"<td>{html.escape(route['auth']['source'])}</td>"
249
+ "</tr>"
250
+ for route in routes
251
+ )
252
+ check_rows = "\n".join(
253
+ "<tr>"
254
+ f"<td>{html.escape(check['name'])}</td>"
255
+ f"<td>{html.escape(check['status'])}</td>"
256
+ f"<td>{html.escape(' '.join(check['command']))}</td>"
257
+ "</tr>"
258
+ for check in verify_report["checks"]
259
+ )
260
+ status = html.escape(verify_report["status"])
261
+ return f"""<!doctype html>
262
+ <html lang="en">
263
+ <head>
264
+ <meta charset="utf-8">
265
+ <title>STK Project Report</title>
266
+ <style>
267
+ body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; color: #1f2933; }}
268
+ h1 {{ font-size: 28px; margin-bottom: 8px; }}
269
+ h2 {{ font-size: 18px; margin-top: 28px; }}
270
+ table {{ border-collapse: collapse; width: 100%; }}
271
+ th, td {{ border-bottom: 1px solid #d8dee4; padding: 8px; text-align: left; }}
272
+ th {{ background: #f6f8fa; }}
273
+ .status {{ display: inline-block; padding: 4px 8px; border: 1px solid #d8dee4; }}
274
+ </style>
275
+ </head>
276
+ <body>
277
+ <h1>STK Project Report</h1>
278
+ <p>Generated route and verification artifact for reviewing project boundaries.</p>
279
+ <p>Verification status: <span class="status">{status}</span></p>
280
+ <h2>Routes</h2>
281
+ <table>
282
+ <thead>
283
+ <tr><th>Route</th><th>Methods</th><th>Blueprint</th><th>Auth</th><th>Source</th></tr>
284
+ </thead>
285
+ <tbody>{rows}</tbody>
286
+ </table>
287
+ <h2>Verification</h2>
288
+ <table>
289
+ <thead>
290
+ <tr><th>Check</th><th>Status</th><th>Command</th></tr>
291
+ </thead>
292
+ <tbody>{check_rows}</tbody>
293
+ </table>
294
+ </body>
295
+ </html>
296
+ """