Update README.MD and add nano-claude-code v3.0 + original-source-code/src
- 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>
This commit is contained in:
184
nano-claude-code/skill/loader.py
Normal file
184
nano-claude-code/skill/loader.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user