@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/CONTRIBUTING.md +5 -3
  3. package/README.md +37 -345
  4. package/RELEASING.md +8 -7
  5. package/STRATEGY.md +2 -2
  6. package/bin/cli.js +297 -52
  7. package/ci/test-user-env-profile.cjs +65 -0
  8. package/docs/README.md +14 -0
  9. package/docs/architecture-and-data.md +90 -0
  10. package/docs/cli-reference.md +57 -0
  11. package/docs/environment-and-configuration.md +76 -0
  12. package/docs/getting-started.md +88 -0
  13. package/docs/mcp-integration.md +75 -0
  14. package/docs/troubleshooting.md +50 -0
  15. package/lib/templates/claude-code-skillforge-global.md +3 -3
  16. package/lib/templates/cursor-skillforge-global.md +6 -2
  17. package/lib/user-env-profile.js +141 -0
  18. package/package.json +3 -2
  19. package/python/app/agent_cli.py +334 -0
  20. package/python/app/explain_route.py +170 -0
  21. package/python/app/health_cli.py +13 -0
  22. package/python/app/main.py +131 -48
  23. package/python/app/materialize.py +150 -68
  24. package/python/app/mcp_contract.py +2 -1
  25. package/python/app/mcp_operator.py +252 -0
  26. package/python/app/mcp_server.py +290 -118
  27. package/python/app/npm_pkg_version.py +38 -0
  28. package/python/app/pick_diversify.py +51 -0
  29. package/python/app/replay_cli.py +145 -0
  30. package/python/app/route_cli.py +251 -87
  31. package/python/app/route_cli_pick.py +35 -0
  32. package/python/app/route_policies.py +18 -3
  33. package/python/app/route_quality.py +70 -1
  34. package/python/app/router_llm.py +85 -0
  35. package/python/app/router_mode.py +21 -0
  36. package/python/app/routing_signals.py +7 -1
  37. package/python/app/skill_manifest.py +67 -0
  38. package/python/app/skills_author_cli.py +117 -0
  39. package/python/app/tips_cli.py +37 -0
  40. package/python/app/tools_cli.py +276 -0
  41. package/python/fixtures/route_eval/smoke.json +5 -0
  42. package/python/requirements.txt +1 -0
  43. package/python/tests/test_capabilities_bundle.py +33 -0
  44. package/python/tests/test_materialize_hosts.py +108 -0
  45. package/python/tests/test_mcp_contract.py +1 -1
  46. package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
  47. package/python/tests/test_mcp_operator.py +84 -0
  48. package/python/tests/test_npm_pkg_version.py +21 -0
  49. package/python/tests/test_pick_diversify.py +47 -0
  50. package/python/tests/test_replay_cli.py +31 -0
  51. package/python/tests/test_route_cli_pick.py +25 -0
  52. package/python/tests/test_route_policies.py +29 -0
  53. package/python/tests/test_route_quality.py +72 -0
  54. package/python/tests/test_router_llm.py +63 -0
  55. package/python/tests/test_router_mode_env.py +21 -0
  56. package/python/tests/test_routing_signals.py +20 -0
  57. package/python/tests/test_skill_manifest.py +48 -0
  58. package/python/tests/test_tools_cli.py +69 -0
@@ -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
- BUNDLED_SKILLS = Path(os.getenv("SKILLFORGE_BUNDLED_SKILLS", "./skills"))
55
- USER_SKILLS = Path(os.getenv("SKILLFORGE_USER_SKILLS", str(Path.home() / ".skillforge" / "skills")))
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" — embedding skips Haiku; host skips in-process pick (MCP must pass picked_names).
67
- SKILLFORGE_ROUTER_MODE = os.getenv("SKILLFORGE_ROUTER_MODE", "").strip().lower()
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
- anthropic = None
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
- anthropic = None
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
- anthropic = AsyncAnthropic()
126
- router_note = "full Haiku router (SKILLFORGE_ROUTER_MODE=full)"
151
+ anth_legacy = AsyncAnthropic()
152
+ router_note_llm = "full Haiku router (SKILLFORGE_ROUTER_MODE=full)"
127
153
  else:
128
- anthropic = None
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
- anthropic = AsyncAnthropic()
135
- router_note = "full Haiku router (default; ANTHROPIC_API_KEY set)"
159
+ anth_legacy = AsyncAnthropic()
160
+ router_note_llm = "full Haiku router (default; ANTHROPIC_API_KEY set)"
136
161
  else:
137
- anthropic = None
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={BUNDLED_SKILLS} user={USER_SKILLS}",
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, anthropic)
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
- for src_dir, label in [(BUNDLED_SKILLS, "bundled"), (USER_SKILLS, "user")]:
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
- by_name[s.name] = s # later sources override
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 (BUNDLED_SKILLS, USER_SKILLS):
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__(self, skills, embed_model, anthropic: Optional[AsyncAnthropic]):
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.anthropic = anthropic
376
- self.context_mode = SKILLFORGE_CONTEXT_MODE if SKILLFORGE_CONTEXT_MODE in (
377
- "chunks",
378
- "full_body",
379
- ) else "chunks"
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.anthropic is None
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 = os.getenv("SKILLFORGE_HAIKU_RERANK_MODEL", "").strip() or ROUTER_MODEL
776
- resp = await self.anthropic.messages.create(
777
- model=rerank_model,
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
- messages=[{"role": "user", "content": user}],
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.anthropic is None:
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
- resp = await self.anthropic.messages.create(
853
- model=ROUTER_MODEL,
854
- max_tokens=400,
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
- messages=[{"role": "user", "content": user}],
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 = max(3, min(TOP_K_CANDIDATES, int(os.getenv("SKILLFORGE_HOST_PICK_MAX", "12"))))
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.anthropic is not None and _env_truthy("SKILLFORGE_HAIKU_RERANK", "0")
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.anthropic is None or SKILLFORGE_ROUTER_MODE == "embedding":
1275
+ elif router.router_llm is None or SKILLFORGE_ROUTER_MODE == "embedding":
1194
1276
  pick_path = "embedding_top"
1195
1277
  else:
1196
- pick_path = "haiku_pick"
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 rule + command, Claude Code command, PRD stub, and CLAUDE.md section.
105
+ """Create or update Cursor and/or Claude Code project stubs, docs, optional CLAUDE.md section.
34
106
 
35
- merge=False skips overwriting existing `.cursor/rules/skillforge.mdc`,
36
- `.cursor/commands/skillforge.md`, and `.claude/commands/skillforge.md` if they already exist
37
- (other files still update).
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
- cursor_rule = root / ".cursor" / "rules" / "skillforge.mdc"
49
- _assert_under(root, cursor_rule)
50
- cursor_rule.parent.mkdir(parents=True, exist_ok=True)
51
- mdc_body = f"""---
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 **`SKILLFORGE_ROUTER_MODE=host`**: first **`route_skills`** without **`picked_names`** (shortlist only); then call again with **`picked_names`** for the chosen catalog ids before continuing.
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
- if cursor_rule.exists() and not merge:
72
- pass
73
- else:
74
- cursor_rule.write_text(mdc_body, encoding="utf-8")
75
- written.append(str(cursor_rule.relative_to(root)))
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
- cursor_cmd = root / ".cursor" / "commands" / "skillforge.md"
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
- - **`SKILLFORGE_ROUTER_MODE=host`**: call once **without** **`picked_names`** (shortlist in the response); then call again with **`picked_names`** (exact catalog ids) to load skill context.
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`** to refresh **`.cursor/rules`**, **`.cursor/commands`**, **`.claude/commands`**, and **docs/SKILLFORGE-PRD.md**.
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
- if cursor_cmd.exists() and not merge:
100
- pass
101
- else:
102
- cursor_cmd.write_text(cmd_body, encoding="utf-8")
103
- written.append(str(cursor_cmd.relative_to(root)))
104
-
105
- claude_cmd = root / ".claude" / "commands" / "skillforge.md"
106
- _assert_under(root, claude_cmd)
107
- claude_cmd.parent.mkdir(parents=True, exist_ok=True)
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
- - **`SKILLFORGE_ROUTER_MODE=host`**: shortlist first, then **`picked_names`**.
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 and **`.cursor`** files.
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
- if claude_cmd.exists() and not merge:
131
- pass
132
- else:
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
- docs_dir = root / "docs"
137
- docs_dir.mkdir(parents=True, exist_ok=True)
138
- prd = docs_dir / "SKILLFORGE-PRD.md"
139
- _assert_under(root, prd)
140
- prd_body = f"""# Skillforge — project PRD (auto-generated stub)
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`**) to steer the agent through **route_skills** for this workspace.
152
- - **Claude Code**: use **`/skillforge`** (**`.claude/commands/skillforge.md`**) the same way.
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
- prd.write_text(prd_body, encoding="utf-8")
169
- written.append(str(prd.relative_to(root)))
249
+ prd.write_text(prd_body, encoding="utf-8")
250
+ written.append(str(prd.relative_to(root)))
170
251
 
171
- claude_md = root / "CLAUDE.md"
172
- skill_list = ", ".join(f"`{n}`" for n in skill_names) or "_(none)_"
173
- block = f"""{MARKER_START}
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
- _assert_under(root, claude_md)
188
- if claude_md.exists():
189
- text = claude_md.read_text(encoding="utf-8")
190
- if MARKER_START in text and MARKER_END in text:
191
- pattern = re.compile(
192
- re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END),
193
- re.DOTALL,
194
- )
195
- text = pattern.sub(block.strip(), text)
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
- text = text.rstrip() + "\n\n" + block
198
- claude_md.write_text(text, encoding="utf-8")
199
- else:
200
- claude_md.write_text(
201
- f"# CLAUDE.md\n\n{block}\n",
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}