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:
14
nano-claude-code/skill/__init__.py
Normal file
14
nano-claude-code/skill/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""skill package — reusable prompt templates (skills)."""
|
||||
from .loader import ( # noqa: F401
|
||||
SkillDef,
|
||||
load_skills,
|
||||
find_skill,
|
||||
substitute_arguments,
|
||||
register_builtin_skill,
|
||||
_parse_skill_file,
|
||||
_parse_list_field,
|
||||
)
|
||||
from .executor import execute_skill # noqa: F401
|
||||
|
||||
# Importing builtin registers the built-in skills
|
||||
from . import builtin as _builtin # noqa: F401
|
||||
100
nano-claude-code/skill/builtin.py
Normal file
100
nano-claude-code/skill/builtin.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Built-in skills that ship with nano-claude-code."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .loader import SkillDef, register_builtin_skill
|
||||
|
||||
# ── /commit ────────────────────────────────────────────────────────────────
|
||||
|
||||
_COMMIT_PROMPT = """\
|
||||
Review the current git state and create a well-structured commit.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git status` and `git diff --staged` to see what is staged.
|
||||
- If nothing is staged, run `git diff` to see unstaged changes, then stage relevant files.
|
||||
2. Analyze the changes:
|
||||
- Summarize the nature of the change (feature, bug fix, refactor, docs, etc.)
|
||||
- Write a concise commit title (≤72 chars) focusing on *why*, not just *what*.
|
||||
- If multiple logical changes exist, ask the user whether to split them.
|
||||
3. Create the commit:
|
||||
```
|
||||
git commit -m "<title>"
|
||||
```
|
||||
If additional context is needed, add a body separated by a blank line.
|
||||
4. Print the commit hash and summary when done.
|
||||
|
||||
**Rules:**
|
||||
- Never use `--no-verify`.
|
||||
- Never commit files that likely contain secrets (.env, credentials, keys).
|
||||
- Prefer imperative mood in the title: "Add X", "Fix Y", "Refactor Z".
|
||||
|
||||
User context: $ARGUMENTS
|
||||
"""
|
||||
|
||||
_REVIEW_PROMPT = """\
|
||||
Review the code or pull request and provide structured feedback.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Understand the scope:
|
||||
- If a PR number or URL is given in $ARGUMENTS, use `gh pr view $ARGUMENTS --patch` to get the diff.
|
||||
- Otherwise, use `git diff main...HEAD` (or `git diff HEAD~1`) for local changes.
|
||||
2. Analyze the diff:
|
||||
- Correctness: Are there bugs, edge cases, or logic errors?
|
||||
- Security: Injection, auth issues, exposed secrets, unsafe operations?
|
||||
- Performance: N+1 queries, unnecessary allocations, blocking calls?
|
||||
- Style: Does it follow existing conventions in the codebase?
|
||||
- Tests: Are new behaviors tested? Do existing tests cover the change?
|
||||
3. Write a structured review:
|
||||
```
|
||||
## Summary
|
||||
One-line overview of what the change does.
|
||||
|
||||
## Issues
|
||||
- [CRITICAL/MAJOR/MINOR] Description and location
|
||||
|
||||
## Suggestions
|
||||
- Nice-to-have improvements
|
||||
|
||||
## Verdict
|
||||
APPROVE / REQUEST CHANGES / COMMENT
|
||||
```
|
||||
4. If changes are needed, list specific file:line references.
|
||||
|
||||
User context: $ARGUMENTS
|
||||
"""
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
register_builtin_skill(SkillDef(
|
||||
name="commit",
|
||||
description="Review staged changes and create a well-structured git commit",
|
||||
triggers=["/commit"],
|
||||
tools=["Bash", "Read"],
|
||||
prompt=_COMMIT_PROMPT,
|
||||
file_path="<builtin>",
|
||||
when_to_use="Use when the user wants to commit changes. Triggers: '/commit', 'commit changes', 'make a commit'.",
|
||||
argument_hint="[optional context]",
|
||||
arguments=[],
|
||||
user_invocable=True,
|
||||
context="inline",
|
||||
source="builtin",
|
||||
))
|
||||
|
||||
register_builtin_skill(SkillDef(
|
||||
name="review",
|
||||
description="Review code changes or a pull request and provide structured feedback",
|
||||
triggers=["/review", "/review-pr"],
|
||||
tools=["Bash", "Read", "Grep"],
|
||||
prompt=_REVIEW_PROMPT,
|
||||
file_path="<builtin>",
|
||||
when_to_use="Use when the user wants a code review. Triggers: '/review', '/review-pr', 'review this PR'.",
|
||||
argument_hint="[PR number or URL]",
|
||||
arguments=["pr"],
|
||||
user_invocable=True,
|
||||
context="inline",
|
||||
source="builtin",
|
||||
))
|
||||
|
||||
|
||||
_register_builtins()
|
||||
66
nano-claude-code/skill/executor.py
Normal file
66
nano-claude-code/skill/executor.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Skill execution: inline (current conversation) or forked (sub-agent)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generator
|
||||
|
||||
from .loader import SkillDef, substitute_arguments
|
||||
|
||||
|
||||
def execute_skill(
|
||||
skill: SkillDef,
|
||||
args: str,
|
||||
state,
|
||||
config: dict,
|
||||
system_prompt: str,
|
||||
) -> Generator:
|
||||
"""Execute a skill.
|
||||
|
||||
If skill.context == "fork", runs as an isolated sub-agent and yields its events.
|
||||
Otherwise (inline), injects the rendered prompt into the current agent loop.
|
||||
|
||||
Args:
|
||||
skill: SkillDef to execute
|
||||
args: raw argument string from user (after the trigger word)
|
||||
state: AgentState
|
||||
config: config dict (may contain _depth, model, etc.)
|
||||
system_prompt: current system prompt string
|
||||
Yields:
|
||||
agent events (TextChunk, ToolStart, ToolEnd, TurnDone, …)
|
||||
"""
|
||||
rendered = substitute_arguments(skill.prompt, args, skill.arguments)
|
||||
message = f"[Skill: {skill.name}]\n\n{rendered}"
|
||||
|
||||
if skill.context == "fork":
|
||||
yield from _execute_forked(skill, message, config, system_prompt)
|
||||
else:
|
||||
yield from _execute_inline(message, state, config, system_prompt)
|
||||
|
||||
|
||||
def _execute_inline(message: str, state, config: dict, system_prompt: str) -> Generator:
|
||||
"""Run skill prompt inline in the current conversation."""
|
||||
import agent as _agent
|
||||
yield from _agent.run(message, state, config, system_prompt)
|
||||
|
||||
|
||||
def _execute_forked(
|
||||
skill: SkillDef,
|
||||
message: str,
|
||||
config: dict,
|
||||
system_prompt: str,
|
||||
) -> Generator:
|
||||
"""Run skill as an isolated sub-agent (separate conversation context)."""
|
||||
import agent as _agent
|
||||
|
||||
# Build a sub-agent config with depth tracking
|
||||
depth = config.get("_depth", 0) + 1
|
||||
sub_config = {**config, "_depth": depth, "_system_prompt": system_prompt}
|
||||
if skill.model:
|
||||
sub_config["model"] = skill.model
|
||||
|
||||
# Restrict tools if skill specifies allowed-tools
|
||||
if skill.tools:
|
||||
sub_config["_allowed_tools"] = skill.tools
|
||||
|
||||
# Run in fresh state (no shared history)
|
||||
sub_state = _agent.AgentState()
|
||||
yield from _agent.run(message, sub_state, sub_config, system_prompt)
|
||||
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
|
||||
110
nano-claude-code/skill/tools.py
Normal file
110
nano-claude-code/skill/tools.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Skill tool: lets the model invoke skills by name via tool call."""
|
||||
from __future__ import annotations
|
||||
|
||||
from tool_registry import ToolDef, register_tool
|
||||
from .loader import find_skill, load_skills, substitute_arguments
|
||||
|
||||
|
||||
_SKILL_SCHEMA = {
|
||||
"name": "Skill",
|
||||
"description": (
|
||||
"Invoke a named skill (reusable prompt template). "
|
||||
"Use SkillList to see available skills and their triggers."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Skill name (e.g. 'commit', 'review')",
|
||||
},
|
||||
"args": {
|
||||
"type": "string",
|
||||
"description": "Arguments to pass to the skill (replaces $ARGUMENTS)",
|
||||
"default": "",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
}
|
||||
|
||||
_SKILL_LIST_SCHEMA = {
|
||||
"name": "SkillList",
|
||||
"description": "List all available skills with their names, triggers, and descriptions.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _skill_tool(params: dict, config: dict) -> str:
|
||||
"""Execute a skill by name and return its output."""
|
||||
skill_name = params.get("name", "").strip()
|
||||
args = params.get("args", "")
|
||||
|
||||
# Look up by name first, then by trigger
|
||||
skill = None
|
||||
for s in load_skills():
|
||||
if s.name == skill_name:
|
||||
skill = s
|
||||
break
|
||||
if skill is None:
|
||||
skill = find_skill(skill_name)
|
||||
if skill is None:
|
||||
names = [s.name for s in load_skills()]
|
||||
return f"Error: skill '{skill_name}' not found. Available: {', '.join(names)}"
|
||||
|
||||
rendered = substitute_arguments(skill.prompt, args, skill.arguments)
|
||||
message = f"[Skill: {skill.name}]\n\n{rendered}"
|
||||
|
||||
# Run inline via agent and collect text output
|
||||
import agent as _agent
|
||||
system_prompt = config.get("_system_prompt", "")
|
||||
|
||||
# Collect output text
|
||||
output_parts: list[str] = []
|
||||
sub_state = _agent.AgentState()
|
||||
sub_config = {**config, "_depth": config.get("_depth", 0) + 1}
|
||||
try:
|
||||
for event in _agent.run(message, sub_state, sub_config, system_prompt):
|
||||
if hasattr(event, "text"):
|
||||
output_parts.append(event.text)
|
||||
except Exception as e:
|
||||
return f"Skill execution error: {e}"
|
||||
|
||||
return "".join(output_parts) or "(skill completed with no text output)"
|
||||
|
||||
|
||||
def _skill_list_tool(params: dict, config: dict) -> str:
|
||||
skills = load_skills()
|
||||
if not skills:
|
||||
return "No skills available."
|
||||
lines = ["Available skills:\n"]
|
||||
for s in skills:
|
||||
triggers = ", ".join(s.triggers)
|
||||
hint = f" args: {s.argument_hint}" if s.argument_hint else ""
|
||||
when = f"\n when: {s.when_to_use}" if s.when_to_use else ""
|
||||
lines.append(f"- **{s.name}** [{triggers}]{hint}\n {s.description}{when}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _register() -> None:
|
||||
register_tool(ToolDef(
|
||||
name="Skill",
|
||||
schema=_SKILL_SCHEMA,
|
||||
func=_skill_tool,
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
))
|
||||
register_tool(ToolDef(
|
||||
name="SkillList",
|
||||
schema=_SKILL_LIST_SCHEMA,
|
||||
func=_skill_list_tool,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
|
||||
|
||||
_register()
|
||||
Reference in New Issue
Block a user