boost-skill-cli 1.0.51__tar.gz → 1.0.52__tar.gz
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.
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/PKG-INFO +1 -1
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/_version.py +2 -2
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/configuration.py +158 -96
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/discovery.py +42 -0
- boost_skill_cli-1.0.52/boost_cli/core/mcp.py +70 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/PKG-INFO +1 -1
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/SOURCES.txt +2 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/rag-architecture.md +33 -15
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_configuration.py +54 -1
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_discovery.py +95 -0
- boost_skill_cli-1.0.52/tests/unit/test_mcp.py +83 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/.gitignore +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/CLAUDE.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/CONTRIBUTING.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/LICENSE +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/MANIFEST.in +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/Makefile +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/README.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/SECURITY.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/__init__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/__main__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/cli.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/cliparse.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/__init__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/bmad.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/hooks.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/info.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/intelligence.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/pkg.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/quality.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/taps.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/team.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/__init__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/agents.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/ai.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/catalog.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/claude_settings.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/config.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/dense.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/embed.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/frontmatter.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/gitutil.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/journal.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/lockfile.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/logs.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/output.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/paths.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/policy.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/rag.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/registry.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/store.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/util.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/data/registries.json +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/errors.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/spin.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/entry_points.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/requires.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/top_level.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/.claude/settings.local.json +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/DEBUGGING.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/demo.gif +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/demo.tape +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/design-roadmap.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/index.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/roadmap.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/index.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/pyproject.toml +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/scripts/build_registries.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/scripts/mutation_gate.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/setup.cfg +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/README.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/boost.css +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/boost.js +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/demo.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/conftest.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_bmad.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_hooks.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_info.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_intelligence.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_pkg.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_quality.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_taps.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_team.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_everyday_loop.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/make_fixture.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/smoke.sh +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_agents.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_ai.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_catalog.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_claude_settings.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_cliparse.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_config.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_dense.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_embed.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_errors_and_cli_table.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_frontmatter.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_gitutil.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_journal.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_lockfile.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_logs.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_mutation_hardening.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_output.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_paths.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_policy.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_rag.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_registry.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_spin.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_store.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_token_parity.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_util.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_version.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 0,
|
|
21
|
+
__version__ = version = '1.0.52'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 52)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -20,7 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
from .. import cliparse
|
|
21
21
|
from .. import __version__
|
|
22
22
|
from ..core import agents, catalog, config, frontmatter, gitutil, journal
|
|
23
|
-
from ..core import lockfile, paths, policy, rag, registry, store, util
|
|
23
|
+
from ..core import lockfile, mcp, paths, policy, rag, registry, store, util
|
|
24
24
|
from ..core import output as out
|
|
25
25
|
from ..errors import BoostError
|
|
26
26
|
|
|
@@ -864,105 +864,167 @@ def cmd_serve(argv) -> int:
|
|
|
864
864
|
|
|
865
865
|
# ---------------------------------------------------------------- mcp
|
|
866
866
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
867
|
+
# The MCP tool surface is an extensible registry (core/mcp.py): each handler is
|
|
868
|
+
# a small `fn(args) -> (text, is_error)` that self-registers its JSON spec, so a
|
|
869
|
+
# new capability is one REGISTRY.register(...) call — no dispatcher edits. This
|
|
870
|
+
# is the Phase-3 "MCP as a hub" seam (docs/rag-architecture.md §8).
|
|
871
|
+
|
|
872
|
+
REGISTRY = mcp.Registry()
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _tool_search(args: dict):
|
|
876
|
+
query = str(args.get("query", ""))
|
|
877
|
+
rag_result = rag.search(query, limit=10)
|
|
878
|
+
if rag_result is not None: # full-content index is built
|
|
879
|
+
hits, _ranker = rag_result
|
|
880
|
+
if not hits:
|
|
881
|
+
return "no skills match %r" % query, False
|
|
882
|
+
return "\n".join(
|
|
883
|
+
"%s — %s (%s)" % (h["entry"]["name"],
|
|
884
|
+
h["entry"].get("description", ""),
|
|
885
|
+
h["entry"]["tap"])
|
|
886
|
+
for h in hits), False
|
|
887
|
+
# no index yet -> keep today's frontmatter search so nothing regresses
|
|
888
|
+
scored = catalog.search(query)[:10]
|
|
889
|
+
if not scored:
|
|
890
|
+
return "no skills match %r" % query, False
|
|
891
|
+
return "\n".join("%s — %s (%s)" % (e["name"], e["description"], e["tap"])
|
|
892
|
+
for e, _score in scored), False
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _tool_list(args: dict):
|
|
896
|
+
skills = lockfile.installed()
|
|
897
|
+
if not skills:
|
|
898
|
+
return "no skills installed", False
|
|
899
|
+
return "\n".join("%s v%s (%s)%s"
|
|
900
|
+
% (n, e.get("version", "?"), e.get("tap", "?"),
|
|
901
|
+
" [pinned]" if e.get("pinned") else "")
|
|
902
|
+
for n, e in sorted(skills.items())), False
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _tool_info(args: dict):
|
|
906
|
+
name = str(args.get("name", ""))
|
|
907
|
+
entry = lockfile.get_skill(name)
|
|
908
|
+
matches = catalog.find(name)
|
|
909
|
+
if not entry and not matches:
|
|
910
|
+
return "no skill named %r (installed or in any tap)" % name, True
|
|
911
|
+
src = matches[0] if matches else {}
|
|
912
|
+
lines = ["name: " + name,
|
|
913
|
+
"version: %s" % (entry or src).get("version", "?"),
|
|
914
|
+
"tap: %s" % (entry or src).get("tap", "?")]
|
|
915
|
+
if src.get("description"):
|
|
916
|
+
lines.append("description: %s" % src["description"])
|
|
917
|
+
if entry:
|
|
918
|
+
lines.append("installed: yes (%s)" % entry.get("installed_at", "?"))
|
|
919
|
+
lines.append("agents: %s" % (", ".join(entry.get("agents") or []) or "none"))
|
|
920
|
+
if entry.get("pinned"):
|
|
921
|
+
lines.append("pinned: yes")
|
|
922
|
+
else:
|
|
923
|
+
lines.append("installed: no")
|
|
924
|
+
return "\n".join(lines), False
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _tool_install(args: dict):
|
|
928
|
+
entry = catalog.resolve_one(str(args.get("name", "")))
|
|
929
|
+
res = store.install(entry)
|
|
930
|
+
lines = ["installed %s v%s from %s → %s"
|
|
931
|
+
% (res.name, entry.get("version", "?"), entry["tap"], res.dest),
|
|
932
|
+
"linked agents: %s" % (", ".join(res.linked) or "none"),
|
|
933
|
+
"quality score: %d/100" % res.score]
|
|
934
|
+
if res.conflicts:
|
|
935
|
+
lines.append("conflicts (left in place): %s" % ", ".join(res.conflicts))
|
|
936
|
+
return "\n".join(lines), False
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _tool_doctor(args: dict):
|
|
940
|
+
plan = store.sync_plan()
|
|
941
|
+
issues = sum(len(v) for v in plan.values())
|
|
942
|
+
taps = registry.list_taps()
|
|
943
|
+
lines = ["installed skills: %d" % len(lockfile.installed()),
|
|
944
|
+
"taps: %d (%d skills available)" % (len(taps), len(catalog.all_entries()))]
|
|
945
|
+
for key, vals in plan.items():
|
|
946
|
+
if vals:
|
|
947
|
+
lines.append("%s: %s" % (key, ", ".join(str(v) for v in vals)))
|
|
948
|
+
lines.append("healthy — no issues found" if issues == 0
|
|
949
|
+
else "%d issue(s) — run `boost sync` to fix" % issues)
|
|
950
|
+
return "\n".join(lines), issues > 0
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _tool_discover_github(args: dict):
|
|
954
|
+
"""Grow the corpus: GitHub code-search for SKILL.md repos (needs `gh`).
|
|
955
|
+
|
|
956
|
+
A Phase-3 reach-out tool. Degrades the ``core/ai.py`` way — if `gh` is absent
|
|
957
|
+
or the search fails, it returns a short helpful message instead of raising,
|
|
958
|
+
so the MCP server never dies on a missing external dependency.
|
|
959
|
+
"""
|
|
960
|
+
from . import discovery
|
|
961
|
+
if not shutil.which("gh"):
|
|
962
|
+
return ("GitHub discovery needs the `gh` CLI — install it with "
|
|
963
|
+
"`brew install gh && gh auth login`, then retry."), True
|
|
964
|
+
query = str(args.get("query", ""))
|
|
965
|
+
raw = args.get("limit")
|
|
966
|
+
limit = int(raw) if isinstance(raw, (int, float)) and int(raw) > 0 else 20
|
|
967
|
+
limit = min(limit, 100)
|
|
968
|
+
hits = discovery.github_skill_search(query, limit)
|
|
969
|
+
if hits is None:
|
|
970
|
+
return ("GitHub code search failed — check `gh auth status` and retry."), True
|
|
971
|
+
if not hits:
|
|
972
|
+
return ("no SKILL.md repositories found on GitHub for %r"
|
|
973
|
+
% (query.strip() or "any query")), False
|
|
974
|
+
return "\n".join("%s — %s" % (h["repo"], h.get("description") or h.get("path", ""))
|
|
975
|
+
for h in hits), False
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
REGISTRY.register(
|
|
979
|
+
"boost_search",
|
|
980
|
+
"Search AI coding skills across the configured tap registries",
|
|
981
|
+
{"type": "object",
|
|
982
|
+
"properties": {"query": {"type": "string", "description": "search terms"}},
|
|
983
|
+
"required": ["query"]},
|
|
984
|
+
_tool_search)
|
|
985
|
+
REGISTRY.register(
|
|
986
|
+
"boost_list",
|
|
987
|
+
"List the skills currently installed by boost",
|
|
988
|
+
{"type": "object", "properties": {}},
|
|
989
|
+
_tool_list)
|
|
990
|
+
REGISTRY.register(
|
|
991
|
+
"boost_info",
|
|
992
|
+
"Show detailed information about one skill",
|
|
993
|
+
{"type": "object",
|
|
994
|
+
"properties": {"name": {"type": "string", "description": "skill name"}},
|
|
995
|
+
"required": ["name"]},
|
|
996
|
+
_tool_info)
|
|
997
|
+
REGISTRY.register(
|
|
998
|
+
"boost_install",
|
|
999
|
+
"Install a skill from a configured tap registry",
|
|
1000
|
+
{"type": "object",
|
|
1001
|
+
"properties": {"name": {"type": "string", "description": "skill name"}},
|
|
1002
|
+
"required": ["name"]},
|
|
1003
|
+
_tool_install)
|
|
1004
|
+
REGISTRY.register(
|
|
1005
|
+
"boost_doctor",
|
|
1006
|
+
"Health summary of the boost skill environment",
|
|
1007
|
+
{"type": "object", "properties": {}},
|
|
1008
|
+
_tool_doctor)
|
|
1009
|
+
REGISTRY.register(
|
|
1010
|
+
"boost_discover_github",
|
|
1011
|
+
"Discover new SKILL.md repositories on GitHub to grow the corpus (needs the "
|
|
1012
|
+
"`gh` CLI; degrades to a hint when unavailable)",
|
|
1013
|
+
{"type": "object",
|
|
1014
|
+
"properties": {
|
|
1015
|
+
"query": {"type": "string",
|
|
1016
|
+
"description": "optional extra search terms (topic, language)"},
|
|
1017
|
+
"limit": {"type": "integer",
|
|
1018
|
+
"description": "max repositories to return (default 20)"}}},
|
|
1019
|
+
_tool_discover_github)
|
|
1020
|
+
|
|
1021
|
+
# Back-compat shims: the JSON-RPC server and tests reference these names.
|
|
1022
|
+
_MCP_TOOLS = REGISTRY.specs()
|
|
893
1023
|
|
|
894
1024
|
|
|
895
1025
|
def _mcp_tool(tool: str, args: dict):
|
|
896
1026
|
"""Run one MCP tool -> (text, is_error). (None, _) for unknown tools."""
|
|
897
|
-
|
|
898
|
-
query = str(args.get("query", ""))
|
|
899
|
-
rag_result = rag.search(query, limit=10)
|
|
900
|
-
if rag_result is not None: # full-content index is built
|
|
901
|
-
hits, _ranker = rag_result
|
|
902
|
-
if not hits:
|
|
903
|
-
return "no skills match %r" % query, False
|
|
904
|
-
return "\n".join(
|
|
905
|
-
"%s — %s (%s)" % (h["entry"]["name"],
|
|
906
|
-
h["entry"].get("description", ""),
|
|
907
|
-
h["entry"]["tap"])
|
|
908
|
-
for h in hits), False
|
|
909
|
-
# no index yet -> keep today's frontmatter search so nothing regresses
|
|
910
|
-
scored = catalog.search(query)[:10]
|
|
911
|
-
if not scored:
|
|
912
|
-
return "no skills match %r" % query, False
|
|
913
|
-
return "\n".join("%s — %s (%s)" % (e["name"], e["description"], e["tap"])
|
|
914
|
-
for e, _score in scored), False
|
|
915
|
-
if tool == "boost_list":
|
|
916
|
-
skills = lockfile.installed()
|
|
917
|
-
if not skills:
|
|
918
|
-
return "no skills installed", False
|
|
919
|
-
return "\n".join("%s v%s (%s)%s"
|
|
920
|
-
% (n, e.get("version", "?"), e.get("tap", "?"),
|
|
921
|
-
" [pinned]" if e.get("pinned") else "")
|
|
922
|
-
for n, e in sorted(skills.items())), False
|
|
923
|
-
if tool == "boost_info":
|
|
924
|
-
name = str(args.get("name", ""))
|
|
925
|
-
entry = lockfile.get_skill(name)
|
|
926
|
-
matches = catalog.find(name)
|
|
927
|
-
if not entry and not matches:
|
|
928
|
-
return "no skill named %r (installed or in any tap)" % name, True
|
|
929
|
-
src = matches[0] if matches else {}
|
|
930
|
-
lines = ["name: " + name,
|
|
931
|
-
"version: %s" % (entry or src).get("version", "?"),
|
|
932
|
-
"tap: %s" % (entry or src).get("tap", "?")]
|
|
933
|
-
if src.get("description"):
|
|
934
|
-
lines.append("description: %s" % src["description"])
|
|
935
|
-
if entry:
|
|
936
|
-
lines.append("installed: yes (%s)" % entry.get("installed_at", "?"))
|
|
937
|
-
lines.append("agents: %s" % (", ".join(entry.get("agents") or []) or "none"))
|
|
938
|
-
if entry.get("pinned"):
|
|
939
|
-
lines.append("pinned: yes")
|
|
940
|
-
else:
|
|
941
|
-
lines.append("installed: no")
|
|
942
|
-
return "\n".join(lines), False
|
|
943
|
-
if tool == "boost_install":
|
|
944
|
-
entry = catalog.resolve_one(str(args.get("name", "")))
|
|
945
|
-
res = store.install(entry)
|
|
946
|
-
lines = ["installed %s v%s from %s → %s"
|
|
947
|
-
% (res.name, entry.get("version", "?"), entry["tap"], res.dest),
|
|
948
|
-
"linked agents: %s" % (", ".join(res.linked) or "none"),
|
|
949
|
-
"quality score: %d/100" % res.score]
|
|
950
|
-
if res.conflicts:
|
|
951
|
-
lines.append("conflicts (left in place): %s" % ", ".join(res.conflicts))
|
|
952
|
-
return "\n".join(lines), False
|
|
953
|
-
if tool == "boost_doctor":
|
|
954
|
-
plan = store.sync_plan()
|
|
955
|
-
issues = sum(len(v) for v in plan.values())
|
|
956
|
-
taps = registry.list_taps()
|
|
957
|
-
lines = ["installed skills: %d" % len(lockfile.installed()),
|
|
958
|
-
"taps: %d (%d skills available)" % (len(taps), len(catalog.all_entries()))]
|
|
959
|
-
for key, vals in plan.items():
|
|
960
|
-
if vals:
|
|
961
|
-
lines.append("%s: %s" % (key, ", ".join(str(v) for v in vals)))
|
|
962
|
-
lines.append("healthy — no issues found" if issues == 0
|
|
963
|
-
else "%d issue(s) — run `boost sync` to fix" % issues)
|
|
964
|
-
return "\n".join(lines), issues > 0
|
|
965
|
-
return None, False
|
|
1027
|
+
return REGISTRY.call(tool, args)
|
|
966
1028
|
|
|
967
1029
|
|
|
968
1030
|
def _mcp_send(msg: dict) -> bool:
|
|
@@ -13,6 +13,7 @@ import shutil
|
|
|
13
13
|
import subprocess
|
|
14
14
|
import sys
|
|
15
15
|
import time
|
|
16
|
+
import urllib.parse
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
19
|
from .. import cliparse, spin
|
|
@@ -350,6 +351,47 @@ def cmd_index(argv):
|
|
|
350
351
|
return 0
|
|
351
352
|
|
|
352
353
|
|
|
354
|
+
def github_skill_search(query="", limit=20):
|
|
355
|
+
"""One-shot GitHub code search for SKILL.md repos (Phase-3 MCP reach-out).
|
|
356
|
+
|
|
357
|
+
Returns a list of ``{repo, path, url, description}`` dicts, or ``None`` when
|
|
358
|
+
the `gh` CLI is unavailable or the search fails — so callers degrade
|
|
359
|
+
gracefully instead of raising. Unlike :func:`cmd_index` this fetches a single
|
|
360
|
+
page and writes no cache: it is sized for an interactive MCP call, not a full
|
|
361
|
+
corpus build.
|
|
362
|
+
"""
|
|
363
|
+
if not shutil.which("gh"):
|
|
364
|
+
return None
|
|
365
|
+
q = "filename:SKILL.md"
|
|
366
|
+
if query and query.strip():
|
|
367
|
+
q += " " + query.strip()
|
|
368
|
+
per_page = max(1, min(int(limit), 100))
|
|
369
|
+
try:
|
|
370
|
+
proc = subprocess.run(
|
|
371
|
+
["gh", "api", "-H", "Accept: application/vnd.github+json",
|
|
372
|
+
"search/code?q=%s&per_page=%d&page=1"
|
|
373
|
+
% (urllib.parse.quote(q, safe=":"), per_page)],
|
|
374
|
+
capture_output=True, text=True, timeout=120)
|
|
375
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
376
|
+
return None
|
|
377
|
+
if proc.returncode != 0:
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
data = json.loads(proc.stdout)
|
|
381
|
+
except json.JSONDecodeError:
|
|
382
|
+
return None
|
|
383
|
+
items = []
|
|
384
|
+
for it in (data.get("items") or [])[:per_page]:
|
|
385
|
+
repo = it.get("repository") or {}
|
|
386
|
+
items.append({
|
|
387
|
+
"repo": repo.get("full_name", "?"),
|
|
388
|
+
"path": it.get("path", ""),
|
|
389
|
+
"url": it.get("html_url", ""),
|
|
390
|
+
"description": repo.get("description") or "",
|
|
391
|
+
})
|
|
392
|
+
return items
|
|
393
|
+
|
|
394
|
+
|
|
353
395
|
def cmd_discover(argv):
|
|
354
396
|
"""Browse/filter the GitHub-wide index built by `boost index`."""
|
|
355
397
|
p = cliparse.parser(
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Extensible MCP tool registry — the Phase-3 "MCP as a hub" seam.
|
|
2
|
+
|
|
3
|
+
An MCP tool is a *handler* — ``fn(args: dict) -> (text, is_error)`` — paired with
|
|
4
|
+
its JSON spec (``name`` / ``description`` / ``inputSchema``). Instead of a flat
|
|
5
|
+
``if/elif`` dispatcher, tools self-register on a :class:`Registry`; the JSON-RPC
|
|
6
|
+
server iterates it for both ``tools/list`` and ``tools/call``. Adding a
|
|
7
|
+
capability (a GitHub reach-out, a database query, some future dependency) is one
|
|
8
|
+
``register()`` call — no dispatcher edits, no server changes.
|
|
9
|
+
|
|
10
|
+
Handlers return ``(text, is_error)``. A handler that returns ``text is None``
|
|
11
|
+
signals "I did not produce a result"; :meth:`Registry.call` also returns
|
|
12
|
+
``(None, False)`` for an unknown tool, so the server can answer with a JSON-RPC
|
|
13
|
+
``unknown tool`` error in exactly one place. Reach-out handlers degrade the way
|
|
14
|
+
``core/ai.py`` does: probe for the dependency, and when it is absent return a
|
|
15
|
+
short helpful message rather than raising.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
# A handler maps parsed arguments to (text, is_error). ``text is None`` means the
|
|
22
|
+
# handler produced no result (treated by the server as an unknown/aborted tool).
|
|
23
|
+
Handler = Callable[[dict], Tuple[Optional[str], bool]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Registry:
|
|
27
|
+
"""An ordered name -> (spec, handler) map for MCP tools."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._order: List[str] = []
|
|
31
|
+
self._specs: Dict[str, dict] = {}
|
|
32
|
+
self._handlers: Dict[str, Handler] = {}
|
|
33
|
+
|
|
34
|
+
def register(self, name: str, description: str, input_schema: dict,
|
|
35
|
+
handler: Handler) -> None:
|
|
36
|
+
"""Register one tool. Raises on an empty or duplicate name."""
|
|
37
|
+
if not name:
|
|
38
|
+
raise ValueError("MCP tool name must be non-empty")
|
|
39
|
+
if name in self._handlers:
|
|
40
|
+
raise ValueError("duplicate MCP tool %r" % name)
|
|
41
|
+
self._order.append(name)
|
|
42
|
+
self._specs[name] = {"name": name, "description": description,
|
|
43
|
+
"inputSchema": input_schema}
|
|
44
|
+
self._handlers[name] = handler
|
|
45
|
+
|
|
46
|
+
def tool(self, name: str, description: str,
|
|
47
|
+
input_schema: dict) -> Callable[[Handler], Handler]:
|
|
48
|
+
"""Decorator form of :meth:`register`; returns the handler unchanged."""
|
|
49
|
+
def deco(fn: Handler) -> Handler:
|
|
50
|
+
self.register(name, description, input_schema, fn)
|
|
51
|
+
return fn
|
|
52
|
+
return deco
|
|
53
|
+
|
|
54
|
+
def specs(self) -> List[dict]:
|
|
55
|
+
"""The ``tools/list`` payload — specs in registration order."""
|
|
56
|
+
return [self._specs[name] for name in self._order]
|
|
57
|
+
|
|
58
|
+
def names(self) -> List[str]:
|
|
59
|
+
"""Registered tool names, in registration order."""
|
|
60
|
+
return list(self._order)
|
|
61
|
+
|
|
62
|
+
def has(self, name: str) -> bool:
|
|
63
|
+
return name in self._handlers
|
|
64
|
+
|
|
65
|
+
def call(self, name: str, args: dict) -> Tuple[Optional[str], bool]:
|
|
66
|
+
"""Dispatch to a handler. ``(None, False)`` for an unknown tool."""
|
|
67
|
+
handler = self._handlers.get(name)
|
|
68
|
+
if handler is None:
|
|
69
|
+
return None, False
|
|
70
|
+
return handler(args)
|
|
@@ -41,6 +41,7 @@ boost_cli/core/gitutil.py
|
|
|
41
41
|
boost_cli/core/journal.py
|
|
42
42
|
boost_cli/core/lockfile.py
|
|
43
43
|
boost_cli/core/logs.py
|
|
44
|
+
boost_cli/core/mcp.py
|
|
44
45
|
boost_cli/core/output.py
|
|
45
46
|
boost_cli/core/paths.py
|
|
46
47
|
boost_cli/core/policy.py
|
|
@@ -97,6 +98,7 @@ tests/unit/test_gitutil.py
|
|
|
97
98
|
tests/unit/test_journal.py
|
|
98
99
|
tests/unit/test_lockfile.py
|
|
99
100
|
tests/unit/test_logs.py
|
|
101
|
+
tests/unit/test_mcp.py
|
|
100
102
|
tests/unit/test_mutation_hardening.py
|
|
101
103
|
tests/unit/test_output.py
|
|
102
104
|
tests/unit/test_paths.py
|
|
@@ -194,23 +194,41 @@ dense retrieval.
|
|
|
194
194
|
|
|
195
195
|
---
|
|
196
196
|
|
|
197
|
-
## 8. Phase 3 — MCP as a hub (GitHub, database, other deps)
|
|
197
|
+
## 8. Phase 3 — MCP as a hub (GitHub, database, other deps) — ✅ shipped
|
|
198
|
+
|
|
199
|
+
The flat `if/elif` dispatcher is now an **extensible registry** (`boost_cli/core/mcp.py`):
|
|
200
|
+
|
|
201
|
+
1. **Registry refactor (done).** `core/mcp.py` defines a `Registry` — an ordered `name -> (spec, handler)`
|
|
202
|
+
map. Each handler is `fn(args: dict) -> (text, is_error)` and is paired with its
|
|
203
|
+
`{name, description, inputSchema}` via `REGISTRY.register(...)` (or the `@REGISTRY.tool(...)`
|
|
204
|
+
decorator). The JSON-RPC server iterates it: `tools/list` returns `REGISTRY.specs()` and `tools/call`
|
|
205
|
+
dispatches through `REGISTRY.call(name, args)` — an unknown tool returns `(None, False)` in exactly one
|
|
206
|
+
place, so the server answers a single `-32602 unknown tool` error. `_MCP_TOOLS` and `_mcp_tool` remain
|
|
207
|
+
as thin back-compat shims over the registry.
|
|
208
|
+
2. **First reach-out tool (done), behind graceful degradation** (the `core/ai.py` pattern — probe, else a
|
|
209
|
+
helpful message, never raise):
|
|
210
|
+
- **`boost_discover_github`** — GitHub code-search for `SKILL.md` repos to grow the corpus, via the
|
|
211
|
+
one-shot `discovery.github_skill_search(query, limit)` helper. No `gh` CLI → returns an install hint
|
|
212
|
+
with `is_error=True`; a failed search → a retry hint; otherwise a ranked repo list. It never writes a
|
|
213
|
+
cache and never crashes the server.
|
|
214
|
+
- Future tools (Phase-2 vector store / a database query, a generic external API) plug in the same way.
|
|
215
|
+
|
|
216
|
+
**Extension contract** — to add an MCP tool, no dispatcher or server edit is needed:
|
|
198
217
|
|
|
199
|
-
|
|
200
|
-
|
|
218
|
+
```python
|
|
219
|
+
def _tool_x(args: dict) -> tuple[str | None, bool]:
|
|
220
|
+
# probe for any external dependency; degrade to a helpful message if absent
|
|
221
|
+
return "…result text…", False # (text, is_error)
|
|
222
|
+
|
|
223
|
+
REGISTRY.register("boost_x", "one-line description",
|
|
224
|
+
{"type": "object", "properties": {…}}, _tool_x)
|
|
225
|
+
```
|
|
201
226
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
2. **Reach-out tools, each behind graceful degradation** (the `core/ai.py` pattern — probe, else no-op
|
|
206
|
-
with a helpful message):
|
|
207
|
-
- `boost_discover_github` — grow the corpus by wrapping the existing `gh`-based `cmd_index`
|
|
208
|
-
(`discovery.py:217`) + auto-tap of promising repos.
|
|
209
|
-
- `boost_semantic_search` capabilities backed by the Phase-2 vector store / a database once present.
|
|
210
|
-
- a documented **extension contract** so any future dependency (a DB, an external API) becomes one new
|
|
211
|
-
handler file, no dispatcher edits.
|
|
227
|
+
A handler that reaches out to a dependency **must** degrade (return a short message, not raise) so the
|
|
228
|
+
long-lived stdio server survives a missing `gh`, offline network, or absent `[rag]` extra. Registration
|
|
229
|
+
order is preserved and is the `tools/list` order.
|
|
212
230
|
|
|
213
|
-
Phase 3
|
|
231
|
+
Phase 3 landed *after* the retrieval core was proven, so the hub grew on a stable seam.
|
|
214
232
|
|
|
215
233
|
---
|
|
216
234
|
|
|
@@ -235,7 +253,7 @@ and RAG indexing compose automatically.
|
|
|
235
253
|
|---|---|---|---|---|
|
|
236
254
|
| **1** | Full-content BM25 retrieval + LLM rerank; `boost_search` upgraded in place; `boost reindex` | **none** (stdlib) | ✅ yes | New `core/rag.py` (mutation-gated) + tests; coverage ≥80%; ruff/mypy/smoke unaffected |
|
|
237
255
|
| **2** | Dense embeddings + vector store | `[rag]` extra only | ❌ opt-in | Extra-only; default path unchanged; TF-IDF fallback keeps green |
|
|
238
|
-
| **3** | MCP hub:
|
|
256
|
+
| **3** ✅ | MCP hub: extensible `core/mcp.py` registry + `boost_discover_github` reach-out | none required (deps per tool, opt-in) | ❌ opt-in | New `core/mcp.py` (mutation-gated) + `test_mcp.py`; `TestMcp`/`TestGithubSkillSearch` cover the tool; coverage 93% |
|
|
239
257
|
|
|
240
258
|
**Invariant preserved at every phase:** `pip install boost-skill-cli` remains zero-runtime-dependency and
|
|
241
259
|
`make check` stays green; all heavier machinery is opt-in behind the `[rag]` extra and the
|
{boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_configuration.py
RENAMED
|
@@ -650,7 +650,7 @@ class TestMcp:
|
|
|
650
650
|
tools = by_id[2]["result"]["tools"]
|
|
651
651
|
assert [t["name"] for t in tools] == [
|
|
652
652
|
"boost_search", "boost_list", "boost_info", "boost_install",
|
|
653
|
-
"boost_doctor"]
|
|
653
|
+
"boost_doctor", "boost_discover_github"]
|
|
654
654
|
assert all(t["inputSchema"]["type"] == "object" for t in tools)
|
|
655
655
|
|
|
656
656
|
def text(id_):
|
|
@@ -703,6 +703,59 @@ class TestMcp:
|
|
|
703
703
|
assert is_err is False
|
|
704
704
|
assert "no skills match 'zzzznothing'" in text
|
|
705
705
|
|
|
706
|
+
def test_registry_dispatches_and_lists_all_tools(self, sandbox):
|
|
707
|
+
from boost_cli.commands import configuration
|
|
708
|
+
# tools/list payload and the dispatcher share one registry
|
|
709
|
+
assert configuration._MCP_TOOLS == configuration.REGISTRY.specs()
|
|
710
|
+
assert "boost_discover_github" in configuration.REGISTRY.names()
|
|
711
|
+
assert configuration._mcp_tool("nonexistent_tool", {}) == (None, False)
|
|
712
|
+
|
|
713
|
+
def test_discover_github_missing_gh_degrades(self, sandbox, monkeypatch):
|
|
714
|
+
from boost_cli.commands import configuration
|
|
715
|
+
monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
|
|
716
|
+
lambda c: None)
|
|
717
|
+
text, is_err = configuration._mcp_tool("boost_discover_github",
|
|
718
|
+
{"query": "react"})
|
|
719
|
+
assert is_err is True
|
|
720
|
+
assert "gh" in text and "brew install gh" in text
|
|
721
|
+
|
|
722
|
+
def test_discover_github_lists_repos(self, sandbox, monkeypatch):
|
|
723
|
+
from boost_cli.commands import configuration, discovery
|
|
724
|
+
monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
|
|
725
|
+
lambda c: "/usr/bin/gh")
|
|
726
|
+
monkeypatch.setattr(
|
|
727
|
+
discovery, "github_skill_search",
|
|
728
|
+
lambda query="", limit=20: [
|
|
729
|
+
{"repo": "octo/skills", "path": "a/SKILL.md", "url": "u",
|
|
730
|
+
"description": "great skills"},
|
|
731
|
+
{"repo": "acme/pack", "path": "b/SKILL.md", "url": "u",
|
|
732
|
+
"description": ""}])
|
|
733
|
+
text, is_err = configuration._mcp_tool("boost_discover_github",
|
|
734
|
+
{"query": "react", "limit": 5})
|
|
735
|
+
assert is_err is False
|
|
736
|
+
assert "octo/skills — great skills" in text
|
|
737
|
+
assert "acme/pack — b/SKILL.md" in text # falls back to path
|
|
738
|
+
|
|
739
|
+
def test_discover_github_no_results(self, sandbox, monkeypatch):
|
|
740
|
+
from boost_cli.commands import configuration, discovery
|
|
741
|
+
monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
|
|
742
|
+
lambda c: "/usr/bin/gh")
|
|
743
|
+
monkeypatch.setattr(discovery, "github_skill_search",
|
|
744
|
+
lambda query="", limit=20: [])
|
|
745
|
+
text, is_err = configuration._mcp_tool("boost_discover_github", {})
|
|
746
|
+
assert is_err is False
|
|
747
|
+
assert "no SKILL.md repositories found" in text
|
|
748
|
+
|
|
749
|
+
def test_discover_github_search_failure(self, sandbox, monkeypatch):
|
|
750
|
+
from boost_cli.commands import configuration, discovery
|
|
751
|
+
monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
|
|
752
|
+
lambda c: "/usr/bin/gh")
|
|
753
|
+
monkeypatch.setattr(discovery, "github_skill_search",
|
|
754
|
+
lambda query="", limit=20: None)
|
|
755
|
+
text, is_err = configuration._mcp_tool("boost_discover_github", {})
|
|
756
|
+
assert is_err is True
|
|
757
|
+
assert "GitHub code search failed" in text
|
|
758
|
+
|
|
706
759
|
def test_register_without_claude_prints_manual_command(self, boost, sandbox,
|
|
707
760
|
monkeypatch):
|
|
708
761
|
monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
|
|
@@ -276,6 +276,101 @@ class TestIndex:
|
|
|
276
276
|
assert "gh api timed out on page 1" in r.err
|
|
277
277
|
|
|
278
278
|
|
|
279
|
+
class TestGithubSkillSearch:
|
|
280
|
+
"""The one-shot reach-out helper behind the boost_discover_github MCP tool."""
|
|
281
|
+
|
|
282
|
+
def test_none_without_gh(self, sandbox, monkeypatch):
|
|
283
|
+
from boost_cli.commands import discovery
|
|
284
|
+
monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
|
|
285
|
+
lambda c: None)
|
|
286
|
+
assert discovery.github_skill_search("react") is None
|
|
287
|
+
|
|
288
|
+
def test_returns_mapped_items(self, sandbox, monkeypatch):
|
|
289
|
+
from boost_cli.commands import discovery
|
|
290
|
+
monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
|
|
291
|
+
lambda c: "/usr/bin/gh")
|
|
292
|
+
seen = []
|
|
293
|
+
|
|
294
|
+
def fake_run(cmd, **kw):
|
|
295
|
+
seen.append(cmd)
|
|
296
|
+
return types.SimpleNamespace(returncode=0, stderr="", stdout=_gh_page(
|
|
297
|
+
[_gh_item("octo/skills", "a/SKILL.md"),
|
|
298
|
+
_gh_item("acme/pack", "b/SKILL.md", desc="")]))
|
|
299
|
+
|
|
300
|
+
monkeypatch.setattr("boost_cli.commands.discovery.subprocess.run", fake_run)
|
|
301
|
+
out = discovery.github_skill_search("react hooks", limit=5)
|
|
302
|
+
assert out == [
|
|
303
|
+
{"repo": "octo/skills", "path": "a/SKILL.md",
|
|
304
|
+
"url": "https://github.com/octo/skills/a/SKILL.md",
|
|
305
|
+
"description": "Skill repo"},
|
|
306
|
+
{"repo": "acme/pack", "path": "b/SKILL.md",
|
|
307
|
+
"url": "https://github.com/acme/pack/b/SKILL.md", "description": ""}]
|
|
308
|
+
# single page, user query appended (url-encoded) to the filename filter
|
|
309
|
+
assert "per_page=5&page=1" in seen[0][-1]
|
|
310
|
+
assert "filename:SKILL.md" in seen[0][-1]
|
|
311
|
+
assert "react" in seen[0][-1] and "hooks" in seen[0][-1]
|
|
312
|
+
|
|
313
|
+
def test_no_query_uses_bare_filename_filter(self, sandbox, monkeypatch):
|
|
314
|
+
from boost_cli.commands import discovery
|
|
315
|
+
monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
|
|
316
|
+
lambda c: "/usr/bin/gh")
|
|
317
|
+
seen = []
|
|
318
|
+
monkeypatch.setattr(
|
|
319
|
+
"boost_cli.commands.discovery.subprocess.run",
|
|
320
|
+
lambda cmd, **kw: seen.append(cmd) or types.SimpleNamespace(
|
|
321
|
+
returncode=0, stderr="", stdout=_gh_page([])))
|
|
322
|
+
assert discovery.github_skill_search() == []
|
|
323
|
+
assert "q=filename:SKILL.md&per_page=20&page=1" in seen[0][-1]
|
|
324
|
+
|
|
325
|
+
def test_limit_capped_and_sliced(self, sandbox, monkeypatch):
|
|
326
|
+
from boost_cli.commands import discovery
|
|
327
|
+
monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
|
|
328
|
+
lambda c: "/usr/bin/gh")
|
|
329
|
+
seen = []
|
|
330
|
+
items = [_gh_item("o/r%d" % i, "s/SKILL.md") for i in range(10)]
|
|
331
|
+
monkeypatch.setattr(
|
|
332
|
+
"boost_cli.commands.discovery.subprocess.run",
|
|
333
|
+
lambda cmd, **kw: seen.append(cmd) or types.SimpleNamespace(
|
|
334
|
+
returncode=0, stderr="", stdout=_gh_page(items)))
|
|
335
|
+
out = discovery.github_skill_search("x", limit=3)
|
|
336
|
+
assert len(out) == 3 # sliced to per_page
|
|
337
|
+
assert "per_page=3&page=1" in seen[0][-1]
|
|
338
|
+
# a limit above the code-search cap is clamped to 100
|
|
339
|
+
discovery.github_skill_search("x", limit=9999)
|
|
340
|
+
assert "per_page=100&page=1" in seen[1][-1]
|
|
341
|
+
|
|
342
|
+
def test_gh_failure_returns_none(self, sandbox, monkeypatch):
|
|
343
|
+
from boost_cli.commands import discovery
|
|
344
|
+
monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
|
|
345
|
+
lambda c: "/usr/bin/gh")
|
|
346
|
+
monkeypatch.setattr(
|
|
347
|
+
"boost_cli.commands.discovery.subprocess.run",
|
|
348
|
+
lambda cmd, **kw: types.SimpleNamespace(
|
|
349
|
+
returncode=1, stdout="", stderr="404"))
|
|
350
|
+
assert discovery.github_skill_search("x") is None
|
|
351
|
+
|
|
352
|
+
def test_bad_json_returns_none(self, sandbox, monkeypatch):
|
|
353
|
+
from boost_cli.commands import discovery
|
|
354
|
+
monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
|
|
355
|
+
lambda c: "/usr/bin/gh")
|
|
356
|
+
monkeypatch.setattr(
|
|
357
|
+
"boost_cli.commands.discovery.subprocess.run",
|
|
358
|
+
lambda cmd, **kw: types.SimpleNamespace(
|
|
359
|
+
returncode=0, stdout="not json", stderr=""))
|
|
360
|
+
assert discovery.github_skill_search("x") is None
|
|
361
|
+
|
|
362
|
+
def test_oserror_returns_none(self, sandbox, monkeypatch):
|
|
363
|
+
from boost_cli.commands import discovery
|
|
364
|
+
monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
|
|
365
|
+
lambda c: "/usr/bin/gh")
|
|
366
|
+
|
|
367
|
+
def boom(cmd, **kw):
|
|
368
|
+
raise OSError("no exec")
|
|
369
|
+
|
|
370
|
+
monkeypatch.setattr("boost_cli.commands.discovery.subprocess.run", boom)
|
|
371
|
+
assert discovery.github_skill_search("x") is None
|
|
372
|
+
|
|
373
|
+
|
|
279
374
|
# ---------------------------------------------------------------- discover
|
|
280
375
|
|
|
281
376
|
def _write_index(items, total=42):
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Unit tests: boost_cli/core/mcp.py — the extensible MCP tool registry.
|
|
2
|
+
|
|
3
|
+
The registry is the Phase-3 "MCP as a hub" seam: tools self-register a spec +
|
|
4
|
+
handler, and the JSON-RPC server iterates it. These tests pin registration
|
|
5
|
+
order, spec shape, dispatch, and the unknown-tool contract so the mutation gate
|
|
6
|
+
has teeth.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from boost_cli.core import mcp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ok(_args):
|
|
16
|
+
return "ok", False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestRegister:
|
|
20
|
+
def test_spec_shape_and_order(self):
|
|
21
|
+
reg = mcp.Registry()
|
|
22
|
+
reg.register("a", "does a", {"type": "object"}, _ok)
|
|
23
|
+
reg.register("b", "does b", {"type": "object", "properties": {}},
|
|
24
|
+
lambda a: ("b!", False))
|
|
25
|
+
assert reg.names() == ["a", "b"] # registration order
|
|
26
|
+
specs = reg.specs()
|
|
27
|
+
assert [s["name"] for s in specs] == ["a", "b"]
|
|
28
|
+
assert specs[0] == {"name": "a", "description": "does a",
|
|
29
|
+
"inputSchema": {"type": "object"}}
|
|
30
|
+
assert specs[1]["inputSchema"] == {"type": "object", "properties": {}}
|
|
31
|
+
|
|
32
|
+
def test_specs_preserve_insertion_not_sorted(self):
|
|
33
|
+
reg = mcp.Registry()
|
|
34
|
+
for n in ("zebra", "alpha", "mid"):
|
|
35
|
+
reg.register(n, n, {"type": "object"}, _ok)
|
|
36
|
+
assert reg.names() == ["zebra", "alpha", "mid"] # not alphabetized
|
|
37
|
+
|
|
38
|
+
def test_empty_name_rejected(self):
|
|
39
|
+
reg = mcp.Registry()
|
|
40
|
+
with pytest.raises(ValueError, match="non-empty"):
|
|
41
|
+
reg.register("", "d", {"type": "object"}, _ok)
|
|
42
|
+
|
|
43
|
+
def test_duplicate_name_rejected(self):
|
|
44
|
+
reg = mcp.Registry()
|
|
45
|
+
reg.register("a", "d", {"type": "object"}, _ok)
|
|
46
|
+
with pytest.raises(ValueError, match="duplicate"):
|
|
47
|
+
reg.register("a", "d2", {"type": "object"}, _ok)
|
|
48
|
+
assert reg.names() == ["a"] # first wins, unchanged
|
|
49
|
+
|
|
50
|
+
def test_decorator_registers_and_returns_fn(self):
|
|
51
|
+
reg = mcp.Registry()
|
|
52
|
+
|
|
53
|
+
@reg.tool("greet", "say hi", {"type": "object"})
|
|
54
|
+
def greet(args):
|
|
55
|
+
return "hi %s" % args.get("who", "?"), False
|
|
56
|
+
|
|
57
|
+
assert reg.has("greet")
|
|
58
|
+
assert callable(greet) # returned unchanged
|
|
59
|
+
assert greet({"who": "x"}) == ("hi x", False)
|
|
60
|
+
assert reg.call("greet", {"who": "y"}) == ("hi y", False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestDispatch:
|
|
64
|
+
def test_call_routes_to_handler(self):
|
|
65
|
+
reg = mcp.Registry()
|
|
66
|
+
seen = {}
|
|
67
|
+
reg.register("echo", "d", {"type": "object"},
|
|
68
|
+
lambda a: (seen.update(a) or "done", bool(a.get("bad"))))
|
|
69
|
+
assert reg.call("echo", {"x": 1}) == ("done", False)
|
|
70
|
+
assert seen == {"x": 1}
|
|
71
|
+
assert reg.call("echo", {"bad": 1})[1] is True # is_error propagates
|
|
72
|
+
|
|
73
|
+
def test_unknown_tool_is_none_false(self):
|
|
74
|
+
reg = mcp.Registry()
|
|
75
|
+
reg.register("a", "d", {"type": "object"}, _ok)
|
|
76
|
+
assert reg.call("nope", {}) == (None, False)
|
|
77
|
+
assert reg.has("nope") is False
|
|
78
|
+
assert reg.has("a") is True
|
|
79
|
+
|
|
80
|
+
def test_handler_none_text_passes_through(self):
|
|
81
|
+
reg = mcp.Registry()
|
|
82
|
+
reg.register("silent", "d", {"type": "object"}, lambda a: (None, True))
|
|
83
|
+
assert reg.call("silent", {}) == (None, True) # not masked as unknown
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|