Add directory-based project skills
This commit is contained in:
+100
-100
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -29,6 +30,8 @@ class BundledSkill:
|
||||
aliases: tuple[str, ...] = ()
|
||||
allowed_tools: tuple[str, ...] = ()
|
||||
user_invocable: bool = True
|
||||
source: str = 'python'
|
||||
path: str | None = None
|
||||
get_prompt: Callable[['LocalCodingAgent', str], str] = lambda _a, _args: ''
|
||||
|
||||
|
||||
@@ -98,33 +101,6 @@ def _simplify_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
{f'Additional context: {args}' if args.strip() else ''}"""
|
||||
|
||||
|
||||
def _verify_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
"""Generate the verify prompt."""
|
||||
return f"""Verify that the recent code changes work correctly.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Identify what was changed (check `git diff` and `git status`)
|
||||
2. Determine the appropriate verification strategy:
|
||||
- **Unit tests**: Run existing tests, check for failures
|
||||
- **Integration tests**: Run broader test suites if available
|
||||
- **Manual verification**: Start the app/server and test the feature
|
||||
3. Run the verification
|
||||
4. Report the result clearly:
|
||||
- PASS: All checks passed, feature works as expected
|
||||
- FAIL: Describe what failed and why
|
||||
- PARTIAL: Some checks passed, some need attention
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
- For CLI tools: Run the command with test inputs
|
||||
- For servers: Start the server, make test requests
|
||||
- For libraries: Run the test suite
|
||||
- For config changes: Validate the config loads correctly
|
||||
|
||||
{f'Specific focus: {args}' if args.strip() else 'Verify the most recent changes.'}"""
|
||||
|
||||
|
||||
def _debug_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
"""Generate the debug prompt."""
|
||||
import os
|
||||
@@ -154,71 +130,94 @@ def _debug_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _update_config_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
"""Generate the update-config prompt."""
|
||||
return f"""Help configure the agent settings.
|
||||
def _directory_skill_prompt(body: str) -> Callable[['LocalCodingAgent', str], str]:
|
||||
def _prompt(_agent: 'LocalCodingAgent', args: str) -> str:
|
||||
if args.strip():
|
||||
return f'{body.rstrip()}\n\n## Invocation Arguments\n\n{args.strip()}'
|
||||
return body
|
||||
|
||||
## Settings File Locations
|
||||
return _prompt
|
||||
|
||||
- **Global**: `~/.claude/settings.json` — applies to all projects
|
||||
- **Project**: `.claude/settings.json` — project-specific, committed to git
|
||||
- **Local**: `.claude/settings.local.json` — project-specific, gitignored
|
||||
|
||||
## Configurable Settings
|
||||
def _parse_front_matter(text: str) -> tuple[dict[str, str], str]:
|
||||
if not text.startswith('---\n'):
|
||||
return {}, text
|
||||
end_marker = text.find('\n---\n', 4)
|
||||
if end_marker == -1:
|
||||
return {}, text
|
||||
raw_meta = text[4:end_marker]
|
||||
body = text[end_marker + len('\n---\n') :]
|
||||
metadata: dict[str, str] = {}
|
||||
for line in raw_meta.splitlines():
|
||||
if not line.strip() or line.lstrip().startswith('#') or ':' not in line:
|
||||
continue
|
||||
key, value = line.split(':', 1)
|
||||
metadata[key.strip()] = value.strip()
|
||||
return metadata, body
|
||||
|
||||
### Hooks
|
||||
Event-driven shell commands that run on tool use or lifecycle events:
|
||||
- `PreToolUse` — runs before a tool executes (can block with exit code 2)
|
||||
- `PostToolUse` — runs after a tool completes
|
||||
- `PreCompact` — runs before conversation compaction
|
||||
|
||||
Hook format:
|
||||
```json
|
||||
{{
|
||||
"hooks": {{
|
||||
"PreToolUse": [
|
||||
{{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{{
|
||||
"type": "command",
|
||||
"command": "echo 'tool: $TOOL_NAME'"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
}}
|
||||
```
|
||||
def _split_csv(value: str | None) -> tuple[str, ...]:
|
||||
if not value:
|
||||
return ()
|
||||
return tuple(item.strip() for item in value.split(',') if item.strip())
|
||||
|
||||
### Permissions
|
||||
Tool permission rules:
|
||||
```json
|
||||
{{
|
||||
"permissions": {{
|
||||
"allow": ["Read", "Grep", "Glob"],
|
||||
"deny": ["Bash(rm:*)"]
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```json
|
||||
{{
|
||||
"env": {{
|
||||
"MY_VAR": "value"
|
||||
}}
|
||||
}}
|
||||
```
|
||||
def _parse_bool(value: str | None, *, default: bool = True) -> bool:
|
||||
if value is None or not value.strip():
|
||||
return default
|
||||
return value.strip().lower() not in {'0', 'false', 'no', 'off'}
|
||||
|
||||
{f'User request: {args}' if args.strip() else 'What would you like to configure?'}"""
|
||||
|
||||
def _load_directory_skill(path: Path, *, source: str = 'directory') -> BundledSkill | None:
|
||||
try:
|
||||
text = path.read_text(encoding='utf-8')
|
||||
except OSError:
|
||||
return None
|
||||
metadata, body = _parse_front_matter(text)
|
||||
name = metadata.get('name') or path.parent.name
|
||||
description = metadata.get('description', '').strip()
|
||||
if not name.strip() or not description:
|
||||
return None
|
||||
return BundledSkill(
|
||||
name=name.strip(),
|
||||
description=description,
|
||||
when_to_use=metadata.get('when_to_use', '').strip(),
|
||||
aliases=_split_csv(metadata.get('aliases')),
|
||||
allowed_tools=_split_csv(metadata.get('allowed_tools')),
|
||||
user_invocable=_parse_bool(metadata.get('user_invocable'), default=True),
|
||||
source=source,
|
||||
path=str(path),
|
||||
get_prompt=_directory_skill_prompt(body.strip()),
|
||||
)
|
||||
|
||||
|
||||
def load_directory_skills(
|
||||
root: Path | None = None,
|
||||
*,
|
||||
source: str = 'directory',
|
||||
) -> tuple[BundledSkill, ...]:
|
||||
skill_root = root or (Path(__file__).resolve().parent / 'skills' / 'bundled')
|
||||
if not skill_root.exists() or not skill_root.is_dir():
|
||||
return ()
|
||||
skills: list[BundledSkill] = []
|
||||
for path in sorted(skill_root.glob('*/SKILL.md')):
|
||||
skill = _load_directory_skill(path, source=source)
|
||||
if skill is not None:
|
||||
skills.append(skill)
|
||||
return tuple(skills)
|
||||
|
||||
|
||||
def load_project_skills(cwd: Path | str | None) -> tuple[BundledSkill, ...]:
|
||||
if cwd is None:
|
||||
return ()
|
||||
return load_directory_skills(Path(cwd).resolve() / 'skills', source='project')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUNDLED_SKILLS: tuple[BundledSkill, ...] = (
|
||||
PYTHON_BUNDLED_SKILLS: tuple[BundledSkill, ...] = (
|
||||
BundledSkill(
|
||||
name='simplify',
|
||||
description='Review changed code for reuse, quality, and efficiency, then fix issues.',
|
||||
@@ -226,13 +225,6 @@ BUNDLED_SKILLS: tuple[BundledSkill, ...] = (
|
||||
allowed_tools=('read_file', 'edit_file', 'write_file', 'bash', 'grep_search', 'glob_search'),
|
||||
get_prompt=_simplify_prompt,
|
||||
),
|
||||
BundledSkill(
|
||||
name='verify',
|
||||
description='Verify a code change works by running the app and tests.',
|
||||
when_to_use='When the user asks to verify, test, or check that recent changes work.',
|
||||
allowed_tools=('read_file', 'bash', 'grep_search', 'glob_search'),
|
||||
get_prompt=_verify_prompt,
|
||||
),
|
||||
BundledSkill(
|
||||
name='debug',
|
||||
description='Debug the current session — show diagnostics, token usage, and config.',
|
||||
@@ -240,32 +232,40 @@ BUNDLED_SKILLS: tuple[BundledSkill, ...] = (
|
||||
user_invocable=True,
|
||||
get_prompt=_debug_prompt,
|
||||
),
|
||||
BundledSkill(
|
||||
name='update-config',
|
||||
description='Configure settings via settings.json — hooks, permissions, env vars.',
|
||||
when_to_use='When the user wants to configure hooks, permissions, or settings.',
|
||||
aliases=('config-help',),
|
||||
allowed_tools=('read_file',),
|
||||
get_prompt=_update_config_prompt,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_bundled_skills() -> tuple[BundledSkill, ...]:
|
||||
"""Return all registered bundled skills."""
|
||||
return BUNDLED_SKILLS
|
||||
def get_bundled_skills(cwd: Path | str | None = None) -> tuple[BundledSkill, ...]:
|
||||
"""Return registered skills, with project skills taking precedence."""
|
||||
skills = [
|
||||
*load_project_skills(cwd),
|
||||
*load_directory_skills(),
|
||||
*PYTHON_BUNDLED_SKILLS,
|
||||
]
|
||||
seen: set[str] = set()
|
||||
deduped: list[BundledSkill] = []
|
||||
for skill in skills:
|
||||
lowered = skill.name.lower()
|
||||
if lowered in seen:
|
||||
continue
|
||||
seen.add(lowered)
|
||||
deduped.append(skill)
|
||||
return tuple(deduped)
|
||||
|
||||
|
||||
def find_bundled_skill(name: str) -> BundledSkill | None:
|
||||
def find_bundled_skill(name: str, cwd: Path | str | None = None) -> BundledSkill | None:
|
||||
"""Look up a bundled skill by name or alias."""
|
||||
lowered = name.lower()
|
||||
for skill in BUNDLED_SKILLS:
|
||||
for skill in get_bundled_skills(cwd):
|
||||
if lowered == skill.name or lowered in skill.aliases:
|
||||
return skill
|
||||
return None
|
||||
|
||||
|
||||
def format_skills_for_system_prompt(max_chars: int = 8000) -> str:
|
||||
def format_skills_for_system_prompt(
|
||||
max_chars: int = 8000,
|
||||
cwd: Path | str | None = None,
|
||||
) -> str:
|
||||
"""Format bundled skills for inclusion in system-reminder messages.
|
||||
|
||||
The model discovers available skills through this listing.
|
||||
@@ -273,7 +273,7 @@ def format_skills_for_system_prompt(max_chars: int = 8000) -> str:
|
||||
lines = ['Available skills (invoke via Skill tool):']
|
||||
char_count = len(lines[0])
|
||||
|
||||
for skill in BUNDLED_SKILLS:
|
||||
for skill in get_bundled_skills(cwd):
|
||||
if not skill.user_invocable:
|
||||
continue
|
||||
entry = f'- {skill.name}: {skill.description}'
|
||||
|
||||
Reference in New Issue
Block a user