@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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI parity for MCP tools (non-MCP callers: scripts, terminals, CI).
|
|
3
|
+
|
|
4
|
+
Dispatches through the same MCPServer tool handlers after a one-time router load.
|
|
5
|
+
Does not speak JSON-RPC; use ``skillforge mcp`` for MCP hosts.
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
skillforge tools search \"refactor typescript\"
|
|
9
|
+
skillforge tools explain --prompt=\"debug routing\" --project-root \"$PWD\"
|
|
10
|
+
skillforge tools get --skill-name my_skill --format card
|
|
11
|
+
skillforge tools catalog
|
|
12
|
+
skillforge tools capabilities --json
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from app.mcp_server import MCPServer
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _die(msg: str, code: int = 2) -> None:
|
|
26
|
+
print(msg, file=sys.stderr)
|
|
27
|
+
raise SystemExit(code)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _base_payload(ns: argparse.Namespace) -> dict[str, Any]:
|
|
31
|
+
d: dict[str, Any] = {}
|
|
32
|
+
if getattr(ns, "project_root", None) and str(ns.project_root).strip():
|
|
33
|
+
d["project_root"] = str(ns.project_root).strip()
|
|
34
|
+
if getattr(ns, "user_id", None) and str(ns.user_id).strip():
|
|
35
|
+
d["user_id"] = str(ns.user_id).strip()
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merged(ns: argparse.Namespace, extra: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
return {**_base_payload(ns), **extra}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _run_tool(server: MCPServer, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
return await server.handle_tools_call({"name": tool, "arguments": arguments})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _emit(payload: dict[str, Any], *, as_json: bool) -> None:
|
|
48
|
+
if as_json:
|
|
49
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
50
|
+
else:
|
|
51
|
+
blocks = payload.get("content") or []
|
|
52
|
+
text = ""
|
|
53
|
+
if blocks and isinstance(blocks[0], dict) and blocks[0].get("type") == "text":
|
|
54
|
+
text = str(blocks[0].get("text") or "")
|
|
55
|
+
sys.stdout.write(text)
|
|
56
|
+
if text and not text.endswith("\n"):
|
|
57
|
+
sys.stdout.write("\n")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
61
|
+
p = argparse.ArgumentParser(
|
|
62
|
+
prog="skillforge tools",
|
|
63
|
+
description=(
|
|
64
|
+
"Invoke MCP-equivalent Skillforge tools from the shell. "
|
|
65
|
+
"Uses the same Python handlers as ``skillforge mcp`` (shared router + DB)."
|
|
66
|
+
),
|
|
67
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
68
|
+
epilog=(
|
|
69
|
+
"CLI mapping: MCP route_skills → ``skillforge route`` "
|
|
70
|
+
"(``tools`` exposes the remaining published tools verbatim)."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
p.add_argument(
|
|
74
|
+
"--project-root",
|
|
75
|
+
metavar="PATH",
|
|
76
|
+
help="Workspace root (per-repo ~/.skillforge); else SKILLFORGE_PROJECT_ROOT",
|
|
77
|
+
)
|
|
78
|
+
p.add_argument("--user-id", metavar="ID", help="Namespace for weights / events (matches MCP user_id)")
|
|
79
|
+
p.add_argument(
|
|
80
|
+
"--json",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Print full MCP tool payload (content + _meta) as JSON on stdout",
|
|
83
|
+
)
|
|
84
|
+
sub = p.add_subparsers(dest="tool", metavar="COMMAND", required=True)
|
|
85
|
+
|
|
86
|
+
sp = sub.add_parser("search", help="Embedding shortlist for a query (MCP: search_skills)")
|
|
87
|
+
sp.add_argument("query", nargs="+", help="Search text")
|
|
88
|
+
sp.add_argument("--limit", type=int, metavar="N", help="Max skills (default server TOP_K; cap 50)")
|
|
89
|
+
|
|
90
|
+
ep = sub.add_parser("explain", help="Routing diagnostics without session writes (MCP: explain_route)")
|
|
91
|
+
ep.add_argument("--prompt", required=True, help="Task text")
|
|
92
|
+
ep.add_argument("--limit", type=int, metavar="N")
|
|
93
|
+
ep.add_argument(
|
|
94
|
+
"--conversation",
|
|
95
|
+
metavar="FILE.json",
|
|
96
|
+
help="Optional JSON array of message objects (same shape as MCP conversation)",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
gp = sub.add_parser("get", help="Fetch one catalog skill by id (MCP: get_skill)")
|
|
100
|
+
gp.add_argument("--skill-name", required=True, metavar="NAME")
|
|
101
|
+
gp.add_argument("--format", choices=("card", "summary", "full"), default="full")
|
|
102
|
+
gp.add_argument("--max-chars", type=int, default=0, metavar="N")
|
|
103
|
+
|
|
104
|
+
sub.add_parser("catalog", help="Skills + usage stats (MCP: list_skills)")
|
|
105
|
+
|
|
106
|
+
fb = sub.add_parser("feedback", help="Thumb up/down (MCP: skill_feedback)")
|
|
107
|
+
fb.add_argument("--skill-name", required=True)
|
|
108
|
+
fb.add_argument("--thumbs", type=int, choices=(-1, 1), required=True)
|
|
109
|
+
fb.add_argument("--session-id", default="", metavar="ID")
|
|
110
|
+
|
|
111
|
+
dis = sub.add_parser("disable", help="Exclude or restore a catalog skill from routing (MCP: disable_skill)")
|
|
112
|
+
dis.add_argument("--skill-name", required=True)
|
|
113
|
+
g = dis.add_mutually_exclusive_group(required=True)
|
|
114
|
+
g.add_argument("--off", dest="disabled", action="store_true", help="Disable routing to this skill")
|
|
115
|
+
g.add_argument("--on", dest="disabled", action="store_false", help="Re-enable routing")
|
|
116
|
+
|
|
117
|
+
ref = sub.add_parser("referenced", help="Increment reference/stat count (MCP: skill_referenced)")
|
|
118
|
+
ref.add_argument("--skill-name", required=True)
|
|
119
|
+
|
|
120
|
+
mp = sub.add_parser("materialize", help="Write project-local skillforge stubs (MCP: materialize_project)")
|
|
121
|
+
mp.add_argument("--root", dest="mat_root", required=True, metavar="PATH", help="Project root directory")
|
|
122
|
+
mp.add_argument("--names", required=True, metavar="CSV", help="Comma-separated skill ids from routing")
|
|
123
|
+
mp.add_argument(
|
|
124
|
+
"--hosts",
|
|
125
|
+
choices=("auto", "both", "cursor", "claude_code"),
|
|
126
|
+
default="auto",
|
|
127
|
+
help="Which host trees to write (same as MCP)",
|
|
128
|
+
)
|
|
129
|
+
mp.add_argument(
|
|
130
|
+
"--no-merge",
|
|
131
|
+
action="store_true",
|
|
132
|
+
help="If set, refuse to overwrite existing managed files where applicable",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
bp = sub.add_parser(
|
|
136
|
+
"bootstrap",
|
|
137
|
+
help="route_skills then materialize (blocked when ROUTER_MODE=host; MCP: skillforge_bootstrap)",
|
|
138
|
+
)
|
|
139
|
+
bp.add_argument("--prompt", required=True)
|
|
140
|
+
bp.add_argument("--root", dest="bootstrap_root", required=True, metavar="PATH")
|
|
141
|
+
bp.add_argument("--conversation", metavar="FILE.json")
|
|
142
|
+
bp.add_argument("--session-id", default="", metavar="ID")
|
|
143
|
+
bp.add_argument(
|
|
144
|
+
"--hosts",
|
|
145
|
+
choices=("auto", "both", "cursor", "claude_code"),
|
|
146
|
+
default="auto",
|
|
147
|
+
)
|
|
148
|
+
bp.add_argument("--no-merge", action="store_true")
|
|
149
|
+
bp.add_argument(
|
|
150
|
+
"--include-project-rag",
|
|
151
|
+
action="store_true",
|
|
152
|
+
help="Attach indexed project chunks (requires index + project root)",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
sub.add_parser("capabilities", help="Bootstrap bundle + tool list JSON (MCP: capabilities)")
|
|
156
|
+
sub.add_parser("router-status", help="Read-only router/env snapshot (MCP: get_router_status)")
|
|
157
|
+
|
|
158
|
+
ip = sub.add_parser("index-status", help="Project index stats from orchestrator DB (MCP: project_index_status)")
|
|
159
|
+
ip.add_argument("--root", dest="idx_root", required=True, metavar="PATH")
|
|
160
|
+
|
|
161
|
+
sub.add_parser("weights-snapshot", help="skill_weights rows as JSON (MCP: weights_snapshot)")
|
|
162
|
+
|
|
163
|
+
ev = sub.add_parser("events-recent", help="Recent SQLite events (MCP: events_recent)")
|
|
164
|
+
ev.add_argument("--limit", type=int, default=25, metavar="N")
|
|
165
|
+
ev.add_argument("--event-type", default="", metavar="TYPE")
|
|
166
|
+
|
|
167
|
+
return p
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_conversation(path: str) -> list[Any]:
|
|
171
|
+
with open(path, encoding="utf-8") as f:
|
|
172
|
+
data = json.load(f)
|
|
173
|
+
if not isinstance(data, list):
|
|
174
|
+
_die("--conversation JSON must be an array")
|
|
175
|
+
return data
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _async_main(raw: list[str]) -> int:
|
|
179
|
+
parser = build_parser()
|
|
180
|
+
ns = parser.parse_args(raw)
|
|
181
|
+
|
|
182
|
+
server = MCPServer()
|
|
183
|
+
await server.setup()
|
|
184
|
+
|
|
185
|
+
if ns.tool == "search":
|
|
186
|
+
q = " ".join(ns.query).strip()
|
|
187
|
+
args = _merged(ns, {"query": q})
|
|
188
|
+
if ns.limit is not None:
|
|
189
|
+
args["limit"] = ns.limit
|
|
190
|
+
payload = await _run_tool(server, "search_skills", args)
|
|
191
|
+
elif ns.tool == "explain":
|
|
192
|
+
args = _merged(ns, {"prompt": ns.prompt.strip()})
|
|
193
|
+
if ns.limit is not None:
|
|
194
|
+
args["limit"] = ns.limit
|
|
195
|
+
if ns.conversation:
|
|
196
|
+
args["conversation"] = _parse_conversation(ns.conversation)
|
|
197
|
+
payload = await _run_tool(server, "explain_route", args)
|
|
198
|
+
elif ns.tool == "get":
|
|
199
|
+
args = _merged(
|
|
200
|
+
ns,
|
|
201
|
+
{"skill_name": ns.skill_name, "format": ns.format, "max_chars": ns.max_chars},
|
|
202
|
+
)
|
|
203
|
+
payload = await _run_tool(server, "get_skill", args)
|
|
204
|
+
elif ns.tool == "catalog":
|
|
205
|
+
payload = await _run_tool(server, "list_skills", _merged(ns, {}))
|
|
206
|
+
elif ns.tool == "feedback":
|
|
207
|
+
args = _merged(
|
|
208
|
+
ns,
|
|
209
|
+
{"skill_name": ns.skill_name, "thumbs": ns.thumbs, "session_id": ns.session_id or ""},
|
|
210
|
+
)
|
|
211
|
+
payload = await _run_tool(server, "skill_feedback", args)
|
|
212
|
+
elif ns.tool == "disable":
|
|
213
|
+
args = _merged(ns, {"skill_name": ns.skill_name, "disabled": bool(ns.disabled)})
|
|
214
|
+
payload = await _run_tool(server, "disable_skill", args)
|
|
215
|
+
elif ns.tool == "referenced":
|
|
216
|
+
args = _merged(ns, {"skill_name": ns.skill_name})
|
|
217
|
+
payload = await _run_tool(server, "skill_referenced", args)
|
|
218
|
+
elif ns.tool == "materialize":
|
|
219
|
+
names = [n.strip() for n in ns.names.replace(",", " ").split() if n.strip()]
|
|
220
|
+
args_m = _merged(
|
|
221
|
+
ns,
|
|
222
|
+
{
|
|
223
|
+
"project_root": str(ns.mat_root).strip(),
|
|
224
|
+
"skill_names": names,
|
|
225
|
+
"merge": not ns.no_merge,
|
|
226
|
+
"hosts": ns.hosts,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
payload = await server.handle_tools_call({"name": "materialize_project", "arguments": args_m})
|
|
230
|
+
elif ns.tool == "bootstrap":
|
|
231
|
+
conv: list[Any] = []
|
|
232
|
+
if ns.conversation:
|
|
233
|
+
conv = _parse_conversation(ns.conversation)
|
|
234
|
+
sid = str(ns.session_id).strip()
|
|
235
|
+
args_b = _merged(
|
|
236
|
+
ns,
|
|
237
|
+
{
|
|
238
|
+
"prompt": ns.prompt,
|
|
239
|
+
"project_root": str(ns.bootstrap_root).strip(),
|
|
240
|
+
"conversation": conv,
|
|
241
|
+
"session_id": sid if sid else None,
|
|
242
|
+
"merge": not ns.no_merge,
|
|
243
|
+
"hosts": ns.hosts,
|
|
244
|
+
"include_project_rag": bool(ns.include_project_rag),
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
payload = await server.handle_tools_call({"name": "skillforge_bootstrap", "arguments": args_b})
|
|
248
|
+
elif ns.tool == "capabilities":
|
|
249
|
+
payload = await server.handle_tools_call({"name": "capabilities", "arguments": _merged(ns, {})})
|
|
250
|
+
elif ns.tool == "router-status":
|
|
251
|
+
payload = await server.handle_tools_call({"name": "get_router_status", "arguments": _merged(ns, {})})
|
|
252
|
+
elif ns.tool == "index-status":
|
|
253
|
+
args_i = _merged(ns, {"project_root": str(ns.idx_root).strip()})
|
|
254
|
+
payload = await server.handle_tools_call({"name": "project_index_status", "arguments": args_i})
|
|
255
|
+
elif ns.tool == "weights-snapshot":
|
|
256
|
+
payload = await server.handle_tools_call({"name": "weights_snapshot", "arguments": _merged(ns, {})})
|
|
257
|
+
elif ns.tool == "events-recent":
|
|
258
|
+
et = str(ns.event_type).strip()
|
|
259
|
+
args_e = _merged(ns, {"limit": ns.limit, **({"event_type": et} if et else {})})
|
|
260
|
+
payload = await server.handle_tools_call({"name": "events_recent", "arguments": args_e})
|
|
261
|
+
else: # pragma: no cover
|
|
262
|
+
parser.error(f"unknown tool: {ns.tool}")
|
|
263
|
+
|
|
264
|
+
_emit(payload, as_json=ns.json)
|
|
265
|
+
if payload.get("isError"):
|
|
266
|
+
return 2
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main() -> None:
|
|
271
|
+
code = asyncio.run(_async_main(sys.argv[1:]))
|
|
272
|
+
raise SystemExit(code)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
main()
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
"id": "docker-patterns",
|
|
14
14
|
"prompt": "docker compose healthcheck restart policy and rollout",
|
|
15
15
|
"expect_in_candidates": ["docker-patterns"]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "postgres-patterns",
|
|
19
|
+
"prompt": "PostgreSQL deadlock investigation and slow query indexing",
|
|
20
|
+
"expect_in_candidates": ["postgres-patterns"]
|
|
16
21
|
}
|
|
17
22
|
]
|
|
18
23
|
}
|
package/python/requirements.txt
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Session bootstrap MCP bundle helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION
|
|
8
|
+
from app.mcp_operator import MCP_PUBLISHED_TOOL_NAMES, build_capabilities_bundle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_build_capabilities_bundle_includes_ordered_tools_and_schema() -> None:
|
|
12
|
+
router = SimpleNamespace(
|
|
13
|
+
anthropic=None,
|
|
14
|
+
context_mode="chunks",
|
|
15
|
+
_hybrid_mode="off",
|
|
16
|
+
_by_name=None,
|
|
17
|
+
)
|
|
18
|
+
bundle = build_capabilities_bundle(router, skill_count=3)
|
|
19
|
+
assert bundle["bundle_version"] == "1"
|
|
20
|
+
assert bundle["mcp_response_schema_version"] == MCP_RESPONSE_SCHEMA_VERSION
|
|
21
|
+
assert bundle["mcp_tools"] == list(MCP_PUBLISHED_TOOL_NAMES)
|
|
22
|
+
assert bundle["progressive_loading"]["get_skill_formats"] == ["card", "summary", "full"]
|
|
23
|
+
uep = bundle["user_env_profile"]
|
|
24
|
+
assert uep["validate_command"] == "skillforge config validate"
|
|
25
|
+
assert "path_command" in uep and "init_command" in uep
|
|
26
|
+
snap = bundle["router_snapshot"]
|
|
27
|
+
assert snap["skills_loaded_count"] == 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_capabilities_tool_names_cover_expected_surface() -> None:
|
|
31
|
+
essential = {"route_skills", "capabilities", "get_router_status", "events_recent"}
|
|
32
|
+
missing = essential - set(MCP_PUBLISHED_TOOL_NAMES)
|
|
33
|
+
assert not missing
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""materialize_project hosts= cursor | claude_code | both."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from app.materialize import (
|
|
10
|
+
infer_materialize_hosts_from_mcp_client,
|
|
11
|
+
materialize_project_files,
|
|
12
|
+
normalize_materialize_hosts,
|
|
13
|
+
resolve_materialize_hosts_argument,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_infer_materialize_hosts_from_mcp_client() -> None:
|
|
18
|
+
assert infer_materialize_hosts_from_mcp_client("cursor-vscode-fork", "") == "cursor"
|
|
19
|
+
assert infer_materialize_hosts_from_mcp_client("", "Claude Code") == "claude_code"
|
|
20
|
+
assert infer_materialize_hosts_from_mcp_client("", "", environ={}) == "both"
|
|
21
|
+
assert infer_materialize_hosts_from_mcp_client("", "", environ={"CURSOR_AGENT": "1"}) == "cursor"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_resolve_materialize_hosts_argument_explicit_wins_inference() -> None:
|
|
25
|
+
mode, meta = resolve_materialize_hosts_argument(
|
|
26
|
+
"both",
|
|
27
|
+
client_name="cursor-ai",
|
|
28
|
+
environ={"SKILLFORGE_MATERIALIZE_HOSTS": "claude_code"},
|
|
29
|
+
)
|
|
30
|
+
assert mode == "both"
|
|
31
|
+
assert meta["hosts_resolution"] == "explicit"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_resolve_materialize_hosts_argument_auto_infer() -> None:
|
|
35
|
+
mode, meta = resolve_materialize_hosts_argument(
|
|
36
|
+
"auto",
|
|
37
|
+
client_name="Cursor IDE",
|
|
38
|
+
)
|
|
39
|
+
assert mode == "cursor"
|
|
40
|
+
assert meta["hosts_resolution"] == "inferred"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_resolve_materialize_hosts_argument_env_when_auto() -> None:
|
|
44
|
+
mode, meta = resolve_materialize_hosts_argument(
|
|
45
|
+
None,
|
|
46
|
+
client_name="",
|
|
47
|
+
environ={"SKILLFORGE_MATERIALIZE_HOSTS": "cursor"},
|
|
48
|
+
)
|
|
49
|
+
assert mode == "cursor"
|
|
50
|
+
assert meta["hosts_resolution"] == "environment"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_resolve_infer_beats_blank_env_skillforge_var_not_set_on_client() -> None:
|
|
54
|
+
"""Client name wins before falling back when SKILLFORGE_MATERIALIZE_HOSTS absent."""
|
|
55
|
+
mode, meta = resolve_materialize_hosts_argument(
|
|
56
|
+
None,
|
|
57
|
+
client_name="com.anthropic.claude-code-helper",
|
|
58
|
+
client_title="",
|
|
59
|
+
environ={},
|
|
60
|
+
)
|
|
61
|
+
assert mode == "claude_code"
|
|
62
|
+
assert meta["hosts_resolution"] == "inferred"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_resolve_environment_overrides_client_inference() -> None:
|
|
66
|
+
mode, meta = resolve_materialize_hosts_argument(
|
|
67
|
+
None,
|
|
68
|
+
client_name="Cursor IDE",
|
|
69
|
+
environ={"SKILLFORGE_MATERIALIZE_HOSTS": "claude_code"},
|
|
70
|
+
)
|
|
71
|
+
assert mode == "claude_code"
|
|
72
|
+
assert meta["hosts_resolution"] == "environment"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_normalize_materialize_hosts() -> None:
|
|
76
|
+
assert normalize_materialize_hosts(None) == "both"
|
|
77
|
+
assert normalize_materialize_hosts(" BOTH ") == "both"
|
|
78
|
+
assert normalize_materialize_hosts("cursor") == "cursor"
|
|
79
|
+
assert normalize_materialize_hosts("claude-code") == "claude_code"
|
|
80
|
+
assert normalize_materialize_hosts("claude") == "claude_code"
|
|
81
|
+
with pytest.raises(ValueError, match="hosts must be"):
|
|
82
|
+
normalize_materialize_hosts("vscode")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_hosts_cursor_writes_no_claude_paths(tmp_path) -> None:
|
|
86
|
+
root = tmp_path / "p"
|
|
87
|
+
root.mkdir()
|
|
88
|
+
out = materialize_project_files(str(root), ["x"], {"x": "y"}, hosts="cursor")
|
|
89
|
+
rel = {Path(p).as_posix() for p in out["written"]}
|
|
90
|
+
assert ".cursor/commands/skillforge.md" in rel
|
|
91
|
+
assert ".cursor/rules/skillforge.mdc" in rel
|
|
92
|
+
assert "docs/SKILLFORGE-PRD.md" in rel
|
|
93
|
+
assert ".claude/commands/skillforge.md" not in rel
|
|
94
|
+
assert "CLAUDE.md" not in rel
|
|
95
|
+
assert not (root / ".claude").exists()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_hosts_claude_code_writes_no_cursor_paths(tmp_path) -> None:
|
|
99
|
+
root = tmp_path / "p"
|
|
100
|
+
root.mkdir()
|
|
101
|
+
out = materialize_project_files(str(root), ["x"], {"x": "y"}, hosts="claude_code")
|
|
102
|
+
rel = {Path(p).as_posix() for p in out["written"]}
|
|
103
|
+
assert ".claude/commands/skillforge.md" in rel
|
|
104
|
+
assert "CLAUDE.md" in rel
|
|
105
|
+
assert "docs/SKILLFORGE-PRD.md" in rel
|
|
106
|
+
assert ".cursor/commands/skillforge.md" not in rel
|
|
107
|
+
assert ".cursor/rules/skillforge.mdc" not in rel
|
|
108
|
+
assert not (root / ".cursor").exists()
|
|
@@ -138,7 +138,7 @@ def test_build_route_skills_meta_error_field() -> None:
|
|
|
138
138
|
|
|
139
139
|
|
|
140
140
|
def test_build_route_skills_meta_includes_route_quality() -> None:
|
|
141
|
-
rq = {"schema": "route_quality/
|
|
141
|
+
rq = {"schema": "route_quality/2", "picked_count": 1}
|
|
142
142
|
meta = build_route_skills_meta(
|
|
143
143
|
result={
|
|
144
144
|
"candidates": [],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""MCP initialize stores clientInfo for materialize hosts inference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from app.mcp_server import MCPServer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_handle_initialize_records_client_info() -> None:
|
|
9
|
+
s = MCPServer()
|
|
10
|
+
s.handle_initialize(
|
|
11
|
+
{
|
|
12
|
+
"protocolVersion": "2024-11-05",
|
|
13
|
+
"clientInfo": {"name": "cursor-ai", "title": "Workspace"},
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
assert s._mcp_client_name == "cursor-ai"
|
|
17
|
+
assert s._mcp_client_title == "Workspace"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_handle_initialize_missing_client_info_resets() -> None:
|
|
21
|
+
s = MCPServer()
|
|
22
|
+
s.handle_initialize({"clientInfo": {"name": "cursor-temp"}})
|
|
23
|
+
assert s._mcp_client_name == "cursor-temp"
|
|
24
|
+
s.handle_initialize({})
|
|
25
|
+
assert s._mcp_client_name == ""
|
|
26
|
+
assert s._mcp_client_title == ""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Unit tests for MCP operator helpers (no MCP server lifecycle)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
from app.mcp_operator import (
|
|
9
|
+
EVENTS_MARKDOWN_PREVIEW_MAX_LINES,
|
|
10
|
+
build_router_status_dict,
|
|
11
|
+
events_recent_rows,
|
|
12
|
+
format_events_markdown,
|
|
13
|
+
project_index_status_dict,
|
|
14
|
+
)
|
|
15
|
+
from app.npm_pkg_version import clear_version_cache_for_tests
|
|
16
|
+
from app.project_index import ensure_project_index_schema
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_build_router_status_dict_without_router(monkeypatch) -> None:
|
|
20
|
+
monkeypatch.delenv("SKILLFORGE_MCP_SERVER_VERSION", raising=False)
|
|
21
|
+
clear_version_cache_for_tests()
|
|
22
|
+
snap = build_router_status_dict(None, skill_count=0)
|
|
23
|
+
assert snap["skills_loaded_count"] == 0
|
|
24
|
+
assert snap["anthropic_available"] is False
|
|
25
|
+
assert "skillforge_router_mode" in snap
|
|
26
|
+
assert "mcp_server_semver" in snap
|
|
27
|
+
semver = snap["mcp_server_semver"]
|
|
28
|
+
assert isinstance(semver, str) and len(semver.split(".")) == 3
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_format_events_markdown_truncation_notice() -> None:
|
|
32
|
+
many = [{"ts": 1.0, "session_id": "sess", "event_type": "route", "payload": {}}]
|
|
33
|
+
md = format_events_markdown(many * (EVENTS_MARKDOWN_PREVIEW_MAX_LINES + 10))
|
|
34
|
+
assert "_meta.rows" in md
|
|
35
|
+
assert "truncated" in md.lower()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_project_index_status_counts(tmp_path, monkeypatch) -> None:
|
|
39
|
+
db = tmp_path / "orch.db"
|
|
40
|
+
con = sqlite3.connect(str(db))
|
|
41
|
+
ensure_project_index_schema(con)
|
|
42
|
+
con.execute(
|
|
43
|
+
"INSERT INTO project_chunks (path,line_start,line_end,mtime,file_size,content,embedding) "
|
|
44
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
45
|
+
("a.py", 1, 2, 0.0, 10, "x", b"\0\0\0\0"),
|
|
46
|
+
)
|
|
47
|
+
con.execute(
|
|
48
|
+
"INSERT INTO project_chunks (path,line_start,line_end,mtime,file_size,content,embedding) "
|
|
49
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
50
|
+
("b.py", 1, 2, 0.0, 10, "y", b"\0\0\0\0"),
|
|
51
|
+
)
|
|
52
|
+
con.commit()
|
|
53
|
+
m = project_index_status_dict(con)
|
|
54
|
+
assert m["chunk_count"] == 2
|
|
55
|
+
assert m["distinct_paths"] == 2
|
|
56
|
+
con.close()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_events_recent_rows_filter_user_and_type(tmp_path) -> None:
|
|
60
|
+
db = tmp_path / "e.db"
|
|
61
|
+
con = sqlite3.connect(str(db))
|
|
62
|
+
con.execute(
|
|
63
|
+
"""CREATE TABLE events (
|
|
64
|
+
id TEXT PRIMARY KEY, ts REAL, user_id TEXT, session_id TEXT, event_type TEXT, payload TEXT
|
|
65
|
+
)"""
|
|
66
|
+
)
|
|
67
|
+
con.execute(
|
|
68
|
+
"INSERT INTO events VALUES (?,?,?,?,?,?)",
|
|
69
|
+
("1", 10.0, "u", "s", "route", json.dumps({"picked": ["a"]})),
|
|
70
|
+
)
|
|
71
|
+
con.execute(
|
|
72
|
+
"INSERT INTO events VALUES (?,?,?,?,?,?)",
|
|
73
|
+
("2", 20.0, "u", "s", "feedback", json.dumps({"skill": "b"})),
|
|
74
|
+
)
|
|
75
|
+
con.execute(
|
|
76
|
+
"INSERT INTO events VALUES (?,?,?,?,?,?)",
|
|
77
|
+
("3", 30.0, "other", "s", "route", "{}"),
|
|
78
|
+
)
|
|
79
|
+
con.commit()
|
|
80
|
+
rows = events_recent_rows(con, limit=10, user_id="u", event_type="route")
|
|
81
|
+
assert len(rows) == 1
|
|
82
|
+
assert rows[0]["event_type"] == "route"
|
|
83
|
+
assert rows[0]["payload"]["picked"] == ["a"]
|
|
84
|
+
con.close()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""published_package_version resolves package/package.json semver."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from app.npm_pkg_version import NPM_PACKAGE_NAME, clear_version_cache_for_tests, published_package_version
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_published_package_version_matches_repo_package_json(monkeypatch) -> None:
|
|
8
|
+
monkeypatch.delenv("SKILLFORGE_MCP_SERVER_VERSION", raising=False)
|
|
9
|
+
clear_version_cache_for_tests()
|
|
10
|
+
v = published_package_version()
|
|
11
|
+
assert v and len(v.split(".")) == 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_published_package_version_env_override(monkeypatch) -> None:
|
|
15
|
+
monkeypatch.setenv("SKILLFORGE_MCP_SERVER_VERSION", "9.8.7-test")
|
|
16
|
+
clear_version_cache_for_tests()
|
|
17
|
+
assert published_package_version() == "9.8.7-test"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_skillforge_package_name_constant() -> None:
|
|
21
|
+
assert "skillforge" in NPM_PACKAGE_NAME
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Tests for SKILLFORGE_PICK_DIVERSIFY trimming."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class _Sk:
|
|
11
|
+
name: str
|
|
12
|
+
source: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_diversify_disabled_by_default(monkeypatch) -> None:
|
|
16
|
+
monkeypatch.delenv("SKILLFORGE_PICK_DIVERSIFY", raising=False)
|
|
17
|
+
from app.pick_diversify import diversify_picked_names
|
|
18
|
+
|
|
19
|
+
by = {"a": _Sk("a", "bundled"), "b": _Sk("b", "bundled")}
|
|
20
|
+
out, meta = diversify_picked_names(["a", "b", "c"], by)
|
|
21
|
+
assert out == ["a", "b", "c"]
|
|
22
|
+
assert meta["applied"] is False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_diversify_caps_per_source(monkeypatch) -> None:
|
|
26
|
+
monkeypatch.setenv("SKILLFORGE_PICK_DIVERSIFY", "1")
|
|
27
|
+
monkeypatch.setenv("SKILLFORGE_PICK_MAX_PER_SOURCE", "2")
|
|
28
|
+
from app.pick_diversify import diversify_picked_names
|
|
29
|
+
|
|
30
|
+
by = {f"s{i}": _Sk(f"s{i}", "bundled") for i in range(5)}
|
|
31
|
+
names = ["s0", "s1", "s2", "s3"]
|
|
32
|
+
out, meta = diversify_picked_names(names, by)
|
|
33
|
+
assert out == ["s0", "s1"]
|
|
34
|
+
assert meta["dropped"] == ["s2", "s3"]
|
|
35
|
+
assert meta["applied"] is True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("truthy", ["1", "true", "yes"])
|
|
39
|
+
def test_diversify_env_truthy(monkeypatch, truthy) -> None:
|
|
40
|
+
monkeypatch.setenv("SKILLFORGE_PICK_DIVERSIFY", truthy)
|
|
41
|
+
monkeypatch.setenv("SKILLFORGE_PICK_MAX_PER_SOURCE", "1")
|
|
42
|
+
from app.pick_diversify import diversify_picked_names
|
|
43
|
+
|
|
44
|
+
by = {"x": _Sk("x", "user"), "y": _Sk("y", "user")}
|
|
45
|
+
out, meta = diversify_picked_names(["x", "y"], by)
|
|
46
|
+
assert out == ["x"]
|
|
47
|
+
assert "y" in meta["dropped"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Replay CLI row helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from app.replay_cli import _replay_rows
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_replay_filters_session(tmp_path) -> None:
|
|
11
|
+
db = tmp_path / "t.db"
|
|
12
|
+
con = sqlite3.connect(str(db))
|
|
13
|
+
con.execute(
|
|
14
|
+
"""CREATE TABLE events (
|
|
15
|
+
id TEXT PRIMARY KEY, ts REAL, user_id TEXT, session_id TEXT, event_type TEXT, payload TEXT
|
|
16
|
+
)"""
|
|
17
|
+
)
|
|
18
|
+
con.execute(
|
|
19
|
+
"INSERT INTO events VALUES (?,?,?,?,?,?)",
|
|
20
|
+
("1", 1.0, "u", "sess-a", "route", "{}"),
|
|
21
|
+
)
|
|
22
|
+
con.execute(
|
|
23
|
+
"INSERT INTO events VALUES (?,?,?,?,?,?)",
|
|
24
|
+
("2", 2.0, "u", "sess-b", "route", "{}"),
|
|
25
|
+
)
|
|
26
|
+
con.commit()
|
|
27
|
+
rows = _replay_rows(con, session_id="sess-a", user_id="u", newest_first_snapshot=False, limit=50)
|
|
28
|
+
con.close()
|
|
29
|
+
assert len(rows) == 1
|
|
30
|
+
assert rows[0][2] == "route"
|
|
31
|
+
|