plain 0.59.0__tar.gz → 0.61.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 (177) hide show
  1. {plain-0.59.0 → plain-0.61.0}/PKG-INFO +1 -1
  2. plain-0.61.0/plain/AGENTS.md +14 -0
  3. {plain-0.59.0 → plain-0.61.0}/plain/CHANGELOG.md +25 -0
  4. plain-0.61.0/plain/cli/agent/__init__.py +20 -0
  5. plain-0.61.0/plain/cli/agent/docs.py +80 -0
  6. plain-0.59.0/plain/cli/docs.py → plain-0.61.0/plain/cli/agent/llmdocs.py +6 -79
  7. plain-0.61.0/plain/cli/agent/md.py +87 -0
  8. plain-0.59.0/plain/cli/agent.py → plain-0.61.0/plain/cli/agent/prompt.py +10 -15
  9. plain-0.61.0/plain/cli/agent/request.py +181 -0
  10. {plain-0.59.0 → plain-0.61.0}/plain/cli/core.py +2 -2
  11. plain-0.61.0/plain/cli/docs.py +38 -0
  12. {plain-0.59.0 → plain-0.61.0}/plain/cli/install.py +1 -1
  13. {plain-0.59.0 → plain-0.61.0}/plain/cli/shell.py +15 -1
  14. {plain-0.59.0 → plain-0.61.0}/plain/cli/upgrade.py +1 -1
  15. {plain-0.59.0 → plain-0.61.0}/plain/runtime/global_settings.py +4 -2
  16. {plain-0.59.0 → plain-0.61.0}/plain/runtime/utils.py +7 -4
  17. plain-0.61.0/plain/templates/AGENTS.md +3 -0
  18. {plain-0.59.0 → plain-0.61.0}/plain/views/objects.py +4 -3
  19. {plain-0.59.0 → plain-0.61.0}/pyproject.toml +1 -1
  20. {plain-0.59.0 → plain-0.61.0}/tests/test_cli.py +0 -8
  21. plain-0.59.0/plain/cli/help.py +0 -27
  22. {plain-0.59.0 → plain-0.61.0}/.gitignore +0 -0
  23. {plain-0.59.0 → plain-0.61.0}/LICENSE +0 -0
  24. {plain-0.59.0 → plain-0.61.0}/README.md +0 -0
  25. {plain-0.59.0 → plain-0.61.0}/plain/README.md +0 -0
  26. {plain-0.59.0 → plain-0.61.0}/plain/__main__.py +0 -0
  27. {plain-0.59.0 → plain-0.61.0}/plain/assets/README.md +0 -0
  28. {plain-0.59.0 → plain-0.61.0}/plain/assets/__init__.py +0 -0
  29. {plain-0.59.0 → plain-0.61.0}/plain/assets/compile.py +0 -0
  30. {plain-0.59.0 → plain-0.61.0}/plain/assets/finders.py +0 -0
  31. {plain-0.59.0 → plain-0.61.0}/plain/assets/fingerprints.py +0 -0
  32. {plain-0.59.0 → plain-0.61.0}/plain/assets/urls.py +0 -0
  33. {plain-0.59.0 → plain-0.61.0}/plain/assets/views.py +0 -0
  34. {plain-0.59.0 → plain-0.61.0}/plain/chores/README.md +0 -0
  35. {plain-0.59.0 → plain-0.61.0}/plain/chores/__init__.py +0 -0
  36. {plain-0.59.0 → plain-0.61.0}/plain/chores/registry.py +0 -0
  37. {plain-0.59.0 → plain-0.61.0}/plain/cli/README.md +0 -0
  38. {plain-0.59.0 → plain-0.61.0}/plain/cli/__init__.py +0 -0
  39. {plain-0.59.0 → plain-0.61.0}/plain/cli/build.py +0 -0
  40. {plain-0.59.0 → plain-0.61.0}/plain/cli/changelog.py +0 -0
  41. {plain-0.59.0 → plain-0.61.0}/plain/cli/chores.py +0 -0
  42. {plain-0.59.0 → plain-0.61.0}/plain/cli/formatting.py +0 -0
  43. {plain-0.59.0 → plain-0.61.0}/plain/cli/output.py +0 -0
  44. {plain-0.59.0 → plain-0.61.0}/plain/cli/preflight.py +0 -0
  45. {plain-0.59.0 → plain-0.61.0}/plain/cli/print.py +0 -0
  46. {plain-0.59.0 → plain-0.61.0}/plain/cli/registry.py +0 -0
  47. {plain-0.59.0 → plain-0.61.0}/plain/cli/scaffold.py +0 -0
  48. {plain-0.59.0 → plain-0.61.0}/plain/cli/settings.py +0 -0
  49. {plain-0.59.0 → plain-0.61.0}/plain/cli/startup.py +0 -0
  50. {plain-0.59.0 → plain-0.61.0}/plain/cli/urls.py +0 -0
  51. {plain-0.59.0 → plain-0.61.0}/plain/cli/utils.py +0 -0
  52. {plain-0.59.0 → plain-0.61.0}/plain/csrf/README.md +0 -0
  53. {plain-0.59.0 → plain-0.61.0}/plain/csrf/middleware.py +0 -0
  54. {plain-0.59.0 → plain-0.61.0}/plain/csrf/views.py +0 -0
  55. {plain-0.59.0 → plain-0.61.0}/plain/debug.py +0 -0
  56. {plain-0.59.0 → plain-0.61.0}/plain/exceptions.py +0 -0
  57. {plain-0.59.0 → plain-0.61.0}/plain/forms/README.md +0 -0
  58. {plain-0.59.0 → plain-0.61.0}/plain/forms/__init__.py +0 -0
  59. {plain-0.59.0 → plain-0.61.0}/plain/forms/boundfield.py +0 -0
  60. {plain-0.59.0 → plain-0.61.0}/plain/forms/exceptions.py +0 -0
  61. {plain-0.59.0 → plain-0.61.0}/plain/forms/fields.py +0 -0
  62. {plain-0.59.0 → plain-0.61.0}/plain/forms/forms.py +0 -0
  63. {plain-0.59.0 → plain-0.61.0}/plain/http/README.md +0 -0
  64. {plain-0.59.0 → plain-0.61.0}/plain/http/__init__.py +0 -0
  65. {plain-0.59.0 → plain-0.61.0}/plain/http/cookie.py +0 -0
  66. {plain-0.59.0 → plain-0.61.0}/plain/http/multipartparser.py +0 -0
  67. {plain-0.59.0 → plain-0.61.0}/plain/http/request.py +0 -0
  68. {plain-0.59.0 → plain-0.61.0}/plain/http/response.py +0 -0
  69. {plain-0.59.0 → plain-0.61.0}/plain/internal/__init__.py +0 -0
  70. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/__init__.py +0 -0
  71. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/base.py +0 -0
  72. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/locks.py +0 -0
  73. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/move.py +0 -0
  74. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/temp.py +0 -0
  75. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/uploadedfile.py +0 -0
  76. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/uploadhandler.py +0 -0
  77. {plain-0.59.0 → plain-0.61.0}/plain/internal/files/utils.py +0 -0
  78. {plain-0.59.0 → plain-0.61.0}/plain/internal/handlers/__init__.py +0 -0
  79. {plain-0.59.0 → plain-0.61.0}/plain/internal/handlers/base.py +0 -0
  80. {plain-0.59.0 → plain-0.61.0}/plain/internal/handlers/exception.py +0 -0
  81. {plain-0.59.0 → plain-0.61.0}/plain/internal/handlers/wsgi.py +0 -0
  82. {plain-0.59.0 → plain-0.61.0}/plain/internal/middleware/__init__.py +0 -0
  83. {plain-0.59.0 → plain-0.61.0}/plain/internal/middleware/headers.py +0 -0
  84. {plain-0.59.0 → plain-0.61.0}/plain/internal/middleware/https.py +0 -0
  85. {plain-0.59.0 → plain-0.61.0}/plain/internal/middleware/slash.py +0 -0
  86. {plain-0.59.0 → plain-0.61.0}/plain/json.py +0 -0
  87. {plain-0.59.0 → plain-0.61.0}/plain/logs/README.md +0 -0
  88. {plain-0.59.0 → plain-0.61.0}/plain/logs/__init__.py +0 -0
  89. {plain-0.59.0 → plain-0.61.0}/plain/logs/configure.py +0 -0
  90. {plain-0.59.0 → plain-0.61.0}/plain/logs/loggers.py +0 -0
  91. {plain-0.59.0 → plain-0.61.0}/plain/logs/utils.py +0 -0
  92. {plain-0.59.0 → plain-0.61.0}/plain/packages/README.md +0 -0
  93. {plain-0.59.0 → plain-0.61.0}/plain/packages/__init__.py +0 -0
  94. {plain-0.59.0 → plain-0.61.0}/plain/packages/config.py +0 -0
  95. {plain-0.59.0 → plain-0.61.0}/plain/packages/registry.py +0 -0
  96. {plain-0.59.0 → plain-0.61.0}/plain/paginator.py +0 -0
  97. {plain-0.59.0 → plain-0.61.0}/plain/preflight/README.md +0 -0
  98. {plain-0.59.0 → plain-0.61.0}/plain/preflight/__init__.py +0 -0
  99. {plain-0.59.0 → plain-0.61.0}/plain/preflight/files.py +0 -0
  100. {plain-0.59.0 → plain-0.61.0}/plain/preflight/messages.py +0 -0
  101. {plain-0.59.0 → plain-0.61.0}/plain/preflight/registry.py +0 -0
  102. {plain-0.59.0 → plain-0.61.0}/plain/preflight/security.py +0 -0
  103. {plain-0.59.0 → plain-0.61.0}/plain/preflight/urls.py +0 -0
  104. {plain-0.59.0 → plain-0.61.0}/plain/runtime/README.md +0 -0
  105. {plain-0.59.0 → plain-0.61.0}/plain/runtime/__init__.py +0 -0
  106. {plain-0.59.0 → plain-0.61.0}/plain/runtime/user_settings.py +0 -0
  107. {plain-0.59.0 → plain-0.61.0}/plain/signals/README.md +0 -0
  108. {plain-0.59.0 → plain-0.61.0}/plain/signals/__init__.py +0 -0
  109. {plain-0.59.0 → plain-0.61.0}/plain/signals/dispatch/__init__.py +0 -0
  110. {plain-0.59.0 → plain-0.61.0}/plain/signals/dispatch/dispatcher.py +0 -0
  111. {plain-0.59.0 → plain-0.61.0}/plain/signals/dispatch/license.txt +0 -0
  112. {plain-0.59.0 → plain-0.61.0}/plain/signing.py +0 -0
  113. {plain-0.59.0 → plain-0.61.0}/plain/templates/README.md +0 -0
  114. {plain-0.59.0 → plain-0.61.0}/plain/templates/__init__.py +0 -0
  115. {plain-0.59.0 → plain-0.61.0}/plain/templates/core.py +0 -0
  116. {plain-0.59.0 → plain-0.61.0}/plain/templates/jinja/__init__.py +0 -0
  117. {plain-0.59.0 → plain-0.61.0}/plain/templates/jinja/environments.py +0 -0
  118. {plain-0.59.0 → plain-0.61.0}/plain/templates/jinja/extensions.py +0 -0
  119. {plain-0.59.0 → plain-0.61.0}/plain/templates/jinja/filters.py +0 -0
  120. {plain-0.59.0 → plain-0.61.0}/plain/templates/jinja/globals.py +0 -0
  121. {plain-0.59.0 → plain-0.61.0}/plain/test/README.md +0 -0
  122. {plain-0.59.0 → plain-0.61.0}/plain/test/__init__.py +0 -0
  123. {plain-0.59.0 → plain-0.61.0}/plain/test/client.py +0 -0
  124. {plain-0.59.0 → plain-0.61.0}/plain/test/encoding.py +0 -0
  125. {plain-0.59.0 → plain-0.61.0}/plain/test/exceptions.py +0 -0
  126. {plain-0.59.0 → plain-0.61.0}/plain/urls/README.md +0 -0
  127. {plain-0.59.0 → plain-0.61.0}/plain/urls/__init__.py +0 -0
  128. {plain-0.59.0 → plain-0.61.0}/plain/urls/converters.py +0 -0
  129. {plain-0.59.0 → plain-0.61.0}/plain/urls/exceptions.py +0 -0
  130. {plain-0.59.0 → plain-0.61.0}/plain/urls/patterns.py +0 -0
  131. {plain-0.59.0 → plain-0.61.0}/plain/urls/resolvers.py +0 -0
  132. {plain-0.59.0 → plain-0.61.0}/plain/urls/routers.py +0 -0
  133. {plain-0.59.0 → plain-0.61.0}/plain/urls/utils.py +0 -0
  134. {plain-0.59.0 → plain-0.61.0}/plain/utils/README.md +0 -0
  135. {plain-0.59.0 → plain-0.61.0}/plain/utils/__init__.py +0 -0
  136. {plain-0.59.0 → plain-0.61.0}/plain/utils/cache.py +0 -0
  137. {plain-0.59.0 → plain-0.61.0}/plain/utils/crypto.py +0 -0
  138. {plain-0.59.0 → plain-0.61.0}/plain/utils/datastructures.py +0 -0
  139. {plain-0.59.0 → plain-0.61.0}/plain/utils/dateparse.py +0 -0
  140. {plain-0.59.0 → plain-0.61.0}/plain/utils/deconstruct.py +0 -0
  141. {plain-0.59.0 → plain-0.61.0}/plain/utils/decorators.py +0 -0
  142. {plain-0.59.0 → plain-0.61.0}/plain/utils/duration.py +0 -0
  143. {plain-0.59.0 → plain-0.61.0}/plain/utils/encoding.py +0 -0
  144. {plain-0.59.0 → plain-0.61.0}/plain/utils/functional.py +0 -0
  145. {plain-0.59.0 → plain-0.61.0}/plain/utils/hashable.py +0 -0
  146. {plain-0.59.0 → plain-0.61.0}/plain/utils/html.py +0 -0
  147. {plain-0.59.0 → plain-0.61.0}/plain/utils/http.py +0 -0
  148. {plain-0.59.0 → plain-0.61.0}/plain/utils/inspect.py +0 -0
  149. {plain-0.59.0 → plain-0.61.0}/plain/utils/ipv6.py +0 -0
  150. {plain-0.59.0 → plain-0.61.0}/plain/utils/itercompat.py +0 -0
  151. {plain-0.59.0 → plain-0.61.0}/plain/utils/module_loading.py +0 -0
  152. {plain-0.59.0 → plain-0.61.0}/plain/utils/regex_helper.py +0 -0
  153. {plain-0.59.0 → plain-0.61.0}/plain/utils/safestring.py +0 -0
  154. {plain-0.59.0 → plain-0.61.0}/plain/utils/text.py +0 -0
  155. {plain-0.59.0 → plain-0.61.0}/plain/utils/timesince.py +0 -0
  156. {plain-0.59.0 → plain-0.61.0}/plain/utils/timezone.py +0 -0
  157. {plain-0.59.0 → plain-0.61.0}/plain/utils/tree.py +0 -0
  158. {plain-0.59.0 → plain-0.61.0}/plain/validators.py +0 -0
  159. {plain-0.59.0 → plain-0.61.0}/plain/views/README.md +0 -0
  160. {plain-0.59.0 → plain-0.61.0}/plain/views/__init__.py +0 -0
  161. {plain-0.59.0 → plain-0.61.0}/plain/views/base.py +0 -0
  162. {plain-0.59.0 → plain-0.61.0}/plain/views/errors.py +0 -0
  163. {plain-0.59.0 → plain-0.61.0}/plain/views/exceptions.py +0 -0
  164. {plain-0.59.0 → plain-0.61.0}/plain/views/forms.py +0 -0
  165. {plain-0.59.0 → plain-0.61.0}/plain/views/redirect.py +0 -0
  166. {plain-0.59.0 → plain-0.61.0}/plain/views/templates.py +0 -0
  167. {plain-0.59.0 → plain-0.61.0}/plain/wsgi.py +0 -0
  168. {plain-0.59.0 → plain-0.61.0}/tests/.gitignore +0 -0
  169. {plain-0.59.0 → plain-0.61.0}/tests/app/.gitignore +0 -0
  170. {plain-0.59.0 → plain-0.61.0}/tests/app/settings.py +0 -0
  171. {plain-0.59.0 → plain-0.61.0}/tests/app/test/__init__.py +0 -0
  172. {plain-0.59.0 → plain-0.61.0}/tests/app/test/default_settings.py +0 -0
  173. {plain-0.59.0 → plain-0.61.0}/tests/app/urls.py +0 -0
  174. {plain-0.59.0 → plain-0.61.0}/tests/conftest.py +0 -0
  175. {plain-0.59.0 → plain-0.61.0}/tests/test_csrf.py +0 -0
  176. {plain-0.59.0 → plain-0.61.0}/tests/test_runtime.py +0 -0
  177. {plain-0.59.0 → plain-0.61.0}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.59.0
3
+ Version: 0.61.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -0,0 +1,14 @@
1
+ # Plain AGENTS.md
2
+
3
+ Plain is a Python web framework that was originally forked from Django. While it still has a lot in common with Django, there are also significant changes -- don't solely rely on knowledge of Django when working with Plain.
4
+
5
+ ## Commands
6
+
7
+ The `plain` CLI is the main entrypoint for the framework. If `plain` is not available by itself, try `uv run plain`.
8
+
9
+ - `plain shell -c <command>`: Run a Python command with Plain configured.
10
+ - `plain run <filename>`: Run a Python script with Plain configured.
11
+ - `plain agent docs <package>`: Show README.md and symbolicated source files for a specific package.
12
+ - `plain agent docs --list`: List packages with docs available.
13
+ - `plain agent request <path> --user <user_id>`: Make an authenticated request to the application and inspect the output.
14
+ - `plain --help`: List all available commands (including those from installed packages)
@@ -1,5 +1,30 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.61.0](https://github.com/dropseed/plain/releases/plain@0.61.0) (2025-09-03)
4
+
5
+ ### What's changed
6
+
7
+ - Added new `plain agent` command with subcommands for coding agents including `docs`, `md`, and `request` ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
8
+ - Added `-c` option to `plain shell` to execute commands and exit, similar to `python -c` ([5e67f0b](https://github.com/dropseed/plain/commit/5e67f0bcd8))
9
+ - The `plain docs --llm` functionality has been moved to `plain agent docs` command ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
10
+ - Removed the `plain help` command in favor of standard `plain --help` ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Replace `plain docs --llm` usage with `plain agent docs` command
15
+ - Use `plain --help` instead of `plain help` command
16
+
17
+ ## [0.60.0](https://github.com/dropseed/plain/releases/plain@0.60.0) (2025-08-27)
18
+
19
+ ### What's changed
20
+
21
+ - Added new `APP_VERSION` setting that defaults to the project version from `pyproject.toml` ([57fb948d46](https://github.com/dropseed/plain/commit/57fb948d46))
22
+ - Updated `get_app_name_from_pyproject()` to `get_app_info_from_pyproject()` to return both name and version ([57fb948d46](https://github.com/dropseed/plain/commit/57fb948d46))
23
+
24
+ ### Upgrade instructions
25
+
26
+ - No changes required
27
+
3
28
  ## [0.59.0](https://github.com/dropseed/plain/releases/plain@0.59.0) (2025-08-22)
4
29
 
5
30
  ### What's changed
@@ -0,0 +1,20 @@
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", invoke_without_command=True)
9
+ @click.pass_context
10
+ def agent(ctx):
11
+ """Tools for coding agents."""
12
+ if ctx.invoked_subcommand is None:
13
+ # If no subcommand provided, show all AGENTS.md files
14
+ ctx.invoke(md, show_all=True, show_list=False, package="")
15
+
16
+
17
+ # Add commands to the group
18
+ agent.add_command(docs)
19
+ agent.add_command(md)
20
+ agent.add_command(request)
@@ -0,0 +1,80 @@
1
+ import importlib.util
2
+ import pkgutil
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from .llmdocs import LLMDocs
8
+
9
+
10
+ @click.command()
11
+ @click.argument("package", default="", required=False)
12
+ @click.option(
13
+ "--list",
14
+ "show_list",
15
+ is_flag=True,
16
+ help="List available packages",
17
+ )
18
+ def docs(package, show_list):
19
+ """Show LLM-friendly documentation and source for a package."""
20
+
21
+ if show_list:
22
+ # List available packages using same discovery logic as md command
23
+ try:
24
+ available_packages = []
25
+
26
+ # Check for plain.* subpackages (including core plain)
27
+ try:
28
+ import plain
29
+
30
+ # Check core plain package (namespace package)
31
+ plain_spec = importlib.util.find_spec("plain")
32
+ if plain_spec and plain_spec.submodule_search_locations:
33
+ available_packages.append("plain")
34
+
35
+ # Check other plain.* subpackages
36
+ for importer, modname, ispkg in pkgutil.iter_modules(
37
+ plain.__path__, "plain."
38
+ ):
39
+ if ispkg:
40
+ available_packages.append(modname)
41
+ except Exception:
42
+ pass
43
+
44
+ if available_packages:
45
+ for pkg in sorted(available_packages):
46
+ click.echo(f"- {pkg}")
47
+ else:
48
+ click.echo("No packages found.")
49
+ except Exception as e:
50
+ click.echo(f"Error listing packages: {e}")
51
+ return
52
+
53
+ if not package:
54
+ raise click.UsageError(
55
+ "Package name required. Usage: plain agent docs [package-name]"
56
+ )
57
+
58
+ # Convert hyphens to dots (e.g., plain-models -> plain.models)
59
+ package = package.replace("-", ".")
60
+
61
+ # Automatically prefix if we need to
62
+ if not package.startswith("plain"):
63
+ package = f"plain.{package}"
64
+
65
+ try:
66
+ # Get the path for this specific package
67
+ spec = importlib.util.find_spec(package)
68
+ if not spec or not spec.origin:
69
+ raise click.UsageError(f"Package {package} not found")
70
+
71
+ package_path = Path(spec.origin).parent
72
+ paths = [package_path]
73
+
74
+ # Generate docs for this specific package
75
+ source_docs = LLMDocs(paths)
76
+ source_docs.load()
77
+ source_docs.print(relative_to=package_path.parent)
78
+
79
+ except Exception as e:
80
+ raise click.UsageError(f"Error loading documentation for {package}: {e}")
@@ -1,75 +1,11 @@
1
1
  import ast
2
- import importlib.util
3
2
  from pathlib import Path
4
3
 
5
4
  import click
6
5
 
7
- from plain.packages import packages_registry
8
-
9
- from .output import iterate_markdown
10
-
11
-
12
- @click.command()
13
- @click.option("--llm", "llm", is_flag=True)
14
- @click.option("--open")
15
- @click.argument("module", default="")
16
- def docs(module, llm, open):
17
- if not module and not llm:
18
- raise click.UsageError("You must specify a module or use --llm")
19
-
20
- if llm:
21
- paths = [Path(__file__).parent.parent]
22
-
23
- for package_config in packages_registry.get_package_configs():
24
- if package_config.name.startswith("app."):
25
- # Ignore app packages for now
26
- continue
27
-
28
- paths.append(Path(package_config.path))
29
-
30
- source_docs = LLMDocs(paths)
31
- source_docs.load()
32
- source_docs.print()
33
-
34
- click.secho(
35
- "That's everything! Copy this into your AI tool of choice.",
36
- err=True,
37
- fg="green",
38
- )
39
-
40
- return
41
-
42
- if module:
43
- # Convert hyphens to dots (e.g., plain-models -> plain.models)
44
- module = module.replace("-", ".")
45
-
46
- # Automatically prefix if we need to
47
- if not module.startswith("plain"):
48
- module = f"plain.{module}"
49
-
50
- # Get the README.md file for the module
51
- spec = importlib.util.find_spec(module)
52
- if not spec:
53
- raise click.UsageError(f"Module {module} not found")
54
-
55
- module_path = Path(spec.origin).parent
56
- readme_path = module_path / "README.md"
57
- if not readme_path.exists():
58
- raise click.UsageError(f"README.md not found for {module}")
59
-
60
- if open:
61
- click.launch(str(readme_path))
62
- else:
63
- click.echo_via_pager(iterate_markdown(readme_path.read_text()))
64
-
65
6
 
66
7
  class LLMDocs:
67
- preamble = (
68
- "Below is all of the documentation and abbreviated source code for the Plain web framework. "
69
- "Your job is to read and understand it, and then act as the Plain Framework Assistant and "
70
- "help the developer accomplish whatever they want to do next."
71
- "\n\n---\n\n"
72
- )
8
+ """Generates LLM-friendly documentation."""
73
9
 
74
10
  def __init__(self, paths):
75
11
  self.paths = paths
@@ -88,6 +24,7 @@ class LLMDocs:
88
24
  self.docs.add(path)
89
25
 
90
26
  # Exclude "migrations" code from plain apps, except for plain/models/migrations
27
+ # Also exclude CHANGELOG.md and AGENTS.md
91
28
  self.docs = {
92
29
  doc
93
30
  for doc in self.docs
@@ -95,6 +32,7 @@ class LLMDocs:
95
32
  "/migrations/" in str(doc)
96
33
  and "/plain/models/migrations/" not in str(doc)
97
34
  )
35
+ and doc.name not in ("CHANGELOG.md", "AGENTS.md")
98
36
  }
99
37
  self.sources = {
100
38
  source
@@ -103,6 +41,7 @@ class LLMDocs:
103
41
  "/migrations/" in str(source)
104
42
  and "/plain/models/migrations/" not in str(source)
105
43
  )
44
+ and source.name != "cli.py"
106
45
  }
107
46
 
108
47
  self.docs = sorted(self.docs)
@@ -120,8 +59,6 @@ class LLMDocs:
120
59
  return path.relative_to(plain_root.parent)
121
60
 
122
61
  def print(self, relative_to=None):
123
- click.secho(self.preamble, fg="yellow")
124
-
125
62
  for doc in self.docs:
126
63
  if relative_to:
127
64
  display_path = doc.relative_to(relative_to)
@@ -159,14 +96,11 @@ class LLMDocs:
159
96
  for d in node.decorator_list
160
97
  ):
161
98
  return True
162
- if node.name.startswith("_"): # and not node.name.endswith("__"):
99
+ if node.name.startswith("_"):
163
100
  return True
164
101
  elif isinstance(node, ast.Assign):
165
102
  for target in node.targets:
166
- if (
167
- isinstance(target, ast.Name) and target.id.startswith("_")
168
- # and not target.id.endswith("__")
169
- ):
103
+ if isinstance(target, ast.Name) and target.id.startswith("_"):
170
104
  return True
171
105
  return False
172
106
 
@@ -186,23 +120,16 @@ class LLMDocs:
186
120
  lines.extend(decorators)
187
121
  bases = [ast.unparse(base) for base in node.bases]
188
122
  lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
189
- # if ast.get_docstring(node):
190
- # lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
191
123
  for child in node.body:
192
124
  child_lines = process_node(child, indent + 1)
193
125
  if child_lines:
194
126
  lines.extend(child_lines)
195
- # if not has_body:
196
- # lines.append(f"{prefix} pass")
197
127
 
198
128
  elif isinstance(node, ast.FunctionDef):
199
129
  decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
200
130
  lines.extend(decorators)
201
131
  args = ast.unparse(node.args)
202
132
  lines.append(f"{prefix}def {node.name}({args})")
203
- # if ast.get_docstring(node):
204
- # lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
205
- # lines.append(f"{prefix} pass")
206
133
 
207
134
  elif isinstance(node, ast.Assign):
208
135
  for target in node.targets:
@@ -0,0 +1,87 @@
1
+ import importlib.util
2
+ import pkgutil
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from ..output import iterate_markdown
8
+
9
+
10
+ def _get_packages_with_agents():
11
+ """Get dict mapping package names to AGENTS.md paths."""
12
+ agents_files = {}
13
+
14
+ # Check for plain.* subpackages (including core plain)
15
+ try:
16
+ import plain
17
+
18
+ # Check core plain package (namespace package)
19
+ plain_spec = importlib.util.find_spec("plain")
20
+ if plain_spec and plain_spec.submodule_search_locations:
21
+ # For namespace packages, use the first search location
22
+ plain_path = Path(plain_spec.submodule_search_locations[0])
23
+ agents_path = plain_path / "AGENTS.md"
24
+ if agents_path.exists():
25
+ agents_files["plain"] = agents_path
26
+
27
+ # Check other plain.* subpackages
28
+ for importer, modname, ispkg in pkgutil.iter_modules(plain.__path__, "plain."):
29
+ if ispkg:
30
+ try:
31
+ spec = importlib.util.find_spec(modname)
32
+ if spec and spec.origin:
33
+ package_path = Path(spec.origin).parent
34
+ # Look for AGENTS.md at package root
35
+ agents_path = package_path / "AGENTS.md"
36
+ if agents_path.exists():
37
+ agents_files[modname] = agents_path
38
+ except Exception:
39
+ continue
40
+ except Exception:
41
+ pass
42
+
43
+ return agents_files
44
+
45
+
46
+ @click.command("md")
47
+ @click.argument("package", default="", required=False)
48
+ @click.option(
49
+ "--all",
50
+ "show_all",
51
+ is_flag=True,
52
+ help="Show AGENTS.md for all packages that have them",
53
+ )
54
+ @click.option(
55
+ "--list",
56
+ "show_list",
57
+ is_flag=True,
58
+ help="List packages with AGENTS.md files",
59
+ )
60
+ def md(package, show_all, show_list):
61
+ """Show AGENTS.md for a package."""
62
+
63
+ agents_files = _get_packages_with_agents()
64
+
65
+ if show_list:
66
+ for pkg in sorted(agents_files.keys()):
67
+ click.echo(f"- {pkg}")
68
+
69
+ return
70
+
71
+ if show_all:
72
+ for pkg in sorted(agents_files.keys()):
73
+ agents_path = agents_files[pkg]
74
+ for line in iterate_markdown(agents_path.read_text()):
75
+ click.echo(line, nl=False)
76
+ print()
77
+
78
+ return
79
+
80
+ if not package:
81
+ raise click.UsageError(
82
+ "Package name or --all required. Use --list to see available packages."
83
+ )
84
+
85
+ agents_path = agents_files[package]
86
+ for line in iterate_markdown(agents_path.read_text()):
87
+ click.echo(line, nl=False)
@@ -5,24 +5,19 @@ import subprocess
5
5
  import click
6
6
 
7
7
 
8
+ def is_agent_environment():
9
+ """Check if we're running inside a coding agent."""
10
+ return bool(
11
+ os.environ.get("CLAUDECODE")
12
+ or os.environ.get("CODEX_SANDBOX")
13
+ or os.environ.get("CURSOR_ENVIRONMENT")
14
+ )
15
+
16
+
8
17
  def prompt_agent(
9
18
  prompt: str, agent_command: str | None = None, print_only: bool = False
10
19
  ) -> bool:
11
- """
12
- Run an agent command with the given prompt, or display the prompt for manual copying.
13
-
14
- Args:
15
- prompt: The prompt to send to the agent
16
- agent_command: Optional command to run (e.g., "claude code"). If not provided,
17
- will check the PLAIN_AGENT_COMMAND environment variable.
18
- print_only: If True, always print the prompt instead of running the agent
19
-
20
- Returns:
21
- True if the agent command succeeded (or no agent command was provided),
22
- False if the agent command failed.
23
- """
24
- # Check if running inside an agent and just print the prompt if so
25
- if os.environ.get("CLAUDECODE") or os.environ.get("CODEX_SANDBOX"):
20
+ if is_agent_environment():
26
21
  click.echo(prompt)
27
22
  return True
28
23
 
@@ -0,0 +1,181 @@
1
+ import json
2
+
3
+ import click
4
+
5
+ from plain.runtime import settings
6
+ from plain.test import Client
7
+
8
+
9
+ @click.command()
10
+ @click.argument("path")
11
+ @click.option(
12
+ "--method",
13
+ default="GET",
14
+ help="HTTP method (GET, POST, PUT, PATCH, DELETE, etc.)",
15
+ )
16
+ @click.option(
17
+ "--data",
18
+ help="Request data (JSON string for POST/PUT/PATCH)",
19
+ )
20
+ @click.option(
21
+ "--user",
22
+ "user_id",
23
+ help="User ID to authenticate as (skips normal authentication)",
24
+ )
25
+ @click.option(
26
+ "--follow/--no-follow",
27
+ default=True,
28
+ help="Follow redirects (default: True)",
29
+ )
30
+ @click.option(
31
+ "--content-type",
32
+ help="Content-Type header for request data",
33
+ )
34
+ @click.option(
35
+ "--header",
36
+ "headers",
37
+ multiple=True,
38
+ help="Additional headers (format: 'Name: Value')",
39
+ )
40
+ def request(path, method, data, user_id, follow, content_type, headers):
41
+ """Make an HTTP request using the test client against the development database."""
42
+
43
+ try:
44
+ # Only allow in DEBUG mode for security
45
+ if not settings.DEBUG:
46
+ click.secho("This command only works when DEBUG=True", fg="red", err=True)
47
+ return
48
+
49
+ # Temporarily add testserver to ALLOWED_HOSTS so the test client can make requests
50
+ original_allowed_hosts = settings.ALLOWED_HOSTS
51
+ settings.ALLOWED_HOSTS = ["*"]
52
+
53
+ try:
54
+ # Create test client
55
+ client = Client()
56
+
57
+ # If user_id provided, force login
58
+ if user_id:
59
+ try:
60
+ # Get the User model using plain.auth utility
61
+ from plain.auth import get_user_model
62
+
63
+ User = get_user_model()
64
+
65
+ # Get the user
66
+ try:
67
+ user = User.objects.get(id=user_id)
68
+ client.force_login(user)
69
+ click.secho(
70
+ f"Authenticated as user {user_id}", fg="green", dim=True
71
+ )
72
+ except User.DoesNotExist:
73
+ click.secho(f"User {user_id} not found", fg="red", err=True)
74
+ return
75
+
76
+ except Exception as e:
77
+ click.secho(f"Authentication error: {e}", fg="red", err=True)
78
+ return
79
+
80
+ # Parse additional headers
81
+ header_dict = {}
82
+ for header in headers:
83
+ if ":" in header:
84
+ key, value = header.split(":", 1)
85
+ header_dict[key.strip()] = value.strip()
86
+
87
+ # Prepare request data
88
+ if data and content_type and "json" in content_type.lower():
89
+ try:
90
+ # Validate JSON
91
+ json.loads(data)
92
+ except json.JSONDecodeError as e:
93
+ click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
94
+ return
95
+
96
+ # Make the request
97
+ method = method.upper()
98
+ kwargs = {
99
+ "path": path,
100
+ "follow": follow,
101
+ "headers": header_dict or None,
102
+ }
103
+
104
+ if method in ("POST", "PUT", "PATCH") and data:
105
+ kwargs["data"] = data
106
+ if content_type:
107
+ kwargs["content_type"] = content_type
108
+
109
+ # Call the appropriate client method
110
+ if method == "GET":
111
+ response = client.get(**kwargs)
112
+ elif method == "POST":
113
+ response = client.post(**kwargs)
114
+ elif method == "PUT":
115
+ response = client.put(**kwargs)
116
+ elif method == "PATCH":
117
+ response = client.patch(**kwargs)
118
+ elif method == "DELETE":
119
+ response = client.delete(**kwargs)
120
+ elif method == "HEAD":
121
+ response = client.head(**kwargs)
122
+ elif method == "OPTIONS":
123
+ response = client.options(**kwargs)
124
+ elif method == "TRACE":
125
+ response = client.trace(**kwargs)
126
+ else:
127
+ click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
128
+ return
129
+
130
+ # Display response information
131
+ click.secho(
132
+ f"HTTP {response.status_code}",
133
+ fg="green" if response.status_code < 400 else "red",
134
+ bold=True,
135
+ )
136
+
137
+ # Show additional response info first
138
+ if hasattr(response, "user"):
139
+ click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
140
+
141
+ if hasattr(response, "resolver_match") and response.resolver_match:
142
+ match = response.resolver_match
143
+ url_name = match.namespaced_url_name or match.url_name or "unnamed"
144
+ click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
145
+
146
+ # Show headers
147
+ if response.headers:
148
+ click.secho("Response Headers:", fg="yellow", bold=True)
149
+ for key, value in response.headers.items():
150
+ click.echo(f" {key}: {value}")
151
+ click.echo()
152
+
153
+ # Show response content last
154
+ if response.content:
155
+ content_type = response.headers.get("Content-Type", "")
156
+
157
+ if "json" in content_type.lower():
158
+ try:
159
+ json_data = response.json()
160
+ click.secho("Response Body (JSON):", fg="yellow", bold=True)
161
+ click.echo(json.dumps(json_data, indent=2))
162
+ except Exception:
163
+ click.secho("Response Body:", fg="yellow", bold=True)
164
+ click.echo(response.content.decode("utf-8", errors="replace"))
165
+ elif "html" in content_type.lower():
166
+ click.secho("Response Body (HTML):", fg="yellow", bold=True)
167
+ content = response.content.decode("utf-8", errors="replace")
168
+ click.echo(content)
169
+ else:
170
+ click.secho("Response Body:", fg="yellow", bold=True)
171
+ content = response.content.decode("utf-8", errors="replace")
172
+ click.echo(content)
173
+ else:
174
+ click.secho("(No response body)", fg="yellow", dim=True)
175
+
176
+ finally:
177
+ # Restore original ALLOWED_HOSTS
178
+ settings.ALLOWED_HOSTS = original_allowed_hosts
179
+
180
+ except Exception as e:
181
+ click.secho(f"Request failed: {e}", fg="red", err=True)
@@ -6,12 +6,12 @@ from click.core import Command, Context
6
6
  import plain.runtime
7
7
  from plain.exceptions import ImproperlyConfigured
8
8
 
9
+ from .agent import agent
9
10
  from .build import build
10
11
  from .changelog import changelog
11
12
  from .chores import chores
12
13
  from .docs import docs
13
14
  from .formatting import PlainContext
14
- from .help import help_cmd
15
15
  from .install import install
16
16
  from .preflight import preflight_checks
17
17
  from .registry import cli_registry
@@ -28,6 +28,7 @@ def plain_cli():
28
28
  pass
29
29
 
30
30
 
31
+ plain_cli.add_command(agent)
31
32
  plain_cli.add_command(docs)
32
33
  plain_cli.add_command(preflight_checks)
33
34
  plain_cli.add_command(create)
@@ -41,7 +42,6 @@ plain_cli.add_command(shell)
41
42
  plain_cli.add_command(run)
42
43
  plain_cli.add_command(install)
43
44
  plain_cli.add_command(upgrade)
44
- plain_cli.add_command(help_cmd)
45
45
 
46
46
 
47
47
  class CLIRegistryGroup(click.Group):
@@ -0,0 +1,38 @@
1
+ import importlib.util
2
+ from pathlib import Path
3
+
4
+ import click
5
+
6
+ from .output import iterate_markdown
7
+
8
+
9
+ @click.command()
10
+ @click.option("--open")
11
+ @click.argument("module", default="")
12
+ def docs(module, open):
13
+ if not module:
14
+ raise click.UsageError(
15
+ "You must specify a module. For LLM-friendly docs, use `plain agent docs`."
16
+ )
17
+
18
+ # Convert hyphens to dots (e.g., plain-models -> plain.models)
19
+ module = module.replace("-", ".")
20
+
21
+ # Automatically prefix if we need to
22
+ if not module.startswith("plain"):
23
+ module = f"plain.{module}"
24
+
25
+ # Get the README.md file for the module
26
+ spec = importlib.util.find_spec(module)
27
+ if not spec:
28
+ raise click.UsageError(f"Module {module} not found")
29
+
30
+ module_path = Path(spec.origin).parent
31
+ readme_path = module_path / "README.md"
32
+ if not readme_path.exists():
33
+ raise click.UsageError(f"README.md not found for {module}")
34
+
35
+ if open:
36
+ click.launch(str(readme_path))
37
+ else:
38
+ click.echo_via_pager(iterate_markdown(readme_path.read_text()))
@@ -3,7 +3,7 @@ import sys
3
3
 
4
4
  import click
5
5
 
6
- from .agent import prompt_agent
6
+ from .agent.prompt import prompt_agent
7
7
 
8
8
 
9
9
  @click.command()