290 lines
9.3 KiB
Python
290 lines
9.3 KiB
Python
"""Bundled skill definitions — prompt-type skills invocable via the Skill tool.
|
|
|
|
Mirrors the npm ``src/skills/bundled/`` module.
|
|
|
|
Bundled skills differ from slash commands:
|
|
- They generate AI prompts sent to the model (prompt-type).
|
|
- They carry ``when_to_use`` guidance for model auto-invocation.
|
|
- They can restrict ``allowed_tools`` during execution.
|
|
- They appear in system-reminder skill listings for model discovery.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Callable
|
|
|
|
if TYPE_CHECKING:
|
|
from .agent_runtime import LocalCodingAgent
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BundledSkill:
|
|
"""A bundled skill definition."""
|
|
|
|
name: str
|
|
description: str
|
|
when_to_use: str = ''
|
|
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: ''
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skill prompt generators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _simplify_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
|
"""Generate the simplify review prompt."""
|
|
cwd = str(agent.runtime_config.cwd)
|
|
diff = ''
|
|
try:
|
|
proc = subprocess.run(
|
|
['git', 'diff', 'HEAD'],
|
|
cwd=cwd, capture_output=True, text=True, timeout=30,
|
|
)
|
|
diff = proc.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
if not diff:
|
|
try:
|
|
proc = subprocess.run(
|
|
['git', 'diff'],
|
|
cwd=cwd, capture_output=True, text=True, timeout=30,
|
|
)
|
|
diff = proc.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
diff_section = f'```diff\n{diff}\n```' if diff else 'No changes detected — run `git diff` to verify.'
|
|
|
|
return f"""Review the changed code for reuse, quality, and efficiency, then fix any issues found.
|
|
|
|
## Changed Code
|
|
|
|
{diff_section}
|
|
|
|
## Review Checklist
|
|
|
|
### Code Reuse
|
|
- Duplicated functions or logic that should be consolidated
|
|
- Redundant utilities or helpers
|
|
- Patterns that should use existing abstractions
|
|
|
|
### Code Quality
|
|
- Redundant state or unnecessary variables
|
|
- Parameter sprawl (too many function arguments)
|
|
- Copy-paste code that should be refactored
|
|
- Leaky abstractions or wrong abstraction level
|
|
- Stringly-typed code that should use enums/types
|
|
- Overly nested conditionals
|
|
|
|
### Efficiency
|
|
- N+1 query or call patterns
|
|
- Missed concurrency opportunities (parallel I/O)
|
|
- Hot-path bloat (unnecessary work in tight loops)
|
|
- Memory leaks or unbounded growth
|
|
|
|
## Instructions
|
|
|
|
1. Read the full diff above
|
|
2. For each category, identify concrete issues with file paths and line numbers
|
|
3. Fix each issue directly — do not just report them
|
|
4. After fixing, verify the changes compile/run correctly
|
|
|
|
{f'Additional context: {args}' if args.strip() else ''}"""
|
|
|
|
|
|
def _debug_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
|
"""Generate the debug prompt."""
|
|
import os
|
|
|
|
lines = ['## Debug Session Info', '']
|
|
lines.append(f'Working directory: {agent.runtime_config.cwd}')
|
|
lines.append(f'Model: {agent.model_config.model}')
|
|
lines.append(f'Base URL: {agent.model_config.base_url}')
|
|
|
|
session = agent.last_session
|
|
if session:
|
|
lines.append(f'Messages in session: {len(session.messages)}')
|
|
tool_count = sum(1 for m in session.messages if m.role == 'tool')
|
|
lines.append(f'Tool results: {tool_count}')
|
|
|
|
usage = agent.cumulative_usage
|
|
lines.append(f'Total tokens used: {usage.total_tokens:,}')
|
|
lines.append(f'Cost: ${agent.cumulative_cost_usd:.4f}')
|
|
|
|
# Check for debug log
|
|
debug_log = os.environ.get('CLAUDE_CODE_DEBUG_LOG', '')
|
|
if debug_log:
|
|
lines.append(f'Debug log: {debug_log}')
|
|
else:
|
|
lines.append('Debug logging: not enabled (set CLAUDE_CODE_DEBUG_LOG to enable)')
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
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
|
|
|
|
return _prompt
|
|
|
|
|
|
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
|
|
|
|
|
|
def _split_csv(value: str | None) -> tuple[str, ...]:
|
|
if not value:
|
|
return ()
|
|
return tuple(item.strip() for item in value.split(',') if item.strip())
|
|
|
|
|
|
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'}
|
|
|
|
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PYTHON_BUNDLED_SKILLS: tuple[BundledSkill, ...] = (
|
|
BundledSkill(
|
|
name='simplify',
|
|
description='Review changed code for reuse, quality, and efficiency, then fix issues.',
|
|
when_to_use='When the user asks to review, simplify, or clean up recent code changes.',
|
|
allowed_tools=('read_file', 'edit_file', 'write_file', 'bash', 'grep_search', 'glob_search'),
|
|
get_prompt=_simplify_prompt,
|
|
),
|
|
BundledSkill(
|
|
name='debug',
|
|
description='Debug the current session — show diagnostics, token usage, and config.',
|
|
when_to_use='When the user asks to debug the agent session or check diagnostics.',
|
|
user_invocable=True,
|
|
get_prompt=_debug_prompt,
|
|
),
|
|
)
|
|
|
|
|
|
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, cwd: Path | str | None = None) -> BundledSkill | None:
|
|
"""Look up a bundled skill by name or alias."""
|
|
lowered = name.lower()
|
|
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,
|
|
cwd: Path | str | None = None,
|
|
) -> str:
|
|
"""Format bundled skills for inclusion in system-reminder messages.
|
|
|
|
The model discovers available skills through this listing.
|
|
"""
|
|
lines = ['Available skills (invoke via Skill tool):']
|
|
char_count = len(lines[0])
|
|
|
|
for skill in get_bundled_skills(cwd):
|
|
if not skill.user_invocable:
|
|
continue
|
|
entry = f'- {skill.name}: {skill.description}'
|
|
if skill.when_to_use:
|
|
entry += f' When to use: {skill.when_to_use}'
|
|
if len(entry) > 250:
|
|
entry = entry[:247] + '...'
|
|
if char_count + len(entry) + 1 > max_chars:
|
|
break
|
|
lines.append(entry)
|
|
char_count += len(entry) + 1
|
|
|
|
return '\n'.join(lines)
|