@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
|
@@ -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"])
|