@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
@@ -0,0 +1,25 @@
1
+ """Parsing interactive CLI host picks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.route_cli_pick import parse_interactive_skill_pick
6
+
7
+
8
+ def test_parse_ranks_and_names() -> None:
9
+ rows = [
10
+ {"rank": 1, "name": "alpha", "id": "alpha"},
11
+ {"rank": 2, "name": "beta", "id": "beta"},
12
+ {"rank": 3, "name": "gamma-sk", "id": "gamma-sk"},
13
+ ]
14
+ out = parse_interactive_skill_pick("1, gamma-sk ", rows)
15
+ assert out == ["alpha", "gamma-sk"]
16
+
17
+
18
+ def test_parse_quit() -> None:
19
+ assert parse_interactive_skill_pick("q", [{}]) == []
20
+ assert parse_interactive_skill_pick("", []) == []
21
+
22
+
23
+ def test_strip_backticks() -> None:
24
+ rows = [{"rank": 1, "name": "x"}]
25
+ assert parse_interactive_skill_pick("`x`", rows) == ["x"]
@@ -113,3 +113,32 @@ def test_invalid_regex_recorded(tmp_path, skill_alpha, monkeypatch) -> None:
113
113
  max_active=7,
114
114
  )
115
115
  assert any(r.get("effect") == "invalid_regex" for r in audit)
116
+
117
+
118
+ def test_bad_inline_policies_warns_stderr(
119
+ capsys: pytest.CaptureFixture[str],
120
+ monkeypatch: pytest.MonkeyPatch,
121
+ ) -> None:
122
+ monkeypatch.setenv("SKILLFORGE_ROUTE_POLICIES", "{not-json")
123
+ monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES_FILE", raising=False)
124
+ cfg = load_route_policies_config(None)
125
+ assert cfg == {"rules": []}
126
+ err = capsys.readouterr().err
127
+ assert "SKILLFORGE_ROUTE_POLICIES" in err
128
+ assert "invalid json" in err.lower()
129
+
130
+
131
+ def test_bad_policies_file_warns_stderr(
132
+ capsys: pytest.CaptureFixture[str],
133
+ monkeypatch: pytest.MonkeyPatch,
134
+ tmp_path,
135
+ ) -> None:
136
+ monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES", raising=False)
137
+ p = tmp_path / "broken.json"
138
+ p.write_text("{bad", encoding="utf-8")
139
+ monkeypatch.setenv("SKILLFORGE_ROUTE_POLICIES_FILE", str(p))
140
+ cfg = load_route_policies_config(None)
141
+ assert cfg == {"rules": []}
142
+ err = capsys.readouterr().err
143
+ assert str(p) in err
144
+ assert "invalid json" in err.lower()
@@ -53,6 +53,9 @@ def test_build_route_quality_empty_facets() -> None:
53
53
  )
54
54
  assert rq["shortlist"]["size"] == 0
55
55
  assert rq["shortlist"]["top_cosine_similarity"] is None
56
+ assert rq["shortlist"]["confidence_tier"] is None
57
+ assert rq["schema"] == "route_quality/2"
58
+ assert rq["router"]["pick_diversify"]["applied"] is False
56
59
  assert rq["session"]["change_jaccard"] == 0.0
57
60
  assert rq["policy"]["rules_loaded"] == 0
58
61
 
@@ -78,6 +81,11 @@ def test_build_route_quality_malformed_metrics() -> None:
78
81
  assert rq["shortlist"]["top_routing_score"] == 0.0
79
82
  assert rq["shortlist"]["second_cosine_similarity"] == 0.5
80
83
  assert rq["shortlist"]["cosine_margin"] == round(-0.5, 6)
84
+ assert rq["shortlist"]["ambiguous"] is True
85
+ assert rq["shortlist"]["confidence_tier"] == "low"
86
+ assert rq["shortlist"]["routing_score_margin"] == round(-0.2, 6)
87
+ assert rq["shortlist"]["second_routing_score"] == 0.2
88
+ assert rq["schema"] == "route_quality/2"
81
89
  assert rq["shortlist"]["top1_dense_and_fused_agree"] is False
82
90
  assert rq["policy"]["rules_loaded"] == 0
83
91
  assert rq["policy"]["includes_added"] == 2
@@ -102,6 +110,7 @@ def test_build_route_quality_hybrid_off_skips_agree() -> None:
102
110
  pick_path="embedding_top",
103
111
  )
104
112
  assert rq["shortlist"]["top1_dense_and_fused_agree"] is None
113
+ assert rq["shortlist"]["cosine_leader_matches_routing_top"] is None
105
114
 
106
115
 
107
116
  def test_build_route_quality_rules_loaded_ok() -> None:
@@ -118,3 +127,66 @@ def test_build_route_quality_rules_loaded_ok() -> None:
118
127
  pick_path="host_shortlist",
119
128
  )
120
129
  assert rq["policy"]["rules_loaded"] == 12
130
+
131
+
132
+ def test_build_route_quality_pick_diversify_meta() -> None:
133
+ facets = [{"name": "x", "cosine_similarity": 0.5, "routing_score": 0.8}]
134
+ div = {"applied": True, "dropped": ["z"], "max_per_source": 1}
135
+ rq = build_route_quality(
136
+ facet_list=facets,
137
+ router_mode="auto",
138
+ router_hybrid="off",
139
+ picked_names=["x"],
140
+ rerouted=False,
141
+ change=0.0,
142
+ policy_rules_loaded=0,
143
+ policy_audit=[],
144
+ host_picked=False,
145
+ pick_path="haiku_pick",
146
+ pick_diversify=div,
147
+ )
148
+ assert rq["router"]["pick_diversify"] == div
149
+
150
+
151
+ def test_build_route_quality_clear_shortlist_not_ambiguous(monkeypatch) -> None:
152
+ facets = [
153
+ {"name": "a", "cosine_similarity": 0.95, "routing_score": 0.9},
154
+ {"name": "b", "cosine_similarity": 0.91, "routing_score": 0.84},
155
+ ]
156
+ monkeypatch.delenv("SKILLFORGE_ROUTE_AMBIGUITY_DISABLE", raising=False)
157
+ rq = build_route_quality(
158
+ facet_list=facets,
159
+ router_mode="host",
160
+ router_hybrid="keyword",
161
+ picked_names=[],
162
+ rerouted=False,
163
+ change=0.0,
164
+ policy_rules_loaded=0,
165
+ policy_audit=[],
166
+ host_picked=False,
167
+ pick_path="host_shortlist",
168
+ )
169
+ assert rq["shortlist"]["ambiguous"] is False
170
+ assert rq["shortlist"]["confidence_tier"] == "high"
171
+
172
+
173
+ def test_build_route_quality_ambiguity_disable_env(monkeypatch) -> None:
174
+ facets = [
175
+ {"name": "a", "cosine_similarity": 0.5, "routing_score": 0.9},
176
+ {"name": "b", "cosine_similarity": 0.498, "routing_score": 0.89},
177
+ ]
178
+ monkeypatch.setenv("SKILLFORGE_ROUTE_AMBIGUITY_DISABLE", "1")
179
+ rq = build_route_quality(
180
+ facet_list=facets,
181
+ router_mode="auto",
182
+ router_hybrid="off",
183
+ picked_names=[],
184
+ rerouted=False,
185
+ change=0.0,
186
+ policy_rules_loaded=0,
187
+ policy_audit=[],
188
+ host_picked=False,
189
+ pick_path="embedding_top",
190
+ )
191
+ assert rq["shortlist"]["ambiguous"] is False
192
+ assert rq["shortlist"]["confidence_tier"] == "medium"
@@ -0,0 +1,63 @@
1
+ """Lightweight assertions for RouterLLM helpers and MCP transport snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ from app.mcp_operator import build_router_status_dict, build_capabilities_bundle
8
+ from app.router_llm import OpenAIRouterLLM, resolve_openai_router_defaults, transport_is_mcp
9
+
10
+
11
+ def test_resolve_openai_router_defaults_contains_base() -> None:
12
+ base, _, _ = resolve_openai_router_defaults()
13
+ assert "v1" in base.lower() or base.startswith("http")
14
+
15
+
16
+ def test_transport_is_mcp_false_by_default(monkeypatch) -> None:
17
+ monkeypatch.delenv("SKILLFORGE_TRANSPORT", raising=False)
18
+ assert transport_is_mcp() is False
19
+
20
+
21
+ def test_transport_is_mcp_true(monkeypatch) -> None:
22
+ monkeypatch.setenv("SKILLFORGE_TRANSPORT", "mcp")
23
+ assert transport_is_mcp() is True
24
+
25
+
26
+ def test_router_status_includes_router_llm_fields() -> None:
27
+ r = SimpleNamespace(
28
+ anthropic=None,
29
+ router_llm=None,
30
+ context_mode="chunks",
31
+ _hybrid_mode="off",
32
+ )
33
+ snap = build_router_status_dict(r, skill_count=1)
34
+ assert snap["router_llm_backend"] == "none"
35
+ assert snap["router_llm_active"] is False
36
+ assert snap["anthropic_available"] is False
37
+
38
+
39
+ def test_router_status_detects_fake_openai_router_llm(monkeypatch) -> None:
40
+ monkeypatch.delenv("SKILLFORGE_OPENAI_API_KEY", raising=False)
41
+
42
+ llm = OpenAIRouterLLM(api_key="", base_url="http://localhost:11434/v1", default_model="t")
43
+ r = SimpleNamespace(
44
+ anthropic=None,
45
+ router_llm=llm,
46
+ context_mode="chunks",
47
+ _hybrid_mode="off",
48
+ )
49
+ snap = build_router_status_dict(r, skill_count=1)
50
+ assert snap["router_llm_backend"] == "openai_compatible"
51
+ assert snap["router_llm_active"] is True
52
+
53
+
54
+ def test_capabilities_bundle_has_standalone_agent_hint() -> None:
55
+ router = SimpleNamespace(
56
+ anthropic=None,
57
+ context_mode="chunks",
58
+ _hybrid_mode="off",
59
+ _by_name=None,
60
+ )
61
+ bundle = build_capabilities_bundle(router, skill_count=0)
62
+ assert "standalone_agent" in bundle
63
+ assert bundle["user_env_profile"]["validate_command"] == "skillforge config validate"
@@ -0,0 +1,21 @@
1
+ """SKILLFORGE_ROUTER_MODE normalization (default host vs explicit auto)."""
2
+ from __future__ import annotations
3
+
4
+ from app.router_mode import normalise_skillforge_router_mode
5
+
6
+
7
+ def test_normalise_defaults_to_literal_host_from_value() -> None:
8
+ assert normalise_skillforge_router_mode("host") == "host"
9
+ assert normalise_skillforge_router_mode("HOST") == "host"
10
+
11
+
12
+ def test_normalise_auto_aliases() -> None:
13
+ assert normalise_skillforge_router_mode("") == ""
14
+ assert normalise_skillforge_router_mode(" ") == ""
15
+ assert normalise_skillforge_router_mode("auto") == ""
16
+ assert normalise_skillforge_router_mode("AuTo") == ""
17
+
18
+
19
+ def test_normalise_embedding_full() -> None:
20
+ assert normalise_skillforge_router_mode("embedding") == "embedding"
21
+ assert normalise_skillforge_router_mode("full") == "full"
@@ -88,6 +88,26 @@ def test_host_pick_shortlist_lines_basic() -> None:
88
88
  assert rows[0]["rank"] == 1
89
89
 
90
90
 
91
+ def test_host_pick_shortlist_defaults_respect_top_k_and_host_pick_max(monkeypatch) -> None:
92
+ monkeypatch.setenv("SKILLFORGE_TOP_K", "50")
93
+ monkeypatch.setenv("SKILLFORGE_HOST_PICK_MAX", "20")
94
+ facets = [
95
+ {
96
+ "name": f"c{i}",
97
+ "title": "t",
98
+ "cosine_similarity": 0.1 - i * 0.001,
99
+ "description_preview": "",
100
+ }
101
+ for i in range(40)
102
+ ]
103
+ _md, rows = host_pick_shortlist_lines(
104
+ prompt="p",
105
+ route_query="p",
106
+ facet_rows=facets,
107
+ )
108
+ assert len(rows) == 20
109
+
110
+
91
111
  def test_normalize_host_picked_main() -> None:
92
112
  from app.main import Skill, normalize_host_picked_names
93
113
 
@@ -0,0 +1,48 @@
1
+ """SKILL manifest validation rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.main import load_all_skills, parse_skill_md
6
+ from app.skill_manifest import validate_skill_manifest
7
+
8
+
9
+ def test_validate_manifest_warns_without_frontmatter(tmp_path) -> None:
10
+ d = tmp_path / "my-skill"
11
+ d.mkdir()
12
+ md = d / "SKILL.md"
13
+ md.write_text("# Hello\n\nBody text here " * 20, encoding="utf-8")
14
+ s = parse_skill_md(md, "user")
15
+ assert s is not None
16
+ errs, warns = validate_skill_manifest(s, md)
17
+ assert errs == []
18
+ assert any("frontmatter" in w for w in warns)
19
+
20
+
21
+ def test_load_all_skills_skips_bad_manifest_only_when_strict(monkeypatch, tmp_path) -> None:
22
+ monkeypatch.setenv("SKILLFORGE_BUNDLED_SKILLS", str(tmp_path / "bundled"))
23
+ monkeypatch.setenv("SKILLFORGE_USER_SKILLS", str(tmp_path / "user"))
24
+
25
+ bundled = tmp_path / "bundled" / "ok-skill"
26
+ bundled.mkdir(parents=True)
27
+ (bundled / "SKILL.md").write_text(
28
+ "---\nname: OK\ndescription: " + ("x" * 50) + "\n---\n\n# OK\nbody " * 30,
29
+ encoding="utf-8",
30
+ )
31
+ weird = tmp_path / "bundled" / "Weird_Case_Skill"
32
+ weird.mkdir(parents=True)
33
+ (weird / "SKILL.md").write_text(
34
+ "---\nname: Weird\ndescription: " + ("y" * 50) + "\n---\n\nbody " * 30,
35
+ encoding="utf-8",
36
+ )
37
+
38
+ monkeypatch.setenv("SKILLFORGE_SKILL_MANIFEST_STRICT", "0")
39
+ lax = load_all_skills(manifest_log_prefix="[test]")
40
+ lax_names = {x.name for x in lax}
41
+ assert "ok-skill" in lax_names
42
+ assert "Weird_Case_Skill" in lax_names
43
+
44
+ monkeypatch.setenv("SKILLFORGE_SKILL_MANIFEST_STRICT", "1")
45
+ strict = load_all_skills(manifest_log_prefix="[test]")
46
+ names = {x.name for x in strict}
47
+ assert "ok-skill" in names
48
+ assert "Weird_Case_Skill" not in names
@@ -0,0 +1,69 @@
1
+ """Smoke tests for tools_cli argument wiring (no heavy router setup)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from app.tools_cli import build_parser
8
+ def test_tools_cli_search_parses() -> None:
9
+ p = build_parser()
10
+ ns = p.parse_args(["search", "hello", "world", "--limit", "5"])
11
+ assert ns.tool == "search"
12
+ assert ns.query == ["hello", "world"]
13
+ assert ns.limit == 5
14
+
15
+
16
+ def test_tools_cli_disable_polarity() -> None:
17
+ p = build_parser()
18
+ ns_off = p.parse_args(["disable", "--skill-name", "x", "--off"])
19
+ assert ns_off.disabled is True
20
+ ns_on = p.parse_args(["disable", "--skill-name", "x", "--on"])
21
+ assert ns_on.disabled is False
22
+
23
+
24
+ def test_tools_cli_global_flags_with_subcommand() -> None:
25
+ p = build_parser()
26
+ ns = p.parse_args(
27
+ ["--project-root", "/tmp/ws", "--user-id", "u1", "catalog"],
28
+ )
29
+ assert ns.tool == "catalog"
30
+ assert ns.project_root == "/tmp/ws"
31
+ assert ns.user_id == "u1"
32
+
33
+
34
+ def test_tools_cli_materialize_roots() -> None:
35
+ p = build_parser()
36
+ ns = p.parse_args(
37
+ [
38
+ "materialize",
39
+ "--root",
40
+ "/repo",
41
+ "--names",
42
+ "skill_a, skill_b",
43
+ "--hosts",
44
+ "both",
45
+ "--no-merge",
46
+ ],
47
+ )
48
+ assert ns.mat_root == "/repo"
49
+ assert ns.names == "skill_a, skill_b"
50
+ assert ns.hosts == "both"
51
+ assert ns.no_merge is True
52
+
53
+
54
+ def test_tools_cli_router_status_hyphen_alias() -> None:
55
+ p = build_parser()
56
+ ns = p.parse_args(["router-status"])
57
+ assert ns.tool == "router-status"
58
+
59
+
60
+ def test_tools_cli_feedback_thumbs_negative() -> None:
61
+ p = build_parser()
62
+ ns = p.parse_args(["feedback", "--skill-name", "s", "--thumbs=-1"])
63
+ assert ns.thumbs == -1
64
+
65
+
66
+ def test_tools_cli_feedback_rejects_bad_thumbs() -> None:
67
+ p = build_parser()
68
+ with pytest.raises(SystemExit):
69
+ p.parse_args(["feedback", "--skill-name", "s", "--thumbs", "2"])