plain 0.101.2__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.
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.103.0](https://github.com/dropseed/plain/releases/plain@0.103.0) (2026-01-30)
4
+
5
+ ### What's changed
6
+
7
+ - `plain docs` now shows markdown documentation by default (previously required `--source`), with a new `--symbols` flag to show only the symbolicated API surface ([b71dab9d5d](https://github.com/dropseed/plain/commit/b71dab9d5d))
8
+ - `plain docs --list` now shows all official Plain packages (installed and uninstalled) with descriptions and install status ([9cba705d62](https://github.com/dropseed/plain/commit/9cba705d62))
9
+ - `plain docs` for uninstalled packages now shows the install command and an online docs URL instead of a generic error ([9cba705d62](https://github.com/dropseed/plain/commit/9cba705d62))
10
+ - Removed the `plain agent context` command and the `SessionStart` hook setup — agent rules now provide context directly without needing a startup hook ([88d9424643](https://github.com/dropseed/plain/commit/88d9424643))
11
+ - `plain agent install` now cleans up old SessionStart hooks from `.claude/settings.json` ([88d9424643](https://github.com/dropseed/plain/commit/88d9424643))
12
+
13
+ ### Upgrade instructions
14
+
15
+ - The `--source` flag for `plain docs` has been removed. Use `--symbols` instead to see the symbolicated API surface.
16
+ - The `--open` flag for `plain docs` has been removed.
17
+ - Run `plain agent install` to clean up the old SessionStart hook from your `.claude/settings.json`.
18
+
19
+ ## [0.102.0](https://github.com/dropseed/plain/releases/plain@0.102.0) (2026-01-28)
20
+
21
+ ### What's changed
22
+
23
+ - Refactored agent integration from skills-based to rules-based: packages now provide `agents/.claude/rules/` files and `agents/.claude/skills/` directories instead of `skills/` directories ([512040ac51](https://github.com/dropseed/plain/commit/512040ac51))
24
+ - The `plain agent install` command now copies both rules (`.md` files) and skills to the project's `.claude/` directory, and cleans up orphaned `plain*` items ([512040ac51](https://github.com/dropseed/plain/commit/512040ac51))
25
+ - Removed standalone skills (`plain-docs`, `plain-shell`, `plain-request`) that are now provided as passive rules instead ([512040ac51](https://github.com/dropseed/plain/commit/512040ac51))
26
+
27
+ ### Upgrade instructions
28
+
29
+ - Run `plain agent install` to update your `.claude/` directory with the new rules-based structure.
30
+
3
31
  ## [0.101.2](https://github.com/dropseed/plain/releases/plain@0.101.2) (2026-01-28)
4
32
 
5
33
  ### What's changed
@@ -0,0 +1,88 @@
1
+ # Plain Framework
2
+
3
+ Plain is a Python web framework.
4
+
5
+ - Always use `uv run` to execute commands — never use bare `python` or `plain` directly.
6
+ - Plain is a Django fork but has different APIs — never assume Django patterns will work.
7
+ - When unsure about an API or something doesn't work, run `uv run plain docs <package>` first. Add `--symbols` if you need the full API surface.
8
+ - Use the `/plain-install` skill to add new Plain packages.
9
+ - Use the `/plain-upgrade` skill to upgrade Plain packages.
10
+
11
+ ## Documentation
12
+
13
+ Run `uv run plain docs --list` to see all official packages (installed and uninstalled) with descriptions.
14
+ Run `uv run plain docs <package>` for markdown documentation (installed packages only).
15
+ Run `uv run plain docs <package> --symbols` for the symbolicated API surface.
16
+ For uninstalled packages, the CLI shows the install command and an online docs URL.
17
+
18
+ Online docs URL pattern: `https://plainframework.com/docs/<pip-name>/<module/path>/README.md`
19
+ Example: `https://plainframework.com/docs/plain-models/plain/models/README.md`
20
+
21
+ Examples:
22
+
23
+ - `uv run plain docs models` - Models and database docs
24
+ - `uv run plain docs models --symbols` - Models API surface
25
+ - `uv run plain docs templates` - Jinja2 templates
26
+ - `uv run plain docs assets` - Static assets
27
+
28
+ ### All official packages
29
+
30
+ - **plain** — Web framework core
31
+ - **plain-admin** — Backend admin interface
32
+ - **plain-api** — Class-based API views
33
+ - **plain-auth** — User authentication and authorization
34
+ - **plain-cache** — Database-backed cache with optional expiration
35
+ - **plain-code** — Preconfigured code formatting and linting
36
+ - **plain-dev** — Local development server with auto-reload
37
+ - **plain-elements** — HTML template components
38
+ - **plain-email** — Send email
39
+ - **plain-esbuild** — Build JavaScript with esbuild
40
+ - **plain-flags** — Feature flags via database models
41
+ - **plain-htmx** — HTMX integration for templates and views
42
+ - **plain-jobs** — Background jobs with a database-driven queue
43
+ - **plain-loginlink** — Link-based authentication
44
+ - **plain-models** — Model data and store it in a database
45
+ - **plain-oauth** — OAuth provider login
46
+ - **plain-observer** — On-page telemetry and observability
47
+ - **plain-pages** — Serve static pages, markdown, and assets
48
+ - **plain-pageviews** — Client-side pageview tracking
49
+ - **plain-passwords** — Password authentication
50
+ - **plain-pytest** — Test with pytest
51
+ - **plain-redirection** — URL redirection with admin and logging
52
+ - **plain-scan** — Test for production best practices
53
+ - **plain-sessions** — Database-backed sessions
54
+ - **plain-start** — Bootstrap a new project from templates
55
+ - **plain-support** — Support forms for your application
56
+ - **plain-tailwind** — Tailwind CSS without JavaScript or npm
57
+ - **plain-toolbar** — Debug toolbar
58
+ - **plain-tunnel** — Remote access to local dev server
59
+ - **plain-vendor** — Vendor CDN scripts and styles
60
+
61
+ ## Shell
62
+
63
+ `uv run plain shell` opens an interactive Python shell with Plain configured and database access.
64
+
65
+ Run a one-off command:
66
+
67
+ ```
68
+ uv run plain shell -c "from app.users.models import User; print(User.query.count())"
69
+ ```
70
+
71
+ Run a script:
72
+
73
+ ```
74
+ uv run plain run script.py
75
+ ```
76
+
77
+ ## HTTP Requests
78
+
79
+ Use `uv run plain request` to make test HTTP requests against the dev database.
80
+
81
+ ```
82
+ uv run plain request /path
83
+ uv run plain request /path --user 1
84
+ uv run plain request /path --header "Accept: application/json"
85
+ uv run plain request /path --method POST --data '{"key": "value"}'
86
+ uv run plain request /path --no-body # Headers only
87
+ uv run plain request /path --no-headers # Body only
88
+ ```
plain/cli/agent.py CHANGED
@@ -9,34 +9,21 @@ from pathlib import Path
9
9
  import click
10
10
 
11
11
 
12
- def _get_packages_with_skills() -> dict[str, list[Path]]:
13
- """Get dict mapping package names to lists of skill directory paths.
12
+ def _get_agent_dirs() -> list[Path]:
13
+ """Get list of agents/.claude/ directories from installed plain.* packages."""
14
+ agent_dirs: list[Path] = []
14
15
 
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
16
  try:
21
17
  import plain
22
18
 
23
19
  # Check core plain package (namespace package)
24
20
  plain_spec = importlib.util.find_spec("plain")
25
21
  if plain_spec and plain_spec.submodule_search_locations:
26
- # For namespace packages, check all search locations
27
22
  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
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
40
27
 
41
28
  # Check other plain.* subpackages
42
29
  if hasattr(plain, "__path__"):
@@ -47,126 +34,142 @@ def _get_packages_with_skills() -> dict[str, list[Path]]:
47
34
  try:
48
35
  spec = importlib.util.find_spec(modname)
49
36
  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
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)
62
40
  except Exception:
63
41
  continue
64
42
  except Exception:
65
43
  pass
66
44
 
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 = []
45
+ return agent_dirs
74
46
 
75
- # Check for Claude (.claude/ directory)
76
- if (cwd / ".claude").exists():
77
- destinations.append(cwd / ".claude" / "skills")
78
47
 
79
- # Check for Codex (.codex/ directory)
80
- if (cwd / ".codex").exists():
81
- destinations.append(cwd / ".codex" / "skills")
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.
82
50
 
83
- return destinations
51
+ Handles skills/ subdirectories and rules/ files.
52
+ Returns (installed_count, removed_count) for reporting.
53
+ """
54
+ installed_count = 0
84
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
85
92
 
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)
93
+ return installed_count, 0
91
94
 
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
95
 
98
- installed_count = 0
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."""
99
98
  removed_count = 0
100
99
 
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():
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():
105
119
  if (
106
- dest_dir.is_dir()
107
- and dest_dir.name.startswith("plain-")
108
- and dest_dir.name not in source_skill_names
120
+ dest.is_dir()
121
+ and dest.name.startswith("plain")
122
+ and dest.name not in source_skills
109
123
  ):
110
- shutil.rmtree(dest_dir)
124
+ shutil.rmtree(dest)
111
125
  removed_count += 1
112
126
 
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
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
132
139
 
133
- return installed_count, removed_count
140
+ return removed_count
134
141
 
135
142
 
136
- def _setup_session_hook(dest_dir: Path) -> None:
137
- """Create or update settings.json with SessionStart hook."""
143
+ def _cleanup_session_hook(dest_dir: Path) -> None:
144
+ """Remove the old plain agent context SessionStart hook from settings.json."""
138
145
  settings_file = dest_dir / "settings.json"
139
146
 
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", [])
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
163
156
  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
157
  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
158
 
169
- settings_file.write_text(json.dumps(settings, indent=2) + "\n")
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()
170
173
 
171
174
 
172
175
  @click.group()
@@ -175,62 +178,57 @@ def agent() -> None:
175
178
  pass
176
179
 
177
180
 
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
181
  @agent.command()
185
182
  def install() -> None:
186
- """Install skills and hooks to agent directories"""
187
- skills_by_package = _get_packages_with_skills()
183
+ """Install skills and rules to agent directories"""
184
+ cwd = Path.cwd()
185
+ claude_dir = cwd / ".claude"
188
186
 
189
- if not skills_by_package:
190
- click.echo("No skills found in installed packages.")
187
+ if not claude_dir.exists():
188
+ click.secho("No .claude/ directory found.", fg="yellow")
191
189
  return
192
190
 
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
191
+ agent_dirs = _get_agent_dirs()
202
192
 
203
- # Install to each destination
204
- for dest in destinations:
205
- installed_count, removed_count = _install_skills_to(dest, skills_by_package)
193
+ # Clean up orphaned plain-* items
194
+ removed_count = _cleanup_orphans(claude_dir, agent_dirs)
206
195
 
207
- parent_dir = dest.parent # .claude/ or .codex/
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
208
201
 
209
- # Setup hook only for Claude (Codex uses a different config format)
210
- if parent_dir.name == ".claude":
211
- _setup_session_hook(parent_dir)
202
+ # Clean up old session hook
203
+ _cleanup_session_hook(claude_dir)
212
204
 
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}/")
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
+ )
222
213
 
223
214
 
224
215
  @agent.command()
225
216
  def skills() -> None:
226
217
  """List available skills from installed packages"""
227
- skills_by_package = _get_packages_with_skills()
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)
228
227
 
229
- if not skills_by_package:
228
+ if not skill_names:
230
229
  click.echo("No skills found in installed packages.")
231
230
  return
232
231
 
233
232
  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})")
233
+ for name in sorted(skill_names):
234
+ click.echo(f" - {name}")
plain/cli/docs.py CHANGED
@@ -1,46 +1,87 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib.util
2
- import pkgutil
3
4
  from pathlib import Path
4
5
 
5
6
  import click
6
7
 
7
8
  from .llmdocs import LLMDocs
8
- from .output import iterate_markdown
9
+
10
+ # All known official Plain packages: pip name -> short description
11
+ KNOWN_PACKAGES = {
12
+ "plain": "Web framework core",
13
+ "plain-admin": "Backend admin interface",
14
+ "plain-api": "Class-based API views",
15
+ "plain-auth": "User authentication and authorization",
16
+ "plain-cache": "Database-backed cache with optional expiration",
17
+ "plain-code": "Preconfigured code formatting and linting",
18
+ "plain-dev": "Local development server with auto-reload",
19
+ "plain-elements": "HTML template components",
20
+ "plain-email": "Send email",
21
+ "plain-esbuild": "Build JavaScript with esbuild",
22
+ "plain-flags": "Feature flags via database models",
23
+ "plain-htmx": "HTMX integration for templates and views",
24
+ "plain-jobs": "Background jobs with a database-driven queue",
25
+ "plain-loginlink": "Link-based authentication",
26
+ "plain-models": "Model data and store it in a database",
27
+ "plain-oauth": "OAuth provider login",
28
+ "plain-observer": "On-page telemetry and observability",
29
+ "plain-pages": "Serve static pages, markdown, and assets",
30
+ "plain-pageviews": "Client-side pageview tracking",
31
+ "plain-passwords": "Password authentication",
32
+ "plain-pytest": "Test with pytest",
33
+ "plain-redirection": "URL redirection with admin and logging",
34
+ "plain-scan": "Test for production best practices",
35
+ "plain-sessions": "Database-backed sessions",
36
+ "plain-start": "Bootstrap a new project from templates",
37
+ "plain-support": "Support forms for your application",
38
+ "plain-tailwind": "Tailwind CSS without JavaScript or npm",
39
+ "plain-toolbar": "Debug toolbar",
40
+ "plain-tunnel": "Remote access to local dev server",
41
+ "plain-vendor": "Vendor CDN scripts and styles",
42
+ }
43
+
44
+
45
+ def _normalize_module(module: str) -> str:
46
+ """Normalize a module string to dotted form (e.g. plain-models -> plain.models)."""
47
+ module = module.replace("-", ".")
48
+ if not module.startswith("plain"):
49
+ module = f"plain.{module}"
50
+ return module
51
+
52
+
53
+ def _pip_package_name(module: str) -> str:
54
+ """Convert a dotted module name to a pip package name (e.g. plain.models -> plain-models)."""
55
+ return module.replace(".", "-")
56
+
57
+
58
+ def _is_installed(module: str) -> bool:
59
+ """Check if a dotted module name is installed."""
60
+ try:
61
+ return importlib.util.find_spec(module) is not None
62
+ except (ModuleNotFoundError, ValueError):
63
+ return False
64
+
65
+
66
+ def _online_docs_url(pip_name: str) -> str:
67
+ """Return the online documentation URL for a package."""
68
+ module = pip_name.replace("-", ".")
69
+ return f"https://plainframework.com/docs/{pip_name}/{module.replace('.', '/')}/"
9
70
 
10
71
 
11
72
  @click.command()
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")
73
+ @click.option("--symbols", is_flag=True, help="Show symbolicated API surface only")
14
74
  @click.option("--list", "show_list", is_flag=True, help="List available packages")
15
75
  @click.argument("module", default="")
16
- def docs(module: str, open: bool, source: bool, show_list: bool) -> None:
76
+ def docs(module: str, symbols: bool, show_list: bool) -> None:
17
77
  """Show documentation for a package"""
18
78
  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.")
79
+ for pip_name in sorted(KNOWN_PACKAGES):
80
+ description = KNOWN_PACKAGES[pip_name]
81
+ dotted = pip_name.replace("-", ".")
82
+ installed = _is_installed(dotted)
83
+ status = " (installed)" if installed else ""
84
+ click.echo(f" {pip_name}{status} {description}")
44
85
  return
45
86
 
46
87
  if not module:
@@ -48,32 +89,27 @@ def docs(module: str, open: bool, source: bool, show_list: bool) -> None:
48
89
  "You must specify a module. Use --list to see available packages."
49
90
  )
50
91
 
51
- # Convert hyphens to dots (e.g., plain-models -> plain.models)
52
- module = module.replace("-", ".")
53
-
54
- # Automatically prefix if we need to
55
- if not module.startswith("plain"):
56
- module = f"plain.{module}"
92
+ module = _normalize_module(module)
57
93
 
58
94
  # Get the module path
59
95
  spec = importlib.util.find_spec(module)
60
96
  if not spec or not spec.origin:
61
- raise click.UsageError(f"Module {module} not found")
97
+ pip_name = _pip_package_name(module)
98
+ if pip_name in KNOWN_PACKAGES:
99
+ msg = (
100
+ f"{module} is not installed.\n\n"
101
+ f" Online docs: {_online_docs_url(pip_name)}"
102
+ )
103
+ else:
104
+ msg = f"Module {module} not found. Use --list to see available packages."
105
+ raise click.UsageError(msg)
62
106
 
63
107
  module_path = Path(spec.origin).parent
64
108
 
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)
70
- else:
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()))
109
+ llm_docs = LLMDocs([module_path])
110
+ llm_docs.load()
111
+ llm_docs.print(
112
+ relative_to=module_path.parent,
113
+ include_docs=not symbols,
114
+ include_symbols=symbols,
115
+ )
plain/cli/llmdocs.py CHANGED
@@ -26,7 +26,7 @@ class LLMDocs:
26
26
  self.docs.add(path)
27
27
 
28
28
  # Exclude "migrations" code from plain apps, except for plain/models/migrations
29
- # Also exclude CHANGELOG.md, AGENTS.md, and skills directory
29
+ # Also exclude CHANGELOG.md, AGENTS.md, and agents directory
30
30
  self.docs = {
31
31
  doc
32
32
  for doc in self.docs
@@ -35,7 +35,7 @@ class LLMDocs:
35
35
  and "/plain/models/migrations/" not in str(doc)
36
36
  )
37
37
  and doc.name not in ("CHANGELOG.md", "AGENTS.md")
38
- and "/skills/" not in str(doc)
38
+ and "/agents/" not in str(doc)
39
39
  }
40
40
  self.sources = {
41
41
  source
@@ -45,7 +45,7 @@ class LLMDocs:
45
45
  and "/plain/models/migrations/" not in str(source)
46
46
  )
47
47
  and source.name != "cli.py"
48
- and "/skills/" not in str(source)
48
+ and "/agents/" not in str(source)
49
49
  }
50
50
 
51
51
  self.docs = sorted(self.docs)
@@ -62,28 +62,35 @@ class LLMDocs:
62
62
  plain_root = Path(*path.parts[: root_index + 1])
63
63
  return path.relative_to(plain_root.parent)
64
64
 
65
- def print(self, relative_to: Path | None = None) -> None:
66
- for doc in self.docs:
67
- if relative_to:
68
- display_path = doc.relative_to(relative_to)
69
- else:
70
- display_path = self.display_path(doc)
71
- click.secho(f"<Docs: {display_path}>", fg="yellow")
72
- click.echo(doc.read_text())
73
- click.secho(f"</Docs: {display_path}>", fg="yellow")
74
- click.echo()
75
-
76
- for source in self.sources:
77
- if symbolicated := self.symbolicate(source):
65
+ def print(
66
+ self,
67
+ relative_to: Path | None = None,
68
+ include_docs: bool = True,
69
+ include_symbols: bool = True,
70
+ ) -> None:
71
+ if include_docs:
72
+ for doc in self.docs:
78
73
  if relative_to:
79
- display_path = source.relative_to(relative_to)
74
+ display_path = doc.relative_to(relative_to)
80
75
  else:
81
- display_path = self.display_path(source)
82
- click.secho(f"<Source: {display_path}>", fg="yellow")
83
- click.echo(symbolicated)
84
- click.secho(f"</Source: {display_path}>", fg="yellow")
76
+ display_path = self.display_path(doc)
77
+ click.secho(f"<Docs: {display_path}>", fg="yellow")
78
+ click.echo(doc.read_text())
79
+ click.secho(f"</Docs: {display_path}>", fg="yellow")
85
80
  click.echo()
86
81
 
82
+ if include_symbols:
83
+ for source in self.sources:
84
+ if symbolicated := self.symbolicate(source):
85
+ if relative_to:
86
+ display_path = source.relative_to(relative_to)
87
+ else:
88
+ display_path = self.display_path(source)
89
+ click.secho(f"<Source: {display_path}>", fg="yellow")
90
+ click.echo(symbolicated)
91
+ click.secho(f"</Source: {display_path}>", fg="yellow")
92
+ click.echo()
93
+
87
94
  @staticmethod
88
95
  def symbolicate(file_path: Path) -> str:
89
96
  if "internal" in str(file_path).split("/"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.101.2
3
+ Version: 0.103.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-Expression: BSD-3-Clause
@@ -1,4 +1,4 @@
1
- plain/CHANGELOG.md,sha256=hvYn3NacP4SzrolTGeNiW9GT4__mq84UI2pxdZtpMfw,55077
1
+ plain/CHANGELOG.md,sha256=FDaTDlnefZUH4Yq9XefuoXUf3CLLA0QKAM6Y0ln_q8I,57385
2
2
  plain/README.md,sha256=VvzhXNvf4I6ddmjBP9AExxxFXxs7RwyoxdgFm-W5dsg,4072
3
3
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
4
4
  plain/debug.py,sha256=C2OnFHtRGMrpCiHSt-P2r58JypgQZ62qzDBpV4mhtFM,855
@@ -8,6 +8,9 @@ plain/paginator.py,sha256=uModWWSnXISNt1Ecb5C17yoaKiTcHltFHLrfqQ-lio0,6240
8
8
  plain/signing.py,sha256=aFwfZew9Ot7_26F88wSOU82MdNUQ3i1A7kI1qKS1q-4,11634
9
9
  plain/validators.py,sha256=JBeycZFilWs69ai-QQb_4snpDgs1fjMGT0ICwVB1mvE,21297
10
10
  plain/wsgi.py,sha256=MWQ09DFNV2GxX752hYJMWka9LTEwzCp6XEC_4TdXF9g,287
11
+ plain/agents/.claude/rules/plain.md,sha256=CgNYXI1pA3DWZv1T5iuempiMIJLD8w9VWbYDlJNuXNI,3617
12
+ plain/agents/.claude/skills/plain-install/SKILL.md,sha256=lPhUMmKAQpjrVMp73T5pBkwkIaJjWTxAqtyS8Hio8mM,746
13
+ plain/agents/.claude/skills/plain-upgrade/SKILL.md,sha256=79otYHv68Tfk3j08kRbg7o9eiNzzFZFyXCfLuIlT2Ho,927
11
14
  plain/assets/README.md,sha256=iRZHHoXZCEFooXoDYSomF12XTcDFl7oNbRHpAFwxTM8,4349
12
15
  plain/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
16
  plain/assets/compile.py,sha256=a-e_nKHJLN3fOp9nw1MF7f7oP6q5qX8oYelowXKb_EE,3587
@@ -21,15 +24,15 @@ plain/chores/core.py,sha256=BxsCSJDQvMjYsAH4QhEoW9ZUEAUIwJgTYyHovPGSLjk,578
21
24
  plain/chores/registry.py,sha256=IRpx3f6Z1qlqcEpHTe6O6JocNmaplLu7BVOqGrafSXU,1221
22
25
  plain/cli/README.md,sha256=8OmOhvKvgFkndwGn8lW6q62mvIB6Zq_NAhEhBf7PVV0,6305
23
26
  plain/cli/__init__.py,sha256=o-dmnnNmXM3fZrKwW1qcoAPlcfG5pcFTFYWEBCocf8A,117
24
- plain/cli/agent.py,sha256=ApEeJRWt_oTksuURvmSTHHe1_eIKWe_2AtqYV20U8hc,8212
27
+ plain/cli/agent.py,sha256=ZjCmeroPhX8Dc9Y0J_-ePzdZIZ4kicC3adsV6GL4xNI,7683
25
28
  plain/cli/build.py,sha256=UxgyAris4xtOZYS4FLMIOQLQ9DflHZkkenUJ650kZuA,3142
26
29
  plain/cli/changelog.py,sha256=ckuP99HtnUSJXf_AELWgmD9S0XjldCzh5urxPiDpRcI,3647
27
30
  plain/cli/chores.py,sha256=jhLKxFs_YpyQDvo5chNGiKnFc2_KK5atq53EpkGmMo0,2542
28
31
  plain/cli/core.py,sha256=_Kg_YnYeDZB3EKlzdejl4eJepzRd2aF1LSs0Ei2uCI8,6923
29
- plain/cli/docs.py,sha256=wC9ShvzCwJZoymV8G_Rs4AaPEU-_Eh-EkI0OFYJxKKA,2599
32
+ plain/cli/docs.py,sha256=XeLt_Zf43NZV4yCkj0nqYcT5ejY7NR_NvyouClZTL0c,4321
30
33
  plain/cli/formatting.py,sha256=t0kxJ-r-his8kGcY38g38p699CfFGUgqEI1tJkEbjGs,3802
31
34
  plain/cli/install.py,sha256=28lXobloqqN50duuKlUqalAmNHUJdz-JlYYmdpXpzCs,1260
32
- plain/cli/llmdocs.py,sha256=xI2ta85zSEmVJePqrecxPd4EDhRSSY29pwSGor1DNJc,5599
35
+ plain/cli/llmdocs.py,sha256=05lMHg6pYUb4zi6CTW5DDAidHFOkZWfG4ReMgUB42aM,5824
33
36
  plain/cli/output.py,sha256=uZTHZR-Axeoi2r6fgcDCpDA7iQSRrktBtTf1yBT5djI,1426
34
37
  plain/cli/preflight.py,sha256=zWnw9RFmN0CQQByAB2vWLbwlDNrQzUQTtKKySVzRucE,7036
35
38
  plain/cli/print.py,sha256=s_yNxtA4vg-AWn4C9TtFH-gOMnzMsXuEr7ak4-oOFPI,265
@@ -133,12 +136,6 @@ plain/signals/__init__.py,sha256=VDhotllLUQVg3eA1LuAJM9pwGTaf_bzRGzc4mJhd-sY,98
133
136
  plain/signals/dispatch/__init__.py,sha256=FzEygqV9HsM6gopio7O2Oh_X230nA4d5Q9s0sUjMq0E,292
134
137
  plain/signals/dispatch/dispatcher.py,sha256=ofpu8wMEx8O-I9SO9Rkk1ABH09-71QnmGZlt8vsZAw8,12317
135
138
  plain/signals/dispatch/license.txt,sha256=o9EhDhsC4Q5HbmD-IfNGVTEkXtNE33r5rIt3lleJ8gc,1727
136
- plain/skills/README.md,sha256=WUhXiLU42pIdcXiT-zugnsOXj4OjiCmec_XcM85KAyo,1434
137
- plain/skills/plain-docs/SKILL.md,sha256=CoOEhTsOpNczBuGxnHbGIVZ9Yahreq0RoXzqhVwtajY,560
138
- plain/skills/plain-install/SKILL.md,sha256=lPhUMmKAQpjrVMp73T5pBkwkIaJjWTxAqtyS8Hio8mM,746
139
- plain/skills/plain-request/SKILL.md,sha256=C37GEWRIpJFr-fbnEb08ENOJg-A7WUZxoYPneFAkHO4,771
140
- plain/skills/plain-shell/SKILL.md,sha256=njxD1dXVmSVZkcUnEm9_62hn_P6JMQmCpVOEzq29RvY,400
141
- plain/skills/plain-upgrade/SKILL.md,sha256=79otYHv68Tfk3j08kRbg7o9eiNzzFZFyXCfLuIlT2Ho,927
142
139
  plain/templates/README.md,sha256=zhAayenfxtwq6r1-NjoaD-wjaazyhm76BCacGF24ACc,8803
143
140
  plain/templates/__init__.py,sha256=bX76FakE9T7mfK3N0deN85HlwHNQpeigytSC9Z8LcOs,451
144
141
  plain/templates/core.py,sha256=mbcH0yTeFOI3XOg9dYSroXRIcdv9sETEy4HzY-ugwco,1258
@@ -194,8 +191,8 @@ plain/views/forms.py,sha256=j5WAMLeW1efU9M0q1W6SFHMDMBZfkFfW4WRLKwrXjqk,2435
194
191
  plain/views/objects.py,sha256=qKK8lYQKK7DTBPrMUebZ2HevgU9MmyWeXFWG1lT5ZbM,5393
195
192
  plain/views/redirect.py,sha256=rt5RF1Rs2yYFLole3Mznu5iU9aeumu49VAaX4WCd_xk,2139
196
193
  plain/views/templates.py,sha256=ElyqgpbkoJt72yU1gF7b626TB3R8viDAf-LYloulUBA,1925
197
- plain-0.101.2.dist-info/METADATA,sha256=EeQAZpll3Jn51d0GwuHSbstJjL_78xe-43GS_fSsQpU,4550
198
- plain-0.101.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
199
- plain-0.101.2.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
200
- plain-0.101.2.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
201
- plain-0.101.2.dist-info/RECORD,,
194
+ plain-0.103.0.dist-info/METADATA,sha256=SQquYimnpZsPSt2gxBB7kw08UrCwGUgqYq0btTNOP34,4550
195
+ plain-0.103.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
196
+ plain-0.103.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
197
+ plain-0.103.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
198
+ plain-0.103.0.dist-info/RECORD,,
plain/skills/README.md DELETED
@@ -1,36 +0,0 @@
1
- # plain.skills
2
-
3
- **Agent skills for working with Plain projects.**
4
-
5
- These skills provide context and workflows for common tasks when using [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Codex](https://codex.openai.com/).
6
-
7
- ## Available skills
8
-
9
- | Skill | Description |
10
- | --------------- | -------------------------------------------------------------- |
11
- | `plain-docs` | Retrieves detailed documentation for Plain packages |
12
- | `plain-install` | Installs Plain packages and guides through setup steps |
13
- | `plain-upgrade` | Upgrades Plain packages and applies required migration changes |
14
- | `plain-shell` | Runs Python with Plain configured and database access |
15
- | `plain-request` | Makes test HTTP requests against the development database |
16
-
17
- ## Installation
18
-
19
- To install skills to your project's `.claude/` or `.codex/` directory:
20
-
21
- ```bash
22
- uv run plain agent install
23
- ```
24
-
25
- This command:
26
-
27
- - Copies skill definitions so your agent can use them
28
- - Sets up a `SessionStart` hook that runs `plain agent context` at the start of every session
29
-
30
- Run it again after upgrading Plain to get updated skills.
31
-
32
- ## Commands
33
-
34
- - `plain agent install` - Install skills and set up hooks
35
- - `plain agent skills` - List available skills from installed packages
36
- - `plain agent context` - Output framework context (used by the SessionStart hook)
@@ -1,25 +0,0 @@
1
- ---
2
- name: plain-docs
3
- description: Retrieves detailed documentation for Plain packages. Use when looking up package APIs or feature details.
4
- ---
5
-
6
- # Getting Documentation
7
-
8
- ## List Available Packages
9
-
10
- ```
11
- uv run plain docs --list
12
- ```
13
-
14
- ## Get Package Documentation
15
-
16
- ```
17
- uv run plain docs <package> --source
18
- ```
19
-
20
- Examples:
21
-
22
- - `uv run plain docs models --source` - Models and database
23
- - `uv run plain docs templates --source` - Jinja2 templates
24
- - `uv run plain docs assets --source` - Static assets
25
- - `uv run plain docs tailwind --source` - Tailwind CSS integration
@@ -1,39 +0,0 @@
1
- ---
2
- name: plain-request
3
- description: Makes HTTP requests to test URLs, check endpoints, fetch pages, or debug routes. Use when asked to look at a URL, hit an endpoint, test a route, or make GET/POST requests.
4
- ---
5
-
6
- # Making HTTP Requests
7
-
8
- Use `uv run plain request` to make test requests against the dev database.
9
-
10
- ## Basic Usage
11
-
12
- ```
13
- uv run plain request /path
14
- ```
15
-
16
- ## With Authentication
17
-
18
- ```
19
- uv run plain request /path --user 1
20
- ```
21
-
22
- ## With Custom Headers
23
-
24
- ```
25
- uv run plain request /path --header "Accept: application/json"
26
- ```
27
-
28
- ## POST/PUT/PATCH with Data
29
-
30
- ```
31
- uv run plain request /path --method POST --data '{"key": "value"}'
32
- ```
33
-
34
- ## Limiting Output
35
-
36
- ```
37
- uv run plain request /path --no-body # Headers only
38
- uv run plain request /path --no-headers # Body only
39
- ```
@@ -1,24 +0,0 @@
1
- ---
2
- name: plain-shell
3
- description: Runs Python with Plain configured and database access. Use for scripts, one-off commands, or interactive sessions.
4
- ---
5
-
6
- # Running Python with Plain
7
-
8
- ## Interactive Shell
9
-
10
- ```
11
- uv run plain shell
12
- ```
13
-
14
- ## One-off Command
15
-
16
- ```
17
- uv run plain shell -c "from app.users.models import User; print(User.query.count())"
18
- ```
19
-
20
- ## Run a Script
21
-
22
- ```
23
- uv run plain run script.py
24
- ```