plain 0.66.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 (197) hide show
  1. plain/CHANGELOG.md +684 -0
  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 -53
  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 +112 -28
  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 +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  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 -13
  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 +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  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 +14 -27
  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 +56 -40
  145. plain/urls/resolvers.py +38 -28
  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.66.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.66.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/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/cli/chores.py CHANGED
@@ -7,79 +7,77 @@ logger = logging.getLogger("plain.chores")
7
7
 
8
8
 
9
9
  @click.group()
10
- def chores():
10
+ def chores() -> None:
11
11
  """Routine maintenance tasks"""
12
12
  pass
13
13
 
14
14
 
15
15
  @chores.command("list")
16
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
17
16
  @click.option(
18
17
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
19
18
  )
20
- def list_chores(group, name):
21
- """
22
- List all registered chores.
23
- """
19
+ def list_chores(name: tuple[str, ...]) -> None:
20
+ """List all registered chores"""
24
21
  from plain.chores.registry import chores_registry
25
22
 
26
23
  chores_registry.import_modules()
27
24
 
28
- if group or name:
29
- chores = [
30
- chore
31
- for chore in chores_registry.get_chores()
32
- if (chore.group in group or not group) and (chore.name in name or not name)
25
+ chore_classes = chores_registry.get_chores()
26
+
27
+ if name:
28
+ chore_classes = [
29
+ chore_class
30
+ for chore_class in chore_classes
31
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
33
32
  ]
34
- else:
35
- chores = chores_registry.get_chores()
36
33
 
37
- for chore in chores:
38
- click.secho(f"{chore}", bold=True, nl=False)
39
- if chore.description:
40
- click.echo(f": {chore.description}")
34
+ for chore_class in chore_classes:
35
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
36
+ click.secho(f"{chore_name}", bold=True, nl=False)
37
+ description = chore_class.__doc__.strip() if chore_class.__doc__ else ""
38
+ if description:
39
+ click.secho(f": {description}", dim=True)
41
40
  else:
42
41
  click.echo("")
43
42
 
44
43
 
45
44
  @chores.command("run")
46
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
47
45
  @click.option(
48
46
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
49
47
  )
50
48
  @click.option(
51
49
  "--dry-run", is_flag=True, help="Show what would be done without executing"
52
50
  )
53
- def run_chores(group, name, dry_run):
54
- """
55
- Run the specified chores.
56
- """
51
+ def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
52
+ """Run specified chores"""
57
53
  from plain.chores.registry import chores_registry
58
54
 
59
55
  chores_registry.import_modules()
60
56
 
61
- if group or name:
62
- chores = [
63
- chore
64
- for chore in chores_registry.get_chores()
65
- if (chore.group in group or not group) and (chore.name in name or not name)
57
+ chore_classes = chores_registry.get_chores()
58
+
59
+ if name:
60
+ chore_classes = [
61
+ chore_class
62
+ for chore_class in chore_classes
63
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
66
64
  ]
67
- else:
68
- chores = chores_registry.get_chores()
69
65
 
70
66
  chores_failed = []
71
67
 
72
- for chore in chores:
73
- click.echo(f"{chore.name}:", nl=False)
68
+ for chore_class in chore_classes:
69
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
70
+ click.echo(f"{chore_name}:", nl=False)
74
71
  if dry_run:
75
- click.echo(" (dry run)", fg="yellow")
72
+ click.secho(" (dry run)", fg="yellow", nl=False)
76
73
  else:
77
74
  try:
75
+ chore = chore_class()
78
76
  result = chore.run()
79
77
  except Exception:
80
78
  click.secho(" Failed", fg="red")
81
- chores_failed.append(chore)
82
- logger.exception(f"Error running chore {chore.name}")
79
+ chores_failed.append(chore_class)
80
+ logger.exception(f"Error running chore {chore_name}")
83
81
  continue
84
82
 
85
83
  if result is None:
plain/cli/core.py CHANGED
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import traceback
4
+ from typing import Any
2
5
 
3
6
  import click
4
7
  from click.core import Command, Context
@@ -13,10 +16,12 @@ from .chores import chores
13
16
  from .docs import docs
14
17
  from .formatting import PlainContext
15
18
  from .install import install
16
- from .preflight import preflight_checks
19
+ from .preflight import preflight_cli
17
20
  from .registry import cli_registry
21
+ from .request import request
18
22
  from .scaffold import create
19
- from .settings import setting
23
+ from .server import server
24
+ from .settings import settings
20
25
  from .shell import run, shell
21
26
  from .upgrade import upgrade
22
27
  from .urls import urls
@@ -24,24 +29,26 @@ from .utils import utils
24
29
 
25
30
 
26
31
  @click.group()
27
- def plain_cli():
32
+ def plain_cli() -> None:
28
33
  pass
29
34
 
30
35
 
31
- plain_cli.add_command(agent)
32
36
  plain_cli.add_command(docs)
33
- plain_cli.add_command(preflight_checks)
37
+ plain_cli.add_command(request)
38
+ plain_cli.add_command(agent)
39
+ plain_cli.add_command(preflight_cli)
34
40
  plain_cli.add_command(create)
35
41
  plain_cli.add_command(chores)
36
42
  plain_cli.add_command(build)
37
43
  plain_cli.add_command(utils)
38
44
  plain_cli.add_command(urls)
39
45
  plain_cli.add_command(changelog)
40
- plain_cli.add_command(setting)
46
+ plain_cli.add_command(settings)
41
47
  plain_cli.add_command(shell)
42
48
  plain_cli.add_command(run)
43
49
  plain_cli.add_command(install)
44
50
  plain_cli.add_command(upgrade)
51
+ plain_cli.add_command(server)
45
52
 
46
53
 
47
54
  class CLIRegistryGroup(click.Group):
@@ -49,42 +56,49 @@ class CLIRegistryGroup(click.Group):
49
56
  Click Group that exposes commands from the CLI registry.
50
57
  """
51
58
 
52
- def __init__(self, *args, **kwargs):
59
+ def __init__(self, *args: Any, **kwargs: Any):
53
60
  super().__init__(*args, **kwargs)
54
61
  cli_registry.import_modules()
55
62
 
56
- def list_commands(self, ctx):
63
+ def list_commands(self, ctx: Context) -> list[str]:
57
64
  return sorted(cli_registry.get_commands().keys())
58
65
 
59
- def get_command(self, ctx, name):
66
+ def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
60
67
  commands = cli_registry.get_commands()
61
- return commands.get(name)
68
+ return commands.get(cmd_name)
62
69
 
63
70
 
64
71
  class PlainCommandCollection(click.CommandCollection):
65
72
  context_class = PlainContext
66
73
 
67
- def __init__(self, *args, **kwargs):
68
- sources = []
74
+ def __init__(self, *args: Any, **kwargs: Any):
75
+ # Start with only built-in commands (no setup needed)
76
+ sources = [plain_cli]
77
+
78
+ super().__init__(*args, **kwargs)
79
+ self.sources = sources
80
+ self._registry_group = None
81
+ self._setup_attempted = False
82
+
83
+ def _ensure_registry_loaded(self) -> None:
84
+ """Lazy load the registry group (requires setup)."""
85
+ if self._registry_group is not None or self._setup_attempted:
86
+ return
87
+
88
+ self._setup_attempted = True
69
89
 
70
90
  try:
71
91
  plain.runtime.setup()
72
-
73
- sources = [
74
- CLIRegistryGroup(),
75
- plain_cli,
76
- ]
92
+ self._registry_group = CLIRegistryGroup()
93
+ # Add registry group to sources
94
+ self.sources.insert(0, self._registry_group)
77
95
  except plain.runtime.AppPathNotFound:
78
- # Allow some commands to work regardless of being in a valid app
96
+ # Allow built-in commands to work regardless of being in a valid app
79
97
  click.secho(
80
98
  "Plain `app` directory not found. Some commands may be missing.",
81
99
  fg="yellow",
82
100
  err=True,
83
101
  )
84
-
85
- sources = [
86
- plain_cli,
87
- ]
88
102
  except ImproperlyConfigured as e:
89
103
  # Show what was configured incorrectly and exit
90
104
  click.secho(
@@ -92,7 +106,6 @@ class PlainCommandCollection(click.CommandCollection):
92
106
  fg="red",
93
107
  err=True,
94
108
  )
95
-
96
109
  exit(1)
97
110
  except Exception as e:
98
111
  # Show the exception and exit
@@ -105,19 +118,90 @@ class PlainCommandCollection(click.CommandCollection):
105
118
  fg="red",
106
119
  err=True,
107
120
  )
108
-
109
121
  exit(1)
110
122
 
111
- super().__init__(*args, **kwargs)
112
-
113
- self.sources = sources
114
-
115
123
  def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
124
+ # Try built-in commands first
116
125
  cmd = super().get_command(ctx, cmd_name)
126
+
127
+ if cmd is None:
128
+ # Command not found in built-ins, try registry (requires setup)
129
+ self._ensure_registry_loaded()
130
+ cmd = super().get_command(ctx, cmd_name)
131
+ elif not getattr(cmd, "without_runtime_setup", False):
132
+ # Command found but needs setup - ensure registry is loaded
133
+ self._ensure_registry_loaded()
134
+
117
135
  if cmd:
118
136
  # Pass the formatting down to subcommands automatically
119
137
  cmd.context_class = self.context_class
120
138
  return cmd
121
139
 
140
+ def list_commands(self, ctx: Context) -> list[str]:
141
+ # For help listing, we need to show registry commands too
142
+ self._ensure_registry_loaded()
143
+ return super().list_commands(ctx)
144
+
145
+ def format_commands(self, ctx: Context, formatter: Any) -> None:
146
+ """Format commands with separate sections for common, core, and package commands."""
147
+ self._ensure_registry_loaded()
148
+
149
+ # Get all commands from both sources, tracking their source
150
+ commands = []
151
+ for source_index, source in enumerate(self.sources):
152
+ for name in source.list_commands(ctx):
153
+ cmd = source.get_command(ctx, name)
154
+ if cmd is not None:
155
+ # source_index 0 = plain_cli (core), 1+ = registry (packages)
156
+ commands.append((name, cmd, source_index))
157
+
158
+ if not commands:
159
+ return
160
+
161
+ # Get metadata from the registry (for shortcuts)
162
+ shortcuts_metadata = cli_registry.get_shortcuts()
163
+
164
+ # Separate commands into common, core, and package
165
+ common_commands = []
166
+ core_commands = []
167
+ package_commands = []
168
+
169
+ for name, cmd, source_index in commands:
170
+ help_text = cmd.get_short_help_str(limit=200)
171
+
172
+ # Check if command is marked as common via decorator
173
+ is_common = getattr(cmd, "is_common_command", False)
174
+
175
+ if is_common:
176
+ # This is a common command
177
+ # Add arrow notation if it's also a shortcut
178
+ if name in shortcuts_metadata:
179
+ shortcut_for = shortcuts_metadata[name].shortcut_for
180
+ if shortcut_for:
181
+ alias_info = click.style(f"(→ {shortcut_for})", italic=True)
182
+ help_text = f"{help_text} {alias_info}"
183
+ common_commands.append((name, help_text))
184
+ elif source_index == 0:
185
+ # Package command (from registry, inserted at index 0)
186
+ package_commands.append((name, help_text))
187
+ else:
188
+ # Core command (from plain_cli, at index 1)
189
+ core_commands.append((name, help_text))
190
+
191
+ # Write common commands section if any exist
192
+ if common_commands:
193
+ with formatter.section("Common Commands"):
194
+ formatter.write_dl(sorted(common_commands))
195
+
196
+ # Write core commands section if any exist
197
+ if core_commands:
198
+ with formatter.section("Core Commands"):
199
+ formatter.write_dl(sorted(core_commands))
200
+
201
+ # Write package commands section if any exist
202
+ if package_commands:
203
+ with formatter.section("Package Commands"):
204
+ formatter.write_dl(sorted(package_commands))
205
+
122
206
 
123
207
  cli = PlainCommandCollection()
plain/cli/docs.py CHANGED
@@ -1,18 +1,51 @@
1
1
  import importlib.util
2
+ import pkgutil
2
3
  from pathlib import Path
3
4
 
4
5
  import click
5
6
 
7
+ from .llmdocs import LLMDocs
6
8
  from .output import iterate_markdown
7
9
 
8
10
 
9
11
  @click.command()
10
- @click.option("--open")
12
+ @click.option("--open", is_flag=True, help="Open the README in your default editor")
13
+ @click.option("--source", is_flag=True, help="Include symbolicated source code")
14
+ @click.option("--list", "show_list", is_flag=True, help="List available packages")
11
15
  @click.argument("module", default="")
12
- def docs(module, open):
16
+ def docs(module: str, open: bool, source: bool, show_list: bool) -> None:
17
+ """Show documentation for a package"""
18
+ if show_list:
19
+ # List available packages
20
+ available_packages = []
21
+ try:
22
+ import plain
23
+
24
+ # Check core plain package (namespace package)
25
+ plain_spec = importlib.util.find_spec("plain")
26
+ if plain_spec and plain_spec.submodule_search_locations:
27
+ available_packages.append("plain")
28
+
29
+ # Check other plain.* subpackages
30
+ if hasattr(plain, "__path__"):
31
+ for importer, modname, ispkg in pkgutil.iter_modules(
32
+ plain.__path__, "plain."
33
+ ):
34
+ if ispkg:
35
+ available_packages.append(modname)
36
+ except Exception:
37
+ pass
38
+
39
+ if available_packages:
40
+ for pkg in sorted(available_packages):
41
+ click.echo(f"- {pkg}")
42
+ else:
43
+ click.echo("No packages found.")
44
+ return
45
+
13
46
  if not module:
14
47
  raise click.UsageError(
15
- "You must specify a module. For LLM-friendly docs, use `plain agent docs`."
48
+ "You must specify a module. Use --list to see available packages."
16
49
  )
17
50
 
18
51
  # Convert hyphens to dots (e.g., plain-models -> plain.models)
@@ -22,17 +55,25 @@ def docs(module, open):
22
55
  if not module.startswith("plain"):
23
56
  module = f"plain.{module}"
24
57
 
25
- # Get the README.md file for the module
58
+ # Get the module path
26
59
  spec = importlib.util.find_spec(module)
27
- if not spec:
60
+ if not spec or not spec.origin:
28
61
  raise click.UsageError(f"Module {module} not found")
29
62
 
30
63
  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
64
 
35
- if open:
36
- click.launch(str(readme_path))
65
+ if source:
66
+ # Output with symbolicated source
67
+ source_docs = LLMDocs([module_path])
68
+ source_docs.load()
69
+ source_docs.print(relative_to=module_path.parent)
37
70
  else:
38
- click.echo_via_pager(iterate_markdown(readme_path.read_text()))
71
+ # Human-readable README output
72
+ readme_path = module_path / "README.md"
73
+ if not readme_path.exists():
74
+ raise click.UsageError(f"README.md not found for {module}")
75
+
76
+ if open:
77
+ click.launch(str(readme_path))
78
+ else:
79
+ click.echo_via_pager(iterate_markdown(readme_path.read_text()))
plain/cli/formatting.py CHANGED
@@ -1,24 +1,29 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
4
+ from typing import Any
2
5
 
3
6
  import click
4
7
  from click.formatting import iter_rows, measure_table, term_len, wrap_text
5
8
 
6
9
 
7
10
  class PlainHelpFormatter(click.HelpFormatter):
8
- def write_heading(self, heading):
9
- styled_heading = click.style(heading, underline=True)
11
+ def write_heading(self, heading: str) -> None:
12
+ styled_heading = click.style(heading, dim=True)
10
13
  self.write(f"{'':>{self.current_indent}}{styled_heading}\n")
11
14
 
12
- def write_usage(self, prog, args, prefix="Usage: "):
13
- prefix_styled = click.style(prefix, italic=True)
15
+ def write_usage( # type: ignore[override]
16
+ self, prog: str, args: str = "", prefix: str = "Usage: "
17
+ ) -> None:
18
+ prefix_styled = click.style(prefix, dim=True)
14
19
  super().write_usage(prog, args, prefix=prefix_styled)
15
20
 
16
- def write_dl(
21
+ def write_dl( # type: ignore[override]
17
22
  self,
18
- rows,
19
- col_max=30,
20
- col_spacing=2,
21
- ):
23
+ rows: list[tuple[str, str]],
24
+ col_max: int = 20,
25
+ col_spacing: int = 2,
26
+ ) -> None:
22
27
  """Writes a definition list into the buffer. This is how options
23
28
  and commands are usually formatted.
24
29
 
@@ -51,10 +56,15 @@ class PlainHelpFormatter(click.HelpFormatter):
51
56
  lines = wrapped_text.splitlines()
52
57
 
53
58
  if lines:
54
- self.write(f"{lines[0]}\n")
59
+ # Dim the description text
60
+ first_line_styled = click.style(lines[0], dim=True)
61
+ self.write(f"{first_line_styled}\n")
55
62
 
56
63
  for line in lines[1:]:
57
- self.write(f"{'':>{first_col + self.current_indent}}{line}\n")
64
+ line_styled = click.style(line, dim=True)
65
+ self.write(
66
+ f"{'':>{first_col + self.current_indent}}{line_styled}\n"
67
+ )
58
68
  else:
59
69
  self.write("\n")
60
70
 
@@ -62,12 +72,25 @@ class PlainHelpFormatter(click.HelpFormatter):
62
72
  class PlainContext(click.Context):
63
73
  formatter_class = PlainHelpFormatter
64
74
 
65
- def __init__(self, *args, **kwargs):
75
+ def __init__(self, *args: Any, **kwargs: Any):
76
+ # Set a wider max_content_width for help text (default is 80)
77
+ # This allows descriptions to fit more comfortably on one line
78
+ if "max_content_width" not in kwargs:
79
+ kwargs["max_content_width"] = 140
80
+
66
81
  super().__init__(*args, **kwargs)
67
82
 
68
- # Force colors in CI environments
69
- if any(
70
- os.getenv(var)
71
- for var in ["CI", "FORCE_COLOR", "GITHUB_ACTIONS", "GITLAB_CI"]
72
- ) and not any(os.getenv(var) for var in ["NO_COLOR", "PYTEST_CURRENT_TEST"]):
83
+ # Follow CLICOLOR standard (http://bixense.com/clicolors/)
84
+ # Priority: NO_COLOR > CLICOLOR_FORCE/FORCE_COLOR > CI detection > CLICOLOR > isatty
85
+ if os.getenv("NO_COLOR") or os.getenv("PYTEST_CURRENT_TEST"):
86
+ self.color = False
87
+ elif os.getenv("CLICOLOR_FORCE") or os.getenv("FORCE_COLOR"):
88
+ self.color = True
89
+ elif os.getenv("CI"):
90
+ # Enable colors in CI/deployment environments even without TTY
91
+ # This matches behavior of modern tools like uv (via Rust's anstyle)
73
92
  self.color = True
93
+ elif os.getenv("CLICOLOR"):
94
+ # CLICOLOR=1 means use colors only if TTY (Click's default behavior)
95
+ pass # Let Click handle it with isatty check
96
+ # Otherwise use Click's default behavior (isatty check)
plain/cli/install.py CHANGED
@@ -3,28 +3,11 @@ import sys
3
3
 
4
4
  import click
5
5
 
6
- from .agent.prompt import prompt_agent
7
-
8
6
 
9
7
  @click.command()
10
8
  @click.argument("packages", nargs=-1, required=True)
11
- @click.option(
12
- "--agent-command",
13
- envvar="PLAIN_AGENT_COMMAND",
14
- help="Run command with generated prompt",
15
- )
16
- @click.option(
17
- "--print",
18
- "print_only",
19
- is_flag=True,
20
- help="Print the prompt without running the agent",
21
- )
22
- def install(
23
- packages: tuple[str, ...],
24
- agent_command: str | None = None,
25
- print_only: bool = False,
26
- ) -> None:
27
- """Install Plain packages with the help of an agent."""
9
+ def install(packages: tuple[str, ...]) -> None:
10
+ """Install Plain packages"""
28
11
  # Validate all package names
29
12
  invalid_packages = [pkg for pkg in packages if not pkg.startswith("plain")]
30
13
  if invalid_packages:
@@ -33,14 +16,14 @@ def install(
33
16
  "This command is only for Plain framework packages."
34
17
  )
35
18
 
36
- # Install all packages first
19
+ # Install all packages
37
20
  if len(packages) == 1:
38
- click.secho(f"Installing {packages[0]}...", bold=True, err=True)
21
+ click.secho(f"Installing {packages[0]}...", bold=True)
39
22
  else:
40
- click.secho(f"Installing {len(packages)} packages...", bold=True, err=True)
23
+ click.secho(f"Installing {len(packages)} packages...", bold=True)
41
24
  for pkg in packages:
42
- click.secho(f" - {pkg}", err=True)
43
- click.echo(err=True)
25
+ click.secho(f" - {pkg}")
26
+ click.echo()
44
27
 
45
28
  install_cmd = ["uv", "add"] + list(packages)
46
29
  result = subprocess.run(install_cmd, check=False, stderr=sys.stderr)
@@ -48,35 +31,8 @@ def install(
48
31
  if result.returncode != 0:
49
32
  raise click.ClickException("Failed to install packages")
50
33
 
51
- click.echo(err=True)
34
+ click.echo()
52
35
  if len(packages) == 1:
53
- click.secho(f"{packages[0]} installed successfully", fg="green", err=True)
36
+ click.secho(f"{packages[0]} installed successfully", fg="green")
54
37
  else:
55
- click.secho(
56
- f"✓ {len(packages)} packages installed successfully", fg="green", err=True
57
- )
58
- click.echo(err=True)
59
-
60
- # Build the prompt for the agent to complete setup
61
- lines = [
62
- f"Complete the setup for the following Plain packages that were just installed: {', '.join(packages)}",
63
- "",
64
- "## Instructions",
65
- "",
66
- "For each package:",
67
- "1. Run `uv run plain docs <package>` and read the installation instructions",
68
- "2. If the docs point out that it is a --dev tool, move it to the dev dependencies in pyproject.toml: `uv remove <package> && uv add <package> --dev`",
69
- "3. Go through the installation instructions and complete any code modifications that are needed",
70
- "",
71
- "DO NOT commit any changes",
72
- "",
73
- "Report back with:",
74
- "- Whether the setup completed successfully",
75
- "- Any manual steps that the user will need to complete",
76
- "- Any issues or errors encountered",
77
- ]
78
-
79
- prompt = "\n".join(lines)
80
- success = prompt_agent(prompt, agent_command, print_only)
81
- if not success:
82
- raise click.Abort()
38
+ click.secho(f"{len(packages)} packages installed successfully", fg="green")