plain 0.78.1__tar.gz → 0.88.0__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 (221) hide show
  1. {plain-0.78.1 → plain-0.88.0}/.gitignore +3 -3
  2. {plain-0.78.1 → plain-0.88.0}/PKG-INFO +2 -1
  3. {plain-0.78.1 → plain-0.88.0}/plain/CHANGELOG.md +201 -0
  4. {plain-0.78.1 → plain-0.88.0}/plain/assets/views.py +7 -2
  5. plain-0.88.0/plain/cli/__init__.py +4 -0
  6. plain-0.88.0/plain/cli/agent/__init__.py +17 -0
  7. {plain-0.78.1 → plain-0.88.0}/plain/cli/agent/docs.py +1 -1
  8. plain-0.88.0/plain/cli/agent/md.py +106 -0
  9. {plain-0.78.1 → plain-0.88.0}/plain/cli/agent/request.py +2 -2
  10. {plain-0.78.1 → plain-0.88.0}/plain/cli/build.py +1 -1
  11. {plain-0.78.1 → plain-0.88.0}/plain/cli/changelog.py +3 -1
  12. {plain-0.78.1 → plain-0.88.0}/plain/cli/chores.py +2 -6
  13. {plain-0.78.1 → plain-0.88.0}/plain/cli/core.py +61 -0
  14. {plain-0.78.1 → plain-0.88.0}/plain/cli/docs.py +1 -0
  15. {plain-0.78.1 → plain-0.88.0}/plain/cli/formatting.py +28 -10
  16. {plain-0.78.1 → plain-0.88.0}/plain/cli/install.py +1 -1
  17. {plain-0.78.1 → plain-0.88.0}/plain/cli/preflight.py +27 -75
  18. plain-0.88.0/plain/cli/registry.py +131 -0
  19. {plain-0.78.1 → plain-0.88.0}/plain/cli/runtime.py +17 -0
  20. {plain-0.78.1 → plain-0.88.0}/plain/cli/scaffold.py +1 -6
  21. {plain-0.78.1 → plain-0.88.0}/plain/cli/server.py +1 -3
  22. {plain-0.78.1 → plain-0.88.0}/plain/cli/shell.py +5 -5
  23. {plain-0.78.1 → plain-0.88.0}/plain/cli/upgrade.py +4 -2
  24. {plain-0.78.1 → plain-0.88.0}/plain/cli/urls.py +2 -3
  25. {plain-0.78.1 → plain-0.88.0}/plain/cli/utils.py +1 -1
  26. {plain-0.78.1 → plain-0.88.0}/plain/csrf/middleware.py +44 -43
  27. {plain-0.78.1 → plain-0.88.0}/plain/exceptions.py +2 -2
  28. plain-0.88.0/plain/forms/__init__.py +59 -0
  29. {plain-0.78.1 → plain-0.88.0}/plain/forms/boundfield.py +1 -1
  30. {plain-0.78.1 → plain-0.88.0}/plain/forms/fields.py +192 -43
  31. {plain-0.78.1 → plain-0.88.0}/plain/forms/forms.py +8 -1
  32. plain-0.88.0/plain/http/README.md +142 -0
  33. {plain-0.78.1 → plain-0.88.0}/plain/http/__init__.py +5 -3
  34. plain-0.88.0/plain/http/middleware.py +32 -0
  35. {plain-0.78.1 → plain-0.88.0}/plain/http/multipartparser.py +2 -2
  36. {plain-0.78.1 → plain-0.88.0}/plain/http/request.py +20 -6
  37. {plain-0.78.1 → plain-0.88.0}/plain/http/response.py +8 -6
  38. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/base.py +1 -1
  39. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/temp.py +2 -1
  40. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/uploadedfile.py +10 -7
  41. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/uploadhandler.py +13 -8
  42. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/utils.py +4 -1
  43. {plain-0.78.1 → plain-0.88.0}/plain/internal/handlers/base.py +8 -14
  44. {plain-0.78.1 → plain-0.88.0}/plain/internal/handlers/exception.py +30 -46
  45. {plain-0.78.1 → plain-0.88.0}/plain/internal/handlers/wsgi.py +5 -3
  46. plain-0.88.0/plain/internal/middleware/headers.py +59 -0
  47. {plain-0.78.1 → plain-0.88.0}/plain/internal/middleware/hosts.py +3 -8
  48. {plain-0.78.1 → plain-0.88.0}/plain/internal/middleware/https.py +5 -5
  49. {plain-0.78.1 → plain-0.88.0}/plain/internal/middleware/slash.py +3 -8
  50. {plain-0.78.1 → plain-0.88.0}/plain/logs/README.md +37 -0
  51. plain-0.88.0/plain/logs/__init__.py +3 -0
  52. plain-0.88.0/plain/logs/configure.py +85 -0
  53. plain-0.88.0/plain/logs/filters.py +15 -0
  54. {plain-0.78.1 → plain-0.88.0}/plain/logs/formatters.py +1 -1
  55. plain-0.88.0/plain/preflight/README.md +83 -0
  56. {plain-0.78.1 → plain-0.88.0}/plain/preflight/results.py +1 -1
  57. {plain-0.78.1 → plain-0.88.0}/plain/runtime/__init__.py +1 -0
  58. {plain-0.78.1 → plain-0.88.0}/plain/runtime/global_settings.py +7 -5
  59. {plain-0.78.1 → plain-0.88.0}/plain/server/arbiter.py +9 -9
  60. {plain-0.78.1 → plain-0.88.0}/plain/server/http/body.py +2 -2
  61. {plain-0.78.1 → plain-0.88.0}/plain/server/http/errors.py +7 -2
  62. {plain-0.78.1 → plain-0.88.0}/plain/server/http/message.py +3 -2
  63. {plain-0.78.1 → plain-0.88.0}/plain/server/http/parser.py +3 -2
  64. {plain-0.78.1 → plain-0.88.0}/plain/server/sock.py +19 -0
  65. {plain-0.78.1 → plain-0.88.0}/plain/server/workers/base.py +11 -9
  66. {plain-0.78.1 → plain-0.88.0}/plain/server/workers/sync.py +5 -3
  67. {plain-0.78.1 → plain-0.88.0}/plain/server/workers/thread.py +8 -7
  68. {plain-0.78.1 → plain-0.88.0}/plain/signals/__init__.py +0 -1
  69. {plain-0.78.1 → plain-0.88.0}/plain/test/client.py +15 -34
  70. {plain-0.78.1 → plain-0.88.0}/plain/test/exceptions.py +2 -5
  71. {plain-0.78.1 → plain-0.88.0}/plain/urls/patterns.py +4 -0
  72. {plain-0.78.1 → plain-0.88.0}/plain/urls/resolvers.py +1 -1
  73. {plain-0.78.1 → plain-0.88.0}/plain/utils/cache.py +5 -5
  74. {plain-0.78.1 → plain-0.88.0}/plain/utils/datastructures.py +4 -2
  75. {plain-0.78.1 → plain-0.88.0}/plain/utils/functional.py +2 -0
  76. {plain-0.78.1 → plain-0.88.0}/plain/utils/html.py +12 -7
  77. {plain-0.78.1 → plain-0.88.0}/plain/utils/regex_helper.py +2 -2
  78. {plain-0.78.1 → plain-0.88.0}/plain/utils/text.py +2 -0
  79. {plain-0.78.1 → plain-0.88.0}/plain/utils/tree.py +8 -5
  80. {plain-0.78.1 → plain-0.88.0}/plain/validators.py +24 -9
  81. {plain-0.78.1 → plain-0.88.0}/plain/views/README.md +7 -17
  82. {plain-0.78.1 → plain-0.88.0}/plain/views/base.py +2 -2
  83. {plain-0.78.1 → plain-0.88.0}/plain/views/errors.py +3 -2
  84. {plain-0.78.1 → plain-0.88.0}/plain/views/forms.py +1 -1
  85. {plain-0.78.1 → plain-0.88.0}/plain/views/objects.py +17 -20
  86. {plain-0.78.1 → plain-0.88.0}/pyproject.toml +4 -9
  87. {plain-0.78.1 → plain-0.88.0}/tests/test_csrf.py +19 -20
  88. {plain-0.78.1 → plain-0.88.0}/tests/test_logs.py +1 -1
  89. plain-0.78.1/plain/cli/__init__.py +0 -3
  90. plain-0.78.1/plain/cli/agent/__init__.py +0 -20
  91. plain-0.78.1/plain/cli/agent/md.py +0 -92
  92. plain-0.78.1/plain/cli/registry.py +0 -49
  93. plain-0.78.1/plain/csrf/views.py +0 -34
  94. plain-0.78.1/plain/forms/__init__.py +0 -8
  95. plain-0.78.1/plain/http/README.md +0 -30
  96. plain-0.78.1/plain/internal/middleware/headers.py +0 -34
  97. plain-0.78.1/plain/logs/__init__.py +0 -3
  98. plain-0.78.1/plain/logs/configure.py +0 -37
  99. plain-0.78.1/plain/logs/utils.py +0 -56
  100. plain-0.78.1/plain/preflight/README.md +0 -61
  101. {plain-0.78.1 → plain-0.88.0}/LICENSE +0 -0
  102. {plain-0.78.1 → plain-0.88.0}/README.md +0 -0
  103. {plain-0.78.1 → plain-0.88.0}/plain/AGENTS.md +0 -0
  104. {plain-0.78.1 → plain-0.88.0}/plain/README.md +0 -0
  105. {plain-0.78.1 → plain-0.88.0}/plain/__main__.py +0 -0
  106. {plain-0.78.1 → plain-0.88.0}/plain/assets/README.md +0 -0
  107. {plain-0.78.1 → plain-0.88.0}/plain/assets/__init__.py +0 -0
  108. {plain-0.78.1 → plain-0.88.0}/plain/assets/compile.py +0 -0
  109. {plain-0.78.1 → plain-0.88.0}/plain/assets/finders.py +0 -0
  110. {plain-0.78.1 → plain-0.88.0}/plain/assets/fingerprints.py +0 -0
  111. {plain-0.78.1 → plain-0.88.0}/plain/assets/urls.py +0 -0
  112. {plain-0.78.1 → plain-0.88.0}/plain/chores/README.md +0 -0
  113. {plain-0.78.1 → plain-0.88.0}/plain/chores/__init__.py +0 -0
  114. {plain-0.78.1 → plain-0.88.0}/plain/chores/core.py +0 -0
  115. {plain-0.78.1 → plain-0.88.0}/plain/chores/registry.py +0 -0
  116. {plain-0.78.1 → plain-0.88.0}/plain/cli/README.md +0 -0
  117. {plain-0.78.1 → plain-0.88.0}/plain/cli/agent/llmdocs.py +0 -0
  118. {plain-0.78.1 → plain-0.88.0}/plain/cli/agent/prompt.py +0 -0
  119. {plain-0.78.1 → plain-0.88.0}/plain/cli/output.py +0 -0
  120. {plain-0.78.1 → plain-0.88.0}/plain/cli/print.py +0 -0
  121. {plain-0.78.1 → plain-0.88.0}/plain/cli/settings.py +0 -0
  122. {plain-0.78.1 → plain-0.88.0}/plain/cli/startup.py +0 -0
  123. {plain-0.78.1 → plain-0.88.0}/plain/csrf/README.md +0 -0
  124. {plain-0.78.1 → plain-0.88.0}/plain/debug.py +0 -0
  125. {plain-0.78.1 → plain-0.88.0}/plain/forms/README.md +0 -0
  126. {plain-0.78.1 → plain-0.88.0}/plain/forms/exceptions.py +0 -0
  127. {plain-0.78.1 → plain-0.88.0}/plain/http/cookie.py +0 -0
  128. {plain-0.78.1 → plain-0.88.0}/plain/internal/__init__.py +0 -0
  129. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/__init__.py +0 -0
  130. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/locks.py +0 -0
  131. {plain-0.78.1 → plain-0.88.0}/plain/internal/files/move.py +0 -0
  132. {plain-0.78.1 → plain-0.88.0}/plain/internal/handlers/__init__.py +0 -0
  133. {plain-0.78.1 → plain-0.88.0}/plain/internal/middleware/__init__.py +0 -0
  134. {plain-0.78.1 → plain-0.88.0}/plain/internal/reloader.py +0 -0
  135. {plain-0.78.1 → plain-0.88.0}/plain/json.py +0 -0
  136. /plain-0.78.1/plain/logs/loggers.py → /plain-0.88.0/plain/logs/app.py +0 -0
  137. {plain-0.78.1 → plain-0.88.0}/plain/logs/debug.py +0 -0
  138. {plain-0.78.1 → plain-0.88.0}/plain/packages/README.md +0 -0
  139. {plain-0.78.1 → plain-0.88.0}/plain/packages/__init__.py +0 -0
  140. {plain-0.78.1 → plain-0.88.0}/plain/packages/config.py +0 -0
  141. {plain-0.78.1 → plain-0.88.0}/plain/packages/registry.py +0 -0
  142. {plain-0.78.1 → plain-0.88.0}/plain/paginator.py +0 -0
  143. {plain-0.78.1 → plain-0.88.0}/plain/preflight/__init__.py +0 -0
  144. {plain-0.78.1 → plain-0.88.0}/plain/preflight/checks.py +0 -0
  145. {plain-0.78.1 → plain-0.88.0}/plain/preflight/files.py +0 -0
  146. {plain-0.78.1 → plain-0.88.0}/plain/preflight/registry.py +0 -0
  147. {plain-0.78.1 → plain-0.88.0}/plain/preflight/security.py +0 -0
  148. {plain-0.78.1 → plain-0.88.0}/plain/preflight/urls.py +0 -0
  149. {plain-0.78.1 → plain-0.88.0}/plain/runtime/README.md +0 -0
  150. {plain-0.78.1 → plain-0.88.0}/plain/runtime/user_settings.py +0 -0
  151. {plain-0.78.1 → plain-0.88.0}/plain/runtime/utils.py +0 -0
  152. {plain-0.78.1 → plain-0.88.0}/plain/server/LICENSE +0 -0
  153. {plain-0.78.1 → plain-0.88.0}/plain/server/README.md +0 -0
  154. {plain-0.78.1 → plain-0.88.0}/plain/server/__init__.py +0 -0
  155. {plain-0.78.1 → plain-0.88.0}/plain/server/app.py +0 -0
  156. {plain-0.78.1 → plain-0.88.0}/plain/server/config.py +0 -0
  157. {plain-0.78.1 → plain-0.88.0}/plain/server/errors.py +0 -0
  158. {plain-0.78.1 → plain-0.88.0}/plain/server/glogging.py +0 -0
  159. {plain-0.78.1 → plain-0.88.0}/plain/server/http/__init__.py +0 -0
  160. {plain-0.78.1 → plain-0.88.0}/plain/server/http/unreader.py +0 -0
  161. {plain-0.78.1 → plain-0.88.0}/plain/server/http/wsgi.py +0 -0
  162. {plain-0.78.1 → plain-0.88.0}/plain/server/pidfile.py +0 -0
  163. {plain-0.78.1 → plain-0.88.0}/plain/server/util.py +0 -0
  164. {plain-0.78.1 → plain-0.88.0}/plain/server/workers/__init__.py +0 -0
  165. {plain-0.78.1 → plain-0.88.0}/plain/server/workers/workertmp.py +0 -0
  166. {plain-0.78.1 → plain-0.88.0}/plain/signals/README.md +0 -0
  167. {plain-0.78.1 → plain-0.88.0}/plain/signals/dispatch/__init__.py +0 -0
  168. {plain-0.78.1 → plain-0.88.0}/plain/signals/dispatch/dispatcher.py +0 -0
  169. {plain-0.78.1 → plain-0.88.0}/plain/signals/dispatch/license.txt +0 -0
  170. {plain-0.78.1 → plain-0.88.0}/plain/signing.py +0 -0
  171. {plain-0.78.1 → plain-0.88.0}/plain/templates/AGENTS.md +0 -0
  172. {plain-0.78.1 → plain-0.88.0}/plain/templates/README.md +0 -0
  173. {plain-0.78.1 → plain-0.88.0}/plain/templates/__init__.py +0 -0
  174. {plain-0.78.1 → plain-0.88.0}/plain/templates/core.py +0 -0
  175. {plain-0.78.1 → plain-0.88.0}/plain/templates/jinja/__init__.py +0 -0
  176. {plain-0.78.1 → plain-0.88.0}/plain/templates/jinja/environments.py +0 -0
  177. {plain-0.78.1 → plain-0.88.0}/plain/templates/jinja/extensions.py +0 -0
  178. {plain-0.78.1 → plain-0.88.0}/plain/templates/jinja/filters.py +0 -0
  179. {plain-0.78.1 → plain-0.88.0}/plain/templates/jinja/globals.py +0 -0
  180. {plain-0.78.1 → plain-0.88.0}/plain/test/README.md +0 -0
  181. {plain-0.78.1 → plain-0.88.0}/plain/test/__init__.py +0 -0
  182. {plain-0.78.1 → plain-0.88.0}/plain/test/encoding.py +0 -0
  183. {plain-0.78.1 → plain-0.88.0}/plain/urls/README.md +0 -0
  184. {plain-0.78.1 → plain-0.88.0}/plain/urls/__init__.py +0 -0
  185. {plain-0.78.1 → plain-0.88.0}/plain/urls/converters.py +0 -0
  186. {plain-0.78.1 → plain-0.88.0}/plain/urls/exceptions.py +0 -0
  187. {plain-0.78.1 → plain-0.88.0}/plain/urls/routers.py +0 -0
  188. {plain-0.78.1 → plain-0.88.0}/plain/urls/utils.py +0 -0
  189. {plain-0.78.1 → plain-0.88.0}/plain/utils/README.md +0 -0
  190. {plain-0.78.1 → plain-0.88.0}/plain/utils/__init__.py +0 -0
  191. {plain-0.78.1 → plain-0.88.0}/plain/utils/crypto.py +0 -0
  192. {plain-0.78.1 → plain-0.88.0}/plain/utils/dateparse.py +0 -0
  193. {plain-0.78.1 → plain-0.88.0}/plain/utils/deconstruct.py +0 -0
  194. {plain-0.78.1 → plain-0.88.0}/plain/utils/decorators.py +0 -0
  195. {plain-0.78.1 → plain-0.88.0}/plain/utils/duration.py +0 -0
  196. {plain-0.78.1 → plain-0.88.0}/plain/utils/encoding.py +0 -0
  197. {plain-0.78.1 → plain-0.88.0}/plain/utils/hashable.py +0 -0
  198. {plain-0.78.1 → plain-0.88.0}/plain/utils/http.py +0 -0
  199. {plain-0.78.1 → plain-0.88.0}/plain/utils/inspect.py +0 -0
  200. {plain-0.78.1 → plain-0.88.0}/plain/utils/ipv6.py +0 -0
  201. {plain-0.78.1 → plain-0.88.0}/plain/utils/itercompat.py +0 -0
  202. {plain-0.78.1 → plain-0.88.0}/plain/utils/module_loading.py +0 -0
  203. {plain-0.78.1 → plain-0.88.0}/plain/utils/safestring.py +0 -0
  204. {plain-0.78.1 → plain-0.88.0}/plain/utils/timesince.py +0 -0
  205. {plain-0.78.1 → plain-0.88.0}/plain/utils/timezone.py +0 -0
  206. {plain-0.78.1 → plain-0.88.0}/plain/views/__init__.py +0 -0
  207. {plain-0.78.1 → plain-0.88.0}/plain/views/exceptions.py +0 -0
  208. {plain-0.78.1 → plain-0.88.0}/plain/views/redirect.py +0 -0
  209. {plain-0.78.1 → plain-0.88.0}/plain/views/templates.py +0 -0
  210. {plain-0.78.1 → plain-0.88.0}/plain/wsgi.py +0 -0
  211. {plain-0.78.1 → plain-0.88.0}/tests/.gitignore +0 -0
  212. {plain-0.78.1 → plain-0.88.0}/tests/app/.gitignore +0 -0
  213. {plain-0.78.1 → plain-0.88.0}/tests/app/settings.py +0 -0
  214. {plain-0.78.1 → plain-0.88.0}/tests/app/test/__init__.py +0 -0
  215. {plain-0.78.1 → plain-0.88.0}/tests/app/test/default_settings.py +0 -0
  216. {plain-0.78.1 → plain-0.88.0}/tests/app/urls.py +0 -0
  217. {plain-0.78.1 → plain-0.88.0}/tests/conftest.py +0 -0
  218. {plain-0.78.1 → plain-0.88.0}/tests/test_cli.py +0 -0
  219. {plain-0.78.1 → plain-0.88.0}/tests/test_http_hosts.py +0 -0
  220. {plain-0.78.1 → plain-0.88.0}/tests/test_runtime.py +0 -0
  221. {plain-0.78.1 → plain-0.88.0}/tests/test_wsgi.py +0 -0
@@ -1,5 +1,5 @@
1
1
  .venv
2
- .env
2
+ /.env
3
3
  *.egg-info
4
4
  *.py[co]
5
5
  __pycache__
@@ -8,8 +8,8 @@ __pycache__
8
8
  # Test apps
9
9
  plain*/tests/.plain
10
10
 
11
- # Ottobot
12
- .aider*
11
+ # Agent scratch files
12
+ /scratch
13
13
 
14
14
  # Plain temp dirs
15
15
  .plain
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.78.1
3
+ Version: 0.88.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
6
7
  License-File: LICENSE
7
8
  Requires-Python: >=3.13
8
9
  Requires-Dist: click>=8.0.0
@@ -1,5 +1,206 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.88.0](https://github.com/dropseed/plain/releases/plain@0.88.0) (2025-11-13)
4
+
5
+ ### What's changed
6
+
7
+ - The `plain.forms` module now uses explicit imports instead of wildcard imports, improving IDE autocomplete and type checking support ([eff36f3](https://github.com/dropseed/plain/commit/eff36f31e8e15f84e11164a44c833aeab096ffbd))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
13
+ ## [0.87.0](https://github.com/dropseed/plain/releases/plain@0.87.0) (2025-11-12)
14
+
15
+ ### What's changed
16
+
17
+ - Internal classes now use abstract base classes with `@abstractmethod` decorators instead of raising `NotImplementedError`, improving type checking and IDE support ([91b329a](https://github.com/dropseed/plain/commit/91b329a8adb477031c4358e638b12f35f19bb85d), [81b5f88](https://github.com/dropseed/plain/commit/81b5f88a4bd39785f6b19c3c00c0ed23a36fb72f), [d2e2423](https://github.com/dropseed/plain/commit/d2e24235f497a92f45d5a21fc83d802897c2dec0), [61e7b5a](https://github.com/dropseed/plain/commit/61e7b5a0c8675aaaf65f0a626ff7959a786dca7f))
18
+ - Updated to latest version of `ty` type checker and fixed type errors and warnings throughout the codebase ([f4dbcef](https://github.com/dropseed/plain/commit/f4dbcefa929058be517cb1d4ab35bd73a89f26b8))
19
+
20
+ ### Upgrade instructions
21
+
22
+ - No changes required
23
+
24
+ ## [0.86.2](https://github.com/dropseed/plain/releases/plain@0.86.2) (2025-11-11)
25
+
26
+ ### What's changed
27
+
28
+ - CLI color output is now enabled in CI environments by checking the `CI` environment variable, matching the behavior of modern tools like uv ([a1500f15ed](https://github.com/dropseed/plain/commit/a1500f15ed))
29
+
30
+ ### Upgrade instructions
31
+
32
+ - No changes required
33
+
34
+ ## [0.86.1](https://github.com/dropseed/plain/releases/plain@0.86.1) (2025-11-10)
35
+
36
+ ### What's changed
37
+
38
+ - The `plain preflight` command now outputs to stderr only when using `--format json`, keeping stdout clean for JSON parsing while avoiding success messages appearing in error logs for text format ([72ebee7729](https://github.com/dropseed/plain/commit/72ebee7729))
39
+ - CLI color handling now follows the CLICOLOR standard with proper priority: `NO_COLOR` > `CLICOLOR_FORCE`/`FORCE_COLOR` > `CLICOLOR` > `isatty` ([c7fea406c5](https://github.com/dropseed/plain/commit/c7fea406c5))
40
+
41
+ ### Upgrade instructions
42
+
43
+ - No changes required
44
+
45
+ ## [0.86.0](https://github.com/dropseed/plain/releases/plain@0.86.0) (2025-11-10)
46
+
47
+ ### What's changed
48
+
49
+ - Log output is now split by severity level: INFO and below go to stdout, WARNING and above go to stderr for proper cloud platform log classification ([52403b15ba](https://github.com/dropseed/plain/commit/52403b15ba))
50
+ - Added `LOG_STREAM` setting to customize log output behavior with options: `"split"` (default), `"stdout"`, or `"stderr"` ([52403b15ba](https://github.com/dropseed/plain/commit/52403b15ba))
51
+ - Log configuration documentation expanded with detailed guidance on output streams and environment variable settings ([52403b15ba](https://github.com/dropseed/plain/commit/52403b15ba))
52
+
53
+ ### Upgrade instructions
54
+
55
+ - No changes required (default behavior splits logs to stdout/stderr automatically, but this can be customized via `PLAIN_LOG_STREAM` environment variable if needed)
56
+
57
+ ## [0.85.0](https://github.com/dropseed/plain/releases/plain@0.85.0) (2025-11-03)
58
+
59
+ ### What's changed
60
+
61
+ - CLI help output now organizes commands into "Common Commands", "Core Commands", and "Package Commands" sections for better discoverability ([73d3a48](https://github.com/dropseed/plain/commit/73d3a48fca))
62
+ - CLI help output has been customized with improved formatting and shortcut indicators showing which commands are shortcuts (e.g., `migrate → models migrate`) ([db882e6](https://github.com/dropseed/plain/commit/db882e6d47))
63
+ - CSRF exception messages now include more detailed context about what was rejected and why (e.g., port mismatches, host mismatches) ([9a8e09c](https://github.com/dropseed/plain/commit/9a8e09c1dc))
64
+ - The `plain agent md` command now saves a combined `AGENTS.md` file to `.plain/` by default when using `plain dev`, making it easier to provide context to coding agents ([786b7a0](https://github.com/dropseed/plain/commit/786b7a0ca1))
65
+ - CLI help text styling has been refined with dimmed descriptions and usage prefixes for improved readability ([d7f7053](https://github.com/dropseed/plain/commit/d7f705398d))
66
+
67
+ ### Upgrade instructions
68
+
69
+ - No changes required
70
+
71
+ ## [0.84.1](https://github.com/dropseed/plain/releases/plain@0.84.1) (2025-10-31)
72
+
73
+ ### What's changed
74
+
75
+ - Added `license = "BSD-3-Clause"` to package metadata in `pyproject.toml` ([8477355](https://github.com/dropseed/plain/commit/8477355e65))
76
+
77
+ ### Upgrade instructions
78
+
79
+ - No changes required
80
+
81
+ ## [0.84.0](https://github.com/dropseed/plain/releases/plain@0.84.0) (2025-10-29)
82
+
83
+ ### What's changed
84
+
85
+ - The `DEFAULT_RESPONSE_HEADERS` setting now supports format string placeholders (e.g., `{request.csp_nonce}`) for dynamic header values instead of requiring a callable function ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
86
+ - Views can now set headers to `None` to explicitly remove default response headers ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
87
+ - Added comprehensive documentation for customizing default response headers including override, remove, and extend patterns ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
88
+
89
+ ### Upgrade instructions
90
+
91
+ - If you have `DEFAULT_RESPONSE_HEADERS` configured as a callable function, convert it to a dictionary with format string placeholders:
92
+
93
+ ```python
94
+ # Before:
95
+ def DEFAULT_RESPONSE_HEADERS(request):
96
+ nonce = request.csp_nonce
97
+ return {
98
+ "Content-Security-Policy": f"script-src 'self' 'nonce-{nonce}'",
99
+ }
100
+
101
+ # After:
102
+ DEFAULT_RESPONSE_HEADERS = {
103
+ "Content-Security-Policy": "script-src 'self' 'nonce-{request.csp_nonce}'",
104
+ }
105
+ ```
106
+
107
+ - If you were overriding default headers to empty strings (`""`) to remove them, change those to `None` instead
108
+
109
+ ## [0.83.0](https://github.com/dropseed/plain/releases/plain@0.83.0) (2025-10-29)
110
+
111
+ ### What's changed
112
+
113
+ - Added comprehensive Content Security Policy (CSP) documentation explaining how to use nonces with inline scripts and styles ([784f3dd972](https://github.com/dropseed/plain/commit/784f3dd972))
114
+ - The `json_script` utility function now accepts an optional `nonce` parameter for CSP-compliant inline JSON scripts ([784f3dd972](https://github.com/dropseed/plain/commit/784f3dd972))
115
+
116
+ ### Upgrade instructions
117
+
118
+ - Any `|json_script` usages need to make sure the second argument is a nonce, not a custom encoder (which is now third)
119
+
120
+ ## [0.82.0](https://github.com/dropseed/plain/releases/plain@0.82.0) (2025-10-29)
121
+
122
+ ### What's changed
123
+
124
+ - The `DEFAULT_RESPONSE_HEADERS` setting can now be a callable that accepts a request argument, enabling dynamic header generation per request ([cb92905834](https://github.com/dropseed/plain/commit/cb92905834))
125
+ - Added `request.csp_nonce` cached property for generating Content Security Policy nonces ([75071dcc70](https://github.com/dropseed/plain/commit/75071dcc70))
126
+ - Simplified the preflight command by moving `plain preflight check` back to `plain preflight` ([40c2c4560e](https://github.com/dropseed/plain/commit/40c2c4560e))
127
+
128
+ ### Upgrade instructions
129
+
130
+ - If you use `plain preflight check`, update to `plain preflight` (the `check` subcommand has been removed for simplicity)
131
+ - If you use `plain preflight check --deploy`, update to `plain preflight --deploy`
132
+
133
+ ## [0.81.0](https://github.com/dropseed/plain/releases/plain@0.81.0) (2025-10-22)
134
+
135
+ ### What's changed
136
+
137
+ - Removed support for category-specific error template fallbacks like `4xx.html` and `5xx.html` ([9513f7c4fa](https://github.com/dropseed/plain/commit/9513f7c4fa))
138
+
139
+ ### Upgrade instructions
140
+
141
+ - If you have `4xx.html` or `5xx.html` error templates, rename them to specific status code templates (e.g., `404.html`, `500.html`) or remove them if you prefer the plain HTTP response fallback
142
+
143
+ ## [0.80.0](https://github.com/dropseed/plain/releases/plain@0.80.0) (2025-10-22)
144
+
145
+ ### What's changed
146
+
147
+ - CSRF failures now raise `SuspiciousOperation` (HTTP 400) instead of `PermissionDenied` (HTTP 403) ([ad146bde3e](https://github.com/dropseed/plain/commit/ad146bde3e))
148
+ - Error templates can now use category-specific fallbacks like `4xx.html` or `5xx.html` instead of the generic `error.html` ([716cfa3cfc](https://github.com/dropseed/plain/commit/716cfa3cfc))
149
+ - Updated error template documentation with best practices for self-contained `500.html` templates ([55cea3b522](https://github.com/dropseed/plain/commit/55cea3b522))
150
+
151
+ ### Upgrade instructions
152
+
153
+ - If you have a `templates/error.html` template, instead create specific error templates for each status code you want to customize (e.g., `400.html`, `403.html`, `404.html`, `500.html`). You can also create category-specific templates like `4xx.html` or `5xx.html` for broader coverage.
154
+
155
+ ## [0.79.0](https://github.com/dropseed/plain/releases/plain@0.79.0) (2025-10-22)
156
+
157
+ ### What's changed
158
+
159
+ - Response objects now have an `exception` attribute that stores the exception that caused 5xx errors ([0a243ba89c](https://github.com/dropseed/plain/commit/0a243ba89c))
160
+ - Middleware classes now use an abstract base class `HttpMiddleware` with a `process_request()` method ([b960eed6c6](https://github.com/dropseed/plain/commit/b960eed6c6))
161
+ - CSRF middleware now raises `PermissionDenied` instead of rendering a custom `CsrfFailureView` ([d4b93e59b3](https://github.com/dropseed/plain/commit/d4b93e59b3))
162
+ - The `HTTP_ERROR_VIEWS` setting has been removed ([7a4e3a31f4](https://github.com/dropseed/plain/commit/7a4e3a31f4))
163
+ - Standalone `plain-changelog` and `plain-upgrade` executables have been removed in favor of the built-in commands ([07c3a4c540](https://github.com/dropseed/plain/commit/07c3a4c540))
164
+ - Standalone `plain-build` executable has been removed ([99301ea797](https://github.com/dropseed/plain/commit/99301ea797))
165
+ - Removed automatic logging of all HTTP 400+ status codes for cleaner logs ([c2769d7281](https://github.com/dropseed/plain/commit/c2769d7281))
166
+
167
+ ### Upgrade instructions
168
+
169
+ - If you have custom middleware, inherit from `HttpMiddleware` and rename your `__call__()` method to `process_request()`:
170
+
171
+ ```python
172
+ # Before:
173
+ class MyMiddleware:
174
+ def __init__(self, get_response):
175
+ self.get_response = get_response
176
+
177
+ def __call__(self, request):
178
+ response = self.get_response(request)
179
+ return response
180
+
181
+ # After:
182
+ from plain.http import HttpMiddleware
183
+
184
+ class MyMiddleware(HttpMiddleware):
185
+ def process_request(self, request):
186
+ response = self.get_response(request)
187
+ return response
188
+ ```
189
+
190
+ - Remove any custom `HTTP_ERROR_VIEWS` setting from your configuration - error views are now controlled entirely by exception handlers
191
+ - If you were calling `plain-changelog` or `plain-upgrade` as standalone commands, use `plain changelog` or `plain upgrade` instead
192
+ - If you were calling `plain-build` as a standalone command, use `plain build` instead
193
+
194
+ ## [0.78.2](https://github.com/dropseed/plain/releases/plain@0.78.2) (2025-10-20)
195
+
196
+ ### What's changed
197
+
198
+ - Updated package metadata to use `[dependency-groups]` instead of `[tool.uv]` for development dependencies, following PEP 735 standard ([1b43a3a272](https://github.com/dropseed/plain/commit/1b43a3a272))
199
+
200
+ ### Upgrade instructions
201
+
202
+ - No changes required
203
+
3
204
  ## [0.78.1](https://github.com/dropseed/plain/releases/plain@0.78.1) (2025-10-17)
4
205
 
5
206
  ### What's changed
@@ -14,6 +14,7 @@ from plain.http import (
14
14
  ResponseRedirect,
15
15
  StreamingResponse,
16
16
  )
17
+ from plain.http.response import ResponseHeaders
17
18
  from plain.runtime import settings
18
19
  from plain.urls import reverse
19
20
  from plain.views import View
@@ -37,7 +38,7 @@ class AssetView(View):
37
38
  def get_url_path(self) -> str:
38
39
  return self.asset_path or self.url_kwargs["path"]
39
40
 
40
- def get(self) -> Response | FileResponse:
41
+ def get(self) -> Response | FileResponse | StreamingResponse:
41
42
  url_path = self.get_url_path()
42
43
 
43
44
  # Make a trailing slash work, but we don't expect it
@@ -52,7 +53,11 @@ class AssetView(View):
52
53
  if redirect_response := self.get_redirect_response(url_path):
53
54
  return redirect_response
54
55
 
56
+ # check_asset_path validates and raises if path is invalid
57
+ # After this point, absolute_path is guaranteed to be a valid str
55
58
  self.check_asset_path(absolute_path)
59
+ # Type guard: absolute_path is now str (check_asset_path raises if None/invalid)
60
+ assert absolute_path is not None
56
61
 
57
62
  if encoded_path := self.get_encoded_path(absolute_path):
58
63
  absolute_path = encoded_path
@@ -127,7 +132,7 @@ class AssetView(View):
127
132
  def get_size(self, path: str) -> int:
128
133
  return os.path.getsize(path)
129
134
 
130
- def update_headers(self, headers: dict, path: str) -> dict:
135
+ def update_headers(self, headers: ResponseHeaders, path: str) -> ResponseHeaders:
131
136
  headers.setdefault("Access-Control-Allow-Origin", "*")
132
137
 
133
138
  # Always vary on Accept-Encoding
@@ -0,0 +1,4 @@
1
+ from .registry import register_cli
2
+ from .runtime import common_command
3
+
4
+ __all__ = ["register_cli", "common_command"]
@@ -0,0 +1,17 @@
1
+ import click
2
+
3
+ from .docs import docs
4
+ from .md import md
5
+ from .request import request
6
+
7
+
8
+ @click.group("agent")
9
+ def agent() -> None:
10
+ """Tools for coding agents"""
11
+ pass
12
+
13
+
14
+ # Add commands to the group
15
+ agent.add_command(docs)
16
+ agent.add_command(md)
17
+ agent.add_command(request)
@@ -16,7 +16,7 @@ from .llmdocs import LLMDocs
16
16
  help="List available packages",
17
17
  )
18
18
  def docs(package: str, show_list: bool) -> None:
19
- """Show LLM-friendly documentation and source for a package."""
19
+ """Show LLM-friendly documentation for a package"""
20
20
 
21
21
  if show_list:
22
22
  # List available packages using same discovery logic as md command
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import pkgutil
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from plain.runtime import PLAIN_TEMP_PATH
10
+
11
+ from ..output import iterate_markdown
12
+
13
+
14
+ def _get_packages_with_agents() -> dict[str, Path]:
15
+ """Get dict mapping package names to AGENTS.md paths."""
16
+ agents_files = {}
17
+
18
+ # Check for plain.* subpackages (including core plain)
19
+ try:
20
+ import plain
21
+
22
+ # Check core plain package (namespace package)
23
+ plain_spec = importlib.util.find_spec("plain")
24
+ if plain_spec and plain_spec.submodule_search_locations:
25
+ # For namespace packages, check all search locations
26
+ for location in plain_spec.submodule_search_locations:
27
+ plain_path = Path(location)
28
+ agents_path = plain_path / "AGENTS.md"
29
+ if agents_path.exists():
30
+ agents_files["plain"] = agents_path
31
+ break # Use the first one found
32
+
33
+ # Check other plain.* subpackages
34
+ if hasattr(plain, "__path__"):
35
+ for importer, modname, ispkg in pkgutil.iter_modules(
36
+ plain.__path__, "plain."
37
+ ):
38
+ if ispkg:
39
+ try:
40
+ spec = importlib.util.find_spec(modname)
41
+ if spec and spec.origin:
42
+ package_path = Path(spec.origin).parent
43
+ # Look for AGENTS.md at package root
44
+ agents_path = package_path / "AGENTS.md"
45
+ if agents_path.exists():
46
+ agents_files[modname] = agents_path
47
+ except Exception:
48
+ continue
49
+ except Exception:
50
+ pass
51
+
52
+ return agents_files
53
+
54
+
55
+ @click.command("md")
56
+ @click.option(
57
+ "--save",
58
+ default=None,
59
+ is_flag=False,
60
+ flag_value="PLAIN_TEMP_PATH",
61
+ help="Save combined AGENTS.md from all packages to file (default: .plain/AGENTS.md)",
62
+ )
63
+ def md(save: str | None) -> None:
64
+ """AGENTS.md from installed Plain packages"""
65
+
66
+ agents_files = _get_packages_with_agents()
67
+
68
+ if not agents_files:
69
+ return
70
+
71
+ # Handle --save flag
72
+ if save:
73
+ # Use PLAIN_TEMP_PATH if flag was used without value
74
+ if save == "PLAIN_TEMP_PATH":
75
+ save_path = PLAIN_TEMP_PATH / "AGENTS.md"
76
+ else:
77
+ save_path = Path(save)
78
+
79
+ # Check if we need to regenerate
80
+ if save_path.exists():
81
+ output_mtime = save_path.stat().st_mtime
82
+ # Check if any source file is newer
83
+ needs_regen = any(
84
+ path.stat().st_mtime > output_mtime for path in agents_files.values()
85
+ )
86
+ if not needs_regen:
87
+ return
88
+
89
+ # Ensure parent directory exists
90
+ save_path.parent.mkdir(parents=True, exist_ok=True)
91
+
92
+ # Generate combined file
93
+ with save_path.open("w") as f:
94
+ for pkg_name in sorted(agents_files.keys()):
95
+ content = agents_files[pkg_name].read_text()
96
+ f.write(content)
97
+ if not content.endswith("\n"):
98
+ f.write("\n")
99
+ f.write("\n")
100
+ else:
101
+ # Display to console
102
+ for pkg in sorted(agents_files.keys()):
103
+ agents_path = agents_files[pkg]
104
+ for line in iterate_markdown(agents_path.read_text()):
105
+ click.echo(line, nl=False)
106
+ print()
@@ -49,7 +49,7 @@ def request(
49
49
  content_type: str | None,
50
50
  headers: tuple[str, ...],
51
51
  ) -> None:
52
- """Make an HTTP request using the test client against the development database."""
52
+ """Make an HTTP test request against the dev database"""
53
53
 
54
54
  try:
55
55
  # Only allow in DEBUG mode for security
@@ -98,7 +98,7 @@ def request(
98
98
 
99
99
  # Make the request
100
100
  method = method.upper()
101
- kwargs: dict[str, object] = {
101
+ kwargs: dict[str, Any] = {
102
102
  "follow": follow,
103
103
  }
104
104
  if header_dict:
@@ -34,7 +34,7 @@ from plain.assets.compile import compile_assets, get_compiled_path
34
34
  help="Compress the assets",
35
35
  )
36
36
  def build(keep_original: bool, fingerprint: bool, compress: bool) -> None:
37
- """Pre-deployment build step (compile assets, css, js, etc.)"""
37
+ """Pre-deployment build step for assets and static files"""
38
38
 
39
39
  if not keep_original and not fingerprint:
40
40
  raise click.UsageError(
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import click
8
8
 
9
9
  from .output import style_markdown
10
+ from .runtime import without_runtime_setup
10
11
 
11
12
 
12
13
  def parse_version(version_str: str) -> tuple[int, ...]:
@@ -42,6 +43,7 @@ def compare_versions(v1: str, v2: str) -> int:
42
43
  return 0
43
44
 
44
45
 
46
+ @without_runtime_setup
45
47
  @click.command("changelog")
46
48
  @click.argument("package_label")
47
49
  @click.option("--from", "from_version", help="Show entries from this version onwards")
@@ -49,7 +51,7 @@ def compare_versions(v1: str, v2: str) -> int:
49
51
  def changelog(
50
52
  package_label: str, from_version: str | None, to_version: str | None
51
53
  ) -> None:
52
- """Show changelog entries for a package."""
54
+ """Show changelog for a package"""
53
55
  module_name = package_label.replace("-", ".")
54
56
  spec = find_spec(module_name)
55
57
  if not spec:
@@ -17,9 +17,7 @@ def chores() -> None:
17
17
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
18
18
  )
19
19
  def list_chores(name: tuple[str, ...]) -> None:
20
- """
21
- List all registered chores.
22
- """
20
+ """List all registered chores"""
23
21
  from plain.chores.registry import chores_registry
24
22
 
25
23
  chores_registry.import_modules()
@@ -51,9 +49,7 @@ def list_chores(name: tuple[str, ...]) -> None:
51
49
  "--dry-run", is_flag=True, help="Show what would be done without executing"
52
50
  )
53
51
  def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
54
- """
55
- Run the specified chores.
56
- """
52
+ """Run specified chores"""
57
53
  from plain.chores.registry import chores_registry
58
54
 
59
55
  chores_registry.import_modules()
@@ -140,5 +140,66 @@ class PlainCommandCollection(click.CommandCollection):
140
140
  self._ensure_registry_loaded()
141
141
  return super().list_commands(ctx)
142
142
 
143
+ def format_commands(self, ctx: Context, formatter: Any) -> None:
144
+ """Format commands with separate sections for common, core, and package commands."""
145
+ self._ensure_registry_loaded()
146
+
147
+ # Get all commands from both sources, tracking their source
148
+ commands = []
149
+ for source_index, source in enumerate(self.sources):
150
+ for name in source.list_commands(ctx):
151
+ cmd = source.get_command(ctx, name)
152
+ if cmd is not None:
153
+ # source_index 0 = plain_cli (core), 1+ = registry (packages)
154
+ commands.append((name, cmd, source_index))
155
+
156
+ if not commands:
157
+ return
158
+
159
+ # Get metadata from the registry (for shortcuts)
160
+ shortcuts_metadata = cli_registry.get_shortcuts()
161
+
162
+ # Separate commands into common, core, and package
163
+ common_commands = []
164
+ core_commands = []
165
+ package_commands = []
166
+
167
+ for name, cmd, source_index in commands:
168
+ help_text = cmd.get_short_help_str(limit=200)
169
+
170
+ # Check if command is marked as common via decorator
171
+ is_common = getattr(cmd, "is_common_command", False)
172
+
173
+ if is_common:
174
+ # This is a common command
175
+ # Add arrow notation if it's also a shortcut
176
+ if name in shortcuts_metadata:
177
+ shortcut_for = shortcuts_metadata[name].shortcut_for
178
+ if shortcut_for:
179
+ alias_info = click.style(f"(→ {shortcut_for})", italic=True)
180
+ help_text = f"{help_text} {alias_info}"
181
+ common_commands.append((name, help_text))
182
+ elif source_index == 0:
183
+ # Package command (from registry, inserted at index 0)
184
+ package_commands.append((name, help_text))
185
+ else:
186
+ # Core command (from plain_cli, at index 1)
187
+ core_commands.append((name, help_text))
188
+
189
+ # Write common commands section if any exist
190
+ if common_commands:
191
+ with formatter.section("Common Commands"):
192
+ formatter.write_dl(sorted(common_commands))
193
+
194
+ # Write core commands section if any exist
195
+ if core_commands:
196
+ with formatter.section("Core Commands"):
197
+ formatter.write_dl(sorted(core_commands))
198
+
199
+ # Write package commands section if any exist
200
+ if package_commands:
201
+ with formatter.section("Package Commands"):
202
+ formatter.write_dl(sorted(package_commands))
203
+
143
204
 
144
205
  cli = PlainCommandCollection()
@@ -10,6 +10,7 @@ from .output import iterate_markdown
10
10
  @click.option("--open", is_flag=True, help="Open the README in your default editor")
11
11
  @click.argument("module", default="")
12
12
  def docs(module: str, open: bool) -> None:
13
+ """Show documentation for a package"""
13
14
  if not module:
14
15
  raise click.UsageError(
15
16
  "You must specify a module. For LLM-friendly docs, use `plain agent docs`."
@@ -9,17 +9,17 @@ from click.formatting import iter_rows, measure_table, term_len, wrap_text
9
9
 
10
10
  class PlainHelpFormatter(click.HelpFormatter):
11
11
  def write_heading(self, heading: str) -> None:
12
- styled_heading = click.style(heading, underline=True)
12
+ styled_heading = click.style(heading, dim=True)
13
13
  self.write(f"{'':>{self.current_indent}}{styled_heading}\n")
14
14
 
15
15
  def write_usage(self, prog: str, args: str = "", prefix: str = "Usage: ") -> None:
16
- prefix_styled = click.style(prefix, italic=True)
16
+ prefix_styled = click.style(prefix, dim=True)
17
17
  super().write_usage(prog, args, prefix=prefix_styled)
18
18
 
19
19
  def write_dl(
20
20
  self,
21
21
  rows: list[tuple[str, str]],
22
- col_max: int = 30,
22
+ col_max: int = 20,
23
23
  col_spacing: int = 2,
24
24
  ) -> None:
25
25
  """Writes a definition list into the buffer. This is how options
@@ -54,10 +54,15 @@ class PlainHelpFormatter(click.HelpFormatter):
54
54
  lines = wrapped_text.splitlines()
55
55
 
56
56
  if lines:
57
- self.write(f"{lines[0]}\n")
57
+ # Dim the description text
58
+ first_line_styled = click.style(lines[0], dim=True)
59
+ self.write(f"{first_line_styled}\n")
58
60
 
59
61
  for line in lines[1:]:
60
- self.write(f"{'':>{first_col + self.current_indent}}{line}\n")
62
+ line_styled = click.style(line, dim=True)
63
+ self.write(
64
+ f"{'':>{first_col + self.current_indent}}{line_styled}\n"
65
+ )
61
66
  else:
62
67
  self.write("\n")
63
68
 
@@ -66,11 +71,24 @@ class PlainContext(click.Context):
66
71
  formatter_class = PlainHelpFormatter
67
72
 
68
73
  def __init__(self, *args: Any, **kwargs: Any):
74
+ # Set a wider max_content_width for help text (default is 80)
75
+ # This allows descriptions to fit more comfortably on one line
76
+ if "max_content_width" not in kwargs:
77
+ kwargs["max_content_width"] = 140
78
+
69
79
  super().__init__(*args, **kwargs)
70
80
 
71
- # Force colors in CI environments
72
- if any(
73
- os.getenv(var)
74
- for var in ["CI", "FORCE_COLOR", "GITHUB_ACTIONS", "GITLAB_CI"]
75
- ) and not any(os.getenv(var) for var in ["NO_COLOR", "PYTEST_CURRENT_TEST"]):
81
+ # Follow CLICOLOR standard (http://bixense.com/clicolors/)
82
+ # Priority: NO_COLOR > CLICOLOR_FORCE/FORCE_COLOR > CI detection > CLICOLOR > isatty
83
+ if os.getenv("NO_COLOR") or os.getenv("PYTEST_CURRENT_TEST"):
84
+ self.color = False
85
+ elif os.getenv("CLICOLOR_FORCE") or os.getenv("FORCE_COLOR"):
86
+ self.color = True
87
+ elif os.getenv("CI"):
88
+ # Enable colors in CI/deployment environments even without TTY
89
+ # This matches behavior of modern tools like uv (via Rust's anstyle)
76
90
  self.color = True
91
+ elif os.getenv("CLICOLOR"):
92
+ # CLICOLOR=1 means use colors only if TTY (Click's default behavior)
93
+ pass # Let Click handle it with isatty check
94
+ # Otherwise use Click's default behavior (isatty check)
@@ -24,7 +24,7 @@ def install(
24
24
  agent_command: str | None = None,
25
25
  print_only: bool = False,
26
26
  ) -> None:
27
- """Install Plain packages with the help of an agent."""
27
+ """Install Plain packages with agent assistance"""
28
28
  # Validate all package names
29
29
  invalid_packages = [pkg for pkg in packages if not pkg.startswith("plain")]
30
30
  if invalid_packages: