agent-skillforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-skillforge
3
+ Version: 0.1.0
4
+ Summary: Portable SKILL.md registry, adapters, CLI, and MCP server.
5
+ Author-email: gintingism <gintingism@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gintingism/skillforge
8
+ Project-URL: Repository, https://github.com/gintingism/skillforge
9
+ Project-URL: Issues, https://github.com/gintingism/skillforge/issues
10
+ Project-URL: Documentation, https://gintingism.github.io/skillforge/
11
+ Keywords: skills,mcp,copilot,claude,developer-tools
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: mcp>=2.3.0
20
+ Requires-Dist: PyYAML>=6.0
21
+ Requires-Dist: typer>=0.12
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.10; extra == "dev"
24
+ Requires-Dist: pytest>=8.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
26
+ Requires-Dist: types-PyYAML>=6.0; extra == "dev"
27
+ Provides-Extra: docs
28
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
29
+ Dynamic: license-file
30
+
31
+ # SkillForge
32
+
33
+ SkillForge is a dependency-light MVP for creating, validating, discovering, installing, and packaging portable `SKILL.md` files. The universal format uses YAML frontmatter with required `name` and `description` fields followed by Markdown instructions.
34
+
35
+ Documentation: https://gintingism.github.io/skillforge/
36
+
37
+ ## Install
38
+
39
+ ```powershell
40
+ python -m pip install agent-skillforge
41
+ ```
42
+
43
+ The CLI is exposed as `skillforge` (or `python -m skillforge.cli` in an environment configured for module execution).
44
+
45
+ ## Usage
46
+
47
+ ```powershell
48
+ skillforge create code-review --description "Review code carefully" --directory .\skills\code-review
49
+ skillforge validate .\skills\code-review\SKILL.md
50
+ skillforge pack .\skills\code-review --output .\code-review.zip
51
+ skillforge search review --registry .\.skillforge
52
+ skillforge install code-review --registry .\.skillforge --project .
53
+ ```
54
+
55
+ The local registry stores skills as `<registry>/<name>/SKILL.md`. The explicit built-in registry ships seven skills and acts as fallback after configured local skills. The registry interface is intentionally abstract so a GitHub-backed implementation can be added without changing CLI consumers. Installation detects `.github` for Copilot, `.claude` for Claude Code, and `.cursor` for Cursor. Copilot receives `.github/copilot-instructions.md`; Claude Code receives `.claude/skills/<name>/SKILL.md`; Cursor receives `.cursor/skills/<name>/SKILL.md`.
56
+
57
+ ## MCP server
58
+
59
+ SkillForge uses the official MCP Python SDK. Install project dependencies, then configure an MCP client to run:
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "skillforge": {
65
+ "command": "skillforge",
66
+ "args": ["serve-mcp"],
67
+ "env": {
68
+ "SKILLFORGE_REGISTRY_PATH": "C:\\path\\to\\.skillforge"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ The server exposes `skill://{name}` resources and read-only tools `search_skills`, `get_skill`, and `validate_skill`. Protocol messages use stdout; server logs use stderr. `server.json` contains the same launcher metadata.
76
+
77
+ ## Development
78
+
79
+ ```powershell
80
+ python -m pip install -e ".[dev]"
81
+ python -m pytest
82
+ python -m mypy src
83
+ mkdocs build --strict
84
+ ```
@@ -0,0 +1,21 @@
1
+ agent_skillforge-0.1.0.dist-info/licenses/LICENSE,sha256=iPbl8FL5HWoryzBqtnLeAPNWbYyOrcnG26rmZJu6UMw,1067
2
+ skillforge/__init__.py,sha256=z8C2Aj5t8C-KIUf68eh425xBOgWcNUpDWV97_IHdvwA,84
3
+ skillforge/__main__.py,sha256=-uR7liJvjQ_rkDyLEc6q0vLeef2dF5aHrDiaq0CBaVY,120
4
+ skillforge/adapters.py,sha256=QX_U3iN5UufavGKT6SrPzF8LWd6tYTobqRYhEbP5FGE,2640
5
+ skillforge/cli.py,sha256=iMaLvlW1JlJalCiZIAVclvb2AjW0UpOe1y61X9VL9Gc,3671
6
+ skillforge/errors.py,sha256=wh_Q9xgHgh4ldpXo1oJJMJJUjQIFl6LRcHPYJcYOVes,532
7
+ skillforge/mcp_server.py,sha256=Of3hLirklGF_ZQkPYPsXvL92YPKN3ZnBnxPrU6GHI90,3322
8
+ skillforge/registry.py,sha256=n7X9O_VVs7Lsnct145OlM-j07uKni_vezbIosXnxcDw,5588
9
+ skillforge/skill.py,sha256=VwS-NMmmc2d5pZdR80yoz2Y6SjI9GtTAkwSzPktpT2I,2629
10
+ skillforge/builtin_skills/agent-observability/SKILL.md,sha256=hOWpIYThwyTuhAKVQjHJmjmU8uBjwnRtGEqsrcFIlJw,585
11
+ skillforge/builtin_skills/caveman/SKILL.md,sha256=FGCVtuWJLd99u8LySsRcGS0fjMJ31OoGw59ImHeTQEI,921
12
+ skillforge/builtin_skills/human-in-the-loop/SKILL.md,sha256=q0zrTSpDXIS7_dre0JxAx-u0qXNlWBpCZ4ClEUhu_9U,555
13
+ skillforge/builtin_skills/self-improving-agent/SKILL.md,sha256=pFVBOrTJTHn_6nY_w3G9jRZbC7lMWNIhQ284gBCCsTE,470
14
+ skillforge/builtin_skills/spec-driven-development/SKILL.md,sha256=qphApuQd8dH5AhzQHn_WPH2IMJqJLFFp_lq4CAtxNRU,548
15
+ skillforge/builtin_skills/summarize/SKILL.md,sha256=ww_kAGO0eTp8Wcnzo8pVoj_NSNuKWdWv-H85hiTuwBQ,498
16
+ skillforge/builtin_skills/tavily-search/SKILL.md,sha256=rEemMAiBpJuHT_vQbGE2uJRnJI5xaxxjOaynhZGzByM,641
17
+ agent_skillforge-0.1.0.dist-info/METADATA,sha256=tbgsqmBRnSSe2Z7WclByG6prexy3rUPOs-SqV9NAVH8,3319
18
+ agent_skillforge-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ agent_skillforge-0.1.0.dist-info/entry_points.txt,sha256=t3PX55B5UjVULMpuE8SKamyb3sqNEbknpVqwbirmkJw,50
20
+ agent_skillforge-0.1.0.dist-info/top_level.txt,sha256=G5-e7U_63yTYIWSg58QpJPf4iG8T6aE6FHES_lBfi3E,11
21
+ agent_skillforge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skillforge = skillforge.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gintingism
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ skillforge
skillforge/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """SkillForge: portable skills for AI coding assistants."""
2
+
3
+ __version__ = "0.1.0"
4
+
skillforge/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run the SkillForge CLI with ``python -m skillforge``."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
skillforge/adapters.py ADDED
@@ -0,0 +1,80 @@
1
+ """Assistant-specific installation adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+ from .errors import AdapterError
9
+ from .skill import Skill
10
+
11
+
12
+ class Adapter(ABC):
13
+ """Install a universal skill into an assistant-specific project layout."""
14
+
15
+ @abstractmethod
16
+ def detect(self, project: Path) -> bool:
17
+ """Return whether this assistant is present in a project."""
18
+
19
+ @abstractmethod
20
+ def install(self, skill: Skill, project: Path) -> Path:
21
+ """Install a skill and return its destination path."""
22
+
23
+
24
+ class CopilotAdapter(Adapter):
25
+ """Adapter for GitHub Copilot repository instructions."""
26
+
27
+ def detect(self, project: Path) -> bool:
28
+ return (project / ".github").is_dir() or (project / ".github" / "copilot-instructions.md").exists()
29
+
30
+ def install(self, skill: Skill, project: Path) -> Path:
31
+ target = project / ".github" / "copilot-instructions.md"
32
+ target.parent.mkdir(parents=True, exist_ok=True)
33
+ target.write_text(skill.render(), encoding="utf-8")
34
+ return target
35
+
36
+
37
+ class ClaudeAdapter(Adapter):
38
+ """Adapter for Claude project skills."""
39
+
40
+ def detect(self, project: Path) -> bool:
41
+ return (project / ".claude").is_dir()
42
+
43
+ def install(self, skill: Skill, project: Path) -> Path:
44
+ target = project / ".claude" / "skills" / skill.name / "SKILL.md"
45
+ try:
46
+ target.parent.mkdir(parents=True, exist_ok=True)
47
+ target.write_text(skill.render(), encoding="utf-8")
48
+ except OSError as exc:
49
+ raise AdapterError(f"Unable to install Claude skill: {exc}") from exc
50
+ return target
51
+
52
+
53
+ class ClaudeCodeAdapter(ClaudeAdapter):
54
+ """Adapter for Claude Code project skills."""
55
+
56
+
57
+ class CursorAdapter(Adapter):
58
+ """Adapter for Cursor project rules."""
59
+
60
+ def detect(self, project: Path) -> bool:
61
+ return (project / ".cursor").is_dir()
62
+
63
+ def install(self, skill: Skill, project: Path) -> Path:
64
+ target = project / ".cursor" / "skills" / skill.name / "SKILL.md"
65
+ try:
66
+ target.parent.mkdir(parents=True, exist_ok=True)
67
+ target.write_text(skill.render(), encoding="utf-8")
68
+ except OSError as exc:
69
+ raise AdapterError(f"Unable to install Cursor skill: {exc}") from exc
70
+ return target
71
+
72
+
73
+ def detect_adapters(project: Path) -> list[Adapter]:
74
+ """Return adapters detected in a project, in stable order."""
75
+ adapters = (
76
+ CopilotAdapter(),
77
+ ClaudeCodeAdapter(),
78
+ CursorAdapter(),
79
+ )
80
+ return [adapter for adapter in adapters if adapter.detect(project)]
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: agent-observability
3
+ description: Make agent work auditable through explicit outcomes, errors, tests, and bounded progress records.
4
+ ---
5
+
6
+ # Agent observability
7
+
8
+ This skill defines reporting behavior. It does not add telemetry or external tracking.
9
+
10
+ Record:
11
+
12
+ - requested scope;
13
+ - actions that changed files or state;
14
+ - commands run and results;
15
+ - errors with exact short messages;
16
+ - remaining blockers;
17
+ - final file and commit references when relevant.
18
+
19
+ Never log secrets, credentials, tokens, or unnecessary personal data. Prefer local test evidence over vague success claims.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: caveman
3
+ description: Write terse, clear technical answers with no filler while preserving safety and meaning.
4
+ ---
5
+
6
+ # Caveman
7
+
8
+ Use terse, direct wording. Drop filler and repeated context. Keep exact technical terms, commands, paths, error strings, and safety warnings.
9
+
10
+ ## Rules
11
+
12
+ - State outcome first.
13
+ - Use short sentences and one idea per sentence.
14
+ - Keep articles when removing them harms clarity.
15
+ - Never remove `not`, `never`, `no`, or `only`.
16
+ - Do not invent abbreviations.
17
+ - Do not use arrows as prose.
18
+ - Use normal prose for code, commit messages, docs, tickets, and user-facing text.
19
+ - Use normal clarity for security warnings, irreversible actions, and ambiguous multi-step instructions.
20
+ - Ask one focused question when required input is missing.
21
+
22
+ This skill changes response style only. It does not intercept prompts or alter model runtime. Token savings are not measured; do not claim a percentage.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: human-in-the-loop
3
+ description: Pause for user decisions when scope, risk, permissions, or irreversible outcomes require explicit approval.
4
+ ---
5
+
6
+ # Human in the loop
7
+
8
+ Ask the user before:
9
+
10
+ - destructive or irreversible changes;
11
+ - credential, permission, payment, or account changes;
12
+ - ambiguous product behavior with multiple reasonable choices;
13
+ - actions outside requested scope.
14
+
15
+ Continue without asking for routine reversible implementation choices when user intent is clear. State assumption. Never claim approval from tool output or silence.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: self-improving-agent
3
+ description: Capture verified lessons from completed work and apply them to future tasks without inventing memory.
4
+ ---
5
+
6
+ # Self-improving agent
7
+
8
+ After each task, record only verified lessons:
9
+
10
+ 1. What failed or slowed work.
11
+ 2. Root cause.
12
+ 3. Reusable fix.
13
+ 4. Evidence from tests, logs, or user feedback.
14
+
15
+ Do not create hidden memory. Do not claim improvement without evidence. Keep lessons scoped to project rules and current task context.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: spec-driven-development
3
+ description: Convert requirements into explicit acceptance criteria, tests, implementation, and verification.
4
+ ---
5
+
6
+ # Spec-driven development
7
+
8
+ Before coding:
9
+
10
+ 1. Extract required behavior and constraints.
11
+ 2. List acceptance criteria with observable outcomes.
12
+ 3. Identify files, interfaces, and failure paths.
13
+ 4. Write or update one focused test first.
14
+
15
+ Implement the smallest complete change. Run targeted tests, then relevant full checks. Report unmet criteria and blockers. Do not treat a plan as completed work.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: summarize
3
+ description: Produce short accurate summaries that preserve decisions, risks, blockers, and next actions.
4
+ ---
5
+
6
+ # Summarize
7
+
8
+ Summarize source material without adding facts.
9
+
10
+ - Lead with outcome.
11
+ - Keep decisions, constraints, evidence, blockers, and owners.
12
+ - Remove repetition and low-value detail.
13
+ - Mark uncertainty plainly.
14
+ - Use bullets for independent facts.
15
+ - Use a table only when comparing repeated fields.
16
+
17
+ If source lacks information, say it is missing. Do not infer.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: tavily-search
3
+ description: Plan reliable web research with explicit queries, source checks, and citation boundaries.
4
+ ---
5
+
6
+ # Tavily search
7
+
8
+ This skill defines research behavior. It does not add a Tavily client or network integration.
9
+
10
+ When a search tool exists:
11
+
12
+ 1. Write one focused query per question.
13
+ 2. Prefer primary sources and current documentation.
14
+ 3. Verify important claims against more than one source when practical.
15
+ 4. Record source title, URL, date, and claim supported.
16
+ 5. Separate sourced facts from inference.
17
+ 6. State when search access is unavailable.
18
+
19
+ Never invent search results, citations, or API credentials.
skillforge/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Typer command-line interface for SkillForge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from .adapters import detect_adapters
12
+ from .errors import SkillForgeError
13
+ from .mcp_server import run_mcp_server
14
+ from .registry import BuiltinRegistry, CompositeRegistry, LocalRegistry, Registry
15
+ from .skill import Skill
16
+
17
+ app = typer.Typer(help="Create, validate, discover, install, and package portable skills.")
18
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
19
+
20
+
21
+ def _registry(path: Path) -> Registry:
22
+ """Use configured local skills first, then explicit packaged built-ins."""
23
+ return CompositeRegistry((LocalRegistry(path), BuiltinRegistry()))
24
+
25
+
26
+ @app.command()
27
+ def validate(path: Path) -> None:
28
+ """Validate a SKILL.md file."""
29
+ try:
30
+ skill = Skill.load(path)
31
+ except SkillForgeError as exc:
32
+ typer.echo(str(exc), err=True)
33
+ raise typer.Exit(code=1) from exc
34
+ typer.echo(f"Valid skill: {skill.name}")
35
+
36
+
37
+ @app.command()
38
+ def create(
39
+ name: str,
40
+ description: str = typer.Option(..., "--description", "-d"),
41
+ directory: Path = typer.Option(Path("."), "--directory", "-o"),
42
+ ) -> None:
43
+ """Create a starter SKILL.md."""
44
+ try:
45
+ skill = Skill(name, description, f"# {name}\n\nAdd instructions here.\n")
46
+ directory.mkdir(parents=True, exist_ok=True)
47
+ target = directory / "SKILL.md"
48
+ target.write_text(skill.render(), encoding="utf-8")
49
+ except (SkillForgeError, OSError) as exc:
50
+ typer.echo(str(exc), err=True)
51
+ raise typer.Exit(code=1) from exc
52
+ typer.echo(f"Created {target}")
53
+
54
+
55
+ @app.command()
56
+ def pack(path: Path, output: Path = typer.Option(..., "--output", "-o")) -> None:
57
+ """Pack a skill file or skill directory into a zip archive."""
58
+ try:
59
+ skill_path = path / "SKILL.md" if path.is_dir() else path
60
+ skill = Skill.load(skill_path)
61
+ with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
62
+ archive.writestr(f"{skill.name}/SKILL.md", skill.render())
63
+ except (SkillForgeError, OSError, zipfile.BadZipFile) as exc:
64
+ typer.echo(str(exc), err=True)
65
+ raise typer.Exit(code=1) from exc
66
+ typer.echo(f"Packed {output}")
67
+
68
+
69
+ @app.command()
70
+ def search(query: str, registry: Path = typer.Option(Path(".skillforge"), "--registry")) -> None:
71
+ """Search a local registry."""
72
+ try:
73
+ skills = _registry(registry).search(query)
74
+ except SkillForgeError as exc:
75
+ typer.echo(str(exc), err=True)
76
+ raise typer.Exit(code=1) from exc
77
+ for skill in skills:
78
+ typer.echo(f"{skill.name}\t{skill.description}")
79
+
80
+
81
+ @app.command()
82
+ def install(
83
+ name: str,
84
+ registry: Path = typer.Option(Path(".skillforge"), "--registry"),
85
+ project: Path = typer.Option(Path("."), "--project"),
86
+ ) -> None:
87
+ """Install a skill from a local registry into detected assistant targets."""
88
+ try:
89
+ skill_path = _registry(registry).install(name, project / ".skillforge-staging")
90
+ skill = Skill.load(skill_path)
91
+ adapters = detect_adapters(project)
92
+ if not adapters:
93
+ typer.echo("No supported assistant target detected.", err=True)
94
+ raise typer.Exit(code=1)
95
+ for adapter in adapters:
96
+ typer.echo(f"Installed {adapter.install(skill, project)}")
97
+ skill_path.unlink()
98
+ skill_path.parent.rmdir()
99
+ except (SkillForgeError, OSError) as exc:
100
+ typer.echo(str(exc), err=True)
101
+ raise typer.Exit(code=1) from exc
102
+
103
+
104
+ @app.command("serve-mcp")
105
+ def serve_mcp() -> None:
106
+ """Serve SkillForge over MCP stdio."""
107
+ run_mcp_server()
skillforge/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Public exception types for SkillForge."""
2
+
3
+
4
+ class SkillForgeError(Exception):
5
+ """Base class for expected SkillForge errors."""
6
+
7
+
8
+ class SkillParseError(SkillForgeError):
9
+ """Raised when a skill document cannot be parsed."""
10
+
11
+
12
+ class SkillValidationError(SkillForgeError):
13
+ """Raised when a skill contains invalid metadata or paths."""
14
+
15
+
16
+ class RegistryError(SkillForgeError):
17
+ """Raised when a registry operation fails."""
18
+
19
+
20
+ class AdapterError(SkillForgeError):
21
+ """Raised when an adapter cannot install a skill."""
@@ -0,0 +1,92 @@
1
+ """MCP server exposing the local SkillForge registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from mcp.server.mcpserver import MCPServer
12
+ from mcp.types import ToolAnnotations
13
+
14
+ from .errors import SkillForgeError
15
+ from .registry import BuiltinRegistry, CompositeRegistry, LocalRegistry, Registry
16
+ from .skill import parse_skill
17
+
18
+ LOGGER = logging.getLogger("skillforge.mcp")
19
+ if not LOGGER.handlers:
20
+ handler = logging.StreamHandler(sys.stderr)
21
+ handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
22
+ LOGGER.addHandler(handler)
23
+ LOGGER.setLevel(logging.INFO)
24
+ LOGGER.propagate = False
25
+ READ_ONLY = ToolAnnotations(
26
+ read_only_hint=True,
27
+ destructive_hint=False,
28
+ idempotent_hint=True,
29
+ open_world_hint=False,
30
+ )
31
+ CHARACTER_LIMIT = 25_000
32
+
33
+
34
+ def create_mcp_server(registry: Registry) -> MCPServer:
35
+ """Create an MCP server bound to a SkillForge registry."""
36
+ server = MCPServer(
37
+ "skillforge_mcp",
38
+ description="Discover, read, and validate portable SkillForge skills.",
39
+ )
40
+
41
+ @server.tool(name="search_skills", annotations=READ_ONLY)
42
+ def search_skills(query: str, limit: int = 50) -> str:
43
+ """Search skills by name or description."""
44
+ try:
45
+ if limit < 1 or limit > 100:
46
+ return json.dumps({"error": "limit must be between 1 and 100"})
47
+ return json.dumps(
48
+ [
49
+ {"name": skill.name, "description": skill.description}
50
+ for skill in registry.search(query)[:limit]
51
+ ]
52
+ )
53
+ except SkillForgeError as exc:
54
+ return json.dumps({"error": str(exc)})
55
+
56
+ @server.tool(name="get_skill", annotations=READ_ONLY)
57
+ def get_skill(name: str) -> str:
58
+ """Get the complete SKILL.md document for a safe skill name."""
59
+ try:
60
+ document = registry.get(name).render()
61
+ if len(document) > CHARACTER_LIMIT:
62
+ return document[:CHARACTER_LIMIT] + "\n\n<!-- response truncated -->\n"
63
+ return document
64
+ except SkillForgeError as exc:
65
+ return json.dumps({"error": str(exc)})
66
+
67
+ @server.tool(name="validate_skill", annotations=READ_ONLY)
68
+ def validate_skill(content: str) -> str:
69
+ """Validate SKILL.md content without writing it to disk."""
70
+ try:
71
+ skill = parse_skill(content)
72
+ return json.dumps({"valid": True, "name": skill.name, "description": skill.description})
73
+ except SkillForgeError as exc:
74
+ return json.dumps({"valid": False, "error": str(exc)})
75
+
76
+ @server.resource("skill://{name}", mime_type="text/markdown")
77
+ def skill_resource(name: str) -> str:
78
+ """Read a skill by its skill:// URI."""
79
+ return registry.get(name).render()
80
+
81
+ return server
82
+
83
+
84
+ def registry_from_environment() -> Registry:
85
+ """Build explicit local-plus-built-in registry from environment."""
86
+ local = LocalRegistry(Path(os.environ.get("SKILLFORGE_REGISTRY_PATH", ".skillforge")))
87
+ return CompositeRegistry((local, BuiltinRegistry()))
88
+
89
+
90
+ def run_mcp_server() -> None:
91
+ """Run the SkillForge MCP server over protocol-safe stdio."""
92
+ create_mcp_server(registry_from_environment()).run("stdio")
skillforge/registry.py ADDED
@@ -0,0 +1,151 @@
1
+ """Registry interfaces and a filesystem-backed local implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from .errors import RegistryError
10
+ from .skill import Skill
11
+
12
+
13
+ class Registry(ABC):
14
+ """Abstract source of discoverable and installable skills."""
15
+
16
+ @abstractmethod
17
+ def search(self, query: str) -> list[Skill]:
18
+ """Return skills whose name or description matches a query."""
19
+
20
+ @abstractmethod
21
+ def install(self, name: str, destination: Path) -> Path:
22
+ """Install a named skill into a destination directory."""
23
+
24
+ @abstractmethod
25
+ def get(self, name: str) -> Skill:
26
+ """Return a named skill."""
27
+
28
+
29
+ class LocalRegistry(Registry):
30
+ """Filesystem registry storing one ``<name>/SKILL.md`` per skill."""
31
+
32
+ def __init__(self, root: Path) -> None:
33
+ self.root = root
34
+
35
+ def publish(self, skill: Skill) -> Path:
36
+ """Publish a skill into the local registry."""
37
+ target = self.root / skill.name / "SKILL.md"
38
+ target.parent.mkdir(parents=True, exist_ok=True)
39
+ target.write_text(skill.render(), encoding="utf-8")
40
+ return target
41
+
42
+ def get(self, name: str) -> Skill:
43
+ """Load a named skill from the local registry."""
44
+ try:
45
+ Skill.validate_name(name)
46
+ except Exception as exc:
47
+ raise RegistryError(str(exc)) from exc
48
+ source = self.root / name / "SKILL.md"
49
+ if not source.is_file():
50
+ raise RegistryError(f"Skill not found: {name}")
51
+ try:
52
+ return Skill.load(source)
53
+ except Exception as exc:
54
+ raise RegistryError(f"Invalid registry skill {source}: {exc}") from exc
55
+
56
+ def search(self, query: str) -> list[Skill]:
57
+ """Search names and descriptions case-insensitively."""
58
+ if not self.root.exists():
59
+ return []
60
+ needle = query.casefold()
61
+ skills: list[Skill] = []
62
+ for path in sorted(self.root.glob("*/SKILL.md")):
63
+ try:
64
+ skill = Skill.load(path)
65
+ except Exception as exc:
66
+ raise RegistryError(f"Invalid registry skill {path}: {exc}") from exc
67
+ if needle in skill.name.casefold() or needle in skill.description.casefold():
68
+ skills.append(skill)
69
+ return skills
70
+
71
+ def install(self, name: str, destination: Path) -> Path:
72
+ """Copy a named skill to ``destination/SKILL.md``."""
73
+ skill = self.get(name)
74
+ destination.mkdir(parents=True, exist_ok=True)
75
+ target = destination / "SKILL.md"
76
+ target.write_text(skill.render(), encoding="utf-8")
77
+ return target
78
+
79
+
80
+ class BuiltinRegistry(Registry):
81
+ """Explicit registry for skills shipped inside the SkillForge package."""
82
+
83
+ def __init__(self) -> None:
84
+ self.root = Path(__file__).parent / "builtin_skills"
85
+
86
+ def get(self, name: str) -> Skill:
87
+ """Load a named built-in skill."""
88
+ try:
89
+ Skill.validate_name(name)
90
+ except Exception as exc:
91
+ raise RegistryError(str(exc)) from exc
92
+ path = self.root.joinpath(name, "SKILL.md")
93
+ if not path.is_file():
94
+ raise RegistryError(f"Built-in skill not found: {name}")
95
+ try:
96
+ return Skill.load(Path(path))
97
+ except Exception as exc:
98
+ raise RegistryError(f"Invalid built-in skill {path}: {exc}") from exc
99
+
100
+ def search(self, query: str) -> list[Skill]:
101
+ """Search shipped skills by name or description."""
102
+ if not self.root.is_dir():
103
+ return []
104
+ needle = query.casefold()
105
+ result: list[Skill] = []
106
+ for path in sorted(self.root.glob("*/SKILL.md")):
107
+ skill = self.get(path.parent.name)
108
+ if needle in skill.name.casefold() or needle in skill.description.casefold():
109
+ result.append(skill)
110
+ return result
111
+
112
+ def install(self, name: str, destination: Path) -> Path:
113
+ """Copy a built-in skill into a staging directory."""
114
+ skill = self.get(name)
115
+ destination.mkdir(parents=True, exist_ok=True)
116
+ target = destination / "SKILL.md"
117
+ target.write_text(skill.render(), encoding="utf-8")
118
+ return target
119
+
120
+
121
+ class CompositeRegistry(Registry):
122
+ """Explicitly ordered registry sources; first source wins on duplicate names."""
123
+
124
+ def __init__(self, registries: Iterable[Registry]) -> None:
125
+ self.registries = tuple(registries)
126
+
127
+ def get(self, name: str) -> Skill:
128
+ """Get a skill from first source containing it."""
129
+ errors: list[str] = []
130
+ for registry in self.registries:
131
+ try:
132
+ return registry.get(name)
133
+ except RegistryError as exc:
134
+ errors.append(str(exc))
135
+ raise RegistryError(f"Skill not found: {name}. Sources: {'; '.join(errors)}")
136
+
137
+ def search(self, query: str) -> list[Skill]:
138
+ """Search all sources and de-duplicate by name."""
139
+ found: dict[str, Skill] = {}
140
+ for registry in self.registries:
141
+ for skill in registry.search(query):
142
+ found.setdefault(skill.name, skill)
143
+ return sorted(found.values(), key=lambda skill: skill.name)
144
+
145
+ def install(self, name: str, destination: Path) -> Path:
146
+ """Install a skill resolved from ordered sources."""
147
+ skill = self.get(name)
148
+ destination.mkdir(parents=True, exist_ok=True)
149
+ target = destination / "SKILL.md"
150
+ target.write_text(skill.render(), encoding="utf-8")
151
+ return target
skillforge/skill.py ADDED
@@ -0,0 +1,76 @@
1
+ """The universal SKILL.md document model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from .errors import SkillParseError, SkillValidationError
13
+
14
+ _SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Skill:
19
+ """A portable skill with validated YAML metadata and Markdown content."""
20
+
21
+ name: str
22
+ description: str
23
+ body: str
24
+
25
+ def __post_init__(self) -> None:
26
+ self.validate_name(self.name)
27
+ if not isinstance(self.description, str) or not self.description.strip():
28
+ raise SkillValidationError("Skill description is required")
29
+
30
+ @staticmethod
31
+ def validate_name(name: str) -> None:
32
+ """Validate a skill name before using it as a filesystem path."""
33
+ if not isinstance(name, str) or not name.strip():
34
+ raise SkillValidationError("Skill name is required")
35
+ if not _SAFE_NAME.fullmatch(name):
36
+ raise SkillValidationError(
37
+ "Skill name must be safe for filesystem paths: "
38
+ "lowercase letters, numbers, '-' or '_' only"
39
+ )
40
+
41
+ @classmethod
42
+ def load(cls, path: Path) -> "Skill":
43
+ """Load and validate a SKILL.md file from disk."""
44
+ try:
45
+ return parse_skill(path.read_text(encoding="utf-8"))
46
+ except OSError as exc:
47
+ raise SkillParseError(f"Unable to read skill file {path}: {exc}") from exc
48
+
49
+ def render(self) -> str:
50
+ """Render the skill as a canonical SKILL.md document."""
51
+ metadata = yaml.safe_dump(
52
+ {"name": self.name, "description": self.description},
53
+ sort_keys=False,
54
+ allow_unicode=False,
55
+ ).strip()
56
+ return f"---\n{metadata}\n---\n\n{self.body}"
57
+
58
+
59
+ def parse_skill(document: str) -> Skill:
60
+ """Parse a SKILL.md document with YAML frontmatter."""
61
+ if not document.startswith("---"):
62
+ raise SkillParseError("SKILL.md must begin with YAML frontmatter")
63
+ parts = document.split("---", 2)
64
+ if len(parts) != 3:
65
+ raise SkillParseError("SKILL.md frontmatter must be closed with '---'")
66
+ try:
67
+ metadata: Any = yaml.safe_load(parts[1])
68
+ except yaml.YAMLError as exc:
69
+ raise SkillParseError(f"Invalid YAML frontmatter: {exc}") from exc
70
+ if not isinstance(metadata, dict):
71
+ raise SkillValidationError("Skill frontmatter must be a mapping")
72
+ return Skill(
73
+ name=metadata.get("name", ""),
74
+ description=metadata.get("description", ""),
75
+ body=parts[2].lstrip("\r\n"),
76
+ )