Skip to content

Commit fac8673

Browse files
committed
Refactor skills CLI to plain agent with SessionStart hook
- Rename `plain skills` to `plain agent` with subcommands: - `plain agent install` - Install skills and set up hooks - `plain agent skills` - List available skills - `plain agent context` - Output framework context - Add SessionStart hook that runs `plain agent context` at session start - Remove plain-principles skill (content now in context output) - Add --no-headers and --no-body flags to plain request - Improve plain-request description for better skill activation - Move code style guidelines to plain-fix skill - Mark plain-tailwind and plain-models as not user-invocable - Update plain dev to call `plain agent install`
1 parent f2b3247 commit fac8673

File tree

13 files changed

+168
-72
lines changed

13 files changed

+168
-72
lines changed

.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"SessionStart": [
4+
{
5+
"matcher": "startup|resume",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "uv run plain agent context 2>/dev/null || true"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

example/.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"SessionStart": [
4+
{
5+
"matcher": "startup|resume",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "uv run plain agent context 2>/dev/null || true"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

plain-code/plain/code/skills/plain-fix/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ uv run plain fix [path]
1111

1212
Automatically fixes formatting and linting issues using ruff and biome.
1313

14+
## Code Style
15+
16+
- Add `from __future__ import annotations` at the top of Python files
17+
- Keep imports at the top of the file unless avoiding circular imports
18+
1419
Options:
1520

1621
- `--unsafe-fixes` - Apply ruff unsafe fixes

plain-dev/plain/dev/core.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def run(self, *, reinstall_ssl: bool = False) -> int:
142142
)
143143

144144
self.symlink_plain_src()
145-
self.install_skills()
145+
self.install_agent()
146146
self.modify_hosts_file()
147147

148148
print_event("Running preflight checks...", newline=False)
@@ -229,24 +229,24 @@ def symlink_plain_src(self) -> None:
229229
if plain_path.exists() and not symlink_path.exists():
230230
symlink_path.symlink_to(plain_path)
231231

232-
def install_skills(self) -> None:
233-
"""Install skills from Plain packages to .claude/agents/."""
232+
def install_agent(self) -> None:
233+
"""Install AI agent skills and hooks."""
234234
try:
235235
result = subprocess.run(
236-
[sys.executable, "-m", "plain", "skills", "--install"],
236+
[sys.executable, "-m", "plain", "agent", "install"],
237237
check=False,
238238
capture_output=True,
239239
text=True,
240240
)
241241
if result.returncode != 0 and result.stderr:
242242
click.secho(
243-
f"Warning: Failed to install skills: {result.stderr}",
243+
f"Warning: Failed to install agent: {result.stderr}",
244244
fg="yellow",
245245
err=True,
246246
)
247247
except Exception as e:
248248
click.secho(
249-
f"Warning: Failed to install skills: {e}",
249+
f"Warning: Failed to install agent: {e}",
250250
fg="yellow",
251251
err=True,
252252
)

plain-dev/plain/dev/skills/plain-dev/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Run `uv run plain dev` to start the development server.
1111

1212
The server URL will be displayed (typically `https://<project>.localhost:8443`).
1313

14+
This also runs `plain agent install` to set up AI agent skills and hooks.
15+
1416
## Logs
1517

1618
- `uv run plain dev logs` - Show log output for running processes

plain-models/plain/models/skills/plain-models/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
name: plain-models
33
description: Manages database migrations and model changes. Use when creating migrations, running migrations, or modifying models.
4+
user-invocable: false
45
---
56

67
# Database Migrations

plain-tailwind/plain/tailwind/skills/plain-tailwind/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
name: plain-tailwind
33
description: Provides Tailwind CSS patterns for Plain templates. Use when styling templates or working with conditional CSS classes.
4+
user-invocable: false
45
---
56

67
# Tailwind CSS Patterns
Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import importlib.util
4+
import json
45
import pkgutil
56
import shutil
67
from pathlib import Path
@@ -127,29 +128,63 @@ def _install_skills_to(
127128
return installed_count, removed_count
128129

129130

130-
@click.command()
131-
@click.option(
132-
"--install/--no-install",
133-
is_flag=True,
134-
help="Install skills to agent directories",
135-
)
136-
def skills(install: bool) -> None:
137-
"""Install skills from Plain packages"""
131+
def _setup_session_hook(dest_dir: Path) -> None:
132+
"""Create or update settings.json with SessionStart hook."""
133+
settings_file = dest_dir / "settings.json"
138134

135+
# Load existing settings or start fresh
136+
if settings_file.exists():
137+
settings = json.loads(settings_file.read_text())
138+
else:
139+
settings = {}
140+
141+
# Ensure hooks structure exists
142+
if "hooks" not in settings:
143+
settings["hooks"] = {}
144+
145+
# Define the Plain hook - calls the agent context command directly
146+
plain_hook = {
147+
"matcher": "startup|resume",
148+
"hooks": [
149+
{
150+
"type": "command",
151+
"command": "uv run plain agent context 2>/dev/null || true",
152+
}
153+
],
154+
}
155+
156+
# Get existing SessionStart hooks, remove any existing plain hook
157+
session_hooks = settings["hooks"].get("SessionStart", [])
158+
session_hooks = [h for h in session_hooks if "plain agent" not in str(h)]
159+
# Also remove old plain-context.md hooks for migration
160+
session_hooks = [h for h in session_hooks if "plain-context.md" not in str(h)]
161+
session_hooks.append(plain_hook)
162+
settings["hooks"]["SessionStart"] = session_hooks
163+
164+
settings_file.write_text(json.dumps(settings, indent=2) + "\n")
165+
166+
167+
@click.group()
168+
def agent() -> None:
169+
"""AI agent integration for Plain projects"""
170+
pass
171+
172+
173+
@agent.command()
174+
def context() -> None:
175+
"""Output Plain framework context for AI agents"""
176+
click.echo("This is a Plain project. Use the /plain-* skills for common tasks.")
177+
178+
179+
@agent.command()
180+
def install() -> None:
181+
"""Install skills and hooks to agent directories"""
139182
skills_by_package = _get_packages_with_skills()
140183

141184
if not skills_by_package:
142185
click.echo("No skills found in installed packages.")
143186
return
144187

145-
if not install:
146-
# Just list available skills
147-
click.echo("Available skills:")
148-
for pkg_name in sorted(skills_by_package.keys()):
149-
for skill_dir in skills_by_package[pkg_name]:
150-
click.echo(f" - {skill_dir.name} (from {pkg_name})")
151-
return
152-
153188
# Find destinations based on what agent directories exist
154189
destinations = _get_skill_destinations()
155190

@@ -163,10 +198,30 @@ def skills(install: bool) -> None:
163198
# Install to each destination
164199
for dest in destinations:
165200
installed_count, removed_count = _install_skills_to(dest, skills_by_package)
166-
if installed_count > 0 or removed_count > 0:
167-
parts = []
168-
if installed_count > 0:
169-
parts.append(f"installed {installed_count}")
170-
if removed_count > 0:
171-
parts.append(f"removed {removed_count}")
172-
click.echo(f"Skills: {', '.join(parts)} in {dest}/")
201+
202+
# Setup hook in parent directory
203+
parent_dir = dest.parent # .claude/ or .codex/
204+
_setup_session_hook(parent_dir)
205+
206+
parts = []
207+
if installed_count > 0:
208+
parts.append(f"installed {installed_count} skills")
209+
if removed_count > 0:
210+
parts.append(f"removed {removed_count} skills")
211+
parts.append("updated hooks")
212+
click.echo(f"Agent: {', '.join(parts)} in {parent_dir}/")
213+
214+
215+
@agent.command()
216+
def skills() -> None:
217+
"""List available skills from installed packages"""
218+
skills_by_package = _get_packages_with_skills()
219+
220+
if not skills_by_package:
221+
click.echo("No skills found in installed packages.")
222+
return
223+
224+
click.echo("Available skills:")
225+
for pkg_name in sorted(skills_by_package.keys()):
226+
for skill_dir in skills_by_package[pkg_name]:
227+
click.echo(f" - {skill_dir.name} (from {pkg_name})")

plain/plain/cli/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import plain.runtime
1010
from plain.exceptions import ImproperlyConfigured
1111

12+
from .agent import agent
1213
from .build import build
1314
from .changelog import changelog
1415
from .chores import chores
@@ -22,7 +23,6 @@
2223
from .server import server
2324
from .settings import settings
2425
from .shell import run, shell
25-
from .skills import skills
2626
from .upgrade import upgrade
2727
from .urls import urls
2828
from .utils import utils
@@ -35,7 +35,7 @@ def plain_cli() -> None:
3535

3636
plain_cli.add_command(docs)
3737
plain_cli.add_command(request)
38-
plain_cli.add_command(skills)
38+
plain_cli.add_command(agent)
3939
plain_cli.add_command(preflight_cli)
4040
plain_cli.add_command(create)
4141
plain_cli.add_command(chores)

plain/plain/cli/request.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@
4040
multiple=True,
4141
help="Additional headers (format: 'Name: Value')",
4242
)
43+
@click.option(
44+
"--no-headers",
45+
is_flag=True,
46+
help="Hide response headers from output",
47+
)
48+
@click.option(
49+
"--no-body",
50+
is_flag=True,
51+
help="Hide response body from output",
52+
)
4353
def request(
4454
path: str,
4555
method: str,
@@ -48,6 +58,8 @@ def request(
4858
follow: bool,
4959
content_type: str | None,
5060
headers: tuple[str, ...],
61+
no_headers: bool,
62+
no_body: bool,
5163
) -> None:
5264
"""Make HTTP requests against the dev database"""
5365

@@ -155,14 +167,14 @@ def request(
155167
click.echo()
156168

157169
# Show headers
158-
if response.headers:
170+
if response.headers and not no_headers:
159171
click.secho("Response Headers:", fg="yellow", bold=True)
160172
for key, value in response.headers.items():
161173
click.echo(f" {key}: {value}")
162174
click.echo()
163175

164176
# Show response content last
165-
if response.content:
177+
if response.content and not no_body:
166178
content_type = response.headers.get("Content-Type", "")
167179

168180
if "json" in content_type.lower():
@@ -187,7 +199,7 @@ def request(
187199
click.secho("Response Body:", fg="yellow", bold=True)
188200
content = response.content.decode("utf-8", errors="replace")
189201
click.echo(content)
190-
else:
202+
elif not no_body:
191203
click.secho("(No response body)", fg="yellow", dim=True)
192204

193205
except Exception as e:

0 commit comments

Comments
 (0)