@heytherevibin/skillforge 0.10.1 → 0.11.7
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.
- package/CHANGELOG.md +49 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +37 -345
- package/RELEASING.md +7 -6
- package/STRATEGY.md +2 -2
- package/bin/cli.js +297 -52
- package/ci/test-user-env-profile.cjs +65 -0
- package/docs/README.md +14 -0
- package/docs/architecture-and-data.md +90 -0
- package/docs/cli-reference.md +57 -0
- package/docs/environment-and-configuration.md +76 -0
- package/docs/getting-started.md +88 -0
- package/docs/mcp-integration.md +75 -0
- package/docs/troubleshooting.md +50 -0
- package/lib/templates/claude-code-skillforge-global.md +3 -3
- package/lib/templates/cursor-skillforge-global.md +6 -2
- package/lib/user-env-profile.js +141 -0
- package/package.json +3 -2
- package/python/app/agent_cli.py +334 -0
- package/python/app/explain_route.py +170 -0
- package/python/app/health_cli.py +13 -0
- package/python/app/main.py +131 -48
- package/python/app/materialize.py +150 -68
- package/python/app/mcp_contract.py +2 -1
- package/python/app/mcp_operator.py +252 -0
- package/python/app/mcp_server.py +290 -118
- package/python/app/npm_pkg_version.py +38 -0
- package/python/app/pick_diversify.py +51 -0
- package/python/app/replay_cli.py +145 -0
- package/python/app/route_cli.py +251 -87
- package/python/app/route_cli_pick.py +35 -0
- package/python/app/route_policies.py +18 -3
- package/python/app/route_quality.py +70 -1
- package/python/app/router_llm.py +85 -0
- package/python/app/router_mode.py +21 -0
- package/python/app/routing_signals.py +7 -1
- package/python/app/skill_manifest.py +67 -0
- package/python/app/skills_author_cli.py +117 -0
- package/python/app/tips_cli.py +37 -0
- package/python/app/tools_cli.py +276 -0
- package/python/fixtures/route_eval/smoke.json +5 -0
- package/python/requirements.txt +1 -0
- package/python/tests/test_capabilities_bundle.py +33 -0
- package/python/tests/test_materialize_hosts.py +108 -0
- package/python/tests/test_mcp_contract.py +1 -1
- package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
- package/python/tests/test_mcp_operator.py +84 -0
- package/python/tests/test_npm_pkg_version.py +21 -0
- package/python/tests/test_pick_diversify.py +47 -0
- package/python/tests/test_replay_cli.py +31 -0
- package/python/tests/test_route_cli_pick.py +25 -0
- package/python/tests/test_route_policies.py +29 -0
- package/python/tests/test_route_quality.py +72 -0
- package/python/tests/test_router_llm.py +63 -0
- package/python/tests/test_router_mode_env.py +21 -0
- package/python/tests/test_routing_signals.py +20 -0
- package/python/tests/test_skill_manifest.py +48 -0
- package/python/tests/test_tools_cli.py +69 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import math
|
|
5
|
+
import os
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
|
|
@@ -31,6 +32,53 @@ def top1_cosine_vs_routing_agreement(facets: list[dict[str, Any]]) -> bool | Non
|
|
|
31
32
|
return top_route == best_cos_name
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _env_float(name: str, default_str: str) -> float:
|
|
36
|
+
raw = os.getenv(name, default_str).strip()
|
|
37
|
+
try:
|
|
38
|
+
return float(raw)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return float(default_str)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _compute_ambiguous_and_tier(
|
|
44
|
+
*,
|
|
45
|
+
n: int,
|
|
46
|
+
cosine_margin: float | None,
|
|
47
|
+
routing_score_margin: float | None,
|
|
48
|
+
) -> tuple[bool, str | None]:
|
|
49
|
+
"""Return (ambiguous, confidence_tier)."""
|
|
50
|
+
if n == 0:
|
|
51
|
+
return False, None
|
|
52
|
+
if n == 1:
|
|
53
|
+
return False, "high"
|
|
54
|
+
ambig_off = os.getenv("SKILLFORGE_ROUTE_AMBIGUITY_DISABLE", "").strip().lower() in (
|
|
55
|
+
"1",
|
|
56
|
+
"true",
|
|
57
|
+
"yes",
|
|
58
|
+
)
|
|
59
|
+
if ambig_off:
|
|
60
|
+
ambiguous = False
|
|
61
|
+
else:
|
|
62
|
+
cos_thr = _env_float("SKILLFORGE_ROUTE_AMBIGUITY_COS_MARGIN", "0.012")
|
|
63
|
+
route_thr = _env_float("SKILLFORGE_ROUTE_AMBIGUITY_ROUTE_MARGIN", "0.018")
|
|
64
|
+
ambiguous = False
|
|
65
|
+
if cosine_margin is not None and cosine_margin < cos_thr:
|
|
66
|
+
ambiguous = True
|
|
67
|
+
if routing_score_margin is not None and routing_score_margin < route_thr:
|
|
68
|
+
ambiguous = True
|
|
69
|
+
tier: str
|
|
70
|
+
if ambiguous:
|
|
71
|
+
tier = "low"
|
|
72
|
+
elif cosine_margin is not None and routing_score_margin is not None:
|
|
73
|
+
if cosine_margin >= 0.04 and routing_score_margin >= 0.06:
|
|
74
|
+
tier = "high"
|
|
75
|
+
else:
|
|
76
|
+
tier = "medium"
|
|
77
|
+
else:
|
|
78
|
+
tier = "medium"
|
|
79
|
+
return ambiguous, tier
|
|
80
|
+
|
|
81
|
+
|
|
34
82
|
def build_route_quality(
|
|
35
83
|
*,
|
|
36
84
|
facet_list: list[dict[str, Any]],
|
|
@@ -45,6 +93,7 @@ def build_route_quality(
|
|
|
45
93
|
host_shortlist_only: bool = False,
|
|
46
94
|
haiku_rerank_applied: bool = False,
|
|
47
95
|
pick_path: str,
|
|
96
|
+
pick_diversify: dict[str, Any] | None = None,
|
|
48
97
|
) -> dict[str, Any]:
|
|
49
98
|
"""Structured signals for operators and MCP hosts (JSON-serializable)."""
|
|
50
99
|
n = len(facet_list)
|
|
@@ -52,14 +101,24 @@ def build_route_quality(
|
|
|
52
101
|
second_cos: float | None = None
|
|
53
102
|
margin: float | None = None
|
|
54
103
|
top_routing_score: float | None = None
|
|
104
|
+
second_routing_score: float | None = None
|
|
105
|
+
routing_score_margin: float | None = None
|
|
55
106
|
if facet_list:
|
|
56
107
|
top_cos = round(coerce_route_float(facet_list[0].get("cosine_similarity")), 6)
|
|
57
108
|
top_routing_score = round(coerce_route_float(facet_list[0].get("routing_score")), 6)
|
|
58
109
|
if len(facet_list) > 1:
|
|
59
110
|
second_cos = round(coerce_route_float(facet_list[1].get("cosine_similarity")), 6)
|
|
60
111
|
margin = round(float(top_cos - second_cos), 6)
|
|
112
|
+
second_routing_score = round(coerce_route_float(facet_list[1].get("routing_score")), 6)
|
|
113
|
+
if top_routing_score is not None and second_routing_score is not None:
|
|
114
|
+
routing_score_margin = round(float(top_routing_score - second_routing_score), 6)
|
|
61
115
|
|
|
62
116
|
agree = top1_cosine_vs_routing_agreement(facet_list) if router_hybrid not in ("", "off", None) else None
|
|
117
|
+
ambiguous, confidence_tier = _compute_ambiguous_and_tier(
|
|
118
|
+
n=n,
|
|
119
|
+
cosine_margin=margin,
|
|
120
|
+
routing_score_margin=routing_score_margin,
|
|
121
|
+
)
|
|
63
122
|
|
|
64
123
|
try:
|
|
65
124
|
prl = int(policy_rules_loaded)
|
|
@@ -67,16 +126,25 @@ def build_route_quality(
|
|
|
67
126
|
prl = 0
|
|
68
127
|
prl = max(0, prl)
|
|
69
128
|
|
|
129
|
+
div = pick_diversify if isinstance(pick_diversify, dict) else None
|
|
130
|
+
if div is None:
|
|
131
|
+
div = {"applied": False, "dropped": [], "max_per_source": None}
|
|
132
|
+
|
|
70
133
|
return {
|
|
71
|
-
"schema": "route_quality/
|
|
134
|
+
"schema": "route_quality/2",
|
|
72
135
|
"shortlist": {
|
|
73
136
|
"size": n,
|
|
74
137
|
"top_cosine_similarity": top_cos,
|
|
75
138
|
"second_cosine_similarity": second_cos,
|
|
76
139
|
"cosine_margin": margin,
|
|
140
|
+
"second_routing_score": second_routing_score,
|
|
141
|
+
"routing_score_margin": routing_score_margin,
|
|
142
|
+
"ambiguous": ambiguous,
|
|
143
|
+
"confidence_tier": confidence_tier,
|
|
77
144
|
"top_routing_score": top_routing_score,
|
|
78
145
|
"hybrid_mode": router_hybrid or "off",
|
|
79
146
|
"top1_dense_and_fused_agree": agree,
|
|
147
|
+
"cosine_leader_matches_routing_top": agree,
|
|
80
148
|
},
|
|
81
149
|
"router": {
|
|
82
150
|
"mode": router_mode,
|
|
@@ -84,6 +152,7 @@ def build_route_quality(
|
|
|
84
152
|
"host_picked": host_picked,
|
|
85
153
|
"host_shortlist_only": host_shortlist_only,
|
|
86
154
|
"haiku_rerank_applied": haiku_rerank_applied,
|
|
155
|
+
"pick_diversify": div,
|
|
87
156
|
},
|
|
88
157
|
"session": {
|
|
89
158
|
"rerouted": rerouted,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Pluggable backend for Router rerank + final-pick LLM phases.
|
|
2
|
+
|
|
3
|
+
When ``SKILLFORGE_TRANSPORT=mcp`` (stdio MCP only), only Anthropic-backed clients
|
|
4
|
+
are wired from ``build_router_and_skills`` (legacy semantics).
|
|
5
|
+
|
|
6
|
+
Standalone CLI may set ``SKILLFORGE_ROUTER_LLM_BACKEND=openai_compatible`` targeting
|
|
7
|
+
``OPENAI_API_BASE`` (e.g. Ollama ``http://localhost:11434/v1``).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from anthropic import AsyncAnthropic
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RouterLLM(Protocol):
|
|
18
|
+
backend_name: str
|
|
19
|
+
|
|
20
|
+
async def complete(self, *, system: str, user: str, max_tokens: int, model: str) -> str: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def transport_is_mcp() -> bool:
|
|
24
|
+
return os.getenv("SKILLFORGE_TRANSPORT", "").strip().lower() == "mcp"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnthropicRouterLLM:
|
|
28
|
+
__slots__ = ("client", "backend_name")
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: AsyncAnthropic) -> None:
|
|
31
|
+
self.client = client
|
|
32
|
+
self.backend_name = "anthropic"
|
|
33
|
+
|
|
34
|
+
async def complete(self, *, system: str, user: str, max_tokens: int, model: str) -> str:
|
|
35
|
+
resp = await self.client.messages.create(
|
|
36
|
+
model=model,
|
|
37
|
+
max_tokens=max_tokens,
|
|
38
|
+
system=system,
|
|
39
|
+
messages=[{"role": "user", "content": user}],
|
|
40
|
+
)
|
|
41
|
+
block = resp.content[0]
|
|
42
|
+
return block.text.strip() # type: ignore[attr-defined]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OpenAIRouterLLM:
|
|
46
|
+
__slots__ = ("_client", "default_model", "backend_name")
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, api_key: str, base_url: str, default_model: str) -> None:
|
|
49
|
+
from openai import AsyncOpenAI
|
|
50
|
+
|
|
51
|
+
self._client = AsyncOpenAI(api_key=api_key or "ollama", base_url=base_url.rstrip("/"))
|
|
52
|
+
self.default_model = default_model
|
|
53
|
+
self.backend_name = "openai_compatible"
|
|
54
|
+
|
|
55
|
+
async def complete(self, *, system: str, user: str, max_tokens: int, model: str) -> str:
|
|
56
|
+
m = model or self.default_model
|
|
57
|
+
resp = await self._client.chat.completions.create(
|
|
58
|
+
model=m,
|
|
59
|
+
max_tokens=max_tokens,
|
|
60
|
+
messages=[
|
|
61
|
+
{"role": "system", "content": system},
|
|
62
|
+
{"role": "user", "content": user},
|
|
63
|
+
],
|
|
64
|
+
temperature=0,
|
|
65
|
+
)
|
|
66
|
+
choice = resp.choices[0]
|
|
67
|
+
return (choice.message.content or "").strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_openai_router_defaults() -> tuple[str, str, str]:
|
|
71
|
+
base = (
|
|
72
|
+
os.getenv("OPENAI_API_BASE", "").strip()
|
|
73
|
+
or os.getenv("SKILLFORGE_OPENAI_API_BASE", "").strip()
|
|
74
|
+
or "http://localhost:11434/v1"
|
|
75
|
+
)
|
|
76
|
+
api_key = (
|
|
77
|
+
os.getenv("OPENAI_API_KEY", "").strip()
|
|
78
|
+
or os.getenv("SKILLFORGE_OPENAI_API_KEY", "").strip()
|
|
79
|
+
)
|
|
80
|
+
model = (
|
|
81
|
+
os.getenv("SKILLFORGE_OPENAI_ROUTER_MODEL", "").strip()
|
|
82
|
+
or os.getenv("SKILLFORGE_CHAT_MODEL", "").strip()
|
|
83
|
+
or "llama3.2"
|
|
84
|
+
)
|
|
85
|
+
return base, api_key, model
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Normalize ``SKILLFORGE_ROUTER_MODE`` (default **host**, explicit ``auto`` for legacy routing)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def normalise_skillforge_router_mode(env_value: str) -> str:
|
|
5
|
+
"""Return router mode token for ``app.main``
|
|
6
|
+
|
|
7
|
+
Expect ``env_value`` from ``os.getenv("SKILLFORGE_ROUTER_MODE", "host")``.
|
|
8
|
+
|
|
9
|
+
Returns
|
|
10
|
+
-------
|
|
11
|
+
""
|
|
12
|
+
**auto** — use Haiku when ``ANTHROPIC_API_KEY`` is set, else embedding-first.
|
|
13
|
+
"host" | "embedding" | "full"
|
|
14
|
+
As selected.
|
|
15
|
+
|
|
16
|
+
**Unset** env → pass default ``"host"`` from ``getenv``. **Explicit auto:** ``auto`` or empty string.
|
|
17
|
+
"""
|
|
18
|
+
s = (env_value or "").strip().lower()
|
|
19
|
+
if s in ("", "auto"):
|
|
20
|
+
return ""
|
|
21
|
+
return s
|
|
@@ -12,6 +12,11 @@ from app.route_quality import coerce_route_float
|
|
|
12
12
|
_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9_\-./]{2,}", re.I)
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
def host_pick_max_candidates(*, top_k_cap: int) -> int:
|
|
16
|
+
"""Caps host-mode numbered shortlist size (parity with ``run_route_turn``)."""
|
|
17
|
+
return max(3, min(top_k_cap, int(os.getenv("SKILLFORGE_HOST_PICK_MAX", "12"))))
|
|
18
|
+
|
|
19
|
+
|
|
15
20
|
class _SkillCard(Protocol):
|
|
16
21
|
title: str
|
|
17
22
|
description: str
|
|
@@ -108,7 +113,8 @@ def host_pick_shortlist_lines(
|
|
|
108
113
|
"""Tight numbered list + structured rows for MCP host-pick phase (no in-process LLM)."""
|
|
109
114
|
mc = max_candidates
|
|
110
115
|
if mc is None:
|
|
111
|
-
|
|
116
|
+
top_k_cap = int(os.getenv("SKILLFORGE_TOP_K", "15"))
|
|
117
|
+
mc = host_pick_max_candidates(top_k_cap=top_k_cap)
|
|
112
118
|
lc = line_chars if line_chars is not None else int(os.getenv("SKILLFORGE_HOST_PICK_LINE_CHARS", "120"))
|
|
113
119
|
prompt_one = (prompt or "").strip().replace("\n", " ")
|
|
114
120
|
if len(prompt_one) > 160:
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""SKILL.md manifest checks (warnings + optional strict exclusion from catalog load)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class SkillLike(Protocol):
|
|
11
|
+
name: str
|
|
12
|
+
description: str
|
|
13
|
+
body: str
|
|
14
|
+
triggers: str
|
|
15
|
+
anti_triggers: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_SKILL_DIR_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def skill_md_has_yaml_frontmatter(path: Path) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
head = path.read_text(encoding="utf-8")[:4096]
|
|
24
|
+
except OSError:
|
|
25
|
+
return False
|
|
26
|
+
return head.lstrip().startswith("---")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_skill_manifest(skill: SkillLike, path: Path) -> tuple[list[str], list[str]]:
|
|
30
|
+
"""Return (errors, warnings). Errors justify exclusion under strict manifest mode."""
|
|
31
|
+
errors: list[str] = []
|
|
32
|
+
warnings: list[str] = []
|
|
33
|
+
dir_name = path.parent.name
|
|
34
|
+
if skill.name != dir_name:
|
|
35
|
+
warnings.append(f"parsed name {skill.name!r} != dirname {dir_name!r} (unexpected)")
|
|
36
|
+
|
|
37
|
+
if not _SKILL_DIR_PATTERN.match(dir_name):
|
|
38
|
+
errors.append(f"skill directory {dir_name!r} should match {_SKILL_DIR_PATTERN.pattern}")
|
|
39
|
+
|
|
40
|
+
body = (skill.body or "").strip()
|
|
41
|
+
if len(body) < 1:
|
|
42
|
+
errors.append("empty SKILL.md body after frontmatter")
|
|
43
|
+
|
|
44
|
+
desc = (skill.description or "").strip()
|
|
45
|
+
if len(desc) < 40:
|
|
46
|
+
warnings.append(f"short description ({len(desc)} chars) — consider expanding frontmatter description")
|
|
47
|
+
|
|
48
|
+
if len(desc) > 2800:
|
|
49
|
+
warnings.append(f"very long description ({len(desc)} chars) — routing card may truncate awkwardly")
|
|
50
|
+
|
|
51
|
+
if not skill_md_has_yaml_frontmatter(path):
|
|
52
|
+
warnings.append("no YAML frontmatter block (---) — title/description inferred from filename/body")
|
|
53
|
+
|
|
54
|
+
trig = ((skill.triggers or "") + (skill.anti_triggers or "")).strip()
|
|
55
|
+
if len(trig) > 4000:
|
|
56
|
+
warnings.append(f"very long triggers/anti_triggers ({len(trig)} chars total)")
|
|
57
|
+
|
|
58
|
+
return errors, warnings
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def skill_manifest_strict_exclusion() -> bool:
|
|
62
|
+
"""When true, skip skills that fail manifest *errors* (see validate_skill_manifest)."""
|
|
63
|
+
import os
|
|
64
|
+
|
|
65
|
+
raw = os.getenv("SKILLFORGE_SKILL_MANIFEST_STRICT", "0").strip().lower()
|
|
66
|
+
return raw not in ("0", "false", "no", "")
|
|
67
|
+
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Scaffold user skills (`init`) and validate SKILL manifests (`lint`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from app.main import parse_skill_md
|
|
12
|
+
from app.skill_manifest import validate_skill_manifest
|
|
13
|
+
|
|
14
|
+
_SAFE_NAME = re.compile(r"^[a-z][a-z0-9_-]{1,62}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def skill_init(slug: str) -> Path:
|
|
18
|
+
if not _SAFE_NAME.match(slug):
|
|
19
|
+
raise ValueError(f"Skill folder name must match {_SAFE_NAME.pattern} — got {slug!r}")
|
|
20
|
+
|
|
21
|
+
skills_root = Path(os.getenv("SKILLFORGE_USER_SKILLS", str(Path.home() / ".skillforge" / "skills"))).expanduser().resolve()
|
|
22
|
+
dest = skills_root / slug
|
|
23
|
+
skill_md = dest / "SKILL.md"
|
|
24
|
+
if skill_md.exists():
|
|
25
|
+
raise ValueError(f"Already exists: {skill_md}")
|
|
26
|
+
|
|
27
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
body = (
|
|
29
|
+
"---\n"
|
|
30
|
+
f"name: {slug.replace('-', ' ').title()}\n"
|
|
31
|
+
"description: One-line pitch for routing (embedding + triggers card).\n"
|
|
32
|
+
"triggers: When does this skill apply?\n"
|
|
33
|
+
"anti_triggers: When should an agent skip this skill?\n"
|
|
34
|
+
"---\n\n"
|
|
35
|
+
f"# {slug}\n\n"
|
|
36
|
+
"## When to apply\n\n"
|
|
37
|
+
"- …\n\n"
|
|
38
|
+
"## Patterns\n\n"
|
|
39
|
+
"- …\n\n"
|
|
40
|
+
"## Verification\n\n"
|
|
41
|
+
"- …\n\n"
|
|
42
|
+
"## Outputs\n\n"
|
|
43
|
+
"- Expected shape of the assistant response\n"
|
|
44
|
+
)
|
|
45
|
+
skill_md.write_text(body, encoding="utf-8")
|
|
46
|
+
return skill_md
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def skill_lint_roots(roots: list[Path]) -> int:
|
|
50
|
+
"""Return count of ERROR-level manifest issues."""
|
|
51
|
+
errors = 0
|
|
52
|
+
for root in roots:
|
|
53
|
+
r = root.expanduser().resolve()
|
|
54
|
+
globs = [r] if r.is_file() and r.name == "SKILL.md" else sorted(r.glob("**/SKILL.md"))
|
|
55
|
+
if not globs:
|
|
56
|
+
print(f"No SKILL.md found under {r}")
|
|
57
|
+
errors += 1
|
|
58
|
+
continue
|
|
59
|
+
for md in globs:
|
|
60
|
+
parsed = parse_skill_md(md, "lint")
|
|
61
|
+
if not parsed:
|
|
62
|
+
print(f"ERROR {md}: unreadable parse")
|
|
63
|
+
errors += 1
|
|
64
|
+
continue
|
|
65
|
+
errs, warns = validate_skill_manifest(parsed, md)
|
|
66
|
+
for w in warns:
|
|
67
|
+
print(f"WARN {md}: {w}")
|
|
68
|
+
for e in errs:
|
|
69
|
+
print(f"ERROR {md}: {e}")
|
|
70
|
+
errors += 1
|
|
71
|
+
return errors
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> None:
|
|
75
|
+
ap = argparse.ArgumentParser(description="Skillforge skill authoring helpers (scaffold + manifest lint).")
|
|
76
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
77
|
+
|
|
78
|
+
p_init = sub.add_parser(
|
|
79
|
+
"init",
|
|
80
|
+
help="Create ~/.skillforge/skills/<slug>/SKILL.md (override SKILLFORGE_USER_SKILLS)",
|
|
81
|
+
)
|
|
82
|
+
p_init.add_argument("slug", help="Directory name under user skills folder (lowercase-kebab-case)")
|
|
83
|
+
|
|
84
|
+
p_lint = sub.add_parser(
|
|
85
|
+
"lint",
|
|
86
|
+
help="Validate SKILL.md files (see SKILLFORGE_SKILL_MANIFEST_STRICT for load-time exclusions)",
|
|
87
|
+
)
|
|
88
|
+
p_lint.add_argument(
|
|
89
|
+
"roots",
|
|
90
|
+
nargs="*",
|
|
91
|
+
default=[],
|
|
92
|
+
help="Directories or SKILL.md paths (default bundled + user trees from env)",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
args = ap.parse_args()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if args.cmd == "init":
|
|
99
|
+
p = skill_init(args.slug.strip().lower())
|
|
100
|
+
print(f"Wrote template: {p}")
|
|
101
|
+
print("(Reload MCP / hot reload to pick up the new catalog entry.)")
|
|
102
|
+
return
|
|
103
|
+
if args.cmd == "lint":
|
|
104
|
+
roots = [Path(p) for p in args.roots] if args.roots else []
|
|
105
|
+
if not roots:
|
|
106
|
+
bf = Path(os.getenv("SKILLFORGE_BUNDLED_SKILLS", "./skills")).expanduser().resolve()
|
|
107
|
+
usr = Path(os.getenv("SKILLFORGE_USER_SKILLS", str(Path.home() / ".skillforge" / "skills"))).expanduser()
|
|
108
|
+
roots = [bf, usr]
|
|
109
|
+
err_count = skill_lint_roots(roots)
|
|
110
|
+
raise SystemExit(1 if err_count else 0)
|
|
111
|
+
except ValueError as e:
|
|
112
|
+
print(f"{e}", file=sys.stderr)
|
|
113
|
+
raise SystemExit(1) from e
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Short terminal+MCP cheatsheet emitted on demand (avoid loading the full README)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
text = """# Skillforge — quick tips
|
|
6
|
+
|
|
7
|
+
## After install (`skillforge install` or first `npx` run)
|
|
8
|
+
|
|
9
|
+
Your machine gets `~/.skillforge/` (venv, global DB, user skills).
|
|
10
|
+
|
|
11
|
+
Stable env (keys + tunables without shell paste): **`~/.skillforge/env`** — **`skillforge config path`**, **`skillforge config init`**, **`skillforge config validate`** (see README Configuration).
|
|
12
|
+
|
|
13
|
+
## MCP (Cursor / Claude / …)
|
|
14
|
+
|
|
15
|
+
- Wire with `skillforge mcp config` (see README for JSON merge targets).
|
|
16
|
+
- Default routing is **host**: first `route_skills` returns a **shortlist**, second call sends `picked_names`.
|
|
17
|
+
- **`capabilities`** tool: one JSON bundle — schema version, tool list, router snapshot — good for session start.
|
|
18
|
+
|
|
19
|
+
## Terminal (same engine as MCP)
|
|
20
|
+
|
|
21
|
+
- `skillforge route "your prompt"` — two-step **host** mode mirrors MCP (shortlist stdout, then re-run):
|
|
22
|
+
`skillforge route "…"` → `skillforge route "…" --picked-names=id1,id2`
|
|
23
|
+
- **`skillforge route -i`** (TTY) prompts for ranks/`1,3` or skill ids after the shortlist step.
|
|
24
|
+
Or set **`SKILLFORGE_ROUTE_INTERACTIVE=1`** (still requires a tty).
|
|
25
|
+
- **`skillforge route --json`** prints one JSON envelope (**`phase`**: `host_shortlist_prompt`, `host_shortlist_static`, `context`, `explain_only`) with `route_meta` for scripts.
|
|
26
|
+
- **`skillforge route --explain`** attaches routing diagnostics (**stderr** Markdown, unless `--json` embeds **`explain`**).
|
|
27
|
+
- **`skillforge route --explain-only`** — diagnostics only (**no finalize** / no session-heavy side effects comparable to MCP `explain_route` intent).
|
|
28
|
+
- **`skillforge tools …`** (**`skillforge tools -h`**): MCP-equivalent subcommands; **`--json`** emits full tool envelopes.
|
|
29
|
+
- **`skillforge agent`**: OpenAI-compatible chat calling the same MCP tool handlers (**`OPENAI_API_BASE`**, **`SKILLFORGE_AGENT_*`**). Run **`skillforge install`** after upgrades so **`openai`** is installed into **`~/.skillforge/venv`**.
|
|
30
|
+
|
|
31
|
+
Docs: **`package/README.md`**.
|
|
32
|
+
"""
|
|
33
|
+
print(text, end="")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|