boost-skill-cli 1.0.51__tar.gz → 1.0.53__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.53}/PKG-INFO +1 -1
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/_version.py +2 -2
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/configuration.py +158 -96
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/discovery.py +42 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/config.py +3 -2
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/journal.py +3 -1
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/lockfile.py +27 -2
- boost_skill_cli-1.0.53/boost_cli/core/mcp.py +70 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/util.py +32 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/PKG-INFO +1 -1
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/SOURCES.txt +2 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/rag-architecture.md +33 -15
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_configuration.py +54 -1
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_discovery.py +95 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_lockfile.py +25 -0
- boost_skill_cli-1.0.53/tests/unit/test_mcp.py +83 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_util.py +44 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/.gitignore +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/CLAUDE.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/CONTRIBUTING.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/LICENSE +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/MANIFEST.in +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/Makefile +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/README.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/SECURITY.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/__init__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/__main__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/cli.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/cliparse.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/__init__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/bmad.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/hooks.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/info.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/intelligence.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/pkg.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/quality.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/taps.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/team.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/__init__.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/agents.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/ai.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/catalog.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/claude_settings.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/dense.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/embed.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/frontmatter.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/gitutil.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/logs.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/output.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/paths.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/policy.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/rag.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/registry.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/store.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/data/registries.json +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/errors.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/spin.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/entry_points.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/requires.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/top_level.txt +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/.claude/settings.local.json +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/DEBUGGING.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/demo.gif +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/demo.tape +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/design-roadmap.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/index.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/roadmap.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/index.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/pyproject.toml +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/scripts/build_registries.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/scripts/mutation_gate.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/setup.cfg +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/README.md +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/boost.css +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/boost.js +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/demo.html +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/conftest.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_bmad.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_hooks.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_info.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_intelligence.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_pkg.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_quality.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_taps.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_team.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_everyday_loop.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/make_fixture.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/smoke.sh +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_agents.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_ai.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_catalog.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_claude_settings.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_cliparse.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_config.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_dense.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_embed.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_errors_and_cli_table.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_frontmatter.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_gitutil.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_journal.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_logs.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_mutation_hardening.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_output.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_paths.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_policy.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_rag.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_registry.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_spin.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_store.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_token_parity.py +0 -0
- {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/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.53'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 53)
|
|
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(
|
|
@@ -5,7 +5,7 @@ import json
|
|
|
5
5
|
from copy import deepcopy
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from . import paths
|
|
8
|
+
from . import paths, util
|
|
9
9
|
|
|
10
10
|
DEFAULTS = {
|
|
11
11
|
"agents": {
|
|
@@ -90,7 +90,8 @@ def load() -> dict:
|
|
|
90
90
|
|
|
91
91
|
def save(cfg: dict) -> None:
|
|
92
92
|
paths.ensure_dirs()
|
|
93
|
-
|
|
93
|
+
util.atomic_write_text(
|
|
94
|
+
paths.config_path(), json.dumps(cfg, indent=2, sort_keys=False) + "\n")
|
|
94
95
|
|
|
95
96
|
|
|
96
97
|
def get(dotted: str, default=None):
|
|
@@ -67,4 +67,6 @@ def _maybe_rotate() -> None:
|
|
|
67
67
|
except OSError:
|
|
68
68
|
return
|
|
69
69
|
if len(lines) > ROTATE_AT:
|
|
70
|
-
|
|
70
|
+
# Atomic swap: a bare write_text here races concurrent log() appends and
|
|
71
|
+
# can truncate the feed mid-line if interrupted.
|
|
72
|
+
util.atomic_write_text(p, "\n".join(lines[-ROTATE_KEEP:]) + "\n")
|
|
@@ -26,16 +26,41 @@ def _skeleton() -> dict:
|
|
|
26
26
|
def read() -> dict:
|
|
27
27
|
p = paths.lockfile_path()
|
|
28
28
|
if not p.exists():
|
|
29
|
-
return _skeleton()
|
|
29
|
+
return _skeleton() # empty: never installed anything yet
|
|
30
30
|
try:
|
|
31
31
|
lock = json.loads(p.read_text())
|
|
32
32
|
except (json.JSONDecodeError, OSError):
|
|
33
|
+
# Corrupt (present but unparseable) is NOT the same as empty: returning
|
|
34
|
+
# a bare skeleton here would let the next write() overwrite the only
|
|
35
|
+
# record of every prior install. Preserve the bytes for recovery and
|
|
36
|
+
# surface it loudly before falling back to the skeleton.
|
|
37
|
+
_preserve_corrupt(p)
|
|
33
38
|
return _skeleton()
|
|
34
39
|
lock.setdefault("version", SCHEMA_VERSION)
|
|
35
40
|
lock.setdefault("skills", {})
|
|
36
41
|
return lock
|
|
37
42
|
|
|
38
43
|
|
|
44
|
+
def _preserve_corrupt(p) -> None:
|
|
45
|
+
"""Copy an unparseable lock file aside as ``<lock>.corrupt`` and warn.
|
|
46
|
+
|
|
47
|
+
Best effort: a read must still succeed (returning the skeleton) even if the
|
|
48
|
+
sidecar cannot be written, so failures here are swallowed after logging.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
backup = p.with_name(p.name + ".corrupt")
|
|
52
|
+
shutil.copy(p, backup)
|
|
53
|
+
except OSError:
|
|
54
|
+
backup = None
|
|
55
|
+
try:
|
|
56
|
+
from . import logs
|
|
57
|
+
logs.get_logger().warning(
|
|
58
|
+
"lock file %s is corrupt; preserved %s and continuing with an "
|
|
59
|
+
"empty lock", p, backup or "(backup failed)")
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
39
64
|
def write(lock: dict) -> None:
|
|
40
65
|
paths.ensure_dirs()
|
|
41
66
|
p = paths.lockfile_path()
|
|
@@ -52,7 +77,7 @@ def write(lock: dict) -> None:
|
|
|
52
77
|
_prune_history()
|
|
53
78
|
lock["version"] = SCHEMA_VERSION
|
|
54
79
|
lock["updated"] = util.now_iso()
|
|
55
|
-
|
|
80
|
+
util.atomic_write_text(p, json.dumps(lock, indent=2, sort_keys=True) + "\n")
|
|
56
81
|
|
|
57
82
|
|
|
58
83
|
def _history_files() -> List:
|
|
@@ -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)
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import hashlib
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
7
|
+
import tempfile
|
|
6
8
|
from datetime import datetime, timezone
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from typing import List, Tuple
|
|
@@ -10,6 +12,36 @@ from typing import List, Tuple
|
|
|
10
12
|
IGNORED = {".git", "__pycache__", ".DS_Store"}
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
|
|
16
|
+
"""Write ``text`` to ``path`` so a reader never sees a partial file.
|
|
17
|
+
|
|
18
|
+
The bytes go to a temp file in the *same* directory (so the rename stays on
|
|
19
|
+
one filesystem and is atomic), get flushed and fsynced to disk, then swap
|
|
20
|
+
into place with ``os.replace``. An interrupted or concurrent write can no
|
|
21
|
+
longer truncate the target: the previous file survives intact until the
|
|
22
|
+
complete new one atomically replaces it. This is the corruption-safe
|
|
23
|
+
substitute for a bare ``Path.write_text`` on any file boost must not lose —
|
|
24
|
+
the lock file, the config, the pulse journal.
|
|
25
|
+
"""
|
|
26
|
+
path = Path(path)
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent),
|
|
29
|
+
prefix="." + path.name + ".", suffix=".tmp")
|
|
30
|
+
try:
|
|
31
|
+
with os.fdopen(fd, "w", encoding=encoding) as f:
|
|
32
|
+
f.write(text)
|
|
33
|
+
f.flush()
|
|
34
|
+
os.fsync(f.fileno())
|
|
35
|
+
os.replace(tmp, str(path))
|
|
36
|
+
except BaseException:
|
|
37
|
+
# Never leave a temp turd behind, and never mask the original error.
|
|
38
|
+
try:
|
|
39
|
+
os.unlink(tmp)
|
|
40
|
+
except OSError:
|
|
41
|
+
pass
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
|
|
13
45
|
def now_iso() -> str:
|
|
14
46
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
15
47
|
|
|
@@ -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
|