@heytherevibin/skillforge 0.10.0 → 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 +53 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +37 -345
- package/RELEASING.md +8 -7
- 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
package/python/app/main.py
CHANGED
|
@@ -32,7 +32,14 @@ from app.project_index import (
|
|
|
32
32
|
retrieve_project_context_items,
|
|
33
33
|
)
|
|
34
34
|
from app.redaction import redaction_enabled, redact_secret_patterns, sanitize_context_items
|
|
35
|
+
from app.router_llm import (
|
|
36
|
+
AnthropicRouterLLM,
|
|
37
|
+
OpenAIRouterLLM,
|
|
38
|
+
resolve_openai_router_defaults,
|
|
39
|
+
transport_is_mcp,
|
|
40
|
+
)
|
|
35
41
|
from app.feedback_meta import build_feedback_effect
|
|
42
|
+
from app.pick_diversify import diversify_picked_names
|
|
36
43
|
from app.route_policies import (
|
|
37
44
|
build_routing_overlay_payload,
|
|
38
45
|
load_route_policies_config,
|
|
@@ -43,16 +50,28 @@ from app.route_policies import (
|
|
|
43
50
|
from app.route_quality import build_route_quality, coerce_route_float
|
|
44
51
|
from app.routing_signals import (
|
|
45
52
|
build_route_query_text,
|
|
53
|
+
host_pick_max_candidates,
|
|
46
54
|
host_pick_shortlist_lines,
|
|
47
55
|
keyword_overlap_scores,
|
|
48
56
|
normalize_minmax,
|
|
49
57
|
skill_routing_card,
|
|
50
58
|
tokenize_skills_query,
|
|
51
59
|
)
|
|
60
|
+
from app.router_mode import normalise_skillforge_router_mode
|
|
61
|
+
from app.skill_manifest import skill_manifest_strict_exclusion, validate_skill_manifest
|
|
52
62
|
|
|
53
63
|
# ---------- Config (env-driven so the Node wrapper controls paths) ----------
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bundled_skills_dir() -> Path:
|
|
67
|
+
raw = os.getenv("SKILLFORGE_BUNDLED_SKILLS", "./skills").strip() or "./skills"
|
|
68
|
+
return Path(raw).expanduser().resolve()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def user_skills_dir() -> Path:
|
|
72
|
+
fallback = str(Path.home() / ".skillforge" / "skills")
|
|
73
|
+
raw = os.getenv("SKILLFORGE_USER_SKILLS", fallback).strip() or fallback
|
|
74
|
+
return Path(raw).expanduser()
|
|
56
75
|
|
|
57
76
|
|
|
58
77
|
DB_PATH = global_db_path()
|
|
@@ -63,8 +82,8 @@ ROUTER_MODEL = os.getenv("SKILLFORGE_ROUTER_MODEL", "claude-haiku-4-5-20251001")
|
|
|
63
82
|
TOP_K_CANDIDATES = int(os.getenv("SKILLFORGE_TOP_K", "15"))
|
|
64
83
|
MAX_ACTIVE_SKILLS = int(os.getenv("SKILLFORGE_MAX_ACTIVE", "7"))
|
|
65
84
|
REROUTE_THRESHOLD = float(os.getenv("SKILLFORGE_REROUTE_THRESHOLD", "0.4"))
|
|
66
|
-
# "" | "full" | "embedding" | "host" —
|
|
67
|
-
SKILLFORGE_ROUTER_MODE = os.getenv("SKILLFORGE_ROUTER_MODE", "")
|
|
85
|
+
# "" | "full" | "embedding" | "host" — "" means auto (Haiku when ANTHROPIC_API_KEY set). Unset env → default host.
|
|
86
|
+
SKILLFORGE_ROUTER_MODE = normalise_skillforge_router_mode(os.getenv("SKILLFORGE_ROUTER_MODE", "host"))
|
|
68
87
|
# chunks: RAG-style line-bounded chunks from picked skills. full_body: inject entire SKILL.md per pick (legacy).
|
|
69
88
|
SKILLFORGE_CONTEXT_MODE = os.getenv("SKILLFORGE_CONTEXT_MODE", "chunks").strip().lower()
|
|
70
89
|
ROUTE_MAX_CONTEXT_CHARS = int(os.getenv("SKILLFORGE_ROUTE_MAX_CHARS", "60000"))
|
|
@@ -104,48 +123,76 @@ def build_router_and_skills(
|
|
|
104
123
|
log: bool = True,
|
|
105
124
|
log_prefix: str = "[skillforge]",
|
|
106
125
|
) -> tuple[Router, dict[str, Skill]]:
|
|
107
|
-
"""Load embedding model, skill catalog, and Router (shared by MCP and ``skillforge route`` CLI).
|
|
126
|
+
"""Load embedding model, skill catalog, and Router (shared by MCP and ``skillforge route`` CLI).
|
|
127
|
+
|
|
128
|
+
When ``SKILLFORGE_TRANSPORT=mcp`` (stdio MCP), router LLM is Anthropic-only when a key backs the
|
|
129
|
+
mode — legacy semantics. Standalone subprocesses omit that env and may set
|
|
130
|
+
``SKILLFORGE_ROUTER_LLM_BACKEND=openai_compatible``.
|
|
131
|
+
"""
|
|
108
132
|
if log:
|
|
109
133
|
print(f"{log_prefix} Loading skills...", file=sys.stderr)
|
|
110
|
-
skills = load_all_skills()
|
|
134
|
+
skills = load_all_skills(manifest_log_prefix=log_prefix)
|
|
111
135
|
embed_model = SentenceTransformer(os.getenv("SKILLFORGE_EMBED_MODEL", "all-MiniLM-L6-v2"))
|
|
112
136
|
key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
|
113
137
|
mode = SKILLFORGE_ROUTER_MODE
|
|
138
|
+
compat_backend_raw = os.getenv("SKILLFORGE_ROUTER_LLM_BACKEND", "").strip().lower()
|
|
139
|
+
mcp_t = transport_is_mcp()
|
|
140
|
+
|
|
141
|
+
anth_legacy = None
|
|
114
142
|
if mode == "embedding":
|
|
115
|
-
|
|
116
|
-
router_note = "embedding-only (SKILLFORGE_ROUTER_MODE=embedding)"
|
|
143
|
+
router_note_llm = "embedding-only (SKILLFORGE_ROUTER_MODE=embedding)"
|
|
117
144
|
elif mode == "host":
|
|
118
|
-
|
|
119
|
-
router_note = (
|
|
145
|
+
router_note_llm = (
|
|
120
146
|
"host-pick (SKILLFORGE_ROUTER_MODE=host): no in-process router LLM; "
|
|
121
147
|
"first route_skills call returns a shortlist — call again with picked_names"
|
|
122
148
|
)
|
|
123
149
|
elif mode == "full":
|
|
124
150
|
if key:
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
anth_legacy = AsyncAnthropic()
|
|
152
|
+
router_note_llm = "full Haiku router (SKILLFORGE_ROUTER_MODE=full)"
|
|
127
153
|
else:
|
|
128
|
-
|
|
129
|
-
router_note = (
|
|
154
|
+
router_note_llm = (
|
|
130
155
|
"embedding-only (SKILLFORGE_ROUTER_MODE=full but no ANTHROPIC_API_KEY — "
|
|
131
156
|
"Haiku routing skipped)"
|
|
132
157
|
)
|
|
133
158
|
elif key:
|
|
134
|
-
|
|
135
|
-
|
|
159
|
+
anth_legacy = AsyncAnthropic()
|
|
160
|
+
router_note_llm = "full Haiku router (default; ANTHROPIC_API_KEY set)"
|
|
136
161
|
else:
|
|
137
|
-
|
|
138
|
-
router_note = (
|
|
162
|
+
router_note_llm = (
|
|
139
163
|
"embedding-only (no ANTHROPIC_API_KEY — keyless. "
|
|
140
164
|
"Set ANTHROPIC_API_KEY for Haiku routing.)"
|
|
141
165
|
)
|
|
166
|
+
|
|
167
|
+
router_llm: AnthropicRouterLLM | OpenAIRouterLLM | None = None
|
|
168
|
+
if mode == "embedding":
|
|
169
|
+
router_note = router_note_llm
|
|
170
|
+
router_llm = None
|
|
171
|
+
elif mcp_t:
|
|
172
|
+
router_note = router_note_llm
|
|
173
|
+
router_llm = AnthropicRouterLLM(anth_legacy) if anth_legacy else None
|
|
174
|
+
elif compat_backend_raw == "openai_compatible":
|
|
175
|
+
b, rk, om = resolve_openai_router_defaults()
|
|
176
|
+
try:
|
|
177
|
+
router_llm = OpenAIRouterLLM(api_key=rk, base_url=b, default_model=om)
|
|
178
|
+
router_note = f"standalone openai_compatible router ({b}, model={om})"
|
|
179
|
+
except Exception as e:
|
|
180
|
+
router_llm = AnthropicRouterLLM(anth_legacy) if anth_legacy else None
|
|
181
|
+
router_note = (
|
|
182
|
+
f"{router_note_llm} — openai_compatible init failed ({e}); "
|
|
183
|
+
f"fallback to anthropic/embed-only wiring"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
router_note = router_note_llm
|
|
187
|
+
router_llm = AnthropicRouterLLM(anth_legacy) if anth_legacy else None
|
|
188
|
+
|
|
142
189
|
if log:
|
|
143
190
|
print(f"{log_prefix} {router_note}", file=sys.stderr)
|
|
144
191
|
print(
|
|
145
|
-
f"{log_prefix} Loaded {len(skills)} skills from bundled={
|
|
192
|
+
f"{log_prefix} Loaded {len(skills)} skills from bundled={bundled_skills_dir()} user={user_skills_dir()}",
|
|
146
193
|
file=sys.stderr,
|
|
147
194
|
)
|
|
148
|
-
router = Router(skills, embed_model,
|
|
195
|
+
router = Router(skills, embed_model, router_llm)
|
|
149
196
|
skmap = {s.name: s for s in skills}
|
|
150
197
|
return router, skmap
|
|
151
198
|
|
|
@@ -230,23 +277,35 @@ def parse_skill_md(path: Path, source: str) -> Skill | None:
|
|
|
230
277
|
)
|
|
231
278
|
|
|
232
279
|
|
|
233
|
-
def load_all_skills() -> list[Skill]:
|
|
280
|
+
def load_all_skills(*, manifest_log_prefix: str = "[skillforge]") -> list[Skill]:
|
|
234
281
|
"""Load from bundled dir first, then user dir (user overrides bundled by name)."""
|
|
235
282
|
by_name: dict[str, Skill] = {}
|
|
236
|
-
|
|
283
|
+
strict_manifest = skill_manifest_strict_exclusion()
|
|
284
|
+
for src_dir, label in [(bundled_skills_dir(), "bundled"), (user_skills_dir(), "user")]:
|
|
237
285
|
if not src_dir.exists():
|
|
238
286
|
continue
|
|
239
287
|
for skill_md in sorted(src_dir.glob("*/SKILL.md")):
|
|
240
288
|
s = parse_skill_md(skill_md, label)
|
|
241
|
-
if s:
|
|
242
|
-
|
|
289
|
+
if not s:
|
|
290
|
+
print(f"{manifest_log_prefix} SKIP unreadable SKILL.md: {skill_md}", file=sys.stderr)
|
|
291
|
+
continue
|
|
292
|
+
errs, warns = validate_skill_manifest(s, skill_md)
|
|
293
|
+
for w in warns:
|
|
294
|
+
print(f"{manifest_log_prefix} manifest [{label}/{s.name}] warning: {w}", file=sys.stderr)
|
|
295
|
+
if errs:
|
|
296
|
+
for e in errs:
|
|
297
|
+
suf = "(excluded from catalog)" if strict_manifest else "(loaded anyway)"
|
|
298
|
+
print(f"{manifest_log_prefix} manifest [{label}/{s.name}] error: {e} {suf}", file=sys.stderr)
|
|
299
|
+
if strict_manifest:
|
|
300
|
+
continue
|
|
301
|
+
by_name[s.name] = s # later sources override
|
|
243
302
|
return list(by_name.values())
|
|
244
303
|
|
|
245
304
|
|
|
246
305
|
def iter_skill_md_paths() -> list[Path]:
|
|
247
306
|
"""All discovered SKILL.md paths in load order (bundled, then user overrides)."""
|
|
248
307
|
paths: list[Path] = []
|
|
249
|
-
for src_dir in (
|
|
308
|
+
for src_dir in (bundled_skills_dir(), user_skills_dir()):
|
|
250
309
|
if not src_dir.exists():
|
|
251
310
|
continue
|
|
252
311
|
for skill_md in sorted(src_dir.glob("*/SKILL.md")):
|
|
@@ -369,14 +428,20 @@ def set_skill_disabled(con, name, disabled: bool, user_id=""):
|
|
|
369
428
|
|
|
370
429
|
# ---------- Router ----------
|
|
371
430
|
class Router:
|
|
372
|
-
def __init__(
|
|
431
|
+
def __init__(
|
|
432
|
+
self,
|
|
433
|
+
skills,
|
|
434
|
+
embed_model,
|
|
435
|
+
router_llm: AnthropicRouterLLM | OpenAIRouterLLM | None,
|
|
436
|
+
):
|
|
373
437
|
self.skills = skills
|
|
374
438
|
self.embed_model = embed_model
|
|
375
|
-
self.
|
|
376
|
-
self.context_mode =
|
|
377
|
-
|
|
378
|
-
"full_body"
|
|
379
|
-
|
|
439
|
+
self.router_llm = router_llm
|
|
440
|
+
self.context_mode = (
|
|
441
|
+
SKILLFORGE_CONTEXT_MODE
|
|
442
|
+
if SKILLFORGE_CONTEXT_MODE in ("chunks", "full_body")
|
|
443
|
+
else "chunks"
|
|
444
|
+
)
|
|
380
445
|
self._by_name: dict[str, Skill] = {s.name: s for s in skills}
|
|
381
446
|
self._hybrid_mode = ROUTER_HYBRID_MODE
|
|
382
447
|
self._hybrid_alpha = ROUTER_HYBRID_ALPHA
|
|
@@ -436,6 +501,21 @@ class Router:
|
|
|
436
501
|
file=sys.stderr,
|
|
437
502
|
)
|
|
438
503
|
|
|
504
|
+
def _pick_router_llm_model(self, *, rerank: bool) -> str:
|
|
505
|
+
if isinstance(self.router_llm, OpenAIRouterLLM):
|
|
506
|
+
return self.router_llm.default_model
|
|
507
|
+
rerank_override = os.getenv("SKILLFORGE_HAIKU_RERANK_MODEL", "").strip()
|
|
508
|
+
if rerank and rerank_override:
|
|
509
|
+
return rerank_override
|
|
510
|
+
return ROUTER_MODEL
|
|
511
|
+
|
|
512
|
+
@property
|
|
513
|
+
def anthropic(self) -> Optional[AsyncAnthropic]:
|
|
514
|
+
"""AsyncAnthropic client when the active router backend is Anthropic (MCP reload path)."""
|
|
515
|
+
if isinstance(self.router_llm, AnthropicRouterLLM):
|
|
516
|
+
return self.router_llm.client
|
|
517
|
+
return None
|
|
518
|
+
|
|
439
519
|
def _sparse_scores(self, route_query: str) -> np.ndarray:
|
|
440
520
|
if not _hybrid_mode_active(self._hybrid_mode):
|
|
441
521
|
return np.zeros(len(self.skills), dtype=np.float64)
|
|
@@ -736,7 +816,7 @@ class Router:
|
|
|
736
816
|
) -> list[tuple[Skill, float]]:
|
|
737
817
|
if (
|
|
738
818
|
not candidates
|
|
739
|
-
or self.
|
|
819
|
+
or self.router_llm is None
|
|
740
820
|
or not _env_truthy("SKILLFORGE_HAIKU_RERANK", "0")
|
|
741
821
|
):
|
|
742
822
|
return candidates
|
|
@@ -772,14 +852,14 @@ class Router:
|
|
|
772
852
|
f"Routing focus:\n{route_query}{hist}\n\nCandidates:\n" + "\n".join(lines)
|
|
773
853
|
)
|
|
774
854
|
try:
|
|
775
|
-
rerank_model =
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
max_tokens=500,
|
|
855
|
+
rerank_model = self._pick_router_llm_model(rerank=True)
|
|
856
|
+
assert self.router_llm is not None
|
|
857
|
+
text = await self.router_llm.complete(
|
|
779
858
|
system=sys,
|
|
780
|
-
|
|
859
|
+
user=user,
|
|
860
|
+
max_tokens=500,
|
|
861
|
+
model=rerank_model,
|
|
781
862
|
)
|
|
782
|
-
text = resp.content[0].text.strip()
|
|
783
863
|
if text.startswith("```"):
|
|
784
864
|
text = text.split("```")[1]
|
|
785
865
|
if text.startswith("json"):
|
|
@@ -816,7 +896,7 @@ class Router:
|
|
|
816
896
|
route_query: str | None = None,
|
|
817
897
|
):
|
|
818
898
|
rq = (route_query if route_query is not None else prompt) or ""
|
|
819
|
-
if self.
|
|
899
|
+
if self.router_llm is None:
|
|
820
900
|
return self.pick_final_embedding_only(candidates)
|
|
821
901
|
if not candidates:
|
|
822
902
|
return [], "no candidates available"
|
|
@@ -849,13 +929,14 @@ class Router:
|
|
|
849
929
|
f"\n\nCandidate skills:\n{catalog}"
|
|
850
930
|
)
|
|
851
931
|
try:
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
932
|
+
pick_model = self._pick_router_llm_model(rerank=False)
|
|
933
|
+
assert self.router_llm is not None
|
|
934
|
+
text = await self.router_llm.complete(
|
|
855
935
|
system=sys,
|
|
856
|
-
|
|
936
|
+
user=user,
|
|
937
|
+
max_tokens=400,
|
|
938
|
+
model=pick_model,
|
|
857
939
|
)
|
|
858
|
-
text = resp.content[0].text.strip()
|
|
859
940
|
if text.startswith("```"):
|
|
860
941
|
text = text.split("```")[1]
|
|
861
942
|
if text.startswith("json"):
|
|
@@ -962,7 +1043,7 @@ async def run_route_turn(
|
|
|
962
1043
|
host_router = SKILLFORGE_ROUTER_MODE == "host"
|
|
963
1044
|
|
|
964
1045
|
if host_router and not picked_names_from_host_supplied:
|
|
965
|
-
k =
|
|
1046
|
+
k = host_pick_max_candidates(top_k_cap=TOP_K_CANDIDATES)
|
|
966
1047
|
facets = router.shortlist_with_facets(
|
|
967
1048
|
route_query,
|
|
968
1049
|
con,
|
|
@@ -1066,7 +1147,7 @@ async def run_route_turn(
|
|
|
1066
1147
|
else:
|
|
1067
1148
|
names_before = [s.name for s, _ in candidates]
|
|
1068
1149
|
rerank_eligible = bool(
|
|
1069
|
-
candidates and router.
|
|
1150
|
+
candidates and router.router_llm is not None and _env_truthy("SKILLFORGE_HAIKU_RERANK", "0")
|
|
1070
1151
|
)
|
|
1071
1152
|
candidates = await router.rerank_candidates_haiku(route_query, conversation, candidates)
|
|
1072
1153
|
names_after = [s.name for s, _ in candidates]
|
|
@@ -1074,6 +1155,7 @@ async def run_route_turn(
|
|
|
1074
1155
|
picked_names, reasoning = await router.pick_final(
|
|
1075
1156
|
prompt, conversation, candidates, route_query=route_query
|
|
1076
1157
|
)
|
|
1158
|
+
picked_names, pick_diversify_meta = diversify_picked_names(picked_names, router._by_name)
|
|
1077
1159
|
picked_names, policy_audit = merge_policy_includes(
|
|
1078
1160
|
prompt,
|
|
1079
1161
|
picked_names,
|
|
@@ -1190,10 +1272,10 @@ async def run_route_turn(
|
|
|
1190
1272
|
|
|
1191
1273
|
if picked_names_from_host_supplied:
|
|
1192
1274
|
pick_path = "host_picked"
|
|
1193
|
-
elif router.
|
|
1275
|
+
elif router.router_llm is None or SKILLFORGE_ROUTER_MODE == "embedding":
|
|
1194
1276
|
pick_path = "embedding_top"
|
|
1195
1277
|
else:
|
|
1196
|
-
pick_path = "
|
|
1278
|
+
pick_path = "llm_pick"
|
|
1197
1279
|
|
|
1198
1280
|
rules_list = policies_cfg.get("rules") if isinstance(policies_cfg.get("rules"), list) else []
|
|
1199
1281
|
route_quality = build_route_quality(
|
|
@@ -1209,6 +1291,7 @@ async def run_route_turn(
|
|
|
1209
1291
|
host_shortlist_only=False,
|
|
1210
1292
|
haiku_rerank_applied=haiku_rerank_applied,
|
|
1211
1293
|
pick_path=pick_path,
|
|
1294
|
+
pick_diversify=pick_diversify_meta,
|
|
1212
1295
|
)
|
|
1213
1296
|
|
|
1214
1297
|
reasoning_out = reasoning
|
|
@@ -1,14 +1,85 @@
|
|
|
1
1
|
"""Write project-local Skillforge bootstrap files (.cursor, .claude/commands, PRD, CLAUDE.md)."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import os
|
|
4
5
|
import re
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
7
|
+
from typing import Any, Mapping
|
|
7
8
|
|
|
8
9
|
MARKER_START = "<!-- skillforge:auto:start -->"
|
|
9
10
|
MARKER_END = "<!-- skillforge:auto:end -->"
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def normalize_materialize_hosts(raw: str | None) -> str:
|
|
14
|
+
"""Normalize MCP ``hosts`` argument: ``both`` | ``cursor`` | ``claude_code``."""
|
|
15
|
+
s = (raw or "both").strip().lower().replace("-", "_")
|
|
16
|
+
if s in ("both", "all", "*", ""):
|
|
17
|
+
return "both"
|
|
18
|
+
if s in ("cursor",):
|
|
19
|
+
return "cursor"
|
|
20
|
+
if s in ("claude_code", "claude"):
|
|
21
|
+
return "claude_code"
|
|
22
|
+
raise ValueError(f"hosts must be both, cursor, or claude_code — got {raw!r}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def infer_materialize_hosts_from_mcp_client(
|
|
26
|
+
client_name: str,
|
|
27
|
+
client_title: str = "",
|
|
28
|
+
*,
|
|
29
|
+
environ: Mapping[str, str] | None = None,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Best-effort host set from MCP ``initialize.params.clientInfo`` (and Cursor env hints)."""
|
|
32
|
+
env = environ if environ is not None else os.environ
|
|
33
|
+
blob = f"{client_name} {client_title}".lower()
|
|
34
|
+
if "cursor" in blob:
|
|
35
|
+
return "cursor"
|
|
36
|
+
if "claude" in blob:
|
|
37
|
+
return "claude_code"
|
|
38
|
+
if env.get("CURSOR_TRACE_ID") or env.get("CURSOR_AGENT"):
|
|
39
|
+
return "cursor"
|
|
40
|
+
return "both"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_materialize_hosts_argument(
|
|
44
|
+
raw: str | None,
|
|
45
|
+
*,
|
|
46
|
+
client_name: str = "",
|
|
47
|
+
client_title: str = "",
|
|
48
|
+
environ: Mapping[str, str] | None = None,
|
|
49
|
+
) -> tuple[str, dict[str, Any]]:
|
|
50
|
+
"""Resolve MCP ``hosts``: explicit modes, or ``auto`` / omit → env then client inference."""
|
|
51
|
+
envmap = environ if environ is not None else os.environ
|
|
52
|
+
if raw is None:
|
|
53
|
+
hosts_requested = ""
|
|
54
|
+
rr = ""
|
|
55
|
+
else:
|
|
56
|
+
hosts_requested = str(raw).strip()
|
|
57
|
+
rr = hosts_requested.lower().replace("-", "_")
|
|
58
|
+
|
|
59
|
+
meta: dict[str, Any] = {
|
|
60
|
+
"hosts_requested": hosts_requested,
|
|
61
|
+
"mcp_client_name": client_name.strip(),
|
|
62
|
+
"mcp_client_title": client_title.strip(),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if rr and rr != "auto":
|
|
66
|
+
mode = normalize_materialize_hosts(hosts_requested)
|
|
67
|
+
meta["hosts_resolution"] = "explicit"
|
|
68
|
+
return mode, meta
|
|
69
|
+
|
|
70
|
+
env_hosts = envmap.get("SKILLFORGE_MATERIALIZE_HOSTS", "").strip()
|
|
71
|
+
if env_hosts:
|
|
72
|
+
mode = normalize_materialize_hosts(env_hosts)
|
|
73
|
+
meta["hosts_resolution"] = "environment"
|
|
74
|
+
return mode, meta
|
|
75
|
+
|
|
76
|
+
mode = infer_materialize_hosts_from_mcp_client(
|
|
77
|
+
client_name, client_title, environ=envmap
|
|
78
|
+
)
|
|
79
|
+
meta["hosts_resolution"] = "inferred"
|
|
80
|
+
return mode, meta
|
|
81
|
+
|
|
82
|
+
|
|
12
83
|
def _safe_root(raw: str) -> Path:
|
|
13
84
|
root = Path(raw).expanduser().resolve()
|
|
14
85
|
if not root.is_dir():
|
|
@@ -29,26 +100,36 @@ def materialize_project_files(
|
|
|
29
100
|
skill_descriptions: dict[str, str],
|
|
30
101
|
*,
|
|
31
102
|
merge: bool = True,
|
|
103
|
+
hosts: str = "both",
|
|
32
104
|
) -> dict[str, Any]:
|
|
33
|
-
"""Create or update Cursor
|
|
105
|
+
"""Create or update Cursor and/or Claude Code project stubs, docs, optional CLAUDE.md section.
|
|
34
106
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
107
|
+
``hosts``:
|
|
108
|
+
- ``both`` (default) — Cursor rules/commands, Claude ``/skillforge``, ``docs/…``, ``CLAUDE.md``.
|
|
109
|
+
- ``cursor`` — only ``.cursor/`` tree + docs (no ``.claude/``, no ``CLAUDE.md``).
|
|
110
|
+
- ``claude_code`` — ``.claude/commands/``, ``docs/``, ``CLAUDE.md`` (no ``.cursor/``).
|
|
111
|
+
|
|
112
|
+
merge=False skips overwriting existing host-specific command/rule files targeted by this run
|
|
113
|
+
if they already exist (other destinations still update per ``hosts``).
|
|
38
114
|
"""
|
|
115
|
+
mode = normalize_materialize_hosts(hosts)
|
|
39
116
|
root = _safe_root(project_root)
|
|
40
117
|
written: list[str] = []
|
|
41
118
|
|
|
119
|
+
write_cursor = mode in ("both", "cursor")
|
|
120
|
+
write_claude = mode in ("both", "claude_code")
|
|
121
|
+
|
|
42
122
|
summaries = [
|
|
43
123
|
f"- `{n}`: {(skill_descriptions.get(n) or '')[:160]}"
|
|
44
124
|
for n in skill_names
|
|
45
125
|
]
|
|
46
126
|
skills_md = "\n".join(summaries) if summaries else "_No skills listed._"
|
|
47
127
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
128
|
+
if write_cursor:
|
|
129
|
+
cursor_rule = root / ".cursor" / "rules" / "skillforge.mdc"
|
|
130
|
+
_assert_under(root, cursor_rule)
|
|
131
|
+
cursor_rule.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
mdc_body = f"""---
|
|
52
133
|
description: Use Skillforge MCP to route SKILL.md context for this repo
|
|
53
134
|
globs: []
|
|
54
135
|
alwaysApply: false
|
|
@@ -60,7 +141,7 @@ When the user invokes **Skillforge**, types **`/skillforge`** (Cursor or **Claud
|
|
|
60
141
|
|
|
61
142
|
1. Call **route_skills** with **`project_root`** set to this workspace root (so learning and SQLite live in **`.skillforge/`** here), the user's task, and optional **session_id** (reuse within a thread for reroute stats). If the host sets **`SKILLFORGE_PROJECT_ROOT`**, you can omit **project_root** on each call.
|
|
62
143
|
|
|
63
|
-
If **`
|
|
144
|
+
If **`host`** routing (default when **`SKILLFORGE_ROUTER_MODE`** is unset): first **`route_skills`** without **`picked_names`** (shortlist only); then call again with **`picked_names`** for the chosen catalog ids before continuing.
|
|
64
145
|
2. Inject the returned skill bodies into context before continuing.
|
|
65
146
|
3. To refresh project files, call **materialize_project** with **project_root** set to this workspace root and **skill_names** from the last **route_skills** result.
|
|
66
147
|
|
|
@@ -68,16 +149,18 @@ When the user invokes **Skillforge**, types **`/skillforge`** (Cursor or **Claud
|
|
|
68
149
|
|
|
69
150
|
{skills_md}
|
|
70
151
|
"""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
152
|
+
if not (cursor_rule.exists() and not merge):
|
|
153
|
+
cursor_rule.write_text(mdc_body, encoding="utf-8")
|
|
154
|
+
written.append(str(cursor_rule.relative_to(root)))
|
|
155
|
+
|
|
156
|
+
cursor_cmd = root / ".cursor" / "commands" / "skillforge.md"
|
|
157
|
+
_assert_under(root, cursor_cmd)
|
|
158
|
+
cursor_cmd.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
cmd_body = f"""---
|
|
160
|
+
description: Route SKILL.md context via Skillforge MCP for this workspace (/skillforge). Use skillforge MCP route_skills.
|
|
161
|
+
---
|
|
76
162
|
|
|
77
|
-
|
|
78
|
-
_assert_under(root, cursor_cmd)
|
|
79
|
-
cursor_cmd.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
-
cmd_body = f"""# Skillforge — route SKILL.md context (MCP)
|
|
163
|
+
# Skillforge — route SKILL.md context (MCP)
|
|
81
164
|
|
|
82
165
|
The user chose the **`/skillforge`** project command. Use the **skillforge** MCP server.
|
|
83
166
|
|
|
@@ -85,27 +168,26 @@ The user chose the **`/skillforge`** project command. Use the **skillforge** MCP
|
|
|
85
168
|
|
|
86
169
|
1. **`route_skills`**: pass **`project_root`** as this workspace root (absolute path) so SQLite lives in **`.skillforge/`** here. Pass the **current user task** as **`prompt`**. Reuse **`session_id`** across turns in the same thread when the MCP returns one.
|
|
87
170
|
|
|
88
|
-
- **`
|
|
171
|
+
- **`host`** routing (default when **`SKILLFORGE_ROUTER_MODE`** is unset): call once **without** **`picked_names`** (shortlist in the response); then call again with **`picked_names`** (exact catalog ids) to load skill context.
|
|
89
172
|
- Optional: pass **`conversation`** when recent turns should influence routing.
|
|
90
173
|
|
|
91
174
|
2. **Use the returned skill text** in your answer (summarize or follow the SKILL.md guidance as appropriate).
|
|
92
175
|
|
|
93
|
-
3. Optionally **`materialize_project`** with the same **`project_root`** and **`skill_names`** from **`route_skills`**
|
|
176
|
+
3. Optionally **`materialize_project`** with the same **`project_root`** and **`skill_names`** from **`route_skills`** — use **`hosts: \"auto\"`** (default via MCP), **`\"cursor\"`**, **`\"claude_code\"`**, or **`\"both\"`** so only relevant IDE files refresh.
|
|
94
177
|
|
|
95
178
|
## Skills last materialized for this project
|
|
96
179
|
|
|
97
180
|
{skills_md}
|
|
98
181
|
"""
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
cc_body = f"""---
|
|
182
|
+
if not (cursor_cmd.exists() and not merge):
|
|
183
|
+
cursor_cmd.write_text(cmd_body, encoding="utf-8")
|
|
184
|
+
written.append(str(cursor_cmd.relative_to(root)))
|
|
185
|
+
|
|
186
|
+
if write_claude:
|
|
187
|
+
claude_cmd = root / ".claude" / "commands" / "skillforge.md"
|
|
188
|
+
_assert_under(root, claude_cmd)
|
|
189
|
+
claude_cmd.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
cc_body = f"""---
|
|
109
191
|
description: Use Skillforge MCP route_skills for this repo. Invoke when the user runs /skillforge or needs routed SKILL.md context.
|
|
110
192
|
---
|
|
111
193
|
|
|
@@ -117,27 +199,26 @@ Project-local **`/skillforge`** for **Claude Code**. Use the **skillforge** MCP
|
|
|
117
199
|
|
|
118
200
|
1. **`route_skills`**: pass **`project_root`** as this workspace root (absolute path). Pass the **current user task** as **`prompt`**. Reuse **`session_id`** when returned.
|
|
119
201
|
|
|
120
|
-
- **`
|
|
202
|
+
- **`host`** routing (default when **`SKILLFORGE_ROUTER_MODE`** is unset): shortlist first, then **`picked_names`**.
|
|
121
203
|
|
|
122
204
|
2. **Use the returned skill text** in your answer.
|
|
123
205
|
|
|
124
|
-
3. Optionally **`materialize_project`** to refresh this file
|
|
206
|
+
3. Optionally **`materialize_project`** (**`hosts: \"auto\"`** (MCP default), **`\"claude_code\"`**, or **`\"both\"`**) to refresh this file.
|
|
125
207
|
|
|
126
208
|
## Skills last materialized for this project
|
|
127
209
|
|
|
128
210
|
{skills_md}
|
|
129
211
|
"""
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
claude_cmd.write_text(cc_body, encoding="utf-8")
|
|
134
|
-
written.append(str(claude_cmd.relative_to(root)))
|
|
212
|
+
if not (claude_cmd.exists() and not merge):
|
|
213
|
+
claude_cmd.write_text(cc_body, encoding="utf-8")
|
|
214
|
+
written.append(str(claude_cmd.relative_to(root)))
|
|
135
215
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
216
|
+
if write_cursor or write_claude:
|
|
217
|
+
docs_dir = root / "docs"
|
|
218
|
+
docs_dir.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
prd = docs_dir / "SKILLFORGE-PRD.md"
|
|
220
|
+
_assert_under(root, prd)
|
|
221
|
+
prd_body = f"""# Skillforge — project PRD (auto-generated stub)
|
|
141
222
|
|
|
142
223
|
Scaffold for goals and milestones. Re-run **materialize_project** after major routing or pack changes.
|
|
143
224
|
|
|
@@ -148,8 +229,8 @@ Scaffold for goals and milestones. Re-run **materialize_project** after major ro
|
|
|
148
229
|
## How to run Skillforge here
|
|
149
230
|
|
|
150
231
|
- **MCP**: configure the `skillforge` server (e.g. `npx -y @heytherevibin/skillforge mcp`). No API key required for embedding-only routing.
|
|
151
|
-
- **Cursor**: use **`/skillforge`** (**`.cursor/commands/skillforge.md`**)
|
|
152
|
-
- **Claude Code**: use **`/skillforge`** (**`.claude/commands/skillforge.md`**)
|
|
232
|
+
- **Cursor**: use **`/skillforge`** (**`.cursor/commands/skillforge.md`**) after **materialize_project** with **`hosts: \"auto\"`**, **`\"cursor\"`**, or **`\"both\"`**.
|
|
233
|
+
- **Claude Code**: use **`/skillforge`** (**`.claude/commands/skillforge.md`**) after **materialize_project** with **`hosts: \"auto\"`**, **`\"claude_code\"`**, or **`\"both\"`**.
|
|
153
234
|
- **session_id**: reuse the same value across **route_skills** calls in one conversation thread.
|
|
154
235
|
- Re-bootstrap this project after new skills: **materialize_project** again.
|
|
155
236
|
|
|
@@ -165,12 +246,13 @@ Scaffold for goals and milestones. Re-run **materialize_project** after major ro
|
|
|
165
246
|
|
|
166
247
|
- [ ] (edit me)
|
|
167
248
|
"""
|
|
168
|
-
|
|
169
|
-
|
|
249
|
+
prd.write_text(prd_body, encoding="utf-8")
|
|
250
|
+
written.append(str(prd.relative_to(root)))
|
|
170
251
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
252
|
+
if write_claude:
|
|
253
|
+
claude_md = root / "CLAUDE.md"
|
|
254
|
+
skill_list = ", ".join(f"`{n}`" for n in skill_names) or "_(none)_"
|
|
255
|
+
block = f"""{MARKER_START}
|
|
174
256
|
|
|
175
257
|
## Skillforge (project bootstrap)
|
|
176
258
|
|
|
@@ -184,23 +266,23 @@ Use [Skillforge](https://www.npmjs.com/package/@heytherevibin/skillforge) for **
|
|
|
184
266
|
|
|
185
267
|
{MARKER_END}
|
|
186
268
|
"""
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
269
|
+
_assert_under(root, claude_md)
|
|
270
|
+
if claude_md.exists():
|
|
271
|
+
text = claude_md.read_text(encoding="utf-8")
|
|
272
|
+
if MARKER_START in text and MARKER_END in text:
|
|
273
|
+
pattern = re.compile(
|
|
274
|
+
re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END),
|
|
275
|
+
re.DOTALL,
|
|
276
|
+
)
|
|
277
|
+
text = pattern.sub(block.strip(), text)
|
|
278
|
+
else:
|
|
279
|
+
text = text.rstrip() + "\n\n" + block
|
|
280
|
+
claude_md.write_text(text, encoding="utf-8")
|
|
196
281
|
else:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
encoding="utf-8",
|
|
203
|
-
)
|
|
204
|
-
written.append(str(claude_md.relative_to(root)))
|
|
282
|
+
claude_md.write_text(
|
|
283
|
+
f"# CLAUDE.md\n\n{block}\n",
|
|
284
|
+
encoding="utf-8",
|
|
285
|
+
)
|
|
286
|
+
written.append(str(claude_md.relative_to(root)))
|
|
205
287
|
|
|
206
|
-
return {"written": written, "project_root": str(root), "skill_names": list(skill_names)}
|
|
288
|
+
return {"written": written, "project_root": str(root), "skill_names": list(skill_names), "hosts": mode}
|