From 41aaee5d0a188ff446723ba7cdfb9e634efdcd39 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:18:04 +0900 Subject: [PATCH 1/2] fix(cli): install-mcp bootstraps the kb when .vouch/ is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a fresh project's `vouch install-mcp claude-code` used to exit 0 with "0 failed", create no .vouch/, and print no warning. every installed hook then no-oped silently forever (they are all wired `|| true`), the mcp server exited 2, and nothing anywhere told the user to run `vouch init` — the only diagnostic that exists (`vouch status`) is itself muffled by `|| true` in the sessionstart hook. reproduced on the released pypi 1.3.0. install-mcp now probes discover_root(target) before writing adapter files: no kb found means it runs the same bootstrap as `vouch init` (shared `_bootstrap_kb` helper, so the two paths cannot drift — same starter seed through the normal approved path, same index rebuild, same audit event), making install a one-command setup. `--no-init` opts out and a loud stderr warning then names the remedy. a kb discovered above the target is reported ("Using existing KB at …") instead of being shadowed by a second one. unknown hosts keep the clean adapter error and never plant a kb as a side effect. hardening from review: staging-dir hosts opt out via a new manifest key `kb_bootstrap: false` — claude-desktop sets it, because its target is a paste-ready staging dir and a kb planted at an arbitrary cwd (often $HOME for a user-global host) would become the ambient capture target for every child project via upward discovery. the preflight passes `respect_env=False` to discover_root so a shell-exported VOUCH_KB_PATH neither suppresses a needed bootstrap nor fakes a "no kb" state (it is surfaced as a stderr note instead), and a bootstrap failure exits as a clean click error naming --no-init rather than a traceback. docs follow the new story: the readme's step 1 is one command, the adapter readme states the manual path's `vouch init` prerequisite, install.sh's nudges and docs/INSTALL_FOR_AGENTS.md steps 2/4 teach the one-command flow, and the redundant review-ui blocks in the readme's step 4 are collapsed into one option list. --- CHANGELOG.md | 15 +++ README.md | 49 ++------- adapters/claude-code/README.md | 12 ++- adapters/claude-desktop/install.yaml | 5 + docs/INSTALL_FOR_AGENTS.md | 12 ++- install.sh | 8 +- src/vouch/cli.py | 92 +++++++++++++++-- src/vouch/install_adapter.py | 22 ++++ src/vouch/storage.py | 7 +- tests/test_install_adapter.py | 146 +++++++++++++++++++++++++++ 10 files changed, 306 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f55e02a..b5fd0595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch install-mcp ` now bootstraps the KB when no `.vouch/` is + discoverable at or above the target, making install a one-command + setup. previously it exited 0 with "0 failed" in a fresh project while + every installed hook silently no-oped forever (`|| true`) and the mcp + server exited 2 — with nothing ever telling the user to run + `vouch init`. opt out with `--no-init` (a loud stderr warning then + names the remedy); an ancestor KB is reported ("Using existing KB + at …"), never shadowed by a second one; unknown hosts still fail + cleanly without planting a KB. staging-dir hosts opt out via a new + `kb_bootstrap: false` manifest key (claude-desktop does — its target + is a paste-ready staging dir, and a KB planted at an arbitrary cwd + would ambiently capture every child project). the preflight ignores + a shell-exported `VOUCH_KB_PATH` (it answers "what does this tree + resolve to", and notes the override on stderr instead). `vouch init` + and the auto-init share one code path so they cannot drift. - cli mirrors for the last five `kb.*` methods that had none — `vouch propose-delete`, `vouch source list`, `vouch session list`, `vouch session transcript`, `vouch session summarize` — and the diff --git a/README.md b/README.md index a991b17c..dccfa314 100644 --- a/README.md +++ b/README.md @@ -116,15 +116,14 @@ make check # the CI gate: lint + type + test After exploring the demo above, set up vouch in your own project: -**1. Set up the KB and wire Claude Code** (one-time, per repo): +**1. Set up the KB and wire Claude Code** (one command, one-time, per repo): ```bash cd /path/to/your/project -vouch init # creates .vouch/ with starter config -vouch install-mcp claude-code # wires capture hooks into Claude Code +vouch install-mcp claude-code # creates .vouch/ (if missing) + wires Claude Code ``` -`install-mcp` writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and three hooks — `PostToolUse` capture, `SessionEnd` rollup, `SessionStart` recall. Restart Claude Code so they load. +`install-mcp` initialises the KB when no `.vouch/` is discoverable (pass `--no-init` to skip; `vouch init` still exists for KB-only setup), then writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and five hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `PostToolUse` capture, `Stop` answer capture, `SessionEnd` rollup. Restart Claude Code so they load. **2. Point `compile` at an LLM** — the only step that needs a model. In `.vouch/config.yaml`: @@ -145,43 +144,15 @@ compile: vouch review # walk pending proposals one at a time ``` -**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. You have four options: +**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. Your options: -- **No setup**: Use the Docker demo (recommended) -- **pip, no clone**: `pipx install 'vouch-kb[web]'` then `vouch console` — serves the same React console from the installed package (Python only, no Docker, no node), open http://localhost:5173 -- **Local development**: Clone the repo, run `make console`, open http://localhost:5173 -- **CLI-only**: Use `vouch review`, `vouch show `, `vouch approve ` commands instead +- **No setup**: the Docker demo above +- **pip, no clone**: `pipx install 'vouch-kb[web]'` then `vouch console` (Python only, no Docker, no node) — open http://localhost:5173 +- **Local development**: clone the repo, run `make console` +- **Lighter built-in queue**: `vouch review-ui` (also in the `[web]` extra) +- **CLI-only**: `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"` -**Point the webapp at your existing KB:** - -```bash -# Terminal 1: start the vouch server pointing at your .vouch/ -cd /path/to/your/project -vouch serve --transport http --port 8731 - -# Terminal 2: run the Docker UI pointing at that server -docker run --rm -p 127.0.0.1:5173:5173 \ - -e VOUCH_TARGET=http://host.docker.internal:8731 \ - ghcr.io/plind-junior/vouch-demo -# then open http://localhost:5173 -``` - -Or serve that same console with no Docker — `vouch console` in place of Terminal 2 (needs the `[web]` extra), then add the `:8731` backend in the connect dialog: - -```bash -vouch console # http://localhost:5173, proxying to the server above -``` - -Or to skip the browser entirely and use the CLI tools: - -```bash -vouch review # walk pending proposals -vouch show # inspect a claim or page -vouch approve # approve a proposal -vouch reject --reason "…" # reject with feedback -``` - -Both browser UIs ship with vouch under the `[web]` extra (`pipx install 'vouch-kb[web]'`): `vouch console` is the full React console shown in the video; `vouch review-ui` is a lighter built-in review queue. Or go piecemeal: `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"`. +To point any of them at an existing KB, start a backend in your project — `vouch serve --transport http --port 8731` — then connect the console to `:8731` (for the Docker demo, pass `-e VOUCH_TARGET=http://host.docker.internal:8731`). **5. Compile the wiki.** diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index 8739da73..0eaf76be 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -7,16 +7,22 @@ Wires `vouch serve` (MCP, stdio) into [Claude Code][cc]. ## 1. Install vouch ```bash -pip install -e '/path/to/vouch[dev]' -# or, once on PyPI: pipx install vouch +pipx install vouch-kb +# or, from a checkout: pip install -e '/path/to/vouch[dev]' ``` Make sure `vouch` is on the `PATH` Claude Code will see. +The one-command path — `vouch install-mcp claude-code` from your project +root — does everything below in one go, and initialises the `.vouch/` KB +first when the project doesn't have one yet (`vouch init` also does that +on its own; `--no-init` skips it). The rest of this file is the manual +equivalent. + ## 2. Drop the MCP server into your project Add `.mcp.json` at the root of your project (the same directory that -contains `.vouch/`): +contains `.vouch/` — created by `vouch init` if you're wiring by hand): ```json { diff --git a/adapters/claude-desktop/install.yaml b/adapters/claude-desktop/install.yaml index 4ef13769..55ebeb10 100644 --- a/adapters/claude-desktop/install.yaml +++ b/adapters/claude-desktop/install.yaml @@ -13,6 +13,11 @@ # would conflict with both). The README walks through the copy step. host: claude-desktop pretty: Claude Desktop +# The target is a review-and-copy staging dir, not project wiring: a KB +# auto-bootstrapped there would land at whatever cwd the user ran the +# command from (often $HOME for a user-global host) and, via upward +# discovery, shadow every project beneath it. +kb_bootstrap: false tiers: T1: - { src: claude_desktop_config.json, dst: claude-desktop/claude_desktop_config.json } diff --git a/docs/INSTALL_FOR_AGENTS.md b/docs/INSTALL_FOR_AGENTS.md index 3ccb2ff9..48d6be27 100644 --- a/docs/INSTALL_FOR_AGENTS.md +++ b/docs/INSTALL_FOR_AGENTS.md @@ -46,9 +46,14 @@ optional flags (all real — verified against `cli.py`): - `--tier T1|T2|T3|T4` — how much to install; tiers stack (default `T4`). - `--path ` or `--target ` — project root to write into (default `.`). +- `--no-init` — do not bootstrap a KB when `.vouch/` is missing. -re-runs are flat-noop: expect lines containing `written`, `appended`, `merged`, -or `skipped`. you may run this command unconditionally on every session start. +when no `.vouch/` is discoverable at or above the target, this command +bootstraps one first (same path as `vouch init`; staging-dir hosts like +`claude-desktop` are exempt) — expect a `No .vouch/ found — initialised KB +at …` line in that case. re-runs are flat-noop: expect lines containing +`written`, `appended`, `merged`, or `skipped`. you may run this command +unconditionally on every session start. **assert:** exits 0; no `error:` lines. @@ -72,7 +77,8 @@ see step 6.) ## 4. create or locate a kb -if `.vouch/` is missing: +step 2 already bootstraps the KB when it is missing, so normally there is +nothing to create here. if you wired with `--no-init` (or skipped step 2): ```bash vouch init diff --git a/install.sh b/install.sh index 7008455a..337051c4 100755 --- a/install.sh +++ b/install.sh @@ -279,11 +279,10 @@ claude_code_nudge() { if [ -d "$HOME/.claude" ] || has_cmd claude; then printf '\n' info "${C_BOLD}Claude Code detected.${C_RESET}" - info "Wire vouch into a project (one-time, per repo):" + info "Wire vouch into a project (one command, one-time, per repo):" printf '\n' printf ' %scd /path/to/your/project%s\n' "$C_BOLD" "$C_RESET" - printf ' %svouch init%s\n' "$C_BOLD" "$C_RESET" - printf ' %svouch install-mcp claude-code%s\n' "$C_BOLD" "$C_RESET" + printf ' %svouch install-mcp claude-code%s # creates .vouch/ if missing\n' "$C_BOLD" "$C_RESET" printf '\n' info "Then restart Claude Code — vouch's kb.* tools and slash commands" info "(/vouch-recall, /vouch-status, …) will be available." @@ -311,7 +310,8 @@ main() { printf '\n' info "${C_BOLD}Next:${C_RESET}" - info " ${C_BOLD}vouch init${C_RESET} # create a .vouch/ KB in your project" + info " ${C_BOLD}vouch install-mcp ${C_RESET} # wire a host (creates .vouch/ if missing)" + info " ${C_BOLD}vouch init${C_RESET} # KB-only setup, no host wiring" info " ${C_BOLD}vouch serve${C_RESET} # start the MCP server" info " ${C_BOLD}vouch --help${C_RESET} # the rest" claude_code_nudge diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5a8bd3ce..a4e66e75 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -55,6 +55,8 @@ from .onboarding import ( DEFAULT_TEMPLATE, TEMPLATES, + SeedResult, + StarterSeedResult, available_templates, seed_starter_kb, ) @@ -185,6 +187,25 @@ def cli() -> None: # --- bootstrap ------------------------------------------------------------ +def _bootstrap_kb( + root: Path, *, template: str = DEFAULT_TEMPLATE +) -> tuple[KBStore, StarterSeedResult, SeedResult | None]: + """Create + seed a KB at ``root`` — the single init path. + + Shared by `vouch init` and `vouch install-mcp`'s auto-init so the two + cannot drift: same starter seed, same index rebuild, same audit event. + """ + root.mkdir(parents=True, exist_ok=True) + store = KBStore.init(root) + seed = seed_starter_kb(store, approved_by=_whoami()) + template_result: SeedResult | None = None + if template != DEFAULT_TEMPLATE: + template_result = TEMPLATES[template](store, approved_by=_whoami()) + health.rebuild_index(store) + audit_mod.log_event(store.kb_dir, event="kb.init", actor=_whoami()) + return store, seed, template_result + + @cli.command() @click.option("--path", default=".", type=click.Path(file_okay=False), show_default=True) @click.option( @@ -196,15 +217,9 @@ def cli() -> None: ) def init(path: str, template: str) -> None: """Initialise a .vouch/ knowledge base at PATH.""" - root = Path(path).resolve() - root.mkdir(parents=True, exist_ok=True) - store = KBStore.init(root) - seed = seed_starter_kb(store, approved_by=_whoami()) - template_result = None - if template != DEFAULT_TEMPLATE: - template_result = TEMPLATES[template](store, approved_by=_whoami()) - health.rebuild_index(store) - audit_mod.log_event(store.kb_dir, event="kb.init", actor=_whoami()) + store, seed, template_result = _bootstrap_kb( + Path(path).resolve(), template=template + ) click.echo(f"Initialised KB at {store.kb_dir}") if seed.created_anything: click.echo(f"Seeded starter claim: {seed.claim_id}") @@ -3899,15 +3914,29 @@ def pr_cache_show(repo: str, state: str, limit: int, as_json: bool, cache_dir: s "T2 = +CLAUDE.md/AGENTS.md, T3 = +slash commands, " "T4 = +host hooks/settings. Tiers stack.", ) +@click.option( + "--init/--no-init", + "auto_init", + default=True, + show_default=True, + help="Initialise a .vouch/ KB at the target when none is discoverable, " + "so install-mcp is a one-command setup.", +) def install_mcp( - host: str | None, list_hosts: bool, path: str, target_alias: str | None, tier: str + host: str | None, + list_hosts: bool, + path: str, + target_alias: str | None, + tier: str, + auto_init: bool, ) -> None: """Install vouch into HOST (claude-code, cursor, …) idempotently. \b Examples: vouch install-mcp --list # show known hosts - vouch install-mcp claude-code # write T1..T4 into cwd + vouch install-mcp claude-code # one command: init .vouch/ if + # missing + write T1..T4 into cwd vouch install-mcp cursor --tier T2 # stop at AGENTS.md vouch install-mcp claude-desktop # drop a paste-ready config vouch install-mcp windsurf --path /abs/path/to/project @@ -3930,6 +3959,47 @@ def install_mcp( ) target = Path(target_alias or path).resolve() + if host in hosts and install_mod.wants_kb_bootstrap(host): + # A wired host without a KB is worse than a failed install: every + # installed hook is `|| true` (silent no-op forever) and `vouch serve` + # exits 2, with nothing left to tell the user why. Unknown hosts skip + # this so the AdapterError below stays the first and only error; + # staging-dir hosts (manifest `kb_bootstrap: false`) skip it because + # their target is not project wiring. The probe ignores VOUCH_KB_PATH: + # the question is what this tree resolves to, not this shell. + if os.environ.get("VOUCH_KB_PATH"): + click.echo( + "note: VOUCH_KB_PATH is set — it takes precedence over the " + "project KB wherever that variable is exported.", + err=True, + ) + try: + kb_root = discover_root(target, respect_env=False) + except KBNotFoundError: + if auto_init: + try: + store, seed, _tmpl = _bootstrap_kb(target) + except Exception as e: + # CLI boundary: a half-done setup must read as an error, + # not a traceback. + raise click.ClickException( + f"could not initialise a KB at {target}: {e} — fix the " + "cause and rerun, run `vouch init` yourself, or pass " + "--no-init" + ) from e + click.echo(f"No .vouch/ found — initialised KB at {store.kb_dir}") + if seed.created_anything: + click.echo(f"Seeded starter claim: {seed.claim_id}") + else: + click.echo( + f"warning: no .vouch/ at or above {target} — the installed " + "hooks and MCP server will do nothing until you run " + "`vouch init` there.", + err=True, + ) + else: + if kb_root != target: + click.echo(f"Using existing KB at {kb_root / '.vouch'}") try: result = install_mod.install(host, target=target, tier=tier) except install_mod.AdapterError as e: diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index e6998c98..86acb592 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -96,6 +96,10 @@ class _Manifest: tiers: dict[str, list[_FileEntry]] fence_begin: str = _DEFAULT_FENCE_BEGIN fence_end: str = _DEFAULT_FENCE_END + # False for hosts whose install target is a staging dir for user-global + # config (claude-desktop) rather than project wiring — a KB bootstrapped + # at the target would land wherever the user happened to be standing. + kb_bootstrap: bool = True def _load_manifest(host: str) -> _Manifest: @@ -188,15 +192,33 @@ def _flag(name: str, raw: Any = raw, tier_name: str = tier_name) -> bool: fence_begin = _DEFAULT_FENCE_BEGIN fence_end = _DEFAULT_FENCE_END + kb_bootstrap = data.get("kb_bootstrap", True) + if not isinstance(kb_bootstrap, bool): + raise AdapterError( + f"{host}: install.yaml `kb_bootstrap` must be a boolean, " + f"got {type(kb_bootstrap).__name__} ({kb_bootstrap!r})" + ) + return _Manifest( host=host, pretty=str(data.get("pretty") or host), tiers=parsed, fence_begin=fence_begin, fence_end=fence_end, + kb_bootstrap=kb_bootstrap, ) +def wants_kb_bootstrap(host: str) -> bool: + """Whether ``install-mcp`` may bootstrap a KB at this host's target. + + Manifest-declared (`kb_bootstrap`, default true) so the knowledge lives + with the adapter: false for staging-dir hosts like claude-desktop whose + real config is user-global and whose target is not project wiring. + """ + return _load_manifest(host).kb_bootstrap + + def available_adapters() -> list[str]: """Every directory under ``adapters/`` that has an ``install.yaml``.""" if not ADAPTERS_DIR.is_dir(): diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 1ec3085c..f3fb1552 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -143,15 +143,18 @@ def sha256_hex(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -def discover_root(start: Path | None = None) -> Path: +def discover_root(start: Path | None = None, *, respect_env: bool = True) -> Path: """Walk up from `start` looking for a `.vouch` directory. Mirrors how git locates its repo root. The walk can be skipped entirely by setting `VOUCH_KB_PATH=/abs/path/.vouch` (documented in `adapters/generic-mcp/README.md`) — useful when the host launches the server from a default cwd (e.g. Claude Desktop on macOS / Windows). + `respect_env=False` ignores that override and answers purely from the + filesystem walk — for callers asking "what would this tree resolve to", + not "what would this process resolve to". """ - forced = os.environ.get("VOUCH_KB_PATH") + forced = os.environ.get("VOUCH_KB_PATH") if respect_env else None if forced: kb = Path(forced).resolve() if not kb.is_dir(): diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index d6da4ebe..58987621 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -875,6 +875,152 @@ def test_cli_install_mcp_default_tier_is_t4(tmp_path: Path) -> None: assert (tmp_path / ".claude" / "settings.json").is_file() +# --- one-command bootstrap (auto-init) ------------------------------------- + + +def test_cli_install_mcp_bootstraps_kb_when_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # install-mcp in a fresh project used to exit 0 with no .vouch/ and no + # warning; every installed hook then no-oped forever ('|| true') and the + # MCP server exited 2. One command must yield a working setup. + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--path", str(tmp_path)] + ) + assert result.exit_code == 0, result.output + assert (tmp_path / ".vouch" / "config.yaml").is_file() + assert "initialised KB" in result.output + # The real init path ran, not a bare mkdir: starter claim seeded. + assert list((tmp_path / ".vouch" / "claims").glob("*.yaml")) + assert "Seeded starter claim" in result.output + + +def test_cli_install_mcp_no_init_warns_and_skips_kb( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--path", str(tmp_path), "--no-init"] + ) + assert result.exit_code == 0, result.output + assert not (tmp_path / ".vouch").exists() + # The warning must name the remedy, not fail silently like before. + assert "vouch init" in result.output + + +def test_cli_install_mcp_target_alias_also_bootstraps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--target", str(tmp_path)] + ) + assert result.exit_code == 0, result.output + assert (tmp_path / ".vouch" / "config.yaml").is_file() + + +def test_cli_install_mcp_staging_host_never_bootstraps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # claude-desktop's target is a paste-ready staging dir, not project + # wiring; run from $HOME it must not plant a $HOME/.vouch that upward + # discovery would treat as every child project's KB. + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-desktop", "--path", str(tmp_path)] + ) + assert result.exit_code == 0, result.output + assert not (tmp_path / ".vouch").exists() + assert "initialised KB" not in result.output + + +def test_cli_install_mcp_ignores_vouch_kb_path_for_bootstrap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # An exported VOUCH_KB_PATH is a per-process override, not an ancestor + # KB: a fresh target must still get its own KB, with a note about the + # override instead of a bogus "Using existing KB". + other = tmp_path / "other" + assert CliRunner().invoke(cli, ["init", "--path", str(other)]).exit_code == 0 + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("VOUCH_KB_PATH", str(other / ".vouch")) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--path", str(project)] + ) + assert result.exit_code == 0, result.output + assert (project / ".vouch" / "config.yaml").is_file() + assert "Using existing KB" not in result.output + assert "VOUCH_KB_PATH" in result.output + + +def test_cli_install_mcp_bootstrap_failure_is_clean_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + + def boom(root: Path, **kwargs: object) -> None: + raise OSError("disk full") + + monkeypatch.setattr("vouch.cli._bootstrap_kb", boom) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--path", str(tmp_path)] + ) + assert result.exit_code != 0 + assert "Traceback" not in result.output + assert "could not initialise" in result.output + assert "--no-init" in result.output + + +def test_cli_install_mcp_existing_kb_is_untouched( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + runner = CliRunner() + assert runner.invoke(cli, ["init", "--path", str(tmp_path)]).exit_code == 0 + before = sorted(p.name for p in (tmp_path / ".vouch" / "claims").glob("*")) + result = runner.invoke( + cli, ["install-mcp", "claude-code", "--path", str(tmp_path)] + ) + assert result.exit_code == 0, result.output + after = sorted(p.name for p in (tmp_path / ".vouch" / "claims").glob("*")) + assert before == after + assert "initialised KB" not in result.output + # KB at exactly the target: no note either, the setup is simply right. + assert "Using existing KB" not in result.output + + +def test_cli_install_mcp_ancestor_kb_is_noted_not_shadowed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A KB above the target (upward discovery) is what the hooks will use; + # surfacing it beats silently planting a second, shadowing KB. + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + runner = CliRunner() + assert runner.invoke(cli, ["init", "--path", str(tmp_path)]).exit_code == 0 + project = tmp_path / "nested" / "proj" + project.mkdir(parents=True) + result = runner.invoke( + cli, ["install-mcp", "claude-code", "--path", str(project)] + ) + assert result.exit_code == 0, result.output + assert not (project / ".vouch").exists() + assert "Using existing KB" in result.output + + +def test_cli_install_mcp_unknown_host_does_not_bootstrap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + result = CliRunner().invoke( + cli, ["install-mcp", "not-a-host", "--path", str(tmp_path)] + ) + assert result.exit_code != 0 + # A typo'd host must not plant a KB as a side effect. + assert not (tmp_path / ".vouch").exists() + + # --- packaging -------------------------------------------------------------- From f68c4787dfc2b33524845d4c4c2e7f3c6844db01 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:28:10 +0900 Subject: [PATCH 2/2] style(cli): lowercase new comments; qualify install.sh kb note coderabbit review on #516: the new source comments now follow the lowercase-prose convention, and install.sh's next-steps line says "project hosts create .vouch/ if missing" since staging-dir hosts (claude-desktop) opt out via kb_bootstrap: false. --- install.sh | 2 +- src/vouch/cli.py | 8 ++++---- src/vouch/install_adapter.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index 337051c4..5caec1d6 100755 --- a/install.sh +++ b/install.sh @@ -310,7 +310,7 @@ main() { printf '\n' info "${C_BOLD}Next:${C_RESET}" - info " ${C_BOLD}vouch install-mcp ${C_RESET} # wire a host (creates .vouch/ if missing)" + info " ${C_BOLD}vouch install-mcp ${C_RESET} # wire a host (project hosts create .vouch/ if missing)" info " ${C_BOLD}vouch init${C_RESET} # KB-only setup, no host wiring" info " ${C_BOLD}vouch serve${C_RESET} # start the MCP server" info " ${C_BOLD}vouch --help${C_RESET} # the rest" diff --git a/src/vouch/cli.py b/src/vouch/cli.py index a4e66e75..c06cf012 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3960,12 +3960,12 @@ def install_mcp( target = Path(target_alias or path).resolve() if host in hosts and install_mod.wants_kb_bootstrap(host): - # A wired host without a KB is worse than a failed install: every + # a wired host without a KB is worse than a failed install: every # installed hook is `|| true` (silent no-op forever) and `vouch serve` - # exits 2, with nothing left to tell the user why. Unknown hosts skip + # exits 2, with nothing left to tell the user why. unknown hosts skip # this so the AdapterError below stays the first and only error; # staging-dir hosts (manifest `kb_bootstrap: false`) skip it because - # their target is not project wiring. The probe ignores VOUCH_KB_PATH: + # their target is not project wiring. the probe ignores VOUCH_KB_PATH: # the question is what this tree resolves to, not this shell. if os.environ.get("VOUCH_KB_PATH"): click.echo( @@ -3980,7 +3980,7 @@ def install_mcp( try: store, seed, _tmpl = _bootstrap_kb(target) except Exception as e: - # CLI boundary: a half-done setup must read as an error, + # cli boundary: a half-done setup must read as an error, # not a traceback. raise click.ClickException( f"could not initialise a KB at {target}: {e} — fix the " diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index 86acb592..06d45529 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -96,7 +96,7 @@ class _Manifest: tiers: dict[str, list[_FileEntry]] fence_begin: str = _DEFAULT_FENCE_BEGIN fence_end: str = _DEFAULT_FENCE_END - # False for hosts whose install target is a staging dir for user-global + # false for hosts whose install target is a staging dir for user-global # config (claude-desktop) rather than project wiring — a KB bootstrapped # at the target would land wherever the user happened to be standing. kb_bootstrap: bool = True