- README.MD: add original-source-code and nano-claude-code sections, update overview table (4 subprojects), add v3.0 news entry, expand comparison table with memory/multi-agent/skills dimensions - nano-claude-code v3.0: multi-agent package (multi_agent/), memory package (memory/), skill package (skill/) with built-in /commit and /review skills, context compression (compaction.py), tool registry plugin system, diff view, 17 slash commands, 18 built-in tools, 101 tests (~5000 lines total) - original-source-code/src: add raw TypeScript source tree (1884 files) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
6.5 KiB
Python
185 lines
6.5 KiB
Python
"""Skill loading: parse markdown files with YAML frontmatter into SkillDef objects."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass
|
|
class SkillDef:
|
|
name: str
|
|
description: str
|
|
triggers: list[str] # ["/commit", "commit changes"]
|
|
tools: list[str] # ["Bash", "Read"] (allowed-tools)
|
|
prompt: str # full prompt body after frontmatter
|
|
file_path: str
|
|
# Enhanced fields
|
|
when_to_use: str = "" # when Claude should auto-invoke this skill
|
|
argument_hint: str = "" # e.g. "[branch] [description]"
|
|
arguments: list[str] = field(default_factory=list) # named arg names
|
|
model: str = "" # model override
|
|
user_invocable: bool = True # appears in /skills list
|
|
context: str = "inline" # "inline" or "fork" (fork = sub-agent)
|
|
source: str = "user" # "user", "project", "builtin"
|
|
|
|
|
|
# ── Directory paths ────────────────────────────────────────────────────────
|
|
|
|
def _get_skill_paths() -> list[Path]:
|
|
return [
|
|
Path.cwd() / ".nano_claude" / "skills", # project-level (priority)
|
|
Path.home() / ".nano_claude" / "skills", # user-level
|
|
]
|
|
|
|
|
|
# ── List field parser ──────────────────────────────────────────────────────
|
|
|
|
def _parse_list_field(value: str) -> list[str]:
|
|
"""Parse YAML-like list: ``[a, b, c]`` or ``"a, b, c"``."""
|
|
value = value.strip()
|
|
if value.startswith("[") and value.endswith("]"):
|
|
value = value[1:-1]
|
|
return [item.strip().strip('"').strip("'") for item in value.split(",") if item.strip()]
|
|
|
|
|
|
# ── Single-file parser ─────────────────────────────────────────────────────
|
|
|
|
def _parse_skill_file(path: Path, source: str = "user") -> Optional[SkillDef]:
|
|
"""Parse a markdown file with ``---`` frontmatter into a SkillDef.
|
|
|
|
Frontmatter fields:
|
|
name, description, triggers, tools / allowed-tools,
|
|
when_to_use, argument-hint, arguments, model,
|
|
user-invocable, context
|
|
"""
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return None
|
|
|
|
if not text.startswith("---"):
|
|
return None
|
|
|
|
parts = text.split("---", 2)
|
|
if len(parts) < 3:
|
|
return None
|
|
|
|
frontmatter_raw = parts[1].strip()
|
|
prompt = parts[2].strip()
|
|
|
|
fields: dict[str, str] = {}
|
|
for line in frontmatter_raw.splitlines():
|
|
line = line.strip()
|
|
if not line or ":" not in line:
|
|
continue
|
|
key, _, val = line.partition(":")
|
|
fields[key.strip().lower()] = val.strip()
|
|
|
|
name = fields.get("name", "")
|
|
if not name:
|
|
return None
|
|
|
|
# allowed-tools wins over tools if present
|
|
tools_raw = fields.get("allowed-tools", fields.get("tools", ""))
|
|
tools = _parse_list_field(tools_raw) if tools_raw else []
|
|
|
|
triggers_raw = fields.get("triggers", "")
|
|
triggers = _parse_list_field(triggers_raw) if triggers_raw else [f"/{name}"]
|
|
|
|
arguments_raw = fields.get("arguments", "")
|
|
arguments = _parse_list_field(arguments_raw) if arguments_raw else []
|
|
|
|
user_invocable_raw = fields.get("user-invocable", "true")
|
|
user_invocable = user_invocable_raw.lower() not in ("false", "0", "no")
|
|
|
|
context = fields.get("context", "inline").strip().lower()
|
|
if context not in ("inline", "fork"):
|
|
context = "inline"
|
|
|
|
return SkillDef(
|
|
name=name,
|
|
description=fields.get("description", ""),
|
|
triggers=triggers,
|
|
tools=tools,
|
|
prompt=prompt,
|
|
file_path=str(path),
|
|
when_to_use=fields.get("when_to_use", ""),
|
|
argument_hint=fields.get("argument-hint", ""),
|
|
arguments=arguments,
|
|
model=fields.get("model", ""),
|
|
user_invocable=user_invocable,
|
|
context=context,
|
|
source=source,
|
|
)
|
|
|
|
|
|
# ── Registry of built-in skills (registered by builtin.py) ────────────────
|
|
|
|
_BUILTIN_SKILLS: list[SkillDef] = []
|
|
|
|
|
|
def register_builtin_skill(skill: SkillDef) -> None:
|
|
_BUILTIN_SKILLS.append(skill)
|
|
|
|
|
|
# ── Load all skills ────────────────────────────────────────────────────────
|
|
|
|
def load_skills(include_builtins: bool = True) -> list[SkillDef]:
|
|
"""Return skills from disk + builtins, deduplicated (project > user > builtin)."""
|
|
seen: dict[str, SkillDef] = {}
|
|
|
|
# Builtins go in first (lowest priority)
|
|
if include_builtins:
|
|
for sk in _BUILTIN_SKILLS:
|
|
seen[sk.name] = sk
|
|
|
|
# User-level next, project-level last (highest priority)
|
|
skill_paths = _get_skill_paths()
|
|
for i, skill_dir in enumerate(reversed(skill_paths)):
|
|
src = "user" if i == 0 else "project"
|
|
if not skill_dir.is_dir():
|
|
continue
|
|
for md_file in sorted(skill_dir.glob("*.md")):
|
|
skill = _parse_skill_file(md_file, source=src)
|
|
if skill:
|
|
seen[skill.name] = skill
|
|
|
|
return list(seen.values())
|
|
|
|
|
|
def find_skill(query: str) -> Optional[SkillDef]:
|
|
"""Find a skill whose trigger matches the first word (or whole string) of query."""
|
|
query = query.strip()
|
|
if not query:
|
|
return None
|
|
|
|
first_word = query.split()[0]
|
|
for skill in load_skills():
|
|
for trigger in skill.triggers:
|
|
if first_word == trigger:
|
|
return skill
|
|
if trigger.startswith(first_word + " "):
|
|
return skill
|
|
return None
|
|
|
|
|
|
# ── Argument substitution ─────────────────────────────────────────────────
|
|
|
|
def substitute_arguments(prompt: str, args: str, arg_names: list[str]) -> str:
|
|
"""Replace $ARGUMENTS (whole args string) and $ARG_NAME placeholders.
|
|
|
|
Named args are positional: first word → first name, etc.
|
|
"""
|
|
# Always substitute $ARGUMENTS
|
|
result = prompt.replace("$ARGUMENTS", args)
|
|
|
|
# Named args: split by whitespace
|
|
arg_values = args.split()
|
|
for i, arg_name in enumerate(arg_names):
|
|
placeholder = f"${arg_name.upper()}"
|
|
value = arg_values[i] if i < len(arg_values) else ""
|
|
result = result.replace(placeholder, value)
|
|
|
|
return result
|