plain 0.68.0__py3-none-any.whl → 0.101.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. plain/CHANGELOG.md +656 -1
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -36
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +110 -26
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -8
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +13 -5
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +38 -22
  145. plain/urls/resolvers.py +35 -25
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/chores/registry.py CHANGED
@@ -1,42 +1,33 @@
1
- from plain.packages import packages_registry
2
-
1
+ from __future__ import annotations
3
2
 
4
- class Chore:
5
- def __init__(self, *, group, func):
6
- self.group = group
7
- self.func = func
8
- self.name = f"{group}.{func.__name__}"
9
- self.description = func.__doc__.strip() if func.__doc__ else ""
3
+ from plain.packages import packages_registry
10
4
 
11
- def __str__(self):
12
- return self.name
13
-
14
- def run(self):
15
- """
16
- Run the chore.
17
- """
18
- return self.func()
5
+ from .core import Chore
19
6
 
20
7
 
21
8
  class ChoresRegistry:
22
- def __init__(self):
23
- self._chores = {}
9
+ def __init__(self) -> None:
10
+ self._chores: dict[str, type[Chore]] = {}
24
11
 
25
- def register_chore(self, chore):
12
+ def register_chore(self, chore_class: type[Chore]) -> None:
26
13
  """
27
- Register a chore with the specified name.
14
+ Register a chore class.
15
+
16
+ Args:
17
+ chore_class: A Chore subclass to register
28
18
  """
29
- self._chores[chore.func] = chore
19
+ name = f"{chore_class.__module__}.{chore_class.__qualname__}"
20
+ self._chores[name] = chore_class
30
21
 
31
- def import_modules(self):
22
+ def import_modules(self) -> None:
32
23
  """
33
24
  Import modules from installed packages and app to trigger registration.
34
25
  """
35
26
  packages_registry.autodiscover_modules("chores", include_app=True)
36
27
 
37
- def get_chores(self):
28
+ def get_chores(self) -> list[type[Chore]]:
38
29
  """
39
- Get all registered chores.
30
+ Get all registered chore classes.
40
31
  """
41
32
  return list(self._chores.values())
42
33
 
@@ -44,19 +35,15 @@ class ChoresRegistry:
44
35
  chores_registry = ChoresRegistry()
45
36
 
46
37
 
47
- def register_chore(group):
38
+ def register_chore(cls: type[Chore]) -> type[Chore]:
48
39
  """
49
- Register a chore with a given group.
40
+ Decorator to register a chore class.
50
41
 
51
42
  Usage:
52
- @register_chore("clear_expired")
53
- def clear_expired():
54
- pass
43
+ @register_chore
44
+ class ClearExpired(Chore):
45
+ def run(self):
46
+ return "Done!"
55
47
  """
56
-
57
- def wrapper(func):
58
- chore = Chore(group=group, func=func)
59
- chores_registry.register_chore(chore)
60
- return func
61
-
62
- return wrapper
48
+ chores_registry.register_chore(cls)
49
+ return cls
plain/cli/README.md CHANGED
@@ -1,41 +1,210 @@
1
- # CLI
1
+ # plain.cli
2
2
 
3
- **The `plain` CLI and how to add your own commands to it.**
3
+ **The `plain` command-line interface and tools for adding custom commands.**
4
4
 
5
5
  - [Overview](#overview)
6
6
  - [Adding commands](#adding-commands)
7
+ - [Register a command group](#register-a-command-group)
8
+ - [Register a shortcut command](#register-a-shortcut-command)
9
+ - [Mark commands as common](#mark-commands-as-common)
10
+ - [Shell](#shell)
11
+ - [Run a script with app context](#run-a-script-with-app-context)
12
+ - [SHELL_IMPORT](#shell_import)
13
+ - [Built-in commands](#built-in-commands)
14
+ - [FAQs](#faqs)
15
+ - [Installation](#installation)
7
16
 
8
17
  ## Overview
9
18
 
10
- Commands are written using [Click](https://click.palletsprojects.com/en/8.1.x/)
11
- (one of Plain's few dependencies),
12
- which has been one of those most popular CLI frameworks in Python for a long time.
19
+ The `plain` CLI provides commands for running your app, managing databases, starting shells, and more. You can also add your own commands using the [`register_cli`](./registry.py#register_cli) decorator.
13
20
 
14
- ## Adding commands
15
-
16
- The [`register_cli`](./registry.py#register_cli) decorator can be used to add your own commands to the `plain` CLI.
21
+ Commands are written using [Click](https://click.palletsprojects.com/), a popular Python CLI framework that is one of Plain's few dependencies.
17
22
 
18
23
  ```python
19
24
  import click
20
25
  from plain.cli import register_cli
21
26
 
22
27
 
23
- @register_cli("example-subgroup-name")
28
+ @register_cli("hello")
29
+ @click.command()
30
+ def cli():
31
+ """Say hello"""
32
+ click.echo("Hello from my custom command!")
33
+ ```
34
+
35
+ After defining this command, you can run it with `plain hello`:
36
+
37
+ ```bash
38
+ $ plain hello
39
+ Hello from my custom command!
40
+ ```
41
+
42
+ ## Adding commands
43
+
44
+ You can register commands from anywhere, but Plain will automatically import `cli.py` modules from your app and installed packages. The most common locations are:
45
+
46
+ - `app/cli.py` for app-specific commands
47
+ - `<package>/cli.py` for package-specific commands
48
+
49
+ ### Register a command group
50
+
51
+ Use [`@register_cli`](./registry.py#register_cli) with a Click group to create subcommands:
52
+
53
+ ```python
54
+ @register_cli("users")
24
55
  @click.group()
25
56
  def cli():
26
- """Custom example commands"""
57
+ """User management commands"""
27
58
  pass
28
59
 
60
+
61
+ @cli.command()
62
+ @click.argument("email")
63
+ def create(email):
64
+ """Create a new user"""
65
+ click.echo(f"Creating user: {email}")
66
+
67
+
29
68
  @cli.command()
30
- def example_command():
31
- click.echo("An example command!")
69
+ def list():
70
+ """List all users"""
71
+ click.echo("Listing users...")
32
72
  ```
33
73
 
34
- Then you can run the command with `plain`.
74
+ This creates `plain users create` and `plain users list` commands.
75
+
76
+ ### Register a shortcut command
77
+
78
+ Some commands are used frequently enough to warrant a top-level shortcut. You can indicate that a command is a shortcut for a subcommand by passing `shortcut_for`:
79
+
80
+ ```python
81
+ @register_cli("migrate", shortcut_for="models")
82
+ @click.command()
83
+ def migrate():
84
+ """Run database migrations"""
85
+ # ...
86
+ ```
87
+
88
+ This makes `plain migrate` available as a shortcut for `plain models migrate`. The shortcut relationship is shown in help output.
89
+
90
+ ### Mark commands as common
91
+
92
+ Use the [`common_command`](./runtime.py#common_command) decorator to highlight frequently used commands in help output:
93
+
94
+ ```python
95
+ from plain.cli import register_cli
96
+ from plain.cli.runtime import common_command
97
+
98
+
99
+ @register_cli("dev")
100
+ @common_command
101
+ @click.command()
102
+ def dev():
103
+ """Start development server"""
104
+ # ...
105
+ ```
106
+
107
+ Common commands appear in a separate "Common Commands" section when running `plain --help`.
108
+
109
+ ## Shell
110
+
111
+ The `plain shell` command starts an interactive Python shell with your Plain app already loaded.
35
112
 
36
113
  ```bash
37
- $ plain example-subgroup-name example-command
38
- An example command!
114
+ $ plain shell
39
115
  ```
40
116
 
41
- Technically you can register a CLI from anywhere, but typically you will do it in either `app/cli.py` or a package's `<pkg>/cli.py`, as those modules will be autoloaded by Plain.
117
+ If you have IPython installed, it will be used automatically. You can also specify an interface explicitly:
118
+
119
+ ```bash
120
+ $ plain shell --interface ipython
121
+ $ plain shell --interface bpython
122
+ $ plain shell --interface python
123
+ ```
124
+
125
+ For one-off commands, use the `-c` flag:
126
+
127
+ ```bash
128
+ $ plain shell -c "from app.users.models import User; print(User.query.count())"
129
+ ```
130
+
131
+ ### Run a script with app context
132
+
133
+ The `plain run` command executes a Python script with your app context already set up:
134
+
135
+ ```bash
136
+ $ plain run scripts/import_data.py
137
+ ```
138
+
139
+ This is useful for one-off scripts that need access to your models and settings.
140
+
141
+ ### SHELL_IMPORT
142
+
143
+ Customize what gets imported automatically when the shell starts by setting `SHELL_IMPORT` in your settings:
144
+
145
+ ```python
146
+ # app/settings.py
147
+ SHELL_IMPORT = "app.shell"
148
+ ```
149
+
150
+ Then create that module with the objects you want available:
151
+
152
+ ```python
153
+ # app/shell.py
154
+ from app.projects.models import Project
155
+ from app.users.models import User
156
+
157
+ __all__ = ["Project", "User"]
158
+ ```
159
+
160
+ Now when you run `plain shell`, those objects will be automatically imported and available.
161
+
162
+ ## Built-in commands
163
+
164
+ Plain includes several built-in commands:
165
+
166
+ | Command | Description |
167
+ | --------------------- | ---------------------------------------- |
168
+ | `plain shell` | Interactive Python shell |
169
+ | `plain run <script>` | Execute a Python script with app context |
170
+ | `plain server` | Production-ready WSGI server |
171
+ | `plain preflight` | Validation checks before deployment |
172
+ | `plain create <name>` | Create a new local package |
173
+ | `plain settings` | View current settings |
174
+ | `plain urls` | List all URL patterns |
175
+ | `plain docs` | View package documentation |
176
+ | `plain build` | Run build commands |
177
+ | `plain install` | Install package dependencies |
178
+ | `plain upgrade` | Upgrade Plain packages |
179
+
180
+ Additional commands are added by installed packages (like `plain models migrate` from plain.models).
181
+
182
+ ## FAQs
183
+
184
+ #### How do I run commands that don't need the app to be set up?
185
+
186
+ Use the [`without_runtime_setup`](./runtime.py#without_runtime_setup) decorator for commands that don't need access to settings or app code. This is useful for commands that fork processes (like `server`) where setup should happen in the worker process:
187
+
188
+ ```python
189
+ from plain.cli.runtime import without_runtime_setup
190
+
191
+
192
+ @without_runtime_setup
193
+ @click.command()
194
+ def server():
195
+ """Start the server"""
196
+ # Setup happens in the worker process, not here
197
+ # ...
198
+ ```
199
+
200
+ #### Where should I put my custom commands?
201
+
202
+ Put app-specific commands in `app/cli.py`. Plain will automatically import this module. If you're building a reusable package, put commands in `<package>/cli.py`.
203
+
204
+ #### Can I use argparse instead of Click?
205
+
206
+ No, Plain's CLI is built on Click and the registration system expects Click commands. However, Click is well-documented and provides a better developer experience than argparse for most use cases.
207
+
208
+ ## Installation
209
+
210
+ The CLI is included with Plain. No additional installation is required.
plain/cli/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
1
  from .registry import register_cli
2
+ from .runtime import common_command
2
3
 
3
- __all__ = ["register_cli"]
4
+ __all__ = ["register_cli", "common_command"]
plain/cli/agent.py ADDED
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import json
5
+ import pkgutil
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+
12
+ def _get_packages_with_skills() -> dict[str, list[Path]]:
13
+ """Get dict mapping package names to lists of skill directory paths.
14
+
15
+ Each skill is a directory containing a SKILL.md file.
16
+ """
17
+ skills_dirs: dict[str, list[Path]] = {}
18
+
19
+ # Check for plain.* subpackages (including core plain)
20
+ try:
21
+ import plain
22
+
23
+ # Check core plain package (namespace package)
24
+ plain_spec = importlib.util.find_spec("plain")
25
+ if plain_spec and plain_spec.submodule_search_locations:
26
+ # For namespace packages, check all search locations
27
+ for location in plain_spec.submodule_search_locations:
28
+ plain_path = Path(location)
29
+ skills_dir = plain_path / "skills"
30
+ if skills_dir.exists() and skills_dir.is_dir():
31
+ # Find subdirectories that contain SKILL.md
32
+ skill_dirs = [
33
+ d
34
+ for d in skills_dir.iterdir()
35
+ if d.is_dir() and (d / "SKILL.md").exists()
36
+ ]
37
+ if skill_dirs:
38
+ skills_dirs["plain"] = skill_dirs
39
+ break # Use the first one found
40
+
41
+ # Check other plain.* subpackages
42
+ if hasattr(plain, "__path__"):
43
+ for importer, modname, ispkg in pkgutil.iter_modules(
44
+ plain.__path__, "plain."
45
+ ):
46
+ if ispkg:
47
+ try:
48
+ spec = importlib.util.find_spec(modname)
49
+ if spec and spec.origin:
50
+ package_path = Path(spec.origin).parent
51
+ # Look for skills/ directory at package root
52
+ skills_dir = package_path / "skills"
53
+ if skills_dir.exists() and skills_dir.is_dir():
54
+ # Find subdirectories that contain SKILL.md
55
+ skill_dirs = [
56
+ d
57
+ for d in skills_dir.iterdir()
58
+ if d.is_dir() and (d / "SKILL.md").exists()
59
+ ]
60
+ if skill_dirs:
61
+ skills_dirs[modname] = skill_dirs
62
+ except Exception:
63
+ continue
64
+ except Exception:
65
+ pass
66
+
67
+ return skills_dirs
68
+
69
+
70
+ def _get_skill_destinations() -> list[Path]:
71
+ """Get list of skill directories to install to based on what's present."""
72
+ cwd = Path.cwd()
73
+ destinations = []
74
+
75
+ # Check for Claude (.claude/ directory)
76
+ if (cwd / ".claude").exists():
77
+ destinations.append(cwd / ".claude" / "skills")
78
+
79
+ # Check for Codex (.codex/ directory)
80
+ if (cwd / ".codex").exists():
81
+ destinations.append(cwd / ".codex" / "skills")
82
+
83
+ return destinations
84
+
85
+
86
+ def _install_skills_to(
87
+ dest_skills_dir: Path, skills_by_package: dict[str, list[Path]]
88
+ ) -> tuple[int, int]:
89
+ """Install skills to a destination directory. Returns (installed_count, removed_count)."""
90
+ dest_skills_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ # Collect all source skill names
93
+ source_skill_names: set[str] = set()
94
+ for skill_dirs in skills_by_package.values():
95
+ for skill_dir in skill_dirs:
96
+ source_skill_names.add(skill_dir.name)
97
+
98
+ installed_count = 0
99
+ removed_count = 0
100
+
101
+ # Remove orphaned plain-* skills (exist in dest but not in source)
102
+ # Only remove skills with plain- prefix to preserve user-created skills
103
+ if dest_skills_dir.exists():
104
+ for dest_dir in dest_skills_dir.iterdir():
105
+ if (
106
+ dest_dir.is_dir()
107
+ and dest_dir.name.startswith("plain-")
108
+ and dest_dir.name not in source_skill_names
109
+ ):
110
+ shutil.rmtree(dest_dir)
111
+ removed_count += 1
112
+
113
+ for pkg_name in sorted(skills_by_package.keys()):
114
+ for skill_dir in skills_by_package[pkg_name]:
115
+ dest_dir = dest_skills_dir / skill_dir.name
116
+ source_skill_file = skill_dir / "SKILL.md"
117
+
118
+ # Check if we need to copy (mtime checking)
119
+ if dest_dir.exists():
120
+ dest_skill_file = dest_dir / "SKILL.md"
121
+ if dest_skill_file.exists():
122
+ source_mtime = source_skill_file.stat().st_mtime
123
+ dest_mtime = dest_skill_file.stat().st_mtime
124
+ if source_mtime <= dest_mtime:
125
+ continue
126
+
127
+ # Copy the entire skill directory
128
+ if dest_dir.exists():
129
+ shutil.rmtree(dest_dir)
130
+ shutil.copytree(skill_dir, dest_dir)
131
+ installed_count += 1
132
+
133
+ return installed_count, removed_count
134
+
135
+
136
+ def _setup_session_hook(dest_dir: Path) -> None:
137
+ """Create or update settings.json with SessionStart hook."""
138
+ settings_file = dest_dir / "settings.json"
139
+
140
+ # Load existing settings or start fresh
141
+ if settings_file.exists():
142
+ settings = json.loads(settings_file.read_text())
143
+ else:
144
+ settings = {}
145
+
146
+ # Ensure hooks structure exists
147
+ if "hooks" not in settings:
148
+ settings["hooks"] = {}
149
+
150
+ # Define the Plain hook - calls the agent context command directly
151
+ plain_hook = {
152
+ "matcher": "startup|resume",
153
+ "hooks": [
154
+ {
155
+ "type": "command",
156
+ "command": "uv run plain agent context 2>/dev/null || true",
157
+ }
158
+ ],
159
+ }
160
+
161
+ # Get existing SessionStart hooks, remove any existing plain hook
162
+ session_hooks = settings["hooks"].get("SessionStart", [])
163
+ session_hooks = [h for h in session_hooks if "plain agent" not in str(h)]
164
+ # Also remove old plain-context.md hooks for migration
165
+ session_hooks = [h for h in session_hooks if "plain-context.md" not in str(h)]
166
+ session_hooks.append(plain_hook)
167
+ settings["hooks"]["SessionStart"] = session_hooks
168
+
169
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
170
+
171
+
172
+ @click.group()
173
+ def agent() -> None:
174
+ """AI agent integration for Plain projects"""
175
+ pass
176
+
177
+
178
+ @agent.command()
179
+ def context() -> None:
180
+ """Output Plain framework context for AI agents"""
181
+ click.echo("This is a Plain project. Use the /plain-* skills for common tasks.")
182
+
183
+
184
+ @agent.command()
185
+ def install() -> None:
186
+ """Install skills and hooks to agent directories"""
187
+ skills_by_package = _get_packages_with_skills()
188
+
189
+ if not skills_by_package:
190
+ click.echo("No skills found in installed packages.")
191
+ return
192
+
193
+ # Find destinations based on what agent directories exist
194
+ destinations = _get_skill_destinations()
195
+
196
+ if not destinations:
197
+ click.secho(
198
+ "No agent directories found (.claude/ or .codex/)",
199
+ fg="yellow",
200
+ )
201
+ return
202
+
203
+ # Install to each destination
204
+ for dest in destinations:
205
+ installed_count, removed_count = _install_skills_to(dest, skills_by_package)
206
+
207
+ parent_dir = dest.parent # .claude/ or .codex/
208
+
209
+ # Setup hook only for Claude (Codex uses a different config format)
210
+ if parent_dir.name == ".claude":
211
+ _setup_session_hook(parent_dir)
212
+
213
+ parts = []
214
+ if installed_count > 0:
215
+ parts.append(f"installed {installed_count} skills")
216
+ if removed_count > 0:
217
+ parts.append(f"removed {removed_count} skills")
218
+ if parent_dir.name == ".claude":
219
+ parts.append("updated hooks")
220
+ if parts:
221
+ click.echo(f"Agent: {', '.join(parts)} in {parent_dir}/")
222
+
223
+
224
+ @agent.command()
225
+ def skills() -> None:
226
+ """List available skills from installed packages"""
227
+ skills_by_package = _get_packages_with_skills()
228
+
229
+ if not skills_by_package:
230
+ click.echo("No skills found in installed packages.")
231
+ return
232
+
233
+ click.echo("Available skills:")
234
+ for pkg_name in sorted(skills_by_package.keys()):
235
+ for skill_dir in skills_by_package[pkg_name]:
236
+ click.echo(f" - {skill_dir.name} (from {pkg_name})")
plain/cli/build.py CHANGED
@@ -9,6 +9,7 @@ import click
9
9
 
10
10
  import plain.runtime
11
11
  from plain.assets.compile import compile_assets, get_compiled_path
12
+ from plain.cli.print import print_event
12
13
 
13
14
 
14
15
  @click.command()
@@ -33,8 +34,8 @@ from plain.assets.compile import compile_assets, get_compiled_path
33
34
  default=True,
34
35
  help="Compress the assets",
35
36
  )
36
- def build(keep_original, fingerprint, compress):
37
- """Pre-deployment build step (compile assets, css, js, etc.)"""
37
+ def build(keep_original: bool, fingerprint: bool, compress: bool) -> None:
38
+ """Pre-deployment build step for assets and static files"""
38
39
 
39
40
  if not keep_original and not fingerprint:
40
41
  raise click.UsageError(
@@ -54,18 +55,16 @@ def build(keep_original, fingerprint, compress):
54
55
  .get("run", {})
55
56
  .items()
56
57
  ):
57
- click.secho(f"Running {name} from pyproject.toml", bold=True)
58
+ print_event(f"{name}...")
58
59
  result = subprocess.run(data["cmd"], shell=True)
59
- print()
60
60
  if result.returncode:
61
61
  click.secho(f"Error in {name} (exit {result.returncode})", fg="red")
62
62
  sys.exit(result.returncode)
63
63
 
64
64
  # Then run installed package build steps (like tailwind, typically should run last...)
65
65
  for entry_point in entry_points(group="plain.build"):
66
- click.secho(f"Running {entry_point.name}", bold=True)
67
- result = entry_point.load()()
68
- print()
66
+ print_event(f"{entry_point.name}...")
67
+ entry_point.load()()
69
68
 
70
69
  # Compile our assets
71
70
  target_dir = get_compiled_path()
@@ -79,7 +78,7 @@ def build(keep_original, fingerprint, compress):
79
78
  total_compiled = 0
80
79
 
81
80
  for url_path, resolved_url_path, compiled_paths in compile_assets(
82
- target_dir=target_dir,
81
+ target_dir=str(target_dir),
83
82
  keep_original=keep_original,
84
83
  fingerprint=fingerprint,
85
84
  compress=compress,
plain/cli/changelog.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import re
2
4
  from importlib.util import find_spec
3
5
  from pathlib import Path
@@ -5,9 +7,10 @@ from pathlib import Path
5
7
  import click
6
8
 
7
9
  from .output import style_markdown
10
+ from .runtime import without_runtime_setup
8
11
 
9
12
 
10
- def parse_version(version_str):
13
+ def parse_version(version_str: str) -> tuple[int, ...]:
11
14
  """Parse a version string into a tuple of integers for comparison."""
12
15
  # Remove 'v' prefix if present and split by dots
13
16
  clean_version = version_str.lstrip("v")
@@ -22,7 +25,7 @@ def parse_version(version_str):
22
25
  return tuple(parts)
23
26
 
24
27
 
25
- def compare_versions(v1, v2):
28
+ def compare_versions(v1: str, v2: str) -> int:
26
29
  """Compare two version strings. Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2."""
27
30
  parsed_v1 = parse_version(v1)
28
31
  parsed_v2 = parse_version(v2)
@@ -40,12 +43,15 @@ def compare_versions(v1, v2):
40
43
  return 0
41
44
 
42
45
 
46
+ @without_runtime_setup
43
47
  @click.command("changelog")
44
48
  @click.argument("package_label")
45
49
  @click.option("--from", "from_version", help="Show entries from this version onwards")
46
50
  @click.option("--to", "to_version", help="Show entries up to this version")
47
- def changelog(package_label, from_version, to_version):
48
- """Show changelog entries for a package."""
51
+ def changelog(
52
+ package_label: str, from_version: str | None, to_version: str | None
53
+ ) -> None:
54
+ """Show changelog for a package"""
49
55
  module_name = package_label.replace("-", ".")
50
56
  spec = find_spec(module_name)
51
57
  if not spec:
@@ -85,7 +91,7 @@ def changelog(package_label, from_version, to_version):
85
91
  if current_version is not None:
86
92
  entries.append((current_version, current_lines))
87
93
 
88
- def version_found(version):
94
+ def version_found(version: str) -> bool:
89
95
  return any(compare_versions(v, version) == 0 for v, _ in entries)
90
96
 
91
97
  if from_version and not version_found(from_version):