tina4-python 3.13.100__tar.gz → 3.13.102__tar.gz

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 (161) hide show
  1. {tina4_python-3.13.100 → tina4_python-3.13.102}/PKG-INFO +16 -1
  2. {tina4_python-3.13.100 → tina4_python-3.13.102}/README.md +15 -0
  3. {tina4_python-3.13.100 → tina4_python-3.13.102}/pyproject.toml +1 -1
  4. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/__init__.py +9 -1
  5. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/ai/__init__.py +10 -0
  6. tina4_python-3.13.102/tina4_python/ai/client.py +460 -0
  7. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/cli/__init__.py +0 -88
  8. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dev_admin/__init__.py +0 -7
  9. tina4_python-3.13.102/tina4_python/dev_admin/metrics.py +118 -0
  10. tina4_python-3.13.100/tina4_python/dev_admin/metrics.py +0 -375
  11. {tina4_python-3.13.100 → tina4_python-3.13.102}/.gitignore +0 -0
  12. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/CLAUDE.md +0 -0
  13. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/HtmlElement.py +0 -0
  14. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/Testing.py +0 -0
  15. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/context/__init__.py +0 -0
  20. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/context/chunker.py +0 -0
  21. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/middleware.py +0 -0
  26. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/port_takeover.py +0 -0
  27. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/rate_limiter.py +0 -0
  28. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/request.py +0 -0
  29. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/response.py +0 -0
  30. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/router.py +0 -0
  31. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/server.py +0 -0
  32. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/crud/__init__.py +0 -0
  33. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/__init__.py +0 -0
  34. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/adapter.py +0 -0
  35. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/connection.py +0 -0
  36. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/database_url.py +0 -0
  37. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/firebird.py +0 -0
  38. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/mongodb.py +0 -0
  39. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/mssql.py +0 -0
  40. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/mysql.py +0 -0
  41. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/odbc.py +0 -0
  42. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/postgres.py +0 -0
  43. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/sql_translator.py +0 -0
  44. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/sqlite.py +0 -0
  45. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/debug/__init__.py +0 -0
  46. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/debug/error_overlay.py +0 -0
  47. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dev_admin/plan.py +0 -0
  48. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dev_admin/project_index.py +0 -0
  49. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/docs.py +0 -0
  50. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/docstore/__init__.py +0 -0
  51. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dotenv/__init__.py +0 -0
  52. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/env.py +0 -0
  53. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/FROND.md +0 -0
  54. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/__init__.py +0 -0
  55. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/compiler.py +0 -0
  56. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/engine.py +0 -0
  57. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/parser.py +0 -0
  58. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/auth/meta.json +0 -0
  59. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  60. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/database/meta.json +0 -0
  61. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  62. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/error-overlay/meta.json +0 -0
  63. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  64. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/orm/meta.json +0 -0
  65. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  66. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  67. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/queue/meta.json +0 -0
  68. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  69. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/rest-api/meta.json +0 -0
  70. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  71. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/templates/meta.json +0 -0
  72. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  73. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  74. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/graphql/__init__.py +0 -0
  75. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/i18n/__init__.py +0 -0
  76. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mcp/__init__.py +0 -0
  77. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mcp/protocol.py +0 -0
  78. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mcp/tools.py +0 -0
  79. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/messenger/__init__.py +0 -0
  80. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/migration/__init__.py +0 -0
  81. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/migration/runner.py +0 -0
  82. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mqtt/__init__.py +0 -0
  83. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mqtt/message.py +0 -0
  84. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/orm/__init__.py +0 -0
  85. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/orm/fields.py +0 -0
  86. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/orm/model.py +0 -0
  87. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/__feedback/widget.js +0 -0
  88. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/css/tina4.css +0 -0
  89. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/css/tina4.min.css +0 -0
  90. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/favicon.ico +0 -0
  91. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/images/logo.svg +0 -0
  92. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  93. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/frond.js +0 -0
  94. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/frond.min.js +0 -0
  95. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  96. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/tina4.min.js +0 -0
  97. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/tina4js.min.js +0 -0
  98. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/swagger/index.html +0 -0
  99. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  100. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/query_builder/__init__.py +0 -0
  101. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/__init__.py +0 -0
  102. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/amqp_url.py +0 -0
  103. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/job.py +0 -0
  104. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/kafka_backend.py +0 -0
  105. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/lite_backend.py +0 -0
  106. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/mongo_backend.py +0 -0
  107. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/rabbitmq_backend.py +0 -0
  108. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/__init__.py +0 -0
  109. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/kafka_backend.py +0 -0
  110. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/mongo_backend.py +0 -0
  111. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  112. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/__init__.py +0 -0
  113. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Attachment.py +0 -0
  114. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Channel.py +0 -0
  115. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/ChannelMember.py +0 -0
  116. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Message.py +0 -0
  117. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Workspace.py +0 -0
  118. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/__init__.py +0 -0
  119. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/storage.py +0 -0
  120. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/seeder/__init__.py +0 -0
  121. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/service/__init__.py +0 -0
  122. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session/__init__.py +0 -0
  123. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/__init__.py +0 -0
  124. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/memcached_handler.py +0 -0
  125. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  126. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/redis_handler.py +0 -0
  127. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/valkey_handler.py +0 -0
  128. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/swagger/__init__.py +0 -0
  129. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/components/crud.twig +0 -0
  130. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  131. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  132. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/python/Dockerfile +0 -0
  133. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  134. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/302.twig +0 -0
  135. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/401.twig +0 -0
  136. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/403.twig +0 -0
  137. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/404.twig +0 -0
  138. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/500.twig +0 -0
  139. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/502.twig +0 -0
  140. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/503.twig +0 -0
  141. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/base.twig +0 -0
  142. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/frontend/README.md +0 -0
  143. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/readme.md +0 -0
  144. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/test/__init__.py +0 -0
  145. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/test_client/__init__.py +0 -0
  146. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/validator/__init__.py +0 -0
  159. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/websocket/__init__.py +0 -0
  160. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/websocket/backplane.py +0 -0
  161. {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.100
3
+ Version: 3.13.102
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -583,6 +583,21 @@ api = Api("https://api.example.com", auth_header="Bearer xyz")
583
583
  result = api.send_request("/users/42")
584
584
  ```
585
585
 
586
+ ### AI Client
587
+
588
+ ```python
589
+ from tina4_python import Ai
590
+
591
+ reply = Ai.chat([{"role": "user", "content": "Summarise this text"}])
592
+ text = Ai.complete("Give me a title")
593
+ vector = Ai.embed("semantic search text")
594
+
595
+ for delta in Ai.chat([{"role": "user", "content": "Stream this"}], stream=True):
596
+ print(delta, end="")
597
+ ```
598
+
599
+ Configure `TINA4_AI_PROVIDER` as `local`, `openai`, or `anthropic`. Hosted providers require `TINA4_AI_KEY`; local OpenAI-compatible endpoints do not.
600
+
586
601
  ### Data Seeder
587
602
 
588
603
  ```python
@@ -532,6 +532,21 @@ api = Api("https://api.example.com", auth_header="Bearer xyz")
532
532
  result = api.send_request("/users/42")
533
533
  ```
534
534
 
535
+ ### AI Client
536
+
537
+ ```python
538
+ from tina4_python import Ai
539
+
540
+ reply = Ai.chat([{"role": "user", "content": "Summarise this text"}])
541
+ text = Ai.complete("Give me a title")
542
+ vector = Ai.embed("semantic search text")
543
+
544
+ for delta in Ai.chat([{"role": "user", "content": "Stream this"}], stream=True):
545
+ print(delta, end="")
546
+ ```
547
+
548
+ Configure `TINA4_AI_PROVIDER` as `local`, `openai`, or `anthropic`. Hosted providers require `TINA4_AI_KEY`; local OpenAI-compatible endpoints do not.
549
+
535
550
  ### Data Seeder
536
551
 
537
552
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.100"
3
+ version = "3.13.102"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.100"
54
+ return "3.13.101"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -133,6 +133,14 @@ _LAZY: dict[str, tuple[str, str]] = {
133
133
  "Container": ("tina4_python.container", "Container"),
134
134
  # HTTP client
135
135
  "Api": ("tina4_python.api", "Api"),
136
+ # App-facing AI client (ADR-0053)
137
+ "Ai": ("tina4_python.ai", "Ai"),
138
+ "ChatResponse": ("tina4_python.ai", "ChatResponse"),
139
+ "AiError": ("tina4_python.ai", "AiError"),
140
+ "AiConfigError": ("tina4_python.ai", "AiConfigError"),
141
+ "AiHTTPError": ("tina4_python.ai", "AiHTTPError"),
142
+ "AiTimeoutError": ("tina4_python.ai", "AiTimeoutError"),
143
+ "AiParseError": ("tina4_python.ai", "AiParseError"),
136
144
  # SOAP / WSDL
137
145
  "WSDL": ("tina4_python.wsdl", "WSDL"),
138
146
  "wsdl_operation": ("tina4_python.wsdl", "wsdl_operation"),
@@ -15,6 +15,16 @@ import shutil
15
15
  import subprocess
16
16
  from pathlib import Path
17
17
 
18
+ from .client import (
19
+ Ai,
20
+ AiConfigError,
21
+ AiError,
22
+ AiHTTPError,
23
+ AiParseError,
24
+ AiTimeoutError,
25
+ ChatResponse,
26
+ )
27
+
18
28
 
19
29
  # Ordered list of supported AI tools
20
30
  AI_TOOLS = [
@@ -0,0 +1,460 @@
1
+ """Zero-dependency app-facing AI client (ADR-0053)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import os
8
+ import socket
9
+ import ssl
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Any, Iterator
13
+ from urllib.parse import urlparse
14
+
15
+
16
+ class AiError(RuntimeError):
17
+ """Base error for the app-facing AI client."""
18
+
19
+
20
+ class AiConfigError(AiError):
21
+ """Invalid or incomplete AI client configuration."""
22
+
23
+
24
+ class AiHTTPError(AiError):
25
+ """Provider or transport failure with no usable response."""
26
+
27
+ def __init__(self, message: str, status: int | None = None):
28
+ super().__init__(message)
29
+ self.status = status
30
+
31
+
32
+ class AiTimeoutError(AiError):
33
+ """The connection or total request deadline expired."""
34
+
35
+
36
+ class AiParseError(AiError):
37
+ """A successful provider response did not satisfy its wire contract."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ChatResponse:
42
+ text: str
43
+ model: str
44
+ usage: dict[str, int]
45
+ finish_reason: str | None
46
+ raw: dict[str, Any]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _Config:
51
+ provider: str
52
+ url: str
53
+ model: str
54
+ key: str | None
55
+ total_timeout: float
56
+ connect_timeout: float
57
+ max_retries: int
58
+
59
+
60
+ class _TransportResponse:
61
+ def __init__(self, connection: http.client.HTTPConnection, response: http.client.HTTPResponse):
62
+ self.connection = connection
63
+ self.response = response
64
+
65
+ def close(self) -> None:
66
+ try:
67
+ self.response.close()
68
+ finally:
69
+ self.connection.close()
70
+
71
+
72
+ class Ai:
73
+ """Provider-neutral AI calls configured through ``TINA4_AI_*``.
74
+
75
+ Methods are static so the smallest useful form stays ``Ai.chat(...)``.
76
+ Explicit call options beat environment values, which beat defaults.
77
+ """
78
+
79
+ _PROVIDERS = {"local", "openai", "anthropic"}
80
+
81
+ @staticmethod
82
+ def chat(
83
+ messages: list[dict[str, Any]],
84
+ *,
85
+ model: str | None = None,
86
+ temperature: float | None = None,
87
+ max_tokens: int | None = None,
88
+ stream: bool = False,
89
+ timeout: float | None = None,
90
+ provider: str | None = None,
91
+ ) -> ChatResponse | Iterator[str]:
92
+ Ai._validate_messages(messages)
93
+ config = Ai._config("chat", model=model, timeout=timeout, provider=provider)
94
+ body = Ai._chat_body(config, messages, temperature, max_tokens, stream)
95
+ headers = Ai._headers(config)
96
+ if stream:
97
+ return Ai._stream(config, headers, body)
98
+ raw = Ai._request_json(config, headers, body)
99
+ return Ai._normalize_chat(config.provider, raw)
100
+
101
+ @staticmethod
102
+ def complete(prompt: str, **options: Any) -> str:
103
+ if not isinstance(prompt, str):
104
+ raise AiConfigError("AI prompt must be a string")
105
+ options.pop("stream", None)
106
+ response = Ai.chat([{"role": "user", "content": prompt}], stream=False, **options)
107
+ assert isinstance(response, ChatResponse)
108
+ return response.text
109
+
110
+ @staticmethod
111
+ def embed(
112
+ text_or_texts: str | list[str],
113
+ *,
114
+ model: str | None = None,
115
+ timeout: float | None = None,
116
+ provider: str | None = None,
117
+ ) -> list[float] | list[list[float]]:
118
+ single = isinstance(text_or_texts, str)
119
+ if not single and not (
120
+ isinstance(text_or_texts, list)
121
+ and text_or_texts
122
+ and all(isinstance(item, str) for item in text_or_texts)
123
+ ):
124
+ raise AiConfigError("AI embedding input must be a string or a non-empty list of strings")
125
+ config = Ai._config("embed", model=model, timeout=timeout, provider=provider)
126
+ if config.provider == "anthropic":
127
+ raise AiConfigError("Anthropic does not provide the embedding endpoint in this contract")
128
+ body = {"model": config.model, "input": text_or_texts}
129
+ raw = Ai._request_json(config, Ai._headers(config), body)
130
+ try:
131
+ ordered = sorted(raw["data"], key=lambda item: item.get("index", 0))
132
+ vectors = [item["embedding"] for item in ordered]
133
+ if not vectors or not all(
134
+ isinstance(vector, list)
135
+ and vector
136
+ and all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in vector)
137
+ for vector in vectors
138
+ ):
139
+ raise ValueError
140
+ expected = 1 if single else len(text_or_texts)
141
+ if len(vectors) != expected:
142
+ raise ValueError
143
+ except (KeyError, TypeError, ValueError):
144
+ raise AiParseError("AI provider returned a malformed embedding response") from None
145
+ return vectors[0] if single else vectors
146
+
147
+ @staticmethod
148
+ def _validate_messages(messages: list[dict[str, Any]]) -> None:
149
+ if not isinstance(messages, list) or not messages:
150
+ raise AiConfigError("AI messages must be a non-empty list")
151
+ for message in messages:
152
+ if not isinstance(message, dict):
153
+ raise AiConfigError("Each AI message must be an object")
154
+ if message.get("role") not in {"system", "user", "assistant"}:
155
+ raise AiConfigError("Each AI message needs a supported role")
156
+ if not isinstance(message.get("content"), str):
157
+ raise AiConfigError("Each AI message needs string content")
158
+
159
+ @staticmethod
160
+ def _number(name: str, default: str, *, minimum: float, integer: bool = False) -> float | int:
161
+ raw = os.environ.get(name, default)
162
+ try:
163
+ value = int(raw) if integer else float(raw)
164
+ except (TypeError, ValueError):
165
+ raise AiConfigError(f"{name} must be numeric") from None
166
+ if value < minimum:
167
+ raise AiConfigError(f"{name} must be at least {minimum:g}")
168
+ return value
169
+
170
+ @staticmethod
171
+ def _config(capability: str, *, model: str | None, timeout: float | None, provider: str | None) -> _Config:
172
+ selected = (provider or os.environ.get("TINA4_AI_PROVIDER") or "local").strip().lower()
173
+ if selected not in Ai._PROVIDERS:
174
+ raise AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic")
175
+ key = os.environ.get("TINA4_AI_KEY") or None
176
+ if selected in {"openai", "anthropic"} and not key:
177
+ raise AiConfigError(f"TINA4_AI_KEY is required for the {selected} provider")
178
+
179
+ defaults = {
180
+ "local": ("http://localhost:11437", "llama3.2"),
181
+ "openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
182
+ "anthropic": ("https://api.anthropic.com/v1", "claude-3-5-haiku-latest"),
183
+ }
184
+ base, default_model = defaults[selected]
185
+ if capability == "embed" and os.environ.get("TINA4_EMBED_URL"):
186
+ url = os.environ["TINA4_EMBED_URL"]
187
+ else:
188
+ url = os.environ.get("TINA4_AI_URL") or base
189
+ url = Ai._endpoint(url, capability, selected)
190
+ chosen_model = model if model is not None else (os.environ.get("TINA4_AI_MODEL") or default_model)
191
+ if not isinstance(chosen_model, str) or not chosen_model.strip():
192
+ raise AiConfigError("AI model must be a non-empty string")
193
+
194
+ total = float(timeout) if timeout is not None else float(Ai._number("TINA4_AI_TIMEOUT", "60", minimum=0.001))
195
+ if total <= 0:
196
+ raise AiConfigError("AI timeout must be greater than zero")
197
+ connect = float(Ai._number("TINA4_AI_CONNECT_TIMEOUT", "10", minimum=0.001))
198
+ retries = int(Ai._number("TINA4_AI_MAX_RETRIES", "2", minimum=0, integer=True))
199
+ return _Config(selected, url, chosen_model.strip(), key, total, connect, retries)
200
+
201
+ @staticmethod
202
+ def _endpoint(value: str, capability: str, provider: str) -> str:
203
+ parsed = urlparse(value)
204
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
205
+ raise AiConfigError("AI URL must be an http or https URL")
206
+ path = parsed.path.rstrip("/")
207
+ if provider == "anthropic":
208
+ suffix = "/messages"
209
+ elif capability == "embed":
210
+ suffix = "/embeddings"
211
+ else:
212
+ suffix = "/chat/completions"
213
+ if path in {"", "/v1", "/api"}:
214
+ prefix = path or ("/v1" if provider in {"local", "openai"} else "/v1")
215
+ path = prefix + suffix
216
+ parsed = parsed._replace(path=path)
217
+ return parsed.geturl()
218
+ return value
219
+
220
+ @staticmethod
221
+ def _headers(config: _Config) -> dict[str, str]:
222
+ headers = {"content-type": "application/json", "accept": "application/json"}
223
+ if config.provider == "openai" and config.key:
224
+ headers["authorization"] = f"Bearer {config.key}"
225
+ elif config.provider == "anthropic" and config.key:
226
+ headers["x-api-key"] = config.key
227
+ headers["anthropic-version"] = "2023-06-01"
228
+ return headers
229
+
230
+ @staticmethod
231
+ def _chat_body(
232
+ config: _Config,
233
+ messages: list[dict[str, Any]],
234
+ temperature: float | None,
235
+ max_tokens: int | None,
236
+ stream: bool,
237
+ ) -> dict[str, Any]:
238
+ body: dict[str, Any] = {"model": config.model, "messages": messages, "stream": stream}
239
+ if temperature is not None:
240
+ body["temperature"] = temperature
241
+ if max_tokens is not None:
242
+ body["max_tokens"] = max_tokens
243
+ if config.provider == "anthropic":
244
+ system = [message["content"] for message in messages if message["role"] == "system"]
245
+ body["messages"] = [message for message in messages if message["role"] != "system"]
246
+ body["max_tokens"] = max_tokens if max_tokens is not None else 1024
247
+ if system:
248
+ body["system"] = "\n\n".join(system)
249
+ return body
250
+
251
+ @staticmethod
252
+ def _connect(config: _Config, deadline: float, body: dict[str, Any], headers: dict[str, str]) -> _TransportResponse:
253
+ parsed = urlparse(config.url)
254
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
255
+ remaining = deadline - time.monotonic()
256
+ if remaining <= 0:
257
+ raise AiTimeoutError("AI total request timeout expired")
258
+ connect_timeout = min(config.connect_timeout, remaining)
259
+ connection_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
260
+ kwargs: dict[str, Any] = {"timeout": connect_timeout}
261
+ if parsed.scheme == "https":
262
+ kwargs["context"] = ssl.create_default_context()
263
+ connection = connection_cls(parsed.hostname, port, **kwargs)
264
+ try:
265
+ try:
266
+ connection.connect()
267
+ except socket.timeout:
268
+ raise AiTimeoutError("AI connection timeout expired") from None
269
+ remaining = deadline - time.monotonic()
270
+ if remaining <= 0:
271
+ raise AiTimeoutError("AI total request timeout expired")
272
+ if connection.sock is not None:
273
+ connection.sock.settimeout(remaining)
274
+ payload = json.dumps(body, separators=(",", ":")).encode()
275
+ path = parsed.path or "/"
276
+ if parsed.query:
277
+ path += "?" + parsed.query
278
+ connection.request("POST", path, body=payload, headers=headers)
279
+ response = connection.getresponse()
280
+ return _TransportResponse(connection, response)
281
+ except socket.timeout:
282
+ connection.close()
283
+ raise AiTimeoutError("AI total request timeout expired") from None
284
+ except AiError:
285
+ connection.close()
286
+ raise
287
+ except (OSError, http.client.HTTPException) as exc:
288
+ connection.close()
289
+ raise AiHTTPError(f"AI transport failed ({type(exc).__name__})") from None
290
+
291
+ @staticmethod
292
+ def _retry_delay(response: http.client.HTTPResponse, deadline: float) -> float:
293
+ raw = response.getheader("retry-after")
294
+ try:
295
+ requested = max(0.0, float(raw)) if raw is not None else 0.1
296
+ except ValueError:
297
+ requested = 0.1
298
+ return min(requested, max(0.0, deadline - time.monotonic()))
299
+
300
+ @staticmethod
301
+ def _request_json(config: _Config, headers: dict[str, str], body: dict[str, Any]) -> dict[str, Any]:
302
+ deadline = time.monotonic() + config.total_timeout
303
+ last_error: AiError | None = None
304
+ for attempt in range(config.max_retries + 1):
305
+ transport: _TransportResponse | None = None
306
+ try:
307
+ transport = Ai._connect(config, deadline, body, headers)
308
+ response = transport.response
309
+ status = response.status
310
+ remaining = deadline - time.monotonic()
311
+ if remaining <= 0:
312
+ raise AiTimeoutError("AI total request timeout expired")
313
+ if transport.connection.sock is not None:
314
+ transport.connection.sock.settimeout(remaining)
315
+ raw_body = response.read()
316
+ if status < 200 or status >= 300:
317
+ error = AiHTTPError(f"AI provider returned HTTP {status}", status=status)
318
+ if status == 429 or status >= 500:
319
+ last_error = error
320
+ if attempt < config.max_retries:
321
+ delay = Ai._retry_delay(response, deadline)
322
+ transport.close()
323
+ transport = None
324
+ if delay:
325
+ time.sleep(delay)
326
+ continue
327
+ raise error
328
+ try:
329
+ parsed = json.loads(raw_body)
330
+ except (UnicodeDecodeError, json.JSONDecodeError):
331
+ raise AiParseError("AI provider returned malformed JSON") from None
332
+ if not isinstance(parsed, dict):
333
+ raise AiParseError("AI provider returned a non-object JSON response")
334
+ return parsed
335
+ except (AiTimeoutError, AiHTTPError) as exc:
336
+ last_error = exc
337
+ if isinstance(exc, AiHTTPError) and exc.status is not None:
338
+ raise
339
+ if attempt >= config.max_retries:
340
+ raise
341
+ except socket.timeout:
342
+ last_error = AiTimeoutError("AI total request timeout expired")
343
+ if attempt >= config.max_retries:
344
+ raise last_error from None
345
+ finally:
346
+ if transport is not None:
347
+ transport.close()
348
+ raise last_error or AiHTTPError("AI request failed")
349
+
350
+ @staticmethod
351
+ def _normalize_chat(provider: str, raw: dict[str, Any]) -> ChatResponse:
352
+ try:
353
+ if provider == "anthropic":
354
+ text_parts = [item["text"] for item in raw["content"] if item.get("type", "text") == "text"]
355
+ if not text_parts:
356
+ raise ValueError
357
+ input_tokens = int(raw.get("usage", {}).get("input_tokens", 0))
358
+ output_tokens = int(raw.get("usage", {}).get("output_tokens", 0))
359
+ usage = {
360
+ "prompt_tokens": input_tokens,
361
+ "completion_tokens": output_tokens,
362
+ "total_tokens": input_tokens + output_tokens,
363
+ }
364
+ return ChatResponse("".join(text_parts), str(raw.get("model", "")), usage, raw.get("stop_reason"), raw)
365
+ choice = raw["choices"][0]
366
+ text = choice["message"]["content"]
367
+ if not isinstance(text, str):
368
+ raise ValueError
369
+ provider_usage = raw.get("usage", {})
370
+ usage = {
371
+ "prompt_tokens": int(provider_usage.get("prompt_tokens", 0)),
372
+ "completion_tokens": int(provider_usage.get("completion_tokens", 0)),
373
+ "total_tokens": int(provider_usage.get("total_tokens", 0)),
374
+ }
375
+ return ChatResponse(text, str(raw.get("model", "")), usage, choice.get("finish_reason"), raw)
376
+ except (KeyError, IndexError, TypeError, ValueError):
377
+ raise AiParseError("AI provider returned a malformed chat response") from None
378
+
379
+ @staticmethod
380
+ def _stream_delta(provider: str, data: str) -> tuple[bool, str | None]:
381
+ if data == "[DONE]":
382
+ return True, None
383
+ try:
384
+ event = json.loads(data)
385
+ if provider == "anthropic":
386
+ delta = event.get("delta", {})
387
+ text = delta.get("text") if event.get("type") == "content_block_delta" else None
388
+ else:
389
+ text = event.get("choices", [{}])[0].get("delta", {}).get("content")
390
+ except (json.JSONDecodeError, IndexError, TypeError, UnicodeDecodeError):
391
+ raise AiParseError("AI provider returned malformed stream data") from None
392
+ if text is not None and not isinstance(text, str):
393
+ raise AiParseError("AI provider returned malformed stream data")
394
+ return False, text
395
+
396
+ @staticmethod
397
+ def _stream_data(transport: _TransportResponse, deadline: float) -> Iterator[str]:
398
+ while True:
399
+ remaining = deadline - time.monotonic()
400
+ if remaining <= 0:
401
+ raise AiTimeoutError("AI total request timeout expired")
402
+ if transport.connection.sock is not None:
403
+ transport.connection.sock.settimeout(remaining)
404
+ line = transport.response.readline()
405
+ if not line:
406
+ return
407
+ stripped = line.decode("utf-8", errors="strict").strip()
408
+ if stripped.startswith("data:"):
409
+ yield stripped[5:].strip()
410
+
411
+ @staticmethod
412
+ def _stream(config: _Config, headers: dict[str, str], body: dict[str, Any]) -> Iterator[str]:
413
+ def iterator() -> Iterator[str]:
414
+ deadline = time.monotonic() + config.total_timeout
415
+ yielded = False
416
+ for attempt in range(config.max_retries + 1):
417
+ transport: _TransportResponse | None = None
418
+ try:
419
+ stream_headers = {**headers, "accept": "text/event-stream"}
420
+ transport = Ai._connect(config, deadline, body, stream_headers)
421
+ response = transport.response
422
+ if response.status < 200 or response.status >= 300:
423
+ status = response.status
424
+ response.read()
425
+ error = AiHTTPError(f"AI provider returned HTTP {status}", status=status)
426
+ if (status == 429 or status >= 500) and attempt < config.max_retries:
427
+ delay = Ai._retry_delay(response, deadline)
428
+ transport.close()
429
+ transport = None
430
+ if delay:
431
+ time.sleep(delay)
432
+ continue
433
+ raise error
434
+ completed = False
435
+ for data in Ai._stream_data(transport, deadline):
436
+ completed, text = Ai._stream_delta(config.provider, data)
437
+ if completed:
438
+ break
439
+ if text is not None:
440
+ yielded = True
441
+ yield text
442
+ if completed:
443
+ return
444
+ raise AiParseError("AI provider stream ended before [DONE]")
445
+ except (AiHTTPError, AiTimeoutError):
446
+ if yielded or attempt >= config.max_retries:
447
+ raise
448
+ except (socket.timeout, OSError, http.client.HTTPException) as exc:
449
+ error: AiError = (
450
+ AiTimeoutError("AI total request timeout expired")
451
+ if isinstance(exc, socket.timeout)
452
+ else AiHTTPError(f"AI transport failed ({type(exc).__name__})")
453
+ )
454
+ if yielded or attempt >= config.max_retries:
455
+ raise error from None
456
+ finally:
457
+ if transport is not None:
458
+ transport.close()
459
+
460
+ return iterator()
@@ -521,93 +521,6 @@ def _console(args=None):
521
521
  code.interact(banner=banner, local=local_vars)
522
522
 
523
523
 
524
- # ── Metrics ───────────────────────────────────────────────────────────
525
-
526
- def _metrics(args):
527
- """Report top code-quality offenders (complexity, size, maintainability, tests).
528
-
529
- tina4python metrics # human report, scans src/ (or framework)
530
- tina4python metrics --top 10 # only the worst 10
531
- tina4python metrics --path tina4_python # scan a specific directory
532
- tina4python metrics --json # machine-readable for CI
533
- tina4python metrics --fail-on warn # exit 1 if any warn/error offender
534
- tina4python metrics --fail-on error # exit 1 only on error-severity
535
- """
536
- import json
537
- from tina4_python.dev_admin import metrics as _m
538
-
539
- flags, _ = _parse_flags(args)
540
-
541
- top = int(flags["top"]) if "top" in flags and str(flags["top"]).isdigit() else 20
542
- as_json = "json" in flags
543
- path = flags.get("path", "src")
544
- fail_on = flags.get("fail-on")
545
- if fail_on not in (None, "warn", "error"):
546
- print(f" invalid --fail-on '{fail_on}' (use warn or error)")
547
- sys.exit(2)
548
-
549
- result = _m.offenders(path, top=top)
550
- summary = result["summary"]
551
- found = result["offenders"]
552
-
553
- if "error" in summary:
554
- print(f" metrics error: {summary['error']}")
555
- sys.exit(2)
556
-
557
- # Decide exit code from the FULL offender set, not just the printed top-N.
558
- # full_analysis is cached, so this reuses the same analysis.
559
- all_offenders = _m.offenders(path, top=summary["total_offenders"] or 1)["offenders"]
560
- severities = {o["severity"] for o in all_offenders}
561
- exit_code = 0
562
- if fail_on == "warn" and ({"warn", "error"} & severities):
563
- exit_code = 1
564
- elif fail_on == "error" and ("error" in severities):
565
- exit_code = 1
566
-
567
- if as_json:
568
- print(json.dumps({"summary": summary, "offenders": found}, indent=2))
569
- sys.exit(exit_code)
570
-
571
- # ── Human report ──────────────────────────────────────────────────
572
- use_color = sys.stdout.isatty()
573
-
574
- def _c(text, code):
575
- return f"\033[{code}m{text}\033[0m" if use_color else text
576
-
577
- sev_color = {"error": "31", "warn": "33", "info": "2"} # red / yellow / dim
578
-
579
- print()
580
- print(f" Tina4 Metrics — {summary['scan_mode']} scan ({summary['scan_root']})")
581
- print(f" files: {summary['files_analyzed']} "
582
- f"functions: {summary['total_functions']} "
583
- f"avg complexity: {summary['avg_complexity']} "
584
- f"avg maintainability: {summary['avg_maintainability']}")
585
- print(f" offenders: {summary['total_offenders']} total"
586
- + (f" (showing top {len(found)})" if found else ""))
587
- print()
588
-
589
- if not found:
590
- print(" " + _c("✓ no offenders — clean", "32"))
591
- print()
592
- sys.exit(exit_code)
593
-
594
- # Compute a column width for the file:line cell so the table lines up.
595
- locs = [f"{o['file']}:{o['line']}" for o in found]
596
- loc_w = max(len("FILE:LINE"), max(len(s) for s in locs))
597
- kind_w = max(len("KIND"), max(len(o["kind"]) for o in found))
598
-
599
- header = f" {'#':>3} {'SEVERITY':<8} {'KIND':<{kind_w}} {'FILE:LINE':<{loc_w}} DETAIL"
600
- print(_c(header, "1"))
601
- print(" " + "-" * (len(header) - 2))
602
- for i, o in enumerate(found, 1):
603
- sev = o["severity"]
604
- sev_cell = _c(f"{sev:<8}", sev_color[sev])
605
- print(f" {i:>3} {sev_cell} {o['kind']:<{kind_w}} "
606
- f"{locs[i - 1]:<{loc_w}} {o['detail']}")
607
- print()
608
- sys.exit(exit_code)
609
-
610
-
611
524
  # ── Init ──────────────────────────────────────────────────────────────
612
525
 
613
526
  def _init(args):
@@ -3283,7 +3196,6 @@ COMMANDS = {
3283
3196
  "ai": {"handler": _ai, "usage": "[--all]", "summary": "Install AI coding assistant context"},
3284
3197
  "generate": {"handler": _generate, "usage": "<what> <name> [options]", "subcommands": list(GENERATORS), "summary": "Generate scaffolding (see Generators below)"},
3285
3198
  "console": {"handler": _console, "summary": "Start interactive REPL with framework loaded"},
3286
- "metrics": {"handler": _metrics, "usage": "[--top N] [--json] [--fail-on warn|error] [--path DIR]", "summary": "Rank top code-quality offenders"},
3287
3199
  "commands": {"handler": _commands, "usage": "[--json]", "summary": "List available commands (add --json for machine form)"},
3288
3200
  "help": {"handler": _help, "summary": "Show this help"},
3289
3201
  }