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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/chores/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Overview
10
10
 
11
- Chores are registered functions that can be run at any time to keep an app in a desirable state.
11
+ Chores are registered classes that can be run at any time to keep an app in a desirable state.
12
12
 
13
13
  ![](https://assets.plainframework.com/docs/plain-chores-run.png)
14
14
 
@@ -16,19 +16,19 @@ A good example is the clearing of expired sessions in [`plain.sessions`](/plain-
16
16
 
17
17
  ```python
18
18
  # plain/sessions/chores.py
19
- from plain.chores import register_chore
19
+ from plain.chores import Chore, register_chore
20
20
  from plain.utils import timezone
21
21
 
22
22
  from .models import Session
23
23
 
24
24
 
25
- @register_chore("sessions")
26
- def clear_expired():
27
- """
28
- Delete sessions that have expired.
29
- """
30
- result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
- return f"{result[0]} expired sessions deleted"
25
+ @register_chore
26
+ class ClearExpired(Chore):
27
+ """Delete sessions that have expired."""
28
+
29
+ def run(self):
30
+ result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
+ return f"{result[0]} expired sessions deleted"
32
32
  ```
33
33
 
34
34
  ## Running chores
@@ -38,33 +38,35 @@ The `plain chores run` command will execute all registered chores. When and how
38
38
  There are several ways you can run chores depending on your needs:
39
39
 
40
40
  - on deploy
41
- - as a [`plain.worker` scheduled job](/plain-worker/plain/worker/README.md#scheduled-jobs)
41
+ - as a [`plain.jobs` scheduled job](/plain-jobs/plain/jobs/README.md#scheduled-jobs)
42
42
  - as a cron job (using any cron-like system where your app is hosted)
43
43
  - manually as needed
44
44
 
45
45
  ## Writing chores
46
46
 
47
- A chore is a function decorated with `@register_chore(chore_group_name)`. It can write a description as a docstring, and it can return a value that will be printed when the chore is run.
47
+ A chore is a class that inherits from [`Chore`](./core.py#Chore) and implements the `run()` method. Register the chore using the [`@register_chore`](./registry.py#register_chore) decorator. The chore name is the class's qualified name (`__qualname__`), and the description comes from the class docstring.
48
48
 
49
49
  ```python
50
50
  # app/chores.py
51
- from plain.chores import register_chore
51
+ from plain.chores import Chore, register_chore
52
+
52
53
 
54
+ @register_chore
55
+ class ChoreName(Chore):
56
+ """A chore description can go here."""
53
57
 
54
- @register_chore("app")
55
- def chore_name():
56
- """
57
- A chore description can go here
58
- """
59
- # Do a thing!
60
- return "We did it!"
58
+ def run(self):
59
+ # Do a thing!
60
+ return "We did it!"
61
61
  ```
62
62
 
63
+ ### Best practices
64
+
63
65
  A good chore is:
64
66
 
65
- - Fast
66
- - Idempotent
67
- - Recurring
68
- - Stateless
67
+ - **Fast** - Should complete quickly, not block for long periods
68
+ - **Idempotent** - Safe to run multiple times without side effects
69
+ - **Recurring** - Designed to run regularly, not just once
70
+ - **Stateless** - Doesn't rely on external state between runs
69
71
 
70
72
  If chores are written in `app/chores.py` or `{pkg}/chores.py`, then they will be imported automatically and registered.
plain/chores/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from .core import Chore
1
2
  from .registry import register_chore
2
3
 
3
- __all__ = ["register_chore"]
4
+ __all__ = ["Chore", "register_chore"]
plain/chores/core.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class Chore(ABC):
8
+ """
9
+ Abstract base class for chores.
10
+
11
+ Subclasses must implement:
12
+ - run() method
13
+
14
+ Example:
15
+ @register_chore
16
+ class ClearExpired(Chore):
17
+ '''Delete sessions that have expired.'''
18
+
19
+ def run(self):
20
+ # ... implementation
21
+ return "10 sessions deleted"
22
+ """
23
+
24
+ @abstractmethod
25
+ def run(self) -> Any:
26
+ """Run the chore. Must be implemented by subclasses."""
27
+ pass
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,234 @@
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_agent_dirs() -> list[Path]:
13
+ """Get list of agents/.claude/ directories from installed plain.* packages."""
14
+ agent_dirs: list[Path] = []
15
+
16
+ try:
17
+ import plain
18
+
19
+ # Check core plain package (namespace package)
20
+ plain_spec = importlib.util.find_spec("plain")
21
+ if plain_spec and plain_spec.submodule_search_locations:
22
+ for location in plain_spec.submodule_search_locations:
23
+ agent_dir = Path(location) / "agents" / ".claude"
24
+ if agent_dir.exists() and agent_dir.is_dir():
25
+ agent_dirs.append(agent_dir)
26
+ break
27
+
28
+ # Check other plain.* subpackages
29
+ if hasattr(plain, "__path__"):
30
+ for importer, modname, ispkg in pkgutil.iter_modules(
31
+ plain.__path__, "plain."
32
+ ):
33
+ if ispkg:
34
+ try:
35
+ spec = importlib.util.find_spec(modname)
36
+ if spec and spec.origin:
37
+ agent_dir = Path(spec.origin).parent / "agents" / ".claude"
38
+ if agent_dir.exists() and agent_dir.is_dir():
39
+ agent_dirs.append(agent_dir)
40
+ except Exception:
41
+ continue
42
+ except Exception:
43
+ pass
44
+
45
+ return agent_dirs
46
+
47
+
48
+ def _install_agent_dir(source_dir: Path, dest_dir: Path) -> tuple[int, int]:
49
+ """Copy contents of a source agents/.claude/ dir to the project's .claude/ dir.
50
+
51
+ Handles skills/ subdirectories and rules/ files.
52
+ Returns (installed_count, removed_count) for reporting.
53
+ """
54
+ installed_count = 0
55
+
56
+ # Copy skills (directories containing SKILL.md)
57
+ source_skills = source_dir / "skills"
58
+ if source_skills.exists():
59
+ dest_skills = dest_dir / "skills"
60
+ dest_skills.mkdir(parents=True, exist_ok=True)
61
+ for skill_dir in source_skills.iterdir():
62
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
63
+ dest_skill = dest_skills / skill_dir.name
64
+ # Check mtime to skip unchanged
65
+ if dest_skill.exists():
66
+ source_mtime = (skill_dir / "SKILL.md").stat().st_mtime
67
+ dest_mtime = (
68
+ (dest_skill / "SKILL.md").stat().st_mtime
69
+ if (dest_skill / "SKILL.md").exists()
70
+ else 0
71
+ )
72
+ if source_mtime <= dest_mtime:
73
+ continue
74
+ shutil.rmtree(dest_skill)
75
+ shutil.copytree(skill_dir, dest_skill)
76
+ installed_count += 1
77
+
78
+ # Copy rules (individual .md files)
79
+ source_rules = source_dir / "rules"
80
+ if source_rules.exists():
81
+ dest_rules = dest_dir / "rules"
82
+ dest_rules.mkdir(parents=True, exist_ok=True)
83
+ for rule_file in source_rules.iterdir():
84
+ if rule_file.is_file() and rule_file.suffix == ".md":
85
+ dest_rule = dest_rules / rule_file.name
86
+ # Check mtime to skip unchanged
87
+ if dest_rule.exists():
88
+ if rule_file.stat().st_mtime <= dest_rule.stat().st_mtime:
89
+ continue
90
+ shutil.copy2(rule_file, dest_rule)
91
+ installed_count += 1
92
+
93
+ return installed_count, 0
94
+
95
+
96
+ def _cleanup_orphans(dest_dir: Path, agent_dirs: list[Path]) -> int:
97
+ """Remove plain* items from .claude/ that no longer exist in any source package."""
98
+ removed_count = 0
99
+
100
+ # Collect all source skill and rule names
101
+ source_skills: set[str] = set()
102
+ source_rules: set[str] = set()
103
+ for agent_dir in agent_dirs:
104
+ skills_dir = agent_dir / "skills"
105
+ if skills_dir.exists():
106
+ for d in skills_dir.iterdir():
107
+ if d.is_dir() and (d / "SKILL.md").exists():
108
+ source_skills.add(d.name)
109
+ rules_dir = agent_dir / "rules"
110
+ if rules_dir.exists():
111
+ for f in rules_dir.iterdir():
112
+ if f.is_file() and f.suffix == ".md":
113
+ source_rules.add(f.name)
114
+
115
+ # Remove orphaned skills
116
+ dest_skills = dest_dir / "skills"
117
+ if dest_skills.exists():
118
+ for dest in dest_skills.iterdir():
119
+ if (
120
+ dest.is_dir()
121
+ and dest.name.startswith("plain")
122
+ and dest.name not in source_skills
123
+ ):
124
+ shutil.rmtree(dest)
125
+ removed_count += 1
126
+
127
+ # Remove orphaned rules
128
+ dest_rules = dest_dir / "rules"
129
+ if dest_rules.exists():
130
+ for dest in dest_rules.iterdir():
131
+ if (
132
+ dest.is_file()
133
+ and dest.name.startswith("plain")
134
+ and dest.suffix == ".md"
135
+ and dest.name not in source_rules
136
+ ):
137
+ dest.unlink()
138
+ removed_count += 1
139
+
140
+ return removed_count
141
+
142
+
143
+ def _cleanup_session_hook(dest_dir: Path) -> None:
144
+ """Remove the old plain agent context SessionStart hook from settings.json."""
145
+ settings_file = dest_dir / "settings.json"
146
+
147
+ if not settings_file.exists():
148
+ return
149
+
150
+ settings = json.loads(settings_file.read_text())
151
+
152
+ hooks = settings.get("hooks", {})
153
+ session_hooks = hooks.get("SessionStart", [])
154
+
155
+ # Remove any plain agent or plain-context.md hooks
156
+ session_hooks = [h for h in session_hooks if "plain agent" not in str(h)]
157
+ session_hooks = [h for h in session_hooks if "plain-context.md" not in str(h)]
158
+
159
+ if session_hooks:
160
+ hooks["SessionStart"] = session_hooks
161
+ else:
162
+ hooks.pop("SessionStart", None)
163
+
164
+ if hooks:
165
+ settings["hooks"] = hooks
166
+ else:
167
+ settings.pop("hooks", None)
168
+
169
+ if settings:
170
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
171
+ else:
172
+ settings_file.unlink()
173
+
174
+
175
+ @click.group()
176
+ def agent() -> None:
177
+ """AI agent integration for Plain projects"""
178
+ pass
179
+
180
+
181
+ @agent.command()
182
+ def install() -> None:
183
+ """Install skills and rules to agent directories"""
184
+ cwd = Path.cwd()
185
+ claude_dir = cwd / ".claude"
186
+
187
+ if not claude_dir.exists():
188
+ click.secho("No .claude/ directory found.", fg="yellow")
189
+ return
190
+
191
+ agent_dirs = _get_agent_dirs()
192
+
193
+ # Clean up orphaned plain-* items
194
+ removed_count = _cleanup_orphans(claude_dir, agent_dirs)
195
+
196
+ # Install from each package
197
+ total_installed = 0
198
+ for source_dir in agent_dirs:
199
+ installed, _ = _install_agent_dir(source_dir, claude_dir)
200
+ total_installed += installed
201
+
202
+ # Clean up old session hook
203
+ _cleanup_session_hook(claude_dir)
204
+
205
+ parts = []
206
+ if total_installed > 0:
207
+ parts.append(f"installed {total_installed}")
208
+ if removed_count > 0:
209
+ parts.append(f"removed {removed_count}")
210
+ click.echo(f"Agent: {', '.join(parts)} in .claude/") if parts else click.echo(
211
+ "Agent: up to date"
212
+ )
213
+
214
+
215
+ @agent.command()
216
+ def skills() -> None:
217
+ """List available skills from installed packages"""
218
+ agent_dirs = _get_agent_dirs()
219
+
220
+ skill_names = []
221
+ for agent_dir in agent_dirs:
222
+ skills_dir = agent_dir / "skills"
223
+ if skills_dir.exists():
224
+ for d in skills_dir.iterdir():
225
+ if d.is_dir() and (d / "SKILL.md").exists():
226
+ skill_names.append(d.name)
227
+
228
+ if not skill_names:
229
+ click.echo("No skills found in installed packages.")
230
+ return
231
+
232
+ click.echo("Available skills:")
233
+ for name in sorted(skill_names):
234
+ click.echo(f" - {name}")