diff --git a/CHANGELOG.md b/CHANGELOG.md index 8023df2d..80c544d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,15 @@ All notable changes to vouch are documented here. Format follows as reviewer; a PR opens only when the repo's own test gate is green and the reviewer signs off. A sibling tool — it never writes to the KB or the review gate. Paired with the `auto-pr` skill. +- `mcp.publish_skills` config flag (#235): when false, `kb.list_skills` + returns an empty catalogue and `kb.get_skill` returns `permission_denied`. + Default is `true` so existing KBs keep current behaviour. The flag is + surfaced on `kb.capabilities.mcp` so clients can detect the gate without + probing. Flipping the flag in `config.yaml` takes effect on the next call + — no server restart required. +- `kb.list_skills` / `kb.get_skill` — discover Claude Code skills and slash + commands from project-local and user-global `.claude/` trees. CLI mirrors: + `vouch list-skills`, `vouch get-skill `. - typed page kinds (#234): a KB can declare extra page kinds in `.vouch/config.yaml` under `page_kinds`, each with `required_fields`, a JSON-Schema-subset `frontmatter_schema`, `required_citations`, and one level diff --git a/schemas/capabilities.schema.json b/schemas/capabilities.schema.json index b986604e..d2388bbc 100644 --- a/schemas/capabilities.schema.json +++ b/schemas/capabilities.schema.json @@ -23,6 +23,12 @@ "title": "Level", "type": "integer" }, + "mcp": { + "additionalProperties": true, + "description": "MCP transport knobs surfaced to clients (issue #235)", + "title": "Mcp", + "type": "object" + }, "methods": { "items": { "type": "string" diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 39872ade..f45b89c4 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -10,6 +10,7 @@ from . import __version__ from .models import Capabilities from .openclaw.context_engine import describe_engine +from .storage import KBStore # The full method surface this implementation exposes. Keep this list in # sync with the MCP server + JSONL server registrations — `test_capabilities` @@ -69,10 +70,12 @@ "kb.impact", "kb.graph_export", "kb.provenance_rebuild", + "kb.list_skills", + "kb.get_skill", ] -def capabilities() -> Capabilities: +def capabilities(store: KBStore | None = None) -> Capabilities: retrieval = ["fts5", "substring"] try: from .embeddings import get_embedder @@ -81,6 +84,11 @@ def capabilities() -> Capabilities: retrieval.append("hybrid") except Exception: pass + mcp_section: dict[str, object] = {"publish_skills": True} + if store is not None: + from .mcp_config import load_config + + mcp_section = load_config(store).to_capabilities_dict() return Capabilities( version=__version__, methods=METHODS, @@ -94,4 +102,5 @@ def capabilities() -> Capabilities: "config_path": "retrieval.scope", }, context_engines=[describe_engine()], + mcp=mcp_section, ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index ca465cae..dc1f8afd 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -59,6 +59,7 @@ from .proposals import ( reject as do_reject, ) +from .skills.errors import SkillsAccessDenied from .storage import ( ArtifactNotFoundError, KBNotFoundError, @@ -83,6 +84,7 @@ def _cli_errors() -> Iterator[None]: ProposalError, LifecycleError, migrations_mod.MigrationError, + SkillsAccessDenied, ) as e: raise click.ClickException(str(e)) from e @@ -179,7 +181,7 @@ 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")) + _emit_json(build_caps(_load_store()).model_dump(mode="json")) # --- status / health ------------------------------------------------------ @@ -1478,6 +1480,27 @@ def neighbors(node_id: str, depth: int, rel_types: tuple[str, ...], _emit_json(result) +@cli.command("list-skills") +def list_skills_cmd() -> None: + """Enumerate Claude Code skills / slash commands (mirrors kb.list_skills).""" + from . import skills as skills_mod + + store = _load_store() + _emit_json(skills_mod.list_skills(store)) + + +@cli.command("get-skill") +@click.argument("name") +def get_skill_cmd(name: str) -> None: + """Return the full markdown body of a named skill (mirrors kb.get_skill).""" + from . import skills as skills_mod + + store = _load_store() + with _cli_errors(): + result = skills_mod.get_skill(store, name) + _emit_json(result) + + @cli.command() @click.argument("task") @click.option("--limit", default=10, show_default=True, type=int) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index ec738c6b..02fac65e 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -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 @@ -50,6 +51,7 @@ reject, reject_auto_extracted, ) +from .skills.errors import SkillsAccessDenied from .stats import collect_stats from .storage import ( ArtifactNotFoundError, @@ -82,7 +84,7 @@ def _agent() -> str: def _h_capabilities(_: dict) -> dict: - return build_caps().model_dump(mode="json") + return build_caps(_store()).model_dump(mode="json") def _h_status(_: dict) -> dict: @@ -632,6 +634,20 @@ def _h_provenance_rebuild(_: dict) -> dict: return {"edges": prov.rebuild_prov_edges(_store())} +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 + + HANDLERS: dict[str, Callable[[dict], Any]] = { "kb.capabilities": _h_capabilities, "kb.status": _h_status, @@ -687,6 +703,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, } @@ -707,6 +725,11 @@ def handle_request(envelope: dict) -> dict: "ok": True, "result": trust_mod.finish_kb_result(result), } + except SkillsAccessDenied 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, diff --git a/src/vouch/mcp_config.py b/src/vouch/mcp_config.py new file mode 100644 index 00000000..ca3ce3f4 --- /dev/null +++ b/src/vouch/mcp_config.py @@ -0,0 +1,40 @@ +"""MCP transport settings read from ``config.yaml``. + +Mirrors gbrain's ``mcp.publish_skills`` gate (issue #235): when false, skill +catalogue endpoints hide the catalogue so a company-brain KB can keep slash +commands on disk without advertising them to every MCP caller. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import yaml + +from .storage import KBStore + + +@dataclass(frozen=True) +class McpConfig: + publish_skills: bool = True + + def to_capabilities_dict(self) -> dict[str, Any]: + return {"publish_skills": self.publish_skills} + + +def load_config(store: KBStore) -> McpConfig: + """Read ``mcp:`` from config.yaml; fall back to defaults.""" + try: + loaded = yaml.safe_load(store.config_path.read_text()) + except (OSError, yaml.YAMLError): + return McpConfig() + if not isinstance(loaded, dict): + return McpConfig() + raw = loaded.get("mcp") + if not isinstance(raw, dict): + return McpConfig() + publish = raw.get("publish_skills", True) + if not isinstance(publish, bool): + publish = bool(publish) + return McpConfig(publish_skills=publish) diff --git a/src/vouch/models.py b/src/vouch/models.py index 23f94aa4..e4449ba4 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -456,3 +456,7 @@ 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 transport knobs surfaced to clients (issue #235)", + ) diff --git a/src/vouch/server.py b/src/vouch/server.py index 6443b975..9ac76c76 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -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 @@ -42,6 +43,7 @@ reject_auto_extracted, ) from .scoping import filter_hits, scoped_fetch_limit, viewer_from +from .skills.errors import SkillsAccessDenied from .stats import collect_stats from .storage import ( ArtifactNotFoundError, @@ -73,7 +75,7 @@ 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") + return build_caps(_store()).model_dump(mode="json") @mcp.tool() @@ -857,6 +859,37 @@ def kb_provenance_rebuild() -> dict[str, Any]: return {"edges": prov.rebuild_prov_edges(_store())} +# === skills (catalogue gated by mcp.publish_skills) ======================= + + +@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. ``/.claude/skills//SKILL.md`` — project-local skills + 2. ``/.claude/commands/.md`` — project-local commands + 3. ``~/.claude/skills//SKILL.md`` — user-global skills + 4. ``~/.claude/commands/.md`` — user-global commands + + Project entries override user ones with the same name. Returns + ``[{name, description, scope, kind, path}]``. When ``mcp.publish_skills`` + is false in config.yaml the catalogue is empty. + """ + 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.""" + try: + return skills_mod.get_skill(_store(), name) + except SkillsAccessDenied as e: + raise PermissionError(str(e)) from e + except KeyError as e: + raise ValueError(str(e)) from e + + def _current_model_name() -> str: try: from .embeddings import get_embedder diff --git a/src/vouch/skills/__init__.py b/src/vouch/skills/__init__.py new file mode 100644 index 00000000..e55a81d5 --- /dev/null +++ b/src/vouch/skills/__init__.py @@ -0,0 +1,20 @@ +"""Skill / slash-command discovery for agents. + +Lets an MCP-connected agent introspect installed skills and slash commands +without reading the filesystem. Scanned locations, in priority order (later +wins on name collision): + +1. ``/.claude/skills//SKILL.md`` — project-local skills +2. ``/.claude/commands/.md`` — project-local commands +3. ``~/.claude/skills//SKILL.md`` — user-global skills +4. ``~/.claude/commands/.md`` — user-global commands + +Project-local entries override user-global ones with the same name. +""" + +from __future__ import annotations + +from .access import get_skill, list_skills +from .errors import SkillsAccessDenied + +__all__ = ["SkillsAccessDenied", "get_skill", "list_skills"] diff --git a/src/vouch/skills/access.py b/src/vouch/skills/access.py new file mode 100644 index 00000000..9a23617d --- /dev/null +++ b/src/vouch/skills/access.py @@ -0,0 +1,29 @@ +"""Publish gate for skill catalogue endpoints (``mcp.publish_skills``).""" + +from __future__ import annotations + +from typing import Any + +from ..mcp_config import load_config +from ..storage import KBStore +from .catalogue import get_catalogue_entry, list_catalogue +from .errors import SkillsAccessDenied + +_GATE_MESSAGE = ( + "skill catalogue is not published " + "(set mcp.publish_skills: true in config.yaml to enable)" +) + + +def list_skills(store: KBStore) -> list[dict[str, Any]]: + """Return the skill catalogue, or ``[]`` when publishing is disabled.""" + if not load_config(store).publish_skills: + return [] + return list_catalogue(store) + + +def get_skill(store: KBStore, name: str) -> dict[str, Any]: + """Return one skill body, or raise when publishing is disabled.""" + if not load_config(store).publish_skills: + raise SkillsAccessDenied(_GATE_MESSAGE) + return get_catalogue_entry(store, name) diff --git a/src/vouch/skills/catalogue.py b/src/vouch/skills/catalogue.py new file mode 100644 index 00000000..b811d3e4 --- /dev/null +++ b/src/vouch/skills/catalogue.py @@ -0,0 +1,114 @@ +"""Filesystem scan for Claude Code skills and slash commands.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..storage import KBStore +from .frontmatter import derive_description, parse_frontmatter + + +@dataclass(frozen=True) +class SkillRecord: + name: str + description: str + scope: str # "project" | "user" + kind: str # "skill" | "command" + path: str # absolute path on disk + + +def _read_skill_file( + path: Path, *, scope: str, kind: str, fallback_name: str, +) -> SkillRecord | None: + """Parse one skill file. Returns None if the file is unreadable.""" + try: + text = path.read_text(encoding="utf-8") + except OSError: + return None + meta, body = parse_frontmatter(text) + name = meta.get("name") if isinstance(meta.get("name"), str) else None + name = (name or fallback_name).strip() + description = derive_description(meta, body) + return SkillRecord( + name=name, + description=description, + scope=scope, + kind=kind, + path=str(path), + ) + + +def _scan_dir(base: Path, *, scope: str) -> list[SkillRecord]: + """Walk one catalogue root and yield records.""" + records: list[SkillRecord] = [] + skills_root = base / "skills" + if skills_root.is_dir(): + for sub in sorted(skills_root.iterdir()): + skill_md = sub / "SKILL.md" + if skill_md.is_file(): + rec = _read_skill_file( + skill_md, scope=scope, kind="skill", fallback_name=sub.name, + ) + if rec is not None: + records.append(rec) + + commands_root = base / "commands" + if commands_root.is_dir(): + for md in sorted(commands_root.glob("*.md")): + rec = _read_skill_file( + md, scope=scope, kind="command", fallback_name=md.stem, + ) + if rec is not None: + records.append(rec) + + return records + + +def build_catalogue(store: KBStore) -> dict[str, SkillRecord]: + """Build the merged catalogue. Project-local entries override user ones.""" + user_base = Path.home() / ".claude" + project_base = store.root / ".claude" + + merged: dict[str, SkillRecord] = {} + for rec in _scan_dir(user_base, scope="user"): + merged[rec.name] = rec + for rec in _scan_dir(project_base, scope="project"): + merged[rec.name] = rec + return merged + + +def list_catalogue(store: KBStore) -> list[dict[str, Any]]: + """Catalogue every discoverable skill / slash command.""" + cat = build_catalogue(store) + return [ + { + "name": r.name, + "description": r.description, + "scope": r.scope, + "kind": r.kind, + "path": r.path, + } + for r in sorted(cat.values(), key=lambda r: r.name) + ] + + +def get_catalogue_entry(store: KBStore, name: str) -> dict[str, Any]: + """Return the full markdown body of a named skill.""" + cat = build_catalogue(store) + rec = cat.get(name) + if rec is None: + raise KeyError(f"unknown skill: {name!r}") + try: + body = Path(rec.path).read_text(encoding="utf-8") + except OSError as e: + raise KeyError(f"could not read skill {name!r}: {e}") from e + return { + "name": rec.name, + "description": rec.description, + "scope": rec.scope, + "kind": rec.kind, + "path": rec.path, + "body": body, + } diff --git a/src/vouch/skills/errors.py b/src/vouch/skills/errors.py new file mode 100644 index 00000000..63f2bd6e --- /dev/null +++ b/src/vouch/skills/errors.py @@ -0,0 +1,7 @@ +"""Skill discovery errors.""" + +from __future__ import annotations + + +class SkillsAccessDenied(PermissionError): + """Raised when ``mcp.publish_skills`` is false.""" diff --git a/src/vouch/skills/frontmatter.py b/src/vouch/skills/frontmatter.py new file mode 100644 index 00000000..eb282a38 --- /dev/null +++ b/src/vouch/skills/frontmatter.py @@ -0,0 +1,44 @@ +"""YAML frontmatter and description extraction for SKILL.md files.""" + +from __future__ import annotations + +import re +from typing import Any + +import yaml + +_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.DOTALL) +_FIRST_HEADING_RE = re.compile(r"^#+\s+(.*?)\s*$", re.MULTILINE) +DESCRIPTION_PREVIEW_CHARS = 280 + + +def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]: + """Split a markdown file into (frontmatter_dict, body).""" + m = _FRONTMATTER_RE.match(text) + if not m: + return {}, text + try: + meta = yaml.safe_load(m.group(1)) or {} + if not isinstance(meta, dict): + return {}, text + except yaml.YAMLError: + return {}, text + return meta, m.group(2) + + +def derive_description(meta: dict[str, Any], body: str) -> str: + """Prefer explicit ``description:`` frontmatter; fall back to the first + paragraph of body text after the first heading.""" + desc = meta.get("description") + if isinstance(desc, str) and desc.strip(): + flat = " ".join(desc.strip().split()) + return flat[:DESCRIPTION_PREVIEW_CHARS] + + stripped = _FIRST_HEADING_RE.sub("", body, count=1).strip() + paragraphs = [p.strip() for p in stripped.split("\n\n") if p.strip()] + if not paragraphs: + return "" + flat = " ".join(paragraphs[0].split()) + if len(flat) <= DESCRIPTION_PREVIEW_CHARS: + return flat + return flat[: DESCRIPTION_PREVIEW_CHARS - 1] + "…" diff --git a/templates/config.template.yaml b/templates/config.template.yaml index 1f9968d7..a4931412 100644 --- a/templates/config.template.yaml +++ b/templates/config.template.yaml @@ -29,6 +29,12 @@ review: # poll_interval_seconds: 2 # background watch interval # max_per_session: 50 +# MCP transport knobs (#235). When publish_skills is false, kb.list_skills +# returns [] and kb.get_skill returns permission_denied — useful for a +# company-brain KB where the catalogue itself is sensitive. +# mcp: +# publish_skills: true # default; flip to false for company-brain mode + # Optional per-agent identity overrides. Keyed by VOUCH_AGENT value. agents: claude-code: diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py new file mode 100644 index 00000000..18221dae --- /dev/null +++ b/tests/test_mcp_config.py @@ -0,0 +1,43 @@ +"""``mcp:`` config block parsing.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from vouch.mcp_config import McpConfig, load_config +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path / "kb") + + +def test_load_config_missing_mcp_block_defaults_true(store: KBStore) -> None: + cfg = load_config(store) + assert cfg == McpConfig(publish_skills=True) + + +def test_load_config_publish_skills_false(store: KBStore) -> None: + cfg_path = store.kb_dir / "config.yaml" + raw = yaml.safe_load(cfg_path.read_text()) + raw["mcp"] = {"publish_skills": False} + cfg_path.write_text(yaml.safe_dump(raw, sort_keys=False)) + + assert load_config(store).publish_skills is False + + +def test_load_config_publish_skills_true_explicit(store: KBStore) -> None: + cfg_path = store.kb_dir / "config.yaml" + raw = yaml.safe_load(cfg_path.read_text()) + raw["mcp"] = {"publish_skills": True} + cfg_path.write_text(yaml.safe_dump(raw, sort_keys=False)) + + assert load_config(store).publish_skills is True + + +def test_to_capabilities_dict(store: KBStore) -> None: + assert load_config(store).to_capabilities_dict() == {"publish_skills": True} diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 00000000..c5bbaaf8 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,242 @@ +"""kb.list_skills / kb.get_skill — Claude Code skill discovery over MCP.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from vouch import skills as skills_mod +from vouch.mcp_config import load_config +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + return KBStore.init(tmp_path / "kb") + + +def _write_skill(base: Path, name: str, *, body: str, frontmatter: bool = True) -> Path: + skill_dir = base / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + path = skill_dir / "SKILL.md" + text = body if not frontmatter else ( + f"---\nname: {name}\ndescription: this is the {name} skill\n---\n\n{body}" + ) + path.write_text(text, encoding="utf-8") + return path + + +def _write_command(base: Path, name: str, *, body: str) -> Path: + cmd_dir = base / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + path = cmd_dir / f"{name}.md" + path.write_text(body, encoding="utf-8") + return path + + +def _set_publish_skills(store: KBStore, value: bool) -> None: + cfg_path = store.kb_dir / "config.yaml" + cfg = yaml.safe_load(cfg_path.read_text()) + cfg["mcp"] = {"publish_skills": value} + cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8") + + +def test_list_skills_scans_project_local(store: KBStore) -> None: + _write_skill(store.root / ".claude", "vouch-recall", body="# vouch-recall\n\nCall kb.recall.") + rows = skills_mod.list_skills(store) + names = [r["name"] for r in rows] + assert "vouch-recall" in names + rec = next(r for r in rows if r["name"] == "vouch-recall") + assert rec["scope"] == "project" + assert rec["kind"] == "skill" + assert rec["description"] == "this is the vouch-recall skill" + + +def test_list_skills_scans_user_global(store: KBStore) -> None: + home = Path.home() + _write_skill(home / ".claude", "global-skill", body="# global-skill\n\nbody") + rows = skills_mod.list_skills(store) + rec = next(r for r in rows if r["name"] == "global-skill") + assert rec["scope"] == "user" + + +def test_project_overrides_user_on_collision(store: KBStore) -> None: + home = Path.home() + _write_skill(home / ".claude", "shared-name", body="# user version") + _write_skill(store.root / ".claude", "shared-name", body="# project version") + rows = skills_mod.list_skills(store) + rec = next(r for r in rows if r["name"] == "shared-name") + assert rec["scope"] == "project" + + +def test_list_includes_slash_commands(store: KBStore) -> None: + _write_command( + store.root / ".claude", "vouch-status", + body="# vouch-status\n\nShow status.\n\nMore details follow.", + ) + rows = skills_mod.list_skills(store) + rec = next(r for r in rows if r["name"] == "vouch-status") + assert rec["kind"] == "command" + assert "Show status" in rec["description"] + + +def test_get_skill_returns_full_body(store: KBStore) -> None: + body_text = ( + "---\nname: vouch-recall\ndescription: the recall skill\n---\n\n" + "# vouch-recall\n\nFull body of the skill.\n" + ) + skill_dir = store.root / ".claude" / "skills" / "vouch-recall" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(body_text, encoding="utf-8") + + result = skills_mod.get_skill(store, "vouch-recall") + assert result["name"] == "vouch-recall" + assert result["scope"] == "project" + assert "Full body of the skill" in result["body"] + + +def test_get_skill_unknown_raises_keyerror(store: KBStore) -> None: + with pytest.raises(KeyError): + skills_mod.get_skill(store, "no-such-skill") + + +def test_list_skills_empty_environment(store: KBStore) -> None: + assert skills_mod.list_skills(store) == [] + + +def test_unreadable_skill_does_not_break_listing(store: KBStore) -> None: + skill_dir = store.root / ".claude" / "skills" / "good" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: good\n---\n\nbody", encoding="utf-8") + (store.root / ".claude" / "skills" / "no-skill-md").mkdir() + + rows = skills_mod.list_skills(store) + names = [r["name"] for r in rows] + assert "good" in names + assert "no-skill-md" not in names + + +def test_description_from_frontmatter_takes_priority(store: KBStore) -> None: + body = ( + "---\nname: hi\ndescription: from frontmatter\n---\n\n# hi\n\nFrom body." + ) + skill_dir = store.root / ".claude" / "skills" / "hi" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(body, encoding="utf-8") + rows = skills_mod.list_skills(store) + rec = next(r for r in rows if r["name"] == "hi") + assert rec["description"] == "from frontmatter" + + +def test_description_falls_back_to_first_paragraph(store: KBStore) -> None: + body = "# header line\n\nThis is the first paragraph.\n\nSecond paragraph." + skill_dir = store.root / ".claude" / "skills" / "no-fm" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(body, encoding="utf-8") + rows = skills_mod.list_skills(store) + rec = next(r for r in rows if r["name"] == "no-fm") + assert rec["description"] == "This is the first paragraph." + + +# --- publish_skills gate (#235) ------------------------------------------- + + +def test_mcp_config_defaults_publish_skills_true(store: KBStore) -> None: + assert load_config(store).publish_skills is True + + +def test_list_skills_empty_when_publish_skills_false(store: KBStore) -> None: + _write_skill(store.root / ".claude", "hidden", body="# hidden") + _set_publish_skills(store, False) + assert skills_mod.list_skills(store) == [] + + +def test_get_skill_permission_denied_when_publish_skills_false(store: KBStore) -> None: + from vouch.skills.errors import SkillsAccessDenied + + _write_skill(store.root / ".claude", "hidden", body="# hidden") + _set_publish_skills(store, False) + with pytest.raises(SkillsAccessDenied): + skills_mod.get_skill(store, "hidden") + + +def test_flipping_publish_skills_takes_effect_immediately(store: KBStore) -> None: + _write_skill(store.root / ".claude", "toggle", body="# toggle") + assert len(skills_mod.list_skills(store)) == 1 + _set_publish_skills(store, False) + assert skills_mod.list_skills(store) == [] + _set_publish_skills(store, True) + assert len(skills_mod.list_skills(store)) == 1 + + +# --- jsonl wiring --------------------------------------------------------- + + +def test_jsonl_list_skills_handler( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_skill(store.root / ".claude", "wired", body="# wired") + monkeypatch.chdir(store.root) + from vouch import jsonl_server + + out = jsonl_server.handle_request( + {"id": "r1", "method": "kb.list_skills", "params": {}}, + ) + assert out["ok"] is True + assert any(r["name"] == "wired" for r in out["result"]) + + +def test_jsonl_get_skill_handler( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_skill(store.root / ".claude", "wired", body="# wired\n\nbody here") + monkeypatch.chdir(store.root) + from vouch import jsonl_server + + out = jsonl_server.handle_request( + {"id": "r2", "method": "kb.get_skill", "params": {"name": "wired"}}, + ) + assert out["ok"] is True + assert "body here" in out["result"]["body"] + + +def test_jsonl_get_skill_unknown_returns_clean_error( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + from vouch import jsonl_server + + out = jsonl_server.handle_request( + {"id": "r3", "method": "kb.get_skill", "params": {"name": "missing"}}, + ) + assert out["ok"] is False + assert "missing" in out["error"]["message"] + + +def test_jsonl_get_skill_permission_denied_when_gated( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_skill(store.root / ".claude", "secret", body="# secret") + _set_publish_skills(store, False) + monkeypatch.chdir(store.root) + from vouch import jsonl_server + + out = jsonl_server.handle_request( + {"id": "r4", "method": "kb.get_skill", "params": {"name": "secret"}}, + ) + assert out["ok"] is False + assert out["error"]["code"] == "permission_denied" + + +def test_capabilities_surfaces_publish_skills_flag(store: KBStore) -> None: + from vouch.capabilities import capabilities + + assert capabilities(store).mcp["publish_skills"] is True + _set_publish_skills(store, False) + assert capabilities(store).mcp["publish_skills"] is False