diff --git a/CHANGELOG.md b/CHANGELOG.md index 75723db..364565d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/.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 ` — 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 diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 39872ad..23ef629 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -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 @@ -94,4 +96,5 @@ def capabilities() -> Capabilities: "config_path": "retrieval.scope", }, context_engines=[describe_engine()], + mcp={"publish_skills": publish_skills}, ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index ca465ca..f8733c4 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -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 @@ -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 ------------------------------------------------------ diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index ec738c6..cbb0552 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 @@ -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: @@ -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 @@ -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, } @@ -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, diff --git a/src/vouch/models.py b/src/vouch/models.py index 23f94aa..4cb7436 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -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." + ), + ) diff --git a/src/vouch/server.py b/src/vouch/server.py index 6443b97..c54f15b 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 @@ -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() @@ -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. ``/.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}]``. 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) ============================================ diff --git a/src/vouch/skills.py b/src/vouch/skills.py new file mode 100644 index 0000000..c259076 --- /dev/null +++ b/src/vouch/skills.py @@ -0,0 +1,239 @@ +"""Skill / slash-command discovery for agents. + +Lets an MCP-connected agent (Claude Code, Cursor, …) introspect what +skills and slash commands are available in the current environment +without having to read the filesystem itself. The agent calls +``kb.list_skills`` to see what's installed, then ``kb.get_skill`` to +pull the body of one it wants to run. + +Scanned locations, in priority order (later wins on name collision): + +1. ``/.claude/skills//SKILL.md`` — project-local skills +2. ``/.claude/commands/.md`` — project-local slash commands +3. ``~/.claude/skills//SKILL.md`` — user-global skills +4. ``~/.claude/commands/.md`` — user-global slash commands + +Project-local skills override user-global ones with the same name so a +KB can ship its own slash-command flavour that masks the user's default. + +A skill description is parsed from YAML frontmatter when present +(``description:`` field, gbrain-style), otherwise from the first +markdown paragraph after the first heading. Discovery silently skips +unreadable files — the catalogue is best-effort. + +Publishing the catalogue over MCP is gated by ``mcp.publish_skills`` in +``config.yaml`` (default ``true``). When flipped to ``false`` — +"company-brain" mode where the slash-command catalogue is itself +sensitive — ``list_skills`` returns an empty list and ``get_skill`` +raises :class:`SkillsDisabledError`, which the MCP / JSONL layer maps to +a ``permission_denied`` error. The flag is read fresh on every call, so +toggling it takes effect without restarting the server. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from .storage import KBStore + +# Matches a YAML frontmatter block at the very top of a file. +_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.DOTALL) +# After stripping frontmatter, find the first markdown heading. +_FIRST_HEADING_RE = re.compile(r"^#+\s+(.*?)\s*$", re.MULTILINE) +DESCRIPTION_PREVIEW_CHARS = 280 + + +class SkillsDisabledError(RuntimeError): + """Raised when the skill catalogue is requested while it's gated off. + + Surfaced when ``mcp.publish_skills`` is ``false`` in ``config.yaml``. + The JSONL / MCP layer translates this into a ``permission_denied`` + error rather than a generic failure. + """ + + +@dataclass(frozen=True) +class SkillRecord: + name: str + description: str + scope: str # "project" | "user" + kind: str # "skill" | "command" + path: str # absolute path on disk + + +def _load_cfg(store: KBStore) -> dict[str, Any]: + """Read ``config.yaml`` defensively — a missing or malformed file is + treated as an empty config (default-on behaviour).""" + try: + loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text()) + except Exception: + return {} + return loaded if isinstance(loaded, dict) else {} + + +def publish_skills_enabled(store: KBStore) -> bool: + """Whether the skill catalogue may be published over MCP. + + Controlled by ``mcp.publish_skills`` in ``config.yaml``. Defaults to + ``True`` so existing KBs with no ``mcp:`` block keep the catalogue. + Only an explicit ``false`` turns the gate off — any other value (or a + missing key) is treated as enabled. + """ + mcp = _load_cfg(store).get("mcp") + if not isinstance(mcp, dict): + return True + return mcp.get("publish_skills", True) is not False + + +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] + + # Drop the first heading line, then take the first non-empty paragraph. + 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] + "…" + + +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 of the four catalogue roots 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 _catalogue(store: KBStore) -> dict[str, SkillRecord]: + """Build the merged catalogue. Project-local entries override user ones.""" + # User-global first, then project — project overwrites on collision. + 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_skills(store: KBStore) -> list[dict[str, Any]]: + """Catalogue every discoverable skill / slash command. + + Returns one row per skill: ``{name, description, scope, kind, path}``. + Sorted by name for stable output. Returns an empty list when + ``mcp.publish_skills`` is ``false`` — the catalogue is hidden without + erroring so a polling client just sees "nothing installed". + """ + if not publish_skills_enabled(store): + return [] + cat = _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_skill(store: KBStore, name: str) -> dict[str, Any]: + """Return the full markdown body of a named skill. + + Raises :class:`SkillsDisabledError` when ``mcp.publish_skills`` is + ``false`` (mapped to ``permission_denied`` by the transport), and + ``KeyError`` if the name isn't in the catalogue (mapped to a clean + ``not_found`` error). + """ + if not publish_skills_enabled(store): + raise SkillsDisabledError( + "skill catalogue is disabled (mcp.publish_skills: false)" + ) + cat = _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/storage.py b/src/vouch/storage.py index af21656..949897a 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -92,6 +92,12 @@ def _starter_config() -> dict[str, Any]: "human review via vouch pending/show/approve", ], }, + "mcp": { + # Publish the slash-command / SKILL.md catalogue over MCP via + # kb.list_skills / kb.get_skill. Flip to false for "company-brain" + # mode where the catalogue itself is sensitive. + "publish_skills": True, + }, # Extra page kinds beyond the built-in PageType enum. Each maps a kind # name to {required_fields, frontmatter_schema, required_citations, # extends}. See `vouch schema list` / docs for the shape. (issue #234) diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..cfb7c60 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,306 @@ +"""kb.list_skills / kb.get_skill — Claude Code skill discovery over MCP, +plus the ``mcp.publish_skills`` gate (issue #235).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from vouch import capabilities as caps_mod +from vouch import skills as skills_mod +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + # Point HOME at a clean dir so the test never picks up the real user's + # ~/.claude/ skills and isn't polluted by it. + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + s = KBStore.init(tmp_path / "kb") + return s + + +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: object) -> None: + """Rewrite config.yaml's mcp.publish_skills flag (or remove the block).""" + cfg = yaml.safe_load(store.config_path.read_text()) or {} + if value is None: + cfg.pop("mcp", None) + else: + cfg.setdefault("mcp", {})["publish_skills"] = value + store.config_path.write_text(yaml.safe_dump(cfg, sort_keys=False)) + + +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, monkeypatch: pytest.MonkeyPatch, +) -> None: + home = Path(skills_mod.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, monkeypatch: pytest.MonkeyPatch, +) -> None: + home = Path(skills_mod.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" + # No frontmatter — description derived from first body paragraph after heading. + 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: + """No .claude/ dirs in either project or fake HOME — returns [].""" + assert skills_mod.list_skills(store) == [] + + +def test_unreadable_skill_does_not_break_listing(store: KBStore) -> None: + """Best-effort discovery — a broken file is skipped silently.""" + 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") + # A directory with no SKILL.md should be skipped, not raise. + (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 (issue #235) ------------------------------------- + + +def test_starter_config_defaults_publish_skills_on(store: KBStore) -> None: + """A freshly init'd KB ships mcp.publish_skills: true.""" + cfg = yaml.safe_load(store.config_path.read_text()) + assert cfg["mcp"]["publish_skills"] is True + assert skills_mod.publish_skills_enabled(store) is True + + +def test_missing_mcp_block_defaults_on(store: KBStore) -> None: + """An existing KB with no mcp: block sees the catalogue (default-on).""" + _set_publish_skills(store, None) + assert skills_mod.publish_skills_enabled(store) is True + _write_skill(store.root / ".claude", "visible", body="# visible") + assert any(r["name"] == "visible" for r in skills_mod.list_skills(store)) + + +def test_list_skills_hidden_when_disabled(store: KBStore) -> None: + _write_skill(store.root / ".claude", "secret", body="# secret") + # Visible by default... + assert any(r["name"] == "secret" for r in skills_mod.list_skills(store)) + # ...hidden once the gate is flipped, with no restart. + _set_publish_skills(store, False) + assert skills_mod.publish_skills_enabled(store) is False + assert skills_mod.list_skills(store) == [] + + +def test_get_skill_denied_when_disabled(store: KBStore) -> None: + _write_skill(store.root / ".claude", "secret", body="# secret\n\nbody") + _set_publish_skills(store, False) + with pytest.raises(skills_mod.SkillsDisabledError): + skills_mod.get_skill(store, "secret") + + +def test_toggle_takes_effect_without_restart(store: KBStore) -> None: + """The flag is read fresh on each call — same store object, no reload.""" + _write_skill(store.root / ".claude", "toggle-me", body="# toggle-me") + assert skills_mod.list_skills(store) # on + _set_publish_skills(store, False) + assert skills_mod.list_skills(store) == [] # off + _set_publish_skills(store, True) + assert skills_mod.list_skills(store) # on again + + +def test_capabilities_surfaces_publish_skills_flag() -> None: + caps_on = caps_mod.capabilities(publish_skills=True) + assert caps_on.mcp["publish_skills"] is True + caps_off = caps_mod.capabilities(publish_skills=False) + assert caps_off.mcp["publish_skills"] is False + # New methods are declared in the surface. + assert "kb.list_skills" in caps_on.methods + assert "kb.get_skill" in caps_on.methods + # Default is on so test_capabilities (no-arg call) stays green. + assert caps_mod.capabilities().mcp["publish_skills"] is True + + +# --- 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_list_skills_empty_when_disabled( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_skill(store.root / ".claude", "wired", body="# wired") + _set_publish_skills(store, False) + monkeypatch.chdir(store.root) + from vouch import jsonl_server + + out = jsonl_server.handle_request( + {"id": "r4", "method": "kb.list_skills", "params": {}}, + ) + assert out["ok"] is True + assert out["result"] == [] + + +def test_jsonl_get_skill_permission_denied_when_disabled( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_skill(store.root / ".claude", "wired", body="# wired\n\nbody") + _set_publish_skills(store, False) + monkeypatch.chdir(store.root) + from vouch import jsonl_server + + out = jsonl_server.handle_request( + {"id": "r5", "method": "kb.get_skill", "params": {"name": "wired"}}, + ) + assert out["ok"] is False + assert out["error"]["code"] == "permission_denied" + + +def test_jsonl_capabilities_reflects_gate( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + from vouch import jsonl_server + + out = jsonl_server.handle_request( + {"id": "c1", "method": "kb.capabilities", "params": {}}, + ) + assert out["result"]["mcp"]["publish_skills"] is True + _set_publish_skills(store, False) + out = jsonl_server.handle_request( + {"id": "c2", "method": "kb.capabilities", "params": {}}, + ) + assert out["result"]["mcp"]["publish_skills"] is False