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.
Files changed (113) hide show
  1. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/PKG-INFO +1 -1
  2. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/_version.py +2 -2
  3. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/configuration.py +158 -96
  4. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/discovery.py +42 -0
  5. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/config.py +3 -2
  6. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/journal.py +3 -1
  7. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/lockfile.py +27 -2
  8. boost_skill_cli-1.0.53/boost_cli/core/mcp.py +70 -0
  9. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/util.py +32 -0
  10. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/PKG-INFO +1 -1
  11. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/SOURCES.txt +2 -0
  12. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/rag-architecture.md +33 -15
  13. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_configuration.py +54 -1
  14. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_discovery.py +95 -0
  15. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_lockfile.py +25 -0
  16. boost_skill_cli-1.0.53/tests/unit/test_mcp.py +83 -0
  17. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_util.py +44 -0
  18. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/.gitignore +0 -0
  19. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/CLAUDE.md +0 -0
  20. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/CONTRIBUTING.md +0 -0
  21. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/LICENSE +0 -0
  22. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/MANIFEST.in +0 -0
  23. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/Makefile +0 -0
  24. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/README.md +0 -0
  25. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/SECURITY.md +0 -0
  26. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost +0 -0
  27. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/__init__.py +0 -0
  28. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/__main__.py +0 -0
  29. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/cli.py +0 -0
  30. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/cliparse.py +0 -0
  31. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/__init__.py +0 -0
  32. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/bmad.py +0 -0
  33. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/hooks.py +0 -0
  34. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/info.py +0 -0
  35. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/intelligence.py +0 -0
  36. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/pkg.py +0 -0
  37. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/quality.py +0 -0
  38. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/taps.py +0 -0
  39. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/commands/team.py +0 -0
  40. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/__init__.py +0 -0
  41. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/agents.py +0 -0
  42. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/ai.py +0 -0
  43. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/catalog.py +0 -0
  44. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/claude_settings.py +0 -0
  45. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/dense.py +0 -0
  46. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/embed.py +0 -0
  47. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/frontmatter.py +0 -0
  48. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/gitutil.py +0 -0
  49. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/logs.py +0 -0
  50. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/output.py +0 -0
  51. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/paths.py +0 -0
  52. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/policy.py +0 -0
  53. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/rag.py +0 -0
  54. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/registry.py +0 -0
  55. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/core/store.py +0 -0
  56. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/data/registries.json +0 -0
  57. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/errors.py +0 -0
  58. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_cli/spin.py +0 -0
  59. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
  60. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/entry_points.txt +0 -0
  61. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/requires.txt +0 -0
  62. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/top_level.txt +0 -0
  63. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/.claude/settings.local.json +0 -0
  64. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/DEBUGGING.md +0 -0
  65. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/demo.gif +0 -0
  66. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/demo.tape +0 -0
  67. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/design-roadmap.html +0 -0
  68. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/index.html +0 -0
  69. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/docs/roadmap.html +0 -0
  70. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/index.html +0 -0
  71. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/pyproject.toml +0 -0
  72. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/scripts/build_registries.py +0 -0
  73. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/scripts/mutation_gate.py +0 -0
  74. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/setup.cfg +0 -0
  75. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/README.md +0 -0
  76. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/boost.css +0 -0
  77. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/boost.js +0 -0
  78. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/style/demo.html +0 -0
  79. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/conftest.py +0 -0
  80. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_bmad.py +0 -0
  81. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_hooks.py +0 -0
  82. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_info.py +0 -0
  83. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_intelligence.py +0 -0
  84. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_pkg.py +0 -0
  85. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_quality.py +0 -0
  86. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_taps.py +0 -0
  87. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_cli_team.py +0 -0
  88. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/functional/test_everyday_loop.py +0 -0
  89. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/make_fixture.py +0 -0
  90. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/smoke.sh +0 -0
  91. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_agents.py +0 -0
  92. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_ai.py +0 -0
  93. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_catalog.py +0 -0
  94. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_claude_settings.py +0 -0
  95. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_cliparse.py +0 -0
  96. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_config.py +0 -0
  97. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_dense.py +0 -0
  98. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_embed.py +0 -0
  99. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_errors_and_cli_table.py +0 -0
  100. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_frontmatter.py +0 -0
  101. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_gitutil.py +0 -0
  102. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_journal.py +0 -0
  103. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_logs.py +0 -0
  104. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_mutation_hardening.py +0 -0
  105. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_output.py +0 -0
  106. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_paths.py +0 -0
  107. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_policy.py +0 -0
  108. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_rag.py +0 -0
  109. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_registry.py +0 -0
  110. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_spin.py +0 -0
  111. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_store.py +0 -0
  112. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_token_parity.py +0 -0
  113. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.53}/tests/unit/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boost-skill-cli
3
- Version: 1.0.51
3
+ Version: 1.0.53
4
4
  Summary: boost — Homebrew for AI coding skills
5
5
  Author: Jonathan Reyes
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -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.51'
22
- __version_tuple__ = version_tuple = (1, 0, 51)
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
- _MCP_TOOLS = [
868
- {"name": "boost_search",
869
- "description": "Search AI coding skills across the configured tap registries",
870
- "inputSchema": {"type": "object",
871
- "properties": {"query": {"type": "string",
872
- "description": "search terms"}},
873
- "required": ["query"]}},
874
- {"name": "boost_list",
875
- "description": "List the skills currently installed by boost",
876
- "inputSchema": {"type": "object", "properties": {}}},
877
- {"name": "boost_info",
878
- "description": "Show detailed information about one skill",
879
- "inputSchema": {"type": "object",
880
- "properties": {"name": {"type": "string",
881
- "description": "skill name"}},
882
- "required": ["name"]}},
883
- {"name": "boost_install",
884
- "description": "Install a skill from a configured tap registry",
885
- "inputSchema": {"type": "object",
886
- "properties": {"name": {"type": "string",
887
- "description": "skill name"}},
888
- "required": ["name"]}},
889
- {"name": "boost_doctor",
890
- "description": "Health summary of the boost skill environment",
891
- "inputSchema": {"type": "object", "properties": {}}},
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
- if tool == "boost_search":
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
- paths.config_path().write_text(json.dumps(cfg, indent=2, sort_keys=False) + "\n")
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
- p.write_text("\n".join(lines[-ROTATE_KEEP:]) + "\n")
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
- p.write_text(json.dumps(lock, indent=2, sort_keys=True) + "\n")
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boost-skill-cli
3
- Version: 1.0.51
3
+ Version: 1.0.53
4
4
  Summary: boost — Homebrew for AI coding skills
5
5
  Author: Jonathan Reyes
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -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
- Today the MCP tool layer is a declarative list `_MCP_TOOLS` (`configuration.py:867`) plus a flat
200
- `if/elif` dispatcher `_mcp_tool` (`configuration.py:895`). To make new capabilities drop-in:
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
- 1. **Registry refactor.** Replace the `if/elif` with a `name -> handler` map; each handler is
203
- `fn(args) -> (text, is_error)` and self-registers its `{name, description, inputSchema}`. `tools/list`
204
- and `tools/call` (`_mcp_serve_stdio`, `configuration.py:965`) iterate the registry unchanged.
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 is intentionally *after* the retrieval core is proven, so the hub grows on a stable seam.
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: GitHub/DB reach-out tools, registry refactor | none required (deps per tool, opt-in) | ❌ opt-in | Registry refactor covered by existing `TestMcp`; new handlers add tests |
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