Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ All notable changes to vouch are documented here. Format follows
## [Unreleased]

### Added
- `kb.list_skills` / `kb.get_skill` — agents can enumerate the Claude Code
slash-command and `SKILL.md` catalogue visible at `<kb_root>/.claude/` and
`~/.claude/` over MCP, then fetch the full body of one by name (project-local
entries override user-global on collision). Exposed across MCP (`kb_list_skills`
/ `kb_get_skill`), JSONL, and the CLI (`vouch list-skills` / `vouch get-skill`).
- `mcp.publish_skills` config flag (default `true`) — gates the skill catalogue
for "company-brain" deployments where the catalogue itself is sensitive. When
`false`, `kb.list_skills` returns an empty list and `kb.get_skill` errors with
`permission_denied`; the flag is read fresh on every call so flipping it hides
the catalogue without restarting the server, and is surfaced on
`kb.capabilities.mcp.publish_skills` so clients can detect the gate. An
existing KB with no `mcp:` block stays default-on (#235).
- `vouch dual-solve <issue-url>` — run claude + codex on one github issue in
isolated git worktrees, compare the two diffs, keep the branch you pick, and
propose the chosen solution's rationale into the KB. A sibling tool to
Expand Down
5 changes: 4 additions & 1 deletion src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@
"kb.impact",
"kb.graph_export",
"kb.provenance_rebuild",
"kb.list_skills",
"kb.get_skill",
]


def capabilities() -> Capabilities:
def capabilities(*, publish_skills: bool = True) -> Capabilities:
retrieval = ["fts5", "substring"]
try:
from .embeddings import get_embedder
Expand All @@ -94,4 +96,5 @@ def capabilities() -> Capabilities:
"config_path": "retrieval.scope",
},
context_engines=[describe_engine()],
mcp={"publish_skills": publish_skills},
)
44 changes: 43 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from . import pr_cache as prc_mod
from . import provenance as prov_mod
from . import sessions as sess_mod
from . import skills as skills_mod
from . import stats as stats_mod
from . import sync as sync_mod
from . import synthesize as synth
Expand Down Expand Up @@ -179,7 +180,48 @@ def discover(path: str) -> None:
@cli.command()
def capabilities() -> None:
"""Emit the JSON capabilities descriptor (mirrors kb.capabilities)."""
_emit_json(build_caps().model_dump(mode="json"))
# Stay usable outside a KB: fall back to the default-on flag if no
# .vouch/ is discoverable here.
try:
publish_skills = skills_mod.publish_skills_enabled(_load_store())
except Exception:
publish_skills = True
_emit_json(build_caps(publish_skills=publish_skills).model_dump(mode="json"))


@cli.command("list-skills")
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.")
def list_skills(as_json: bool) -> None:
"""List discoverable Claude Code skills / slash commands (mirrors kb.list_skills)."""
store = _load_store()
rows = skills_mod.list_skills(store)
if as_json:
_emit_json(rows)
return
if not rows:
# Either nothing installed, or mcp.publish_skills is false.
click.echo("no skills published")
return
for r in rows:
click.echo(f"{r['name']} [{r['scope']}/{r['kind']}] {r['description']}")


@cli.command("get-skill")
@click.argument("name")
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of the body.")
def get_skill(name: str, as_json: bool) -> None:
"""Print the full body of a named skill / slash command (mirrors kb.get_skill)."""
store = _load_store()
try:
result = skills_mod.get_skill(store, name)
except skills_mod.SkillsDisabledError as e:
raise click.ClickException(str(e)) from e
except KeyError as e:
raise click.ClickException(str(e)) from e
if as_json:
_emit_json(result)
return
click.echo(result["body"])


# --- status / health ------------------------------------------------------
Expand Down
28 changes: 27 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from . import lifecycle as life
from . import salience as salience_mod
from . import sessions as sess_mod
from . import skills as skills_mod
from . import trust as trust_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
Expand Down Expand Up @@ -82,7 +83,11 @@ def _agent() -> str:


def _h_capabilities(_: dict) -> dict:
return build_caps().model_dump(mode="json")
try:
publish_skills = skills_mod.publish_skills_enabled(_store())
except Exception:
publish_skills = True
return build_caps(publish_skills=publish_skills).model_dump(mode="json")


def _h_status(_: dict) -> dict:
Expand Down Expand Up @@ -595,6 +600,20 @@ def _h_embeddings_stats(_: dict) -> dict:
}


def _h_list_skills(_: dict) -> list[dict]:
return skills_mod.list_skills(_store())


def _h_get_skill(p: dict) -> dict:
name = p.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("`name` is required")
try:
return skills_mod.get_skill(_store(), name)
except KeyError as e:
raise ValueError(str(e)) from e


def _h_why(p: dict) -> dict:
from . import provenance as prov

Expand Down Expand Up @@ -687,6 +706,8 @@ def _h_provenance_rebuild(_: dict) -> dict:
"kb.impact": _h_impact,
"kb.graph_export": _h_graph_export,
"kb.provenance_rebuild": _h_provenance_rebuild,
"kb.list_skills": _h_list_skills,
"kb.get_skill": _h_get_skill,
}


Expand All @@ -707,6 +728,11 @@ def handle_request(envelope: dict) -> dict:
"ok": True,
"result": trust_mod.finish_kb_result(result),
}
except skills_mod.SkillsDisabledError as e:
return {
"id": req_id, "ok": False,
"error": {"code": "permission_denied", "message": str(e)},
}
except KeyError as e:
return {
"id": req_id, "ok": False,
Expand Down
7 changes: 7 additions & 0 deletions src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,10 @@ class Capabilities(BaseModel):
default_factory=list,
description="OpenClaw context engines exposed (see openclaw.plugin.json)",
)
mcp: dict[str, Any] = Field(
default_factory=lambda: {"publish_skills": True},
description=(
"mcp surface flags mirrored from config.yaml `mcp:` block. "
"`publish_skills` gates kb.list_skills / kb.get_skill."
),
)
39 changes: 38 additions & 1 deletion src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from . import lifecycle as life
from . import salience as salience_mod
from . import sessions as sess_mod
from . import skills as skills_mod
from . import trust as trust_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
Expand Down Expand Up @@ -73,7 +74,11 @@ def _agent() -> str:
@mcp.tool()
def kb_capabilities() -> dict[str, Any]:
"""Return the protocol capabilities of this server."""
return build_caps().model_dump(mode="json")
try:
publish_skills = skills_mod.publish_skills_enabled(_store())
except Exception:
publish_skills = True
return build_caps(publish_skills=publish_skills).model_dump(mode="json")


@mcp.tool()
Expand All @@ -92,6 +97,38 @@ def kb_stats(*, days: int = 30) -> dict[str, Any]:
return collect_stats(_store(), since_days=since)


@mcp.tool()
def kb_list_skills() -> list[dict[str, Any]]:
"""Enumerate every Claude Code skill / slash command visible to vouch.

Scans, in priority order:
1. ``<kb_root>/.claude/skills/<name>/SKILL.md`` — project-local skills
2. ``<kb_root>/.claude/commands/<name>.md`` — project-local commands
3. ``~/.claude/skills/<name>/SKILL.md`` — user-global skills
4. ``~/.claude/commands/<name>.md`` — user-global commands

Project entries override user ones with the same name. Returns
``[{name, description, scope, kind, path}]``. Returns an empty list
when ``mcp.publish_skills`` is ``false`` in config.yaml.
"""
return skills_mod.list_skills(_store())


@mcp.tool()
def kb_get_skill(name: str) -> dict[str, Any]:
"""Return the full markdown body of a named skill / slash command.

Errors with ``permission_denied`` when ``mcp.publish_skills`` is
``false``, and ``not_found`` when the name isn't in the catalogue.
"""
try:
return skills_mod.get_skill(_store(), name)
except skills_mod.SkillsDisabledError as e:
raise PermissionError(str(e)) from e
except KeyError as e:
raise ValueError(str(e)) from e


# === read tools (unrestricted) ============================================


Expand Down
Loading
Loading