\AppData\Local\mscodebase
++ project_chunks 78.0 MB 4 file(s) D:\work\repo\.mscodebase
+ ...
+
+index 7278 chunk(s) (D:\work\repo\.mscodebase\chunks.db)
+total 4.3 GB over 31385 file(s)
+```
+
+`+` — существует, `-` — нет. `index missing` значит, что текстовый индекс для
+текущего каталога ещё не построен (его создаст первый вызов
+`codebase(action="index")`), а не что поиск сломан. `--json` печатает те же данные
+для скриптов.
diff --git a/docs/ru/FAQ.md b/docs/ru/FAQ.md
index 6b56ff78..0122c7a4 100644
--- a/docs/ru/FAQ.md
+++ b/docs/ru/FAQ.md
@@ -123,7 +123,8 @@ project_root=".")` (полный профиль: `intel_trigger_reindex()`), и
Да. В дефолтном пути ничего не требует GPU, облачного API или интернета: FTS5,
AST-граф и разбор через tree-sitter — всё локальное. Сеть используется только для
-скачивания опциональных моделей (GGUF/ONNX через `install.py`) и опционального
+скачивания опциональных моделей (`install.py --with-models`, по умолчанию выключено:
+`models_enabled()` в `install.py`) и опционального
`tree-sitter-language-pack`, чьи парсеры подтягиваются при первом использовании
(`MSCODEBASE_LANGUAGE_PACK=true`). С выключенным флагом каждая грамматика берётся
из закреплённого wheel, и никаких скачиваний не происходит.
diff --git a/docs/ru/INSTALL.md b/docs/ru/INSTALL.md
index a9a63075..dfe1586a 100644
--- a/docs/ru/INSTALL.md
+++ b/docs/ru/INSTALL.md
@@ -1,6 +1,6 @@
# Установка MSCodebase Intelligence
-> Перевод: 2026-11-08, источник — docs/en/INSTALL.md. Английская версия — канон.
+> Перевод: 2026-11-12, источник — docs/en/INSTALL.md. Английская версия — канон.
@@ -8,9 +8,11 @@
[🇬🇧 English](../en/INSTALL.md) • [🇷🇺 Русский](INSTALL.md) • [🇨🇳 中文](../zh/INSTALL.md)
-> Переписано 2026-11-08. Сервер — локальный MCP-сервер. **OpenCode — основной
-> хост**; расширение Zed по-прежнему поддерживается. Загрузка моделей опциональна —
-> дефолтная установка text-first и не требует эмбеддингов, GPU или сети.
+> Переписано 2026-11-12 по `install.py` v3.2. Сервер — локальный MCP-сервер, а
+> установщик **нейтрален к хосту**: он создаёт обычное Python-окружение, и любой
+> MCP-хост его запускает. Zed — опциональный адаптер. Дефолтная установка
+> **text-first**: без эмбеддингов, GPU, сети и без загрузки моделей, если их не
+> попросили явно.
---
@@ -21,45 +23,57 @@
| ОС | Windows 10+ (проверенная платформа), Linux (CI), macOS |
| Python | **3.14+** (тестируется только 3.14) |
| RAM | 4 ГБ; 8 ГБ+ при включении локальных моделей |
-| Диск | ~500 МБ без моделей; до ~2 ГБ с моделями GGUF |
-| Хост | OpenCode (основной) или Zed, либо любой MCP-клиент |
+| Диск | ~1.5 ГБ без моделей (основное — venv); +~650 МБ с `--with-models` |
+| Хост | OpenCode, Zed, Claude Code, Cursor или любой MCP-клиент |
| Опционально | LM Studio / Ollama / llama.cpp для реранка и legacy dense-пути |
Docker и WSL на машине разработчика не нужны. Сборка контейнера — вариант
развёртывания (`deploy/docker/`).
-## 2. Полная установка
+## 2. Установка
```bash
cd
-python install.py # interactive: venv, pip, optional llama.cpp + models
+python install.py # venv + зависимости + код; text-first
+python install.py --with-models # то же + ONNX/GGUF модели (~657 МБ, сеть)
+python install.py --no-zed # вообще не трогать настройки Zed
+python install.py --yes # без вопросов: принимать каждый шаг
```
В Windows также есть `install.bat`; в Linux/macOS — `install.sh`. Установщик:
-1. проверяет Python и окружение;
-2. создаёт/обновляет виртуальное окружение и ставит зависимости;
-3. опционально скачивает llama.cpp и модели GGUF;
-4. копирует код в каталог установленного расширения;
-5. пишет `uninstall.bat`.
+1. останавливает старые процессы MCP/llama, чтобы работал именно новый код;
+2. создаёт/обновляет виртуальное окружение и ставит запиненные зависимости;
+3. копирует код в каталог установки (`--sync-only` пропускает 1–2 и делает только это);
+4. опционально (адаптер Zed) пишет запись `context_servers` в `settings.json`;
+5. пишет `uninstall.bat` / `uninstall.sh` в каталог установки.
-Без моделей сервер всё равно отвечает на вопросы о коде — поиск откатывается на
-SQLite FTS5/BM25 плюс граф.
+Каталог установки по умолчанию (имя историческое, к Zed относится только оно):
-## 3. Цикл обновления (часть, которую обычно путают)
+```
+Windows : %LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence
+macOS : ~/Library/Application Support/Zed/extensions/mscodebase-intelligence
+Linux : ~/.local/share/zed/extensions/mscodebase-intelligence
+```
-Установленная копия живёт вне репозитория:
+Перенос: `MSCODEBASE_INSTALL_ROOT=`; кэши/логи/артефакты — в
+`MSCODEBASE_DATA_DIR` (по умолчанию `%LOCALAPPDATA%\mscodebase`). Что куда пишется и
+сколько весит (с замерами): [DATA_MAP.md](DATA_MAP.md).
-```
-%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence
+Без установщика (любой менеджер, дающий console scripts, entry point
+`mscodebase-mcp = src.main:main`):
+
+```bash
+pipx install . # или: uv tool install .
```
-Имя каталога историческое; OpenCode запускает тот же venv
-(`…\venv\Scripts\mscodebase-mcp.exe -u -m src.main`). Поэтому:
+## 3. Цикл обновления (часть, которую обычно путают)
+
+Установленная копия живёт вне репозитория:
```bash
-python install.py --sync-only # copy code, drop __pycache__, no pip/models
-# then restart the host: the OpenCode session (or the Zed window)
+python install.py --sync-only # копирует код, чистит __pycache__, без pip/моделей
+# затем перезапуск хоста: сессия OpenCode, окно Zed, процесс CLI
```
`--sync-only` (алиас `--sync`) — быстрый путь после каждого изменения исходников.
@@ -68,50 +82,69 @@ python install.py --sync-only # copy code, drop __pycache__, no pip/models
## 4. Подключение хоста
-**OpenCode** — объявите локальный MCP-сервер, указывающий на установленный venv:
-
-```jsonc
-"mcp": {
- "mscodebase-intelligence": {
- "type": "local",
- "command": ["/venv/Scripts/mscodebase-mcp.exe", "-u", "-m", "src.main"],
- "environment": {
- "PYTHONPATH": "",
- "PROJECT_PATH": "${workspaceRoot}",
- "PYTHONUTF8": "1"
- }
- }
-}
-```
+Не пишите команду руками — напечатайте её и вставьте в конфиг хоста.
-**Zed** — расширение читает тот же каталог; дополнительной настройки сверх самого
-расширения не требуется.
+```bash
+python install.py --print-config --client generic # хосты формы mcpServers
+python install.py --print-config --client opencode
+python install.py --print-config --client zed
+python install.py --print-config --client claude-code
+python install.py --print-config --client cursor
+```
-**Claude Code / VS Code / Cursor** — скопируйте форму из `adapters/clients/` и
-подставьте путь к venv, путь к репозиторию и путь к проекту. Полная матрица —
-в [CLIENTS.md](CLIENTS.md).
+Имена файлов, готовый JSON для копипаста и проверка запуска
+(`python scripts/verify_client_snippets.py`) — в [CLIENTS.md](CLIENTS.md).
## 5. Проверка
Из панели агента, в этом порядке:
```
-tool_help() # profile + visible/hidden tools
-get_health_report() # index, providers, resources
+tool_help() # профиль + видимые/скрытые инструменты
+get_health_report() # индекс, провайдеры, ресурсы
codebase(action="index", path="status") # chunks > 0?
```
Если текстовый индекс отсутствует или устарел:
```bash
-python scripts/reindex_text_only.py --project . # chunks + graph, no embeddings
-python scripts/rebuild_graph.py # graph only
+python scripts/reindex_text_only.py --project . # чанки + граф, без эмбеддингов
+python scripts/rebuild_graph.py # только граф
```
Затем перезапустите сервер, чтобы новый процесс открыл пересобранные хранилища.
Замеренные числа для этого репозитория — в [FACTS.md](../FACTS.md).
-## 6. Опциональные провайдеры
+## 6. Что на диске и как это убрать
+
+```bash
+python install.py --doctor # версии, пути, размеры, состояние индекса (--json)
+python install.py --clean --dry-run # каждый артефакт с размером; ничего не удаляет
+python install.py --clean # спрашивает подтверждение, затем удаляет
+python install.py --clean --yes # без вопросов (скрипты)
+```
+
+`--clean` удаляет каталог установки (код + venv + модели + деинсталлятор), data root
+(`projects/`, `logs/`, `models/`, `repos/`) и в текущем проекте `.mscodebase`,
+`.codebase_indices`, `.codebase`, `.codebase_models`. Он отказывается удалять путь,
+который является исходным чекаутом, корнем файловой системы или домашним каталогом,
+и сообщает об остатке (заблокированные файлы работающим хостом) с ненулевым кодом
+возврата. Политики кэшей (ротация логов, чистка 7/30/90 дней, LRU(5)+24 ч для клонов)
+— в [DATA_MAP.md](DATA_MAP.md).
+
+## 7. Удаление
+
+```bash
+python install.py --clean --yes # все слои, проверено: 0 байт остатка
+./uninstall.bat # Windows: генерируется шагом step_uninst
+```
+
+Сгенерированный деинсталлятор убивает процессы сервера и удаляет только каталог
+установки. `--clean` — полная версия. Ни то, ни другое не трогает конфиг хоста:
+удалите запись `mscodebase` из `settings.json` (Zed), `opencode.json` (OpenCode) или
+`.mcp.json` / `.cursor/mcp.json` сами.
+
+## 8. Опциональные провайдеры
| Провайдер | Зачем | Где |
|---|---|---|
@@ -123,18 +156,7 @@ python scripts/rebuild_graph.py # graph only
Ни один из них не нужен для дефолтного text-first ответа. Настройки провайдеров:
[CONFIGURATION.md](CONFIGURATION.md).
-## 7. Удаление
-
-```bash
-./uninstall.bat # Windows
-python install.py --uninstall
-```
-
-Это удаляет установленную копию расширения. Данные проекта в
-`%LOCALAPPDATA%/mscodebase/` и `.mscodebase/chunks.db` проекта — отдельно;
-удалите их вручную, если нужен чистый лист.
-
-## 8. Диагностика проблем
+## 9. Диагностика проблем
| Симптом | Вероятная причина |
|---|---|
@@ -143,3 +165,4 @@ python install.py --uninstall
| Новый код не даёт эффекта | нет `--sync-only` и/или перезапуска хоста |
| Сервер стартует, инструменты уходят в таймаут | завис провайдер или LSP — см. [GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md) |
| Мигают консольные окна в Windows | подпроцесс без `CREATE_NO_WINDOW` — см. [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md) |
+| `--clean` оставил файлы | их держит запущенный хост (блокировки) — остановите хост и повторите |
diff --git a/docs/ru/INSTALL_MODELS.md b/docs/ru/INSTALL_MODELS.md
index c6d37d1c..ae7742d8 100644
--- a/docs/ru/INSTALL_MODELS.md
+++ b/docs/ru/INSTALL_MODELS.md
@@ -33,15 +33,17 @@ SHA-256 записаны для части записей (qwen3-embedding, bge-
справочное значение, а не проверенный пин. (Архив **бинарника** llama.cpp,
наоборот, проверяется по хэшу перед распаковкой.)
-## 2. Способ 1 — `install.py` (интерактивный)
+## 2. Способ 1 — `install.py` (модели по явному запросу)
```bash
-python install.py
+python install.py --with-models
```
-12 шагов. Сетевые шаги спрашивают перед действием:
+Без `--with-models` установщик text-first: `step_models` и `step_gguf` сразу
+возвращаются (`install.py::models_enabled`), поэтому не скачивается ни одна модель,
+не создаётся модельный каталог и не задаётся ни одного вопроса. С `--with-models`:
-- ONNX-модели: *«Download ONNX models (e5-small-int8 ~113 MB + reranker ~544 MB)? (Y/n)»*;
+- ONNX: `multilingual-e5-small-int8` (~113 МБ) + `bge-reranker-v2-m3` (~544 МБ);
- GGUF + llama.cpp: шаг GGUF скачивает `multilingual-e5-small` и
`bge-reranker-v2-m3`, а также бинарник llama.cpp для выбранного бэкенда;
- каждый шаг при сбое предлагает `[r]etry / [s]kip / [c]ancel`.
@@ -50,13 +52,15 @@ python install.py
| Флаг | Эффект |
|---|---|
-| `--sync-only` / `--sync` | скопировать код в каталог расширения, больше ничего не трогать (используйте после правки `src/`) |
-| `--skip-models` / `--no-models` | полностью пропустить шаги с моделями |
-| `--yes` / `-y` | отвечать «да» вместо вопроса |
+| `--sync-only` / `--sync` | скопировать код в каталог установки, больше ничего не трогать (после правки `src/`) |
+| `--with-models` | выполнить шаги с моделями (ONNX + GGUF); единственный способ их получить |
+| `--skip-models` / `--no-models` | принимаются для старых скриптов; по умолчанию модели и так пропускаются |
+| `--no-zed` | не трогать настройки Zed (нейтральная установка) |
+| `--yes` / `-y` | принимать каждый шаг вместо вопросов |
| `--verbose` / `-v` | полный вывод шагов вместо компактного вида |
-Установка целенаправленно пишет в каталог расширения
-`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`:
+Файлы попадают в каталог установки (`MSCODEBASE_INSTALL_ROOT`, по умолчанию
+исторический `%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`):
- ONNX → `/.codebase_models/onnx//model_quantized.onnx`
(его специализированная подпапка реранкера использует `model.onnx`);
diff --git a/docs/ru/README.md b/docs/ru/README.md
index c257ab84..5417db74 100644
--- a/docs/ru/README.md
+++ b/docs/ru/README.md
@@ -27,7 +27,7 @@
## Что работает сегодня
-| Область | Что делает | Инструмент |
+| Область | Что делает | Точка входа (entry point) |
|---|---|---|
| **Один вызов на вопрос** | Вопрос → параллельно поиск + символы + grep, затем impact + вызывающие + тесты + память проекта; слияние по file:line с провенансом и `degraded[]` | `investigate` |
| Поиск по коду | BM25/FTS5 по чанкам; короткое замыкание по графу для идентификаторов; опциональный rerank верхних кандидатов | `search_code` |
@@ -86,17 +86,36 @@ investigate("как индексатор решает, что файл надо
## Быстрый старт
-Требования: Windows — проверенная платформа; Python 3.14+; MCP-хост (**основной — OpenCode**, расширение Zed тоже работает).
+Требования: Windows — проверенная платформа; Python 3.14+; любой MCP-хост
+(OpenCode, Zed, Claude Code, Cursor или универсальный `mcpServers`-клиент).
```bash
cd
-python install.py # полная установка: venv, pip, опциональные модели
-python install.py --sync # только код — в установленную копию
+python install.py # venv + зависимости + код: text-first, без моделей
+python install.py --with-models # опционально: + ONNX/GGUF модели (~657 МБ)
+python install.py --sync # после правки кода — только копирование кода
```
-После `install.py` перезапустить хост: сессию OpenCode или окно Zed. Установленная
-копия лежит в `%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`, и оба хоста
-запускают именно её venv. Проверка из панели агента:
+Установка нейтральна к хосту: на диск кладётся обычное Python-окружение (по умолчанию
+`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence` — имя историческое). Другой
+путь задаётся `MSCODEBASE_INSTALL_ROOT`. Затем напечатайте конфиг для своего хоста
+вместо того, чтобы писать его руками:
+
+```bash
+python install.py --print-config --client generic # хосты формы mcpServers
+python install.py --print-config --client opencode
+python install.py --print-config --client claude-code
+python install.py --print-config --client cursor
+python install.py --print-config --client zed
+```
+
+Каждая команда печатает один JSON-объект: интерпретатор, `-u -m src.main`,
+`PYTHONPATH`, `PROJECT_PATH`, `PYTHONUTF8=1`; вставьте его в MCP-конфиг хоста.
+Секции для каждого клиента и проверка запуска
+(`python scripts/verify_client_snippets.py`) — в [docs/ru/CLIENTS.md](CLIENTS.md).
+После настройки перезапустите хост.
+
+Проверка из панели агента:
```text
tool_help() # профиль, видимые и скрытые инструменты
@@ -244,7 +263,8 @@ python scripts/smoke_e2e.py --project .
| [docs/FACTS.md](../FACTS.md) | Все публичные числа и команды перевывода |
| [docs/ru/ARCHITECTURE.md](ARCHITECTURE.md) | Слои, DI, границы модулей, профиль инструментов |
| [docs/ru/SEARCH_PIPELINE.md](SEARCH_PIPELINE.md) | Retrieval, ранжирование, docs-роутинг, fan-out `investigate` |
-| [docs/ru/INSTALL.md](INSTALL.md) | Установка, цикл sync+restart, диагностика |
+| [docs/ru/INSTALL.md](INSTALL.md) | Установка, цикл sync+restart, doctor/clean, диагностика |
+| [docs/ru/DATA_MAP.md](DATA_MAP.md) | Каждый путь, созданный установщиком/сервером: размеры и политики хранения |
| [docs/ru/CONFIGURATION.md](CONFIGURATION.md) | Все переменные окружения и их значения по умолчанию |
| [docs/ru/CLIENTS.md](CLIENTS.md) | OpenCode, Zed, Claude Code, VS Code, CLI, Docker |
| [docs/ru/HOOKS_AND_GATES.md](HOOKS_AND_GATES.md) | Хук OpenCode, 12 гейтов pre-commit, джобы CI |
diff --git a/docs/ru/SEARCH_PIPELINE.md b/docs/ru/SEARCH_PIPELINE.md
index ffdbdda9..83c5e912 100644
--- a/docs/ru/SEARCH_PIPELINE.md
+++ b/docs/ru/SEARCH_PIPELINE.md
@@ -187,3 +187,15 @@ hints=["get_progress_file"])` использует их в символьном
переставило?
4. `investigate(...)` → какие каналы нашли файл (`[search]`, `[grep]`)?
5. Проверьте хвост лога: `%LOCALAPPDATA%/mscodebase/logs/mcp_global.log`.
+
+## 9. Ссылки на код (аудит 2026-11-12)
+
+| Утверждение на странице | Код |
+|---|---|
+| запрос длиннее 512 символов отклоняется | `src/mcp/tools/search_tools.py:288` |
+| веса каналов `investigate` (search 0.6, grep 0.3, tests 0.1, memory 0.4) | `src/mcp/tools/investigate_tool.py:44`, `:1025`, `:1041` |
+| потолок вывода: 8 строк, 180 символов | `src/mcp/tools/investigate_tool.py:681`, `:1191` |
+| margin документов, дефолт 4.0 | `src/config/settings.py::SearchConfig.docs_margin` (строка 268) |
+| константы BM25 (`K1` 1.2, `B` 0.45) | `src/config/settings.py` строки 219, 222 |
+| request guard и генерация кандидатов FTS5 | `src/core/search/engine.py` |
+
diff --git a/docs/ru/SYSTEM_REQUIREMENTS.md b/docs/ru/SYSTEM_REQUIREMENTS.md
index 81f41b5b..3efdc6c3 100644
--- a/docs/ru/SYSTEM_REQUIREMENTS.md
+++ b/docs/ru/SYSTEM_REQUIREMENTS.md
@@ -61,9 +61,9 @@ OpenCode запускает MCP-сервер напрямую:
Практическое правило: текстовый индекс пропорционален индексируемому корпусу и
примерно на порядок меньше векторного индекса по тому же корпусу. Опциональные
каталоги моделей доминируют, когда они есть, и отсутствуют в остальных случаях.
-`install.py` скачивает модели только по запросу (`--skip-models` или ответ «нет»
-на его вопрос), поэтому установка только с текстом занимает размер кода плюс
-хранилище чанков.
+`install.py` скачивает модели только с `--with-models` (`models_enabled()` в
+`install.py`); дефолт — text-first без вопросов и без загрузки, поэтому установка
+только с текстом занимает размер кода плюс хранилище чанков.
`artifact_paths.check_disk_space()` предупреждает, когда на data root остаётся
меньше **500 MB** (`MIN_FREE_DISK_MB`); гейт в CI падает ниже 1 GB.
diff --git a/docs/zh/ARCHITECTURE.md b/docs/zh/ARCHITECTURE.md
index a45ff4a1..8c09cb2d 100644
--- a/docs/zh/ARCHITECTURE.md
+++ b/docs/zh/ARCHITECTURE.md
@@ -140,3 +140,17 @@ replace/insert),带 preview 与 impact guard。
- **向量存储:** LanceDB,仅在启用 dense 检索时使用。
默认的文本优先路径不需要其中任何一项。
+
+## 9. 代码引用(2026-11-12 审计)
+
+| 本页断言 | 代码 |
+|---|---|
+| 入口先建 DI 再建服务器 | `src/main.py::main`、`src/mcp/server_factory.py::create_mcp_server`(第 156 行)、`src/core/di_container.py` |
+| 产物布局 | `src/core/artifact_paths.py::get_data_root`、`::project_hash`、`::get_graph_db_path`、`::get_logs_dir` |
+| 分块在 `/.mscodebase/chunks.db` | `src/config/settings.py::SearchConfig.sqlite_chunks` |
+| `experiments/**` 不参与索引 | `src/core/indexing/file_guard.py` |
+| 检索实现 | `src/core/search/engine.py::Searcher` |
+| 解析与图 | `src/core/indexing/parser.py`、`src/core/graph.py` |
+| 配置档 `core` = 69 中的 17 | `src/mcp/server_tools.py::CORE_PROFILE`;`tools/list` 实测:17 / 69,2026-11-12 |
+| 可选 LSP 层 | `lsp_*` 工具,30 s 失败缓存(`src/config/settings.py::LSPConfig`) |
+
diff --git a/docs/zh/CLIENTS.md b/docs/zh/CLIENTS.md
index 97acac00..9b7470a6 100644
--- a/docs/zh/CLIENTS.md
+++ b/docs/zh/CLIENTS.md
@@ -1,104 +1,243 @@
# 客户端与部署
-> 翻译:2026-11-08,来源 docs/en/CLIENTS.md。英文版为准。
+> 翻译:2026-11-12,来源 docs/en/CLIENTS.md。英文版为准。
-> 2026-11-08 重写。描述各宿主如何启动服务器,以及 `install.py` 究竟安装了什么。
-> 已对照 `install.py`、`extension.toml`、`adapters/clients/*`、`.opencode/plugin/`
-> 以及开发机上的实时 OpenCode 配置核实。
+> 2026-11-12 重写。描述各宿主如何启动服务器,以及 `install.py` 究竟安装了什么。
+> 已对照 `install.py`、`adapters/clients/snippets.py`、`src/main.py`、`extension.toml`、
+> `.opencode/plugin/`、`tests/test_install_cli.py` 与 `scripts/verify_client_snippets.py`
+> (后者会从每个片段真正启动服务器)核实。
## 1. 服务器是什么
-一个使用 stdio 通信的 Python MCP 服务器(`src/main.py`),外加一个可选的
+一个使用 stdio 通信的 Python MCP 服务器(`src/main.py`,入口点
+`mscodebase-mcp = src.main:main`,见 `pyproject.toml`),外加一个可选的
Streamable-HTTP 入口(`src/remote_main.py`),用于 docker/远程部署。它本身不是
编辑器插件 —— 由宿主启动它并通过 MCP 通信。
-## 2. OpenCode(主要宿主)
-
-OpenCode 把服务器作为本地 MCP 服务器启动。开发机上的条目如下(路径已截短):
-
-```jsonc
-"mcp": {
- "mscodebase-intelligence": {
- "type": "local",
- "command": [
- "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence\\venv\\Scripts\\mscodebase-mcp.exe",
- "-u", "-m", "src.main"
- ],
- "environment": {
- "PYTHONPATH": "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence",
- "PROJECT_PATH": "${workspaceRoot}",
- "PYTHONUTF8": "1"
+自 v3.2 起安装器**与宿主无关**:它只在磁盘上放置一个普通 Python 环境,任何 MCP
+客户端都能启动它。Zed 只是其中一个适配器,不是必需步骤。
+
+## 2. 安装一次服务器
+
+```bash
+cd
+python install.py # venv + 依赖 + 代码,text-first,无模型
+python install.py --with-models # 同上 + ONNX/GGUF 模型(约 657 MB)
+python install.py --no-zed # 完全不修改 Zed 设置
+python install.py --sync-only # 改代码后:仅复制代码
+```
+
+默认安装位置(历史路径,仅名字与 Zed 有关):
+
+```
+Windows : %LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence
+macOS : ~/Library/Application Support/Zed/extensions/mscodebase-intelligence
+Linux : ~/.local/share/zed/extensions/mscodebase-intelligence
+```
+
+用 `MSCODEBASE_INSTALL_ROOT=` 可放到任意位置。缓存、日志与项目产物在别处
+(`MSCODEBASE_DATA_DIR`,默认 `%LOCALAPPDATA%\mscodebase`)。完整清单见
+[DATA_MAP.md](DATA_MAP.md)。
+
+不用安装器的替代方式(任何提供 console scripts 的包管理器):
+
+```bash
+pipx install . # 或:uv tool install .
+mscodebase-mcp # 入口点已在 PATH 中
+```
+
+## 3. 为你的宿主生成配置
+
+不要手写命令与环境变量 —— 让安装器打印:
+
+```bash
+python install.py --print-config --client generic # 任何 mcpServers 风格宿主
+python install.py --print-config --client opencode
+python install.py --print-config --client zed
+python install.py --print-config --client claude-code
+python install.py --print-config --client cursor
+```
+
+每条命令只打印一个 JSON 对象,因此可以重定向:
+`python install.py --print-config --client cursor > .cursor/mcp.json`。片段中已包含
+解析后的解释器、`-u -m src.main`、`PYTHONPATH`、`PROJECT_PATH` 和 `PYTHONUTF8=1`。
+
+### 3.1 通用 MCP 客户端(`mcpServers`)
+
+文件:宿主读取其 MCP 配置的位置。`` 是要索引的工作区(即 `PROJECT_PATH`)。
+
+```json
+{
+ "mcpServers": {
+ "mscodebase": {
+ "command": "/venv/Scripts/mscodebase-mcp.exe",
+ "args": ["-u", "-m", "src.main"],
+ "env": {
+ "PYTHONPATH": "",
+ "PROJECT_PATH": "",
+ "PYTHONUTF8": "1"
+ }
}
}
}
```
-两个关键事实:
+### 3.2 OpenCode
+
+文件:项目根目录或 `~/.config/opencode/` 下的 `opencode.json(c)`。OpenCode V2 把
+服务器名称放在 **`mcp.servers`** 之下 —— 扁平的 V1 形式 `mcp.` 仍被接受
+(迁移兼容),但不是新配置的写法(来源:,
+"V2 does not place server names directly under `mcp`"):
+
+```json
+{
+ "mcp": {
+ "servers": {
+ "mscodebase": {
+ "type": "local",
+ "command": ["/venv/Scripts/mscodebase-mcp.exe", "-u", "-m", "src.main"],
+ "environment": {
+ "PYTHONPATH": "",
+ "PROJECT_PATH": "",
+ "PYTHONUTF8": "1"
+ }
+ }
+ }
+ }
+}
+```
-1. **已安装副本**位于扩展目录,而不是 git checkout。把当前代码放进去的正是
- `install.py`(见 §5)。
-2. 该目录因历史原因命名为 `…\Zed\extensions\…`。它就是一个普通 Python 环境;
- OpenCode 使用它的方式与 Zed 扩展完全相同。
+也可以让 CLI 写入(会保留其它设置):
-项目级配置:`.opencode/plugin/mscodebase_gate.ts`(提交钩子)和
-`.opencode/command/*.md`(可复用的 agent 命令)。见
-[HOOKS_AND_GATES.md](HOOKS_AND_GATES.md)。
+```bash
+opencode mcp add mscodebase -- /venv/Scripts/mscodebase-mcp.exe -u -m src.main
+```
-## 3. Zed(受支持,但非主要)
+若宿主会展开工作区变量,`PROJECT_PATH` 可替换为该变量。配置修改无需重启即生效
+(`/reload` 可强制重载)。项目级自动化在 `.opencode/plugin/mscodebase_gate.ts` 与
+`.opencode/command/*.md`(见 [HOOKS_AND_GATES.md](HOOKS_AND_GATES.md))。
+
+### 3.3 Zed(支持,但不是主要宿主)
+
+可以让安装器修改 `settings.json`(默认步骤,用 `--no-zed` 关闭),或把打印出的片段
+粘贴到 `context_servers` 下:
+
+```json
+{
+ "context_servers": {
+ "mscodebase": {
+ "command": "/venv/Scripts/mscodebase-mcp.exe",
+ "args": ["-u", "-m", "src.main"],
+ "env": {
+ "PYTHONPATH": "",
+ "PROJECT_PATH": "$ZED_WORKTREE_ROOT",
+ "PYTHONUTF8": "1"
+ },
+ "enabled": true
+ }
+ }
+}
+```
-Zed 扩展(`extension.toml`、`__mscodebase_ext__.marker`)从同一目录启动同一个
-服务器。Windows/Zed 特有的行为记录在 [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md);
-请把 Zed 视为替代宿主,而不是参考实现。
+键与 `adapters/zed/zed_config.py::_make_server_entry` 写入的一致。Windows/Zed 细节:
+[ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md)。
-## 4. 其他 MCP 客户端
+### 3.4 Claude Code
-`adapters/clients/` 中包含 stdio 与 Streamable-HTTP 的现成配置形状:
+项目根目录的 `.mcp.json`,与 3.1 相同的 `mcpServers` 结构。等价的 CLI 写法:
-| 文件 | 客户端 | 配置段 |
-|---|---|---|
-| `claude.code.mcp.json` | Claude Code / Desktop | `mcpServers` |
-| `vscode.mcp.json` | VS Code(`.vscode/mcp.json`) | `servers` |
+```bash
+claude mcp add-json mscodebase '{"command":"/venv/Scripts/mscodebase-mcp.exe","args":["-u","-m","src.main"],"env":{"PYTHONPATH":"","PROJECT_PATH":"","PYTHONUTF8":"1"}}'
+```
-填入 ``、``、``。HTTP 变体指向
-`http://:8089/mcp`,并要求
-`Authorization: Bearer `;远程服务器由
-`deploy/docker/` 构建。
+### 3.5 Cursor
+
+文件:`.cursor/mcp.json`(或 `~/.cursor/mcp.json` 用于所有项目),结构与 3.1 相同:
+
+```json
+{
+ "mcpServers": {
+ "mscodebase": {
+ "command": "/venv/Scripts/mscodebase-mcp.exe",
+ "args": ["-u", "-m", "src.main"],
+ "env": {
+ "PYTHONPATH": "",
+ "PROJECT_PATH": "",
+ "PYTHONUTF8": "1"
+ }
+ }
+ }
+}
+```
-脚本和 CI 完全不需要 MCP:
+## 4. 不用 GUI 验证片段
```bash
-python -m src.cli '' [--project ]
+python scripts/verify_client_snippets.py --install-root
```
-CLI 使用同一个 DI 容器和工具类;其分发器是一份精选 allowlist(`src/cli.py`),
-而不是全部 69 个工具的接口。
+脚本按安装器的相同方式渲染每个客户端的片段,在临时目录中启动该命令,完成 MCP
+`initialize` + `tools/list` 握手,并为每个客户端打印一行。预期结果(2026-11-12 实测):
+
+```
+generic PASS: server=MSCodebase Intelligence Server tools=17
+opencode PASS: server=MSCodebase Intelligence Server tools=17
+zed PASS: server=MSCodebase Intelligence Server tools=17
+claude-code PASS: server=MSCodebase Intelligence Server tools=17
+cursor PASS: server=MSCodebase Intelligence Server tools=17
+```
-## 5. 安装与更新循环
+`tools=17` 是默认 `core` 档案(注册 69,可见 17);档案表见 README。
-```bash
-python install.py # interactive full install (venv, pip, llama.cpp, models)
-python install.py --sync-only # code only: copy + drop __pycache__, no pip/models
+在代理面板中依次执行:
+
+```
+tool_help() # 档案 + 可见/隐藏工具
+get_health_report() # 索引、提供方、资源
+codebase(action="index", path="status") # chunks > 0?
```
-`install.py` 把 `src/`、脚本和支持文件复制到
-`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`(或平台对应位置),
-并保留扩展 venv。代码改动之后:
+## 5. 更新循环
+
+安装副本不是 git 检出:
```
-edit → python install.py --sync-only → restart the host (OpenCode session / Zed window)
+修改 src/... -> python install.py --sync-only -> 重启宿主(会话 / 窗口)
```
-不重启,旧进程会继续提供旧代码;不同步,新代码就不在已安装位置的磁盘上。
-两个步骤都必需。
+不重启则旧进程继续提供旧代码;不 sync 则安装位置没有新代码。两步都必需。如果直接
+从检出运行 —— 把检出路径写进 `command`/`PYTHONPATH`,就不需要 sync。
## 6. 远程 / Docker
-`src/remote_main.py` 暴露带 bearer token 认证的 Streamable HTTP;
-`deploy/docker/Dockerfile` + `docker-compose.yml` 构建镜像,CI 运行
-`docker-build` 任务。Docker 是部署选项,不是本地必需。
+`src/remote_main.py` 提供带 bearer 令牌的 Streamable HTTP;
+`deploy/docker/Dockerfile` + `docker-compose.yml` 构建镜像,CI 运行 `docker-build`
+job。Docker 是部署选项,不是本地必需。
+
+```json
+{
+ "mcpServers": {
+ "mscodebase-remote": {
+ "type": "http",
+ "url": "http://:8089/mcp",
+ "headers": { "Authorization": "Bearer " }
+ }
+ }
+}
+```
+
+## 7. 脚本与 CI(完全不用 MCP)
+
+```bash
+python -m src.cli '' [--project ]
+echo '' | python -m src.cli -
+```
+
+CLI 使用同一 DI 容器与同一批工具类;分发器是精选的 allowlist(`src/cli.py`),
+而不是全部 69 个工具。
-## 7. bug 报告应说明哪个客户端
+## 8. 缺陷报告应包含什么
-说明宿主(OpenCode / Zed / Claude / VS Code / CLI)、profile(`core`/`full`),
-以及服务器是如何启动的。缺少这三项的报告无法区分“工具被 profile 隐藏”与
-“工具损坏”。
+说明宿主(OpenCode / Zed / Claude Code / Cursor / generic / CLI)、档案
+(`core`/`full`)以及服务器的启动方式。缺少这三项的报告无法区分
+"工具被档案隐藏" 与 "工具损坏"。
diff --git a/docs/zh/CONFIGURATION.md b/docs/zh/CONFIGURATION.md
index 3b2a4f7c..1b9ad4dd 100644
--- a/docs/zh/CONFIGURATION.md
+++ b/docs/zh/CONFIGURATION.md
@@ -75,7 +75,7 @@
| `MSCODEBASE_LINK_TIMEOUT_SEC` | — | 图连边阶段预算 |
| `MSCODEBASE_REINDEX_STALL_SEC` | — | 长时间重建的停滞看门狗 |
| `MSCODEBASE_IVF_MAX_ABANDONS` | — | N 次 optimize/create 失败后自动关闭 IVF |
-| `MSCODEBASE_MAX_FILE_SIZE_MB`(security) | `10` | 超过此大小的文件会被跳过 |
+| `MAX_FILE_SIZE_MB`(security,无前缀 —— 由 `SecurityConfig` 读取,`src/config/settings.py:425`) | `10` | 超过此大小的文件会被跳过 |
## 5. Provider(全部可选)
@@ -125,3 +125,15 @@ python -c "from src.config.settings import get_config; c=get_config(); print(c.s
配置在每个进程中只构建一次:修改 `.env` 后重启 MCP 宿主,随后
`get_health_report()` 应显示新值(至少不应再显示旧的 provider 状态)。
+
+## 9. 代码引用(2026-11-12 审计)
+
+| 分组 | 读取位置 |
+|---|---|
+| server/profile/storage(`MSCODEBASE_MCP_PROFILE`、`MSCODEBASE_DATA_DIR` 等) | `src/config/settings.py`、`src/mcp/server_tools.py::CORE_PROFILE`、`src/core/artifact_paths.py::get_data_root` |
+| 检索默认值(`MSCODEBASE_DOCS_MARGIN`、`_ONDEMAND_RERANK_*`、`_CODE_BM25_*`) | `src/config/settings.py::SearchConfig`(第 189–268 行) |
+| `MAX_FILE_SIZE_MB`(无前缀) | `src/config/settings.py::SecurityConfig`(第 425 行) |
+| 插件门禁(`MSCODEBASE_STALE_GATE`、`_ISOLATION_GATE`、`_GATE_LOG`、`MSCODEBASE_PY`) | `.opencode/plugin/mscodebase_gate.ts`(第 25–27 行) |
+| pre-commit 预算(`MSCB_PRECOMMIT_FAST`、`MSCB_PRECOMMIT_GATE_TIMEOUT`) | `.githooks/pre-commit`(第 33、153 行) |
+
+当上表中的名称在代码里消失时,`python scripts/check_docs_vs_config.py` 会失败。
diff --git a/docs/zh/CONTRIBUTING.md b/docs/zh/CONTRIBUTING.md
index aaef0d64..07670ee7 100644
--- a/docs/zh/CONTRIBUTING.md
+++ b/docs/zh/CONTRIBUTING.md
@@ -36,8 +36,8 @@ python -m venv venv; venv\Scripts\activate; pip install -e ".[dev]"
```
文本优先的运行时只需要 `pip install -e .`。模型是可选的,且绝不会静默下载:
-`python install.py` 在每次下载前询问,并接受 `--skip-models`;`--sync-only` 只把
-代码复制进扩展目录,不碰 pip 也不碰模型。
+`install.py` 默认 text-first,只有 `--with-models` 才会下载(`models_enabled`);
+`--sync-only` 只把代码复制进安装根,不碰 pip 也不碰模型。
## 3. 测试与 lint
diff --git a/docs/zh/DATA_MAP.md b/docs/zh/DATA_MAP.md
new file mode 100644
index 00000000..9dcc5712
--- /dev/null
+++ b/docs/zh/DATA_MAP.md
@@ -0,0 +1,106 @@
+# MSCodeBase 数据存放位置
+
+> 编写于 2026-11-12,已对照 `install.py` v3.2、`src/core/artifact_paths.py`、
+> `src/core/artifact_gc.py`、`src/core/log_manager.py`、
+> `src/sources/git_url/__init__.py` 以及一次真实的 `install.py --doctor` 运行核实。
+> 数字为实测,不是估算。
+
+本页回答一个问题:*安装器与服务器在磁盘上创建了什么,每个路径归谁,如何清除?*
+
+```bash
+python install.py --doctor # 装了什么、占多少空间
+python install.py --clean --dry-run # 只列路径与大小,不删除
+python install.py --clean --yes # 删除下面所有层
+```
+
+## 1. 四个层
+
+| 层 | 默认路径 | 创建者 | 内容 |
+|---|---|---|---|
+| **安装根** | Windows `%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`;macOS `~/Library/Application Support/Zed/extensions/…`;Linux `~/.local/share/zed/extensions/…` | `install.py`:`step_copy`、`step_venv`、`step_pip`、`step_llama`、`step_gguf`、`step_models`、`step_uninst` | 已安装副本:代码、`venv/`、可选模型、卸载器 |
+| **数据根** | `%LOCALAPPDATA%\mscodebase`(Win)· `$XDG_CACHE_HOME/mscodebase` 或 `~/.cache/mscodebase`(POSIX) | `src/core/artifact_paths.py::get_data_root` | 项目缓存、日志、共享模型、仓库克隆 |
+| **项目产物** | `/.mscodebase/chunks.db` | 服务器(`MSCODEBASE_SQLITE_CHUNKS`) | 该项目的 FTS5 文本索引 |
+| **宿主配置** | Zed:`%APPDATA%\Zed\settings.json`(`context_servers`) | `install.py` 的 `step_zedcfg` → `adapters/zed/zed_config.py` | 一条让宿主启动服务器的条目 |
+
+两个根都可重定位:`MSCODEBASE_INSTALL_ROOT` 与 `MSCODEBASE_DATA_DIR`
+(后者仍接受历史别名 `BASE_INDEX_DIR`)。
+
+## 2. 安装根内容
+
+| 路径 | 归属 | 用途 | 大小 |
+|---|---|---|---|
+| `src/`、`scripts/`、`adapters/`、`locales/`、`docs/` | `step_copy`(`_sync_dir`) | 仓库的可执行副本(不含 `experiments/`) | 数 MB |
+| `venv/` | `step_venv`、`step_pip` | 含锁定依赖的 Python 环境 | ~1 GB |
+| `venv/Scripts/mscodebase-mcp.exe` | `pyproject.toml [project.scripts]` 的入口点 | 宿主启动的 stdio 命令 | — |
+| `.codebase_models/onnx/…` | `step_models`(仅 `--with-models`) | ONNX 嵌入 + 重排模型 | ~657 MB |
+| `llama_msvc/` | `step_llama` | GGUF 路径用的 llama-server | ~200 MB |
+| `models/*.gguf` | `step_gguf`(仅 `--with-models`) | GGUF 嵌入 + 重排模型 | ~700 MB |
+| `uninstall.bat` / `uninstall.sh` | `step_uninst`(`_build_uninstaller`) | 结束后台进程并删除本目录 | < 1 KB |
+| `__mscodebase_ext__.marker` | `step_copy` | 告诉 `src/main.py` 当前运行的是已安装副本 | < 1 KB |
+
+开发机实测(2026-11-12,已安装模型):**3.0 GB / 28 429 个文件**,几乎全部是
+`venv/` 与模型。
+
+## 3. 数据根内容
+
+| 路径 | 归属 | 用途 | 策略 |
+|---|---|---|---|
+| `projects//graph.db` | `artifact_paths.get_graph_db_path` | PropertyGraph:文件、符号、调用、导入 | 随项目目录一起删除 |
+| `projects//lancedb_v2/` | `get_index_dir`、`get_db_path` | 向量索引(仅 dense 开启时) | 同上 |
+| `projects//intelligence/` | `get_intelligence_dir` | 项目记忆:ADR、事件、技术债 | 同上 |
+| `projects//metrics/` | `get_metrics_dir` | 索引任务历史(ETA 模型) | 同上 |
+| `projects//commit_memory/` | `get_commit_memory_dir` | 提交语义缓存 | 同上 |
+| `projects//branches/` | `get_branches_dir` | 按分支的索引状态 | 同上 |
+| `projects//telemetry/` | `get_telemetry_dir` | 每日 JSON 快照 | 超过 **90 天**清理 |
+| `projects//summaries_cache/` | `get_summaries_cache_dir` | 分块 LLM 摘要 | 同项目目录 |
+| `projects//progress.json` | `get_progress_file` | 索引进度契约 | 覆盖写入 |
+| `projects//action_receipts.jsonl` | `src/core/action_receipt.py` | 写操作回执 | verified 60 天 / inconclusive 7 天(`purge`) |
+| `logs/mscodebase-intelligence.log` | `log_manager.get_main_log_path` | 服务器日志 | 2 MB × 3 文件轮转,**7 天**后删除 |
+| `logs/crash.json` | `get_crash_log_path` | 最近一次资源监控崩溃 | 覆盖写入 |
+| `models/.codebase_models/onnx/…` | `get_onnx_models_base` | 已下载模型的共享副本 | 仅 `--clean` |
+| `repos//` | `get_repos_cache_dir` | 远程仓库克隆 | LRU(5) + 24 小时 TTL(`src/sources/git_url`) |
+| `projects//`(整个目录) | — | — | 不活跃超过 **30 天**的目录与空目录由 `artifact_gc.prune_stale_artifacts()` 清理 |
+
+开发机实测(2026-11-12):**302.9 MB / 2 934 个文件**。
+在同时含有 `chunks.db`(78 MB)与本地模型副本(960 MB)的真实项目目录中,
+`--doctor` 对所有层报告 **4.3 GB / 31 385 个文件**。
+
+## 4. 项目产物
+
+| 路径 | 归属 | 用途 |
+|---|---|---|
+| `/.mscodebase/chunks.db` | `MSCODEBASE_SQLITE_CHUNKS` 存储 | 默认搜索路径使用的文本索引(FTS5/BM25);`--doctor` 报告其分块数 |
+| `/.codebase_indices/`、`.codebase/`、`.mscodebase/telemetry/` | 历史遗留 | 首次打开项目时迁入数据根(`artifact_paths.migrate_legacy_artifacts`) |
+| `/.codebase_models/` | `scripts/download_model.py`(暂存) | 模型安装到安装根之前的临时副本 |
+
+`experiments/**` 按设计不参与索引,因此不会让索引增长。
+
+## 5. 策略
+
+- **模型为 opt-in。** `install.py` 默认 text-first:不带 `--with-models` 时不会发起
+ 任何模型下载,也不会创建模型目录。`--skip-models`/`--no-models` 仍被接受。
+- **日志**按大小轮转(2 MB,3 个文件),7 天后删除
+ (`log_manager._MAX_LOG_BYTES`、`_MAX_LOG_FILES`、`_LOG_RETENTION_DAYS`)。
+- **遥测**保留 90 天;**不活跃项目目录**保留 30 天(`artifact_gc.DEFAULT_*`);
+ 空项目目录立即删除。
+- **仓库克隆**为 LRU(5) + 24 小时 TTL,并有 500 MB 克隆后上限
+ (`git_url.DEFAULT_MAX_CLONE_BYTES`)。
+- **共享模型缓存**(`data_root/models`)无 TTL:只能由 `install.py --clean` 删除。
+- **卸载** = `install.py --clean --yes`,清除四层中的三层半,宿主配置条目除外
+ (请自行从 `settings.json` / `opencode.json` / `.mcp.json` 删除 `mscodebase`
+ 条目,或用 `--no-zed` 安装,根本不创建它)。
+
+## 6. 如何阅读 doctor 输出
+
+```
++ install_root 3.0 GB 28429 file(s) C:\Users\\AppData\Local\Zed\extensions\mscodebase-intelligence
++ data_root 302.9 MB 2934 file(s) C:\Users\\AppData\Local\mscodebase
++ project_chunks 78.0 MB 4 file(s) D:\work\repo\.mscodebase
+ ...
+
+index 7278 chunk(s) (D:\work\repo\.mscodebase\chunks.db)
+total 4.3 GB over 31385 file(s)
+```
+
+`+` 表示存在,`-` 表示不存在。`index missing` 表示当前目录的文本索引尚未建立
+(首次 `codebase(action="index")` 会创建),而不是搜索损坏。`--json` 输出同样数据。
diff --git a/docs/zh/FAQ.md b/docs/zh/FAQ.md
index a38c2414..3aee48da 100644
--- a/docs/zh/FAQ.md
+++ b/docs/zh/FAQ.md
@@ -109,7 +109,8 @@ Remove-Item "$env:LOCALAPPDATA\mscodebase\projects\" -Recurse -Force
## 没有 GPU 或没有网络能运行吗?
能。默认路径中没有任何东西需要 GPU、云 API 或互联网:FTS5、AST 图与 tree-sitter
-解析都在本地。唯一的网络用途是下载可选模型(通过 `install.py` 获取 GGUF/ONNX)
+解析都在本地。唯一的网络用途是下载可选模型(`install.py --with-models`,默认关闭:
+`install.py` 的 `models_enabled()`)
以及可选的 `tree-sitter-language-pack`,其解析器在首次使用时抓取
(`MSCODEBASE_LANGUAGE_PACK=true`)。关掉该开关后,所有语法都来自固定版本的
wheel,不会发生任何下载。
diff --git a/docs/zh/INSTALL.md b/docs/zh/INSTALL.md
index 6124d1bc..48de7eb9 100644
--- a/docs/zh/INSTALL.md
+++ b/docs/zh/INSTALL.md
@@ -1,139 +1,162 @@
+# 安装 MSCodebase Intelligence
+
+> 翻译:2026-11-12,来源 docs/en/INSTALL.md。英文版为准。
+
[🇬🇧 English](../en/INSTALL.md) • [🇷🇺 Русский](../ru/INSTALL.md) • [🇨🇳 中文](INSTALL.md)
-# 安装 MSCodebase Intelligence
-
-> 翻译:2026-11-08,来源 docs/en/INSTALL.md。英文版为准。
-
-> 2026-11-08 重写。本服务器是一个本地 MCP 服务器。**OpenCode 是主要宿主**;
-> Zed 扩展仍然受支持。模型下载是可选的 —— 默认安装是文本优先的,不需要
-> embedding、GPU 或网络。
+> 2026-11-12 依据 `install.py` v3.2 重写。服务器是本地 MCP 服务器,安装器
+> **与宿主无关**:它只创建普通 Python 环境,任何 MCP 宿主都能启动。Zed 是可选
+> 适配器。默认安装是 **text-first**:不使用嵌入、GPU、网络,除非明确要求,否则
+> 不下载任何模型。
---
-## 1. 环境要求
+## 1. 要求
| 组件 | 要求 |
|---|---|
-| 操作系统 | Windows 10+(已验证平台)、Linux(CI)、macOS |
-| Python | **3.14+**(仅测试过 3.14) |
+| 操作系统 | Windows 10+(验证平台)、Linux(CI)、macOS |
+| Python | **3.14+**(仅测试 3.14) |
| 内存 | 4 GB;启用本地模型时 8 GB+ |
-| 磁盘 | 不含模型约 500 MB;含 GGUF 模型最多约 2 GB |
-| 宿主 | OpenCode(主要)或 Zed,或任意 MCP 客户端 |
-| 可选 | LM Studio / Ollama / llama.cpp,用于 rerank 和 legacy dense 路径 |
+| 磁盘 | 无模型约 1.5 GB(主要是 venv);`--with-models` 再加约 650 MB |
+| 宿主 | OpenCode、Zed、Claude Code、Cursor 或任意 MCP 客户端 |
+| 可选 | LM Studio / Ollama / llama.cpp(重排与 legacy dense 路径) |
-开发机上不需要 Docker 和 WSL。容器构建是一种部署选项(`deploy/docker/`)。
+开发机不需要 Docker/WSL。容器构建是部署选项(`deploy/docker/`)。
-## 2. 完整安装
+## 2. 安装
```bash
cd
-python install.py # interactive: venv, pip, optional llama.cpp + models
+python install.py # venv + 依赖 + 代码;text-first
+python install.py --with-models # 同上 + ONNX/GGUF 模型(约 657 MB,需网络)
+python install.py --no-zed # 完全不修改 Zed 设置
+python install.py --yes # 非交互:接受每个步骤
```
-Windows 还有 `install.bat`;Linux/macOS 有 `install.sh`。安装器会:
+Windows 另有 `install.bat`;Linux/macOS 有 `install.sh`。安装器会:
-1. 检查 Python 与环境;
-2. 创建/更新虚拟环境并安装依赖;
-3. 可选地下载 llama.cpp 和 GGUF 模型;
-4. 把代码复制到扩展安装目录;
-5. 写出 `uninstall.bat`。
+1. 结束旧的 MCP/llama 进程,确保运行的是新代码;
+2. 创建/更新虚拟环境并安装锁定依赖;
+3. 复制代码到安装根(`--sync-only` 跳过 1–2,只做这一步);
+4. 可选(Zed 适配器)向 `settings.json` 写入 `context_servers` 条目;
+5. 在安装根写入 `uninstall.bat` / `uninstall.sh`。
-没有模型时服务器仍能回答代码问题 —— 搜索会回落到 SQLite FTS5/BM25 加图。
+默认安装根(名字是历史遗留,内容与 Zed 无关):
-## 3. 更新循环(最容易出错的部分)
+```
+Windows : %LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence
+macOS : ~/Library/Application Support/Zed/extensions/mscodebase-intelligence
+Linux : ~/.local/share/zed/extensions/mscodebase-intelligence
+```
-已安装的副本位于仓库之外:
+用 `MSCODEBASE_INSTALL_ROOT=` 可移动;缓存/日志/产物在
+`MSCODEBASE_DATA_DIR`(默认 `%LOCALAPPDATA%\mscodebase`)。各路径与实测大小:
+[DATA_MAP.md](DATA_MAP.md)。
+不用安装器(任何提供 console scripts 的管理器,入口点
+`mscodebase-mcp = src.main:main`):
+
+```bash
+pipx install . # 或:uv tool install .
```
-%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence
-```
-该目录名是历史原因留下的;OpenCode 启动的是同一个 venv
-(`…\venv\Scripts\mscodebase-mcp.exe -u -m src.main`)。因此:
+## 3. 更新循环(最容易出错的环节)
+
+安装副本不在仓库里:
```bash
-python install.py --sync-only # copy code, drop __pycache__, no pip/models
-# then restart the host: the OpenCode session (or the Zed window)
+python install.py --sync-only # 复制代码、清 __pycache__,不装 pip/模型
+# 然后重启宿主:OpenCode 会话、Zed 窗口或 CLI 进程
```
-`--sync-only`(别名 `--sync`)是每次源码改动后的快速路径。跳过重启会让旧进程继续
-运行;跳过同步则安装位置里仍是旧代码。两者都必须做。
+`--sync-only`(别名 `--sync`)是改代码后的快速路径。不重启则旧进程继续运行;不
+sync 则安装位置仍是旧代码。两者都必需。
## 4. 连接宿主
-**OpenCode** —— 声明一个指向已安装 venv 的本地 MCP 服务器:
-
-```jsonc
-"mcp": {
- "mscodebase-intelligence": {
- "type": "local",
- "command": ["/venv/Scripts/mscodebase-mcp.exe", "-u", "-m", "src.main"],
- "environment": {
- "PYTHONPATH": "",
- "PROJECT_PATH": "${workspaceRoot}",
- "PYTHONUTF8": "1"
- }
- }
-}
-```
+不要手写命令 —— 打印出来再粘贴到宿主配置:
-**Zed** —— 扩展读取同一目录;除扩展本身外无需额外配置。
+```bash
+python install.py --print-config --client generic # mcpServers 风格宿主
+python install.py --print-config --client opencode
+python install.py --print-config --client zed
+python install.py --print-config --client claude-code
+python install.py --print-config --client cursor
+```
-**Claude Code / VS Code / Cursor** —— 从 `adapters/clients/` 复制配置形状,填入
-venv 路径、仓库路径和项目路径。完整矩阵见 [CLIENTS.md](CLIENTS.md)。
+各客户端的文件名、可复制 JSON 与启动校验
+(`python scripts/verify_client_snippets.py`)见 [CLIENTS.md](CLIENTS.md)。
## 5. 验证
-在 agent 面板中按以下顺序执行:
+在助手面板中依次执行:
```
-tool_help() # profile + visible/hidden tools
-get_health_report() # index, providers, resources
+tool_help() # 配置档 + 可见/隐藏工具
+get_health_report() # 索引、提供方、资源
codebase(action="index", path="status") # chunks > 0?
```
-如果文本索引缺失或过期:
+若文本索引缺失或过期:
```bash
-python scripts/reindex_text_only.py --project . # chunks + graph, no embeddings
-python scripts/rebuild_graph.py # graph only
+python scripts/reindex_text_only.py --project . # 分块 + 图,不用嵌入
+python scripts/rebuild_graph.py # 仅图
```
然后重启服务器,让新进程打开重建后的存储。本仓库的实测数字见
[FACTS.md](../FACTS.md)。
-## 6. 可选 provider
+## 6. 磁盘上有什么,以及如何清除
-| Provider | 用途 | 位置 |
-|---|---|---|
-| llama.cpp (GGUF) | 按需 rerank、legacy dense 路径 | `LLAMA_CPP_PORT` (8080),reranker 8081 |
-| LM Studio | 通过 HTTP 提供 embedding/rerank | `LM_STUDIO_HOST:1234` |
-| Ollama | 通过 HTTP 提供 embedding/rerank | `OLLAMA_HOST:11434` |
-| basedpyright | 六个 `lsp_*` 工具 | PATH 或扩展 venv |
+```bash
+python install.py --doctor # 版本、路径、大小、索引状态(--json)
+python install.py --clean --dry-run # 列出每个产物及大小;不删除
+python install.py --clean # 询问确认后删除
+python install.py --clean --yes # 不询问(脚本)
+```
-默认的文本优先答案不需要其中任何一项。Provider 设置见
-[CONFIGURATION.md](CONFIGURATION.md)。
+`--clean` 删除安装根(代码 + venv + 模型 + 卸载器)、数据根
+(`projects/`、`logs/`、`models/`、`repos/`)以及当前项目的 `.mscodebase`、
+`.codebase_indices`、`.codebase`、`.codebase_models`。它拒绝删除源代码检出、文件
+系统根或用户主目录,并在有残留(被运行中的宿主锁定)时以非零码退出。缓存策略
+(日志轮转、7/30/90 天清理、克隆 LRU(5)+24 小时)见 [DATA_MAP.md](DATA_MAP.md)。
## 7. 卸载
```bash
-./uninstall.bat # Windows
-python install.py --uninstall
+python install.py --clean --yes # 所有层,已验证:剩余 0 字节
+./uninstall.bat # Windows:由 step_uninst 生成
```
-这会移除已安装的扩展副本。`%LOCALAPPDATA%/mscodebase/` 下的项目数据和项目的
-`.mscodebase/chunks.db` 是分开的;如需彻底干净,请手动删除它们。
+生成的卸载器只结束服务器进程并删除安装根;`--clean` 是完整版本。两者都不修改
+宿主配置:请自行从 `settings.json`(Zed)、`opencode.json`(OpenCode)或
+`.mcp.json` / `.cursor/mcp.json` 删除 `mscodebase` 条目。
+
+## 8. 可选提供方
+
+| 提供方 | 用途 | 位置 |
+|---|---|---|
+| llama.cpp (GGUF) | 按需重排、legacy dense 路径 | `LLAMA_CPP_PORT`(8080)、重排 8081 |
+| LM Studio | HTTP 嵌入/重排 | `LM_STUDIO_HOST:1234` |
+| Ollama | HTTP 嵌入/重排 | `OLLAMA_HOST:11434` |
+| basedpyright | 六个 `lsp_*` 工具 | PATH 或扩展 venv |
+
+默认 text-first 回答不需要它们中的任何一个。提供方配置:
+[CONFIGURATION.md](CONFIGURATION.md)。
-## 8. 故障排查
+## 9. 故障排查
| 症状 | 可能原因 |
|---|---|
-| 预期工具不出现 | `core` profile —— 运行 `tool_help()`,切换到 `full` |
-| 任何查询都“未找到” | 索引为空 —— 检查 `codebase(action="index", path="status")` |
-| 新代码没有生效 | 未执行 `--sync-only` 和/或未重启宿主 |
-| 服务器启动后工具超时 | provider 或 LSP 卡住 —— 见 [GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md) |
-| Windows 控制台窗口闪烁 | subprocess 未使用 `CREATE_NO_WINDOW` —— 见 [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md) |
+| 缺少期望的工具 | `core` 档案 —— 运行 `tool_help()`,切换到 `full` |
+| 任何查询都"未找到" | 索引为空 —— 检查 `codebase(action="index", path="status")` |
+| 新代码没有效果 | 未执行 `--sync-only` 和/或未重启宿主 |
+| 服务器启动但工具超时 | 提供方或 LSP 挂起 —— 见 [GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md) |
+| Windows 弹出控制台窗口 | 子进程缺少 `CREATE_NO_WINDOW` —— 见 [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md) |
+| `--clean` 留下文件 | 被运行中的宿主锁定 —— 停止宿主后重试 |
diff --git a/docs/zh/INSTALL_MODELS.md b/docs/zh/INSTALL_MODELS.md
index 216d0987..c82c9448 100644
--- a/docs/zh/INSTALL_MODELS.md
+++ b/docs/zh/INSTALL_MODELS.md
@@ -30,15 +30,17 @@ GGUF 表(`src/providers/reranker/llama_install.py` 中的 `GGUF_MODELS`)还
下载时写入日志;下载路径并不比对它们,所以这里的哈希是参考值,不是已验证的固定值。
(llama.cpp **二进制**归档在解压前*确实*会做哈希校验。)
-## 2. 方法一 —— `install.py`(交互式)
+## 2. 方法一 —— `install.py`(模型需显式开启)
```bash
-python install.py
+python install.py --with-models
```
-12 个步骤。涉及网络的步骤在执行前会询问:
+不带 `--with-models` 时安装器为 text-first:`step_models` 与 `step_gguf` 立即返回
+(`install.py::models_enabled`),不下载任何模型、不创建模型目录、也不弹出任何提示。
+带 `--with-models` 时:
-- ONNX 模型:*"Download ONNX models (e5-small-int8 ~113 MB + reranker ~544 MB)? (Y/n)"*;
+- ONNX:`multilingual-e5-small-int8`(约 113 MB)+ `bge-reranker-v2-m3`(约 544 MB);
- GGUF + llama.cpp:GGUF 步骤下载 `multilingual-e5-small` 与
`bge-reranker-v2-m3`,以及所选后端的 llama.cpp 二进制;
- 每一步在失败时都提供 `[r]etry / [s]kip / [c]ancel`。
@@ -47,12 +49,15 @@ python install.py
| 标志 | 作用 |
|---|---|
-| `--sync-only` / `--sync` | 把代码复制进扩展目录,不碰其它任何东西(修改 `src/` 之后用这个) |
-| `--skip-models` / `--no-models` | 完全跳过模型步骤 |
-| `--yes` / `-y` | 直接回答 yes,而不是提示 |
+| `--sync-only` / `--sync` | 把代码复制进安装根,不碰其它任何东西(修改 `src/` 之后用这个) |
+| `--with-models` | 执行模型步骤(ONNX + GGUF);获取模型的唯一途径 |
+| `--skip-models` / `--no-models` | 为旧脚本保留;默认已跳过 |
+| `--no-zed` | 不修改 Zed 设置(宿主中立安装) |
+| `--yes` / `-y` | 接受每个步骤,而不是提示 |
| `--verbose` / `-v` | 输出完整步骤内容,而不是紧凑视图 |
-安装目标是扩展目录 `%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`:
+文件写入安装根(`MSCODEBASE_INSTALL_ROOT`,默认历史路径
+`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`):
- ONNX → `/.codebase_models/onnx//model_quantized.onnx`
(其专用 reranker 子目录使用 `model.onnx`);
diff --git a/docs/zh/README.md b/docs/zh/README.md
index 2a430f73..12f7c432 100644
--- a/docs/zh/README.md
+++ b/docs/zh/README.md
@@ -86,16 +86,33 @@ investigate("索引器如何决定重新索引一个文件")
## 快速开始
-要求:以 Windows 为验证平台;Python 3.14+;MCP 宿主(**主要是 OpenCode**,Zed 扩展也可用)。
+要求:以 Windows 为验证平台;Python 3.14+;任意 MCP 宿主(OpenCode、Zed、
+Claude Code、Cursor 或通用 `mcpServers` 客户端)。
```bash
cd
-python install.py # 完整安装:venv、pip、可选模型
-python install.py --sync # 仅同步代码到已安装副本
+python install.py # venv + 依赖 + 代码:text-first,无模型
+python install.py --with-models # 可选:+ ONNX/GGUF 模型(约 657 MB)
+python install.py --sync # 改代码后:仅复制代码
```
-`install.py` 完成后重启宿主:OpenCode 会话或 Zed 窗口。已安装副本位于
-`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`,两种宿主都启动该 venv。
+安装与宿主无关:磁盘上放置的是一个普通 Python 环境(默认
+`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`,名字是历史遗留)。
+用 `MSCODEBASE_INSTALL_ROOT` 可放到别处。然后让安装器打印配置,而不是手写:
+
+```bash
+python install.py --print-config --client generic # mcpServers 风格宿主
+python install.py --print-config --client opencode
+python install.py --print-config --client claude-code
+python install.py --print-config --client cursor
+python install.py --print-config --client zed
+```
+
+每条命令只打印一个 JSON 对象:解释器、`-u -m src.main`、`PYTHONPATH`、
+`PROJECT_PATH`、`PYTHONUTF8=1`;粘贴到宿主的 MCP 配置中。各客户端的分节与启动校验
+(`python scripts/verify_client_snippets.py`)见 [docs/zh/CLIENTS.md](CLIENTS.md)。
+配置完成后重启宿主。
+
在助手面板验证:
```text
@@ -243,7 +260,8 @@ python scripts/smoke_e2e.py --project .
| [docs/FACTS.md](../FACTS.md) | 所有公开数字与复现命令 |
| [docs/zh/ARCHITECTURE.md](ARCHITECTURE.md) | 分层、DI、模块边界、工具配置档 |
| [docs/zh/SEARCH_PIPELINE.md](SEARCH_PIPELINE.md) | 检索、排序、文档路由、`investigate` fan-out |
-| [docs/zh/INSTALL.md](INSTALL.md) | 安装、sync+restart 循环、故障排查 |
+| [docs/zh/INSTALL.md](INSTALL.md) | 安装、sync+restart 循环、doctor/clean、故障排查 |
+| [docs/zh/DATA_MAP.md](DATA_MAP.md) | 安装器/服务器创建的每个路径:大小与保留策略 |
| [docs/zh/CONFIGURATION.md](CONFIGURATION.md) | 所有环境变量及其默认值 |
| [docs/zh/CLIENTS.md](CLIENTS.md) | OpenCode、Zed、Claude Code、VS Code、CLI、Docker |
| [docs/zh/HOOKS_AND_GATES.md](HOOKS_AND_GATES.md) | OpenCode 钩子、12 个 pre-commit 门禁、CI 任务 |
diff --git a/docs/zh/SEARCH_PIPELINE.md b/docs/zh/SEARCH_PIPELINE.md
index 1498eccc..f27b747e 100644
--- a/docs/zh/SEARCH_PIPELINE.md
+++ b/docs/zh/SEARCH_PIPELINE.md
@@ -170,3 +170,15 @@ hint 传入后为 3/4、3/4、3/4。剩下的未命中是 gold 存在歧义(
同一查询 → 是检索漏了,还是排序重排了?
4. `investigate(...)` → 哪些通道找到了该文件(`[search]`、`[grep]`)?
5. 检查日志尾部:`%LOCALAPPDATA%/mscodebase/logs/mcp_global.log`。
+
+## 9. 代码引用(2026-11-12 审计)
+
+| 本页断言 | 代码 |
+|---|---|
+| 超过 512 字符的查询被拒绝 | `src/mcp/tools/search_tools.py:288` |
+| `investigate` 通道权重(search 0.6、grep 0.3、tests 0.1、memory 0.4) | `src/mcp/tools/investigate_tool.py:44`、`:1025`、`:1041` |
+| 输出上限:8 行、每行 180 字符 | `src/mcp/tools/investigate_tool.py:681`、`:1191` |
+| 文档通道 margin,默认 4.0 | `src/config/settings.py::SearchConfig.docs_margin`(第 268 行) |
+| BM25 常量(`K1` 1.2、`B` 0.45) | `src/config/settings.py` 第 219、222 行 |
+| 请求守卫与 FTS5 候选生成 | `src/core/search/engine.py` |
+
diff --git a/docs/zh/SYSTEM_REQUIREMENTS.md b/docs/zh/SYSTEM_REQUIREMENTS.md
index dbc0fdff..4d684eb4 100644
--- a/docs/zh/SYSTEM_REQUIREMENTS.md
+++ b/docs/zh/SYSTEM_REQUIREMENTS.md
@@ -55,8 +55,9 @@ OpenCode 直接启动 MCP 服务器:
| `models/`(可选) | **839.4 MiB** | 为扩展复制的 GGUF 模型 |
经验法则:文本索引与被索引语料成正比,并且大致比同一语料上的向量索引小一个数量级。
-可选模型目录在存在时占主导,不存在时为零。`install.py` 只在被要求时下载模型
-(`--skip-models`,或在其提示处回答 "no"),所以纯文本安装的体积 = 代码 + 块存储。
+可选模型目录在存在时占主导,不存在时为零。`install.py` 只在指定 `--with-models`
+时下载模型(`install.py` 的 `models_enabled()`);默认 text-first,既不提示也不下载,
+所以纯文本安装的体积 = 代码 + 块存储。
当 data root 的空闲空间低于 **500 MB** 时,`artifact_paths.check_disk_space()` 会
警告(`MIN_FREE_DISK_MB`);CI 守卫在低于 1 GB 时失败。`artifact_gc` 在启动 3 s
diff --git a/install.py b/install.py
index 6b3d6bad..46ba870c 100644
--- a/install.py
+++ b/install.py
@@ -1,8 +1,22 @@
"""
-MSCodebase Intelligence — Installer & Updater v3.1
+MSCodebase Intelligence — Installer & Updater v3.2
=================================================
Robust TUI with error recovery, self-healing, and safe cross-platform I/O.
+Host-neutral since v3.2: the server is installed into a plain Python
+environment and any MCP host can launch it. Zed is an optional adapter, not a
+required step. The default install root keeps the historical Zed extension path
+so existing installs keep updating in place; set MSCODEBASE_INSTALL_ROOT to put
+it anywhere else.
+
+Non-interactive modes (no TUI, safe for scripts and tests):
+ install.py --print-config [--client generic|opencode|zed|claude-code|cursor]
+ print a ready-to-paste stdio snippet
+ install.py --doctor [--json] versions, paths, sizes, index state
+ install.py --clean [--dry-run] [--yes]
+ remove every artifact this installer created
+ install.py --help list modes
+
Design principles:
- Every step wrapped in try/except with retry/skip/cancel
- Progress bar via single \r line (no flicker)
@@ -29,6 +43,16 @@
резолвится за пределы ZED_EXT_DIR.
7. step_pip теперь предпочитает requirements-lock.txt, если он есть рядом с
requirements.txt (совместимо с dependency-lock практикой, принятой в проекте).
+
+Changelog v3.1 -> v3.2 (G5 universal install + G6 data hygiene):
+ 1. Zed больше не импортируется на верхнем уровне: install.py запускается на
+ машине без Zed и без adapters/ — адаптер подключается лениво внутри шагов.
+ 2. INSTALL_ROOT отвязан от Zed: MSCODEBASE_INSTALL_ROOT переопределяет каталог
+ установки (историческое имя папки расширения остаётся только default).
+ 3. Добавлены --print-config/--doctor/--clean/--help: сниппет для любого MCP-
+ клиента, инвентарь «что стоит и сколько весит», полное удаление по слоям.
+ 4. Модели стали opt-in (--with-models): по умолчанию text-first, ни одного
+ сетевого запроса за модели во время установки.
"""
import io
@@ -45,18 +69,34 @@
from pathlib import Path
from typing import Optional
-from adapters.zed.zed_config import get_zed_config_dir, patch_zed_settings
-
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
PROJECT_ROOT = Path(__file__).resolve().parent
-ZED_EXT_DIR = (
+
+# ─── Roots: server installation vs host adapter ─────────────────────────────
+# The installed copy is a plain Python environment. Nothing about it is
+# Zed-specific; the path below is only the historical default so that existing
+# installs keep updating in place. MSCODEBASE_INSTALL_ROOT moves it anywhere
+# (a neutral location, a CI temp dir, a per-user tools directory).
+LEGACY_INSTALL_ROOT = (
Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")))
/ "Zed"
/ "extensions"
/ "mscodebase-intelligence"
)
+
+
+def resolve_install_root() -> Path:
+ """INSTALL_ROOT from MSCODEBASE_INSTALL_ROOT, else the legacy Zed path."""
+ raw = os.environ.get("MSCODEBASE_INSTALL_ROOT", "").strip()
+ if raw:
+ return Path(raw).expanduser().resolve()
+ return LEGACY_INSTALL_ROOT
+
+
+INSTALL_ROOT = resolve_install_root()
+ZED_EXT_DIR = INSTALL_ROOT # legacy alias: the Zed extension reads this tree
VENV_DIR = ZED_EXT_DIR / "venv"
IS_WINDOWS = sys.platform == "win32"
PYTHON_EXE = VENV_DIR / "Scripts" / "python.exe" if IS_WINDOWS else VENV_DIR / "bin" / "python3"
@@ -383,6 +423,19 @@ def deco(fn):
@_step(0)
def step_zed(lines, lang):
+ """Optional host adapter: the Zed config directory only.
+
+ The server itself does not need Zed. On a machine without the adapter the
+ step is a no-op, not a failure (G5: server != host adapter).
+ """
+ if not zed_enabled():
+ lines.append((C.D, "· Skipped (--no-zed)"))
+ return
+ try:
+ from adapters.zed.zed_config import get_zed_config_dir
+ except Exception as e: # adapter absent / broken: server still installs
+ lines.append((C.YEL, f"· Zed adapter unavailable ({e})"))
+ return
zcd = get_zed_config_dir()
zcd.mkdir(parents=True, exist_ok=True)
lines.append((C.GRN, f"✓ {zcd}"))
@@ -662,7 +715,15 @@ def step_llama(lines, lang):
@_step(7)
def step_gguf(lines, lang):
- """Скачивает GGUF модели: multilingual-e5-small (embedder) + bge-reranker-v2-m3."""
+ """Скачивает GGUF модели: multilingual-e5-small (embedder) + bge-reranker-v2-m3.
+
+ G6: opt-in. Text-first — дефолт, поэтому без --with-models шаг ничего не
+ качает и не трогает диск.
+ """
+ if not models_enabled():
+ lines.append((C.D, "· Skipped (text-first default; add --with-models)"))
+ return
+
from src.providers.reranker.llama_runner import is_model_downloaded, download_gguf_model, GGUF_MODELS, _get_models_dir
all_ok = True
@@ -711,9 +772,15 @@ def step_gguf(lines, lang):
def step_models(lines, lang):
"""Скачивает ONNX-модели: multilingual-e5-small-int8 (embedder) + bge-reranker-v2-m3.
- Проверяет по порядку: ZED_EXT_DIR -> общий кэш ~/.cache/mscodebase -> PROJECT_ROOT.
+ Проверяет по порядку: ZED_EXT_DIR -> общий кэш data_root/models -> PROJECT_ROOT.
Найдено в (2) или (3) -> копирует в (1). Не найдено нигде -> скачивает.
+
+ G6: opt-in. Без --with-models шаг не делает сетевых запросов: дефолтная
+ установка text-first и весит без моделей.
"""
+ if not models_enabled():
+ lines.append((C.D, "· Skipped (text-first default; add --with-models)"))
+ return
SHARED_DIR = Path.home() / ".cache" / "mscodebase" / "models"
models = {
"multilingual-e5-small-int8": ("keisuke-miyako/multilingual-e5-small-onnx-int8", "embedding", 113),
@@ -839,6 +906,18 @@ def step_zedcfg(lines, lang):
# Имя exe — то, что показывает Task Manager (у pythonw нет консоли для
# title): запускаемся через mscodebase-mcp.exe-хардлинк → pythonw.exe,
# fallback — plain pythonw при ошибке создания линка.
+ #
+ # G5: шаг опционален. Любой другой хост получает конфиг через
+ # `install.py --print-config --client <...>`; отказ Zed-адаптера не роняет
+ # установку.
+ if not zed_enabled():
+ lines.append((C.D, "· Skipped (--no-zed)"))
+ return
+ try:
+ from adapters.zed.zed_config import patch_zed_settings
+ except Exception as e:
+ lines.append((C.YEL, f"· Zed adapter unavailable ({e})"))
+ return
mcp_exe = str(_ensure_mcp_hardlink()) if IS_WINDOWS else str(MCP_PYTHON)
cmd = f"{mcp_exe} -u -m src.main"
if patch_zed_settings(
@@ -888,6 +967,406 @@ def _build_uninstaller() -> str:
)
+# ─── Non-interactive modes: config snippet / doctor / clean ────────────────
+def zed_enabled(argv: Optional[list[str]] = None) -> bool:
+ """Zed adapter is opt-out: --no-zed keeps the install host-neutral."""
+ argv = sys.argv if argv is None else argv
+ return "--no-zed" not in argv
+
+
+def models_enabled(argv: Optional[list[str]] = None) -> bool:
+ """Models are opt-in: text-first by default (G6).
+
+ `--skip-models`/`--no-models` stay accepted so old commands keep working.
+ """
+ argv = sys.argv if argv is None else argv
+ return "--with-models" in argv
+
+
+def _human_bytes(n: int) -> str:
+ size = float(n)
+ for unit in ("B", "KB", "MB", "GB", "TB"):
+ if size < 1024 or unit == "TB":
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
+ size /= 1024
+ return f"{size:.1f} TB"
+
+
+def _usage(path: Path) -> tuple[int, int]:
+ """(bytes, files) под path; симлинки не разворачиваются, ошибки — не фатальны."""
+ if not path.exists():
+ return 0, 0
+ try:
+ if path.is_file():
+ return path.stat().st_size, 1
+ except OSError:
+ return 0, 0
+ total = files = 0
+ for root, _dirs, names in os.walk(path, followlinks=False):
+ for name in names:
+ fp = Path(root) / name
+ try:
+ total += fp.stat().st_size
+ files += 1
+ except OSError:
+ continue
+ return total, files
+
+
+def _data_root_readonly() -> Path:
+ """data_root без создания каталога (source of truth: src/core/artifact_paths.get_data_root).
+
+ Порядок источников повторяет artifact_paths: MSCODEBASE_DATA_DIR →
+ BASE_INDEX_DIR → платформенный default. Отличие только одно: doctor/clean
+ не должны создавать пустые каталоги, поэтому mkdir здесь не вызывается.
+ """
+ env = os.getenv("MSCODEBASE_DATA_DIR", "").strip()
+ if env:
+ return Path(env).expanduser()
+ legacy = os.getenv("BASE_INDEX_DIR", "").strip()
+ if legacy and Path(legacy).is_absolute():
+ return Path(legacy).expanduser()
+ if IS_WINDOWS:
+ return Path(os.environ.get("LOCALAPPDATA", "")) / "mscodebase"
+ xdg = os.environ.get("XDG_CACHE_HOME", "").strip()
+ base = Path(xdg) if xdg else Path.home() / ".cache"
+ return base / "mscodebase"
+
+
+def _mcp_launch_command(argv: list[str]) -> tuple[str, list[str]]:
+ """The exact command a host must run: installed entry point, else venv python.
+
+ `--python PATH` overrides the interpreter (used by the cold-start test and by
+ people who installed with pipx/uv, where `mscodebase-mcp` is on PATH).
+ """
+ if "--python" in argv:
+ idx = argv.index("--python")
+ if idx + 1 >= len(argv):
+ raise ValueError("--python needs a path")
+ return argv[idx + 1], ["-u", "-m", "src.main"]
+ for exe in (MCP_EXE, PYTHON_EXE):
+ try:
+ if Path(exe).exists():
+ return str(exe), ["-u", "-m", "src.main"]
+ except OSError:
+ continue
+ return sys.executable, ["-u", "-m", "src.main"]
+
+
+def _print_config(client: str, project: Path, argv: list[str]) -> int:
+ """Print a ready-to-paste stdio snippet for one MCP host."""
+ try:
+ from adapters.clients.snippets import CLIENTS, render_snippet
+ except Exception as e: # pragma: no cover - adapters are shipped with the repo
+ print(f"error: client adapters unavailable: {e}", file=sys.stderr)
+ return 1
+
+ if client not in CLIENTS:
+ print(
+ f"error: unknown client '{client}'. Known: {', '.join(CLIENTS)}",
+ file=sys.stderr,
+ )
+ return 2
+
+ command, args = _mcp_launch_command(argv)
+ env = {
+ "PYTHONPATH": str(INSTALL_ROOT),
+ "PROJECT_PATH": str(project),
+ "PYTHONUTF8": "1",
+ }
+ print(render_snippet(client, command=command, args=args, env=env), end="")
+ return 0
+
+
+def _count_chunks(db_path: Path) -> Optional[int]:
+ """Число чанков в SQLite-сторе; None — файла нет/схема не та."""
+ if not db_path.is_file():
+ return None
+ import sqlite3
+
+ try:
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5)
+ try:
+ return int(conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0])
+ finally:
+ conn.close()
+ except Exception as e:
+ logger.debug("chunk count failed for %s: %s", db_path, e)
+ return None
+
+
+def _doctor_report(project: Path) -> dict:
+ """Inventory of everything this project puts on disk (G6).
+
+ Read-only: no directory is created. Sizes are measured, never estimated.
+ """
+ data_root = _data_root_readonly()
+ venv_python = PYTHON_EXE
+
+ raw_layers = [
+ ("install_root", INSTALL_ROOT),
+ ("data_root", data_root),
+ ("project_chunks", project / ".mscodebase"),
+ ("project_legacy_index", project / ".codebase_indices"),
+ ("project_legacy_codebase", project / ".codebase"),
+ ("project_models", project / ".codebase_models"),
+ ]
+
+ # Nested paths would be counted twice (e.g. data_root inside install_root).
+ seen: list[Path] = []
+ layers = []
+ for name, path in raw_layers:
+ try:
+ resolved = path.resolve()
+ except OSError:
+ resolved = path
+ if any(resolved == s or s in resolved.parents for s in seen):
+ continue
+ seen.append(resolved)
+ size, files = _usage(path)
+ layers.append(
+ {
+ "name": name,
+ "path": str(path),
+ "exists": path.exists(),
+ "bytes": size,
+ "files": files,
+ }
+ )
+
+ chunks_db = project / ".mscodebase" / "chunks.db"
+ chunks = _count_chunks(chunks_db)
+
+ zed_settings = None
+ try:
+ from adapters.zed.zed_config import get_zed_config_dir
+
+ zed_settings = str(get_zed_config_dir() / "settings.json")
+ except Exception as e:
+ logger.debug("zed adapter unavailable for doctor: %s", e)
+
+ venv_version = None
+ if venv_python.exists():
+ r = _run(f'"{venv_python}" --version', timeout=10)
+ if r and r.returncode == 0:
+ venv_version = r.stdout.strip()
+
+ return {
+ "python": {
+ "version": sys.version.split()[0],
+ "executable": sys.executable,
+ "venv_python": str(venv_python),
+ "venv_python_exists": venv_python.exists(),
+ "venv_version": venv_version,
+ },
+ "install_root": str(INSTALL_ROOT),
+ "data_root": str(data_root),
+ "layers": layers,
+ "index": {
+ "path": str(chunks_db),
+ "exists": chunks_db.exists(),
+ "chunks": chunks,
+ },
+ "host_config": {"zed_settings": zed_settings},
+ "total_bytes": sum(layer["bytes"] for layer in layers),
+ "total_files": sum(layer["files"] for layer in layers),
+ }
+
+
+def _doctor_cli(argv: list[str]) -> int:
+ project = Path.cwd()
+ report = _doctor_report(project)
+ if "--json" in argv:
+ print(json.dumps(report, indent=2, ensure_ascii=False))
+ return 0
+
+ print("MSCodeBase — installed state")
+ print(f" python {report['python']['version']} ({report['python']['executable']})")
+ print(f" install root {report['install_root']}")
+ print(f" data root {report['data_root']}")
+ print()
+ for layer in report["layers"]:
+ mark = "+" if layer["exists"] else "-"
+ print(
+ f" {mark} {layer['name']:<22} {_human_bytes(layer['bytes']):>9}"
+ f" {layer['files']:>5} file(s) {layer['path']}"
+ )
+ index = report["index"]
+ state = "missing" if not index["exists"] else (
+ "unreadable" if index["chunks"] is None else f"{index['chunks']} chunk(s)"
+ )
+ print(f"\n index {state} ({index['path']})")
+ if report["host_config"]["zed_settings"]:
+ print(f" zed settings {report['host_config']['zed_settings']}")
+ print(
+ f"\n total {_human_bytes(report['total_bytes'])}"
+ f" over {report['total_files']} file(s)"
+ )
+ return 0
+
+
+def _clean_targets(project: Path) -> list[tuple[str, Path]]:
+ """Every layer an install can create, outermost first."""
+ return [
+ ("install_root", INSTALL_ROOT),
+ ("data_root", _data_root_readonly()),
+ ("project_chunks", project / ".mscodebase"),
+ ("project_legacy_index", project / ".codebase_indices"),
+ ("project_legacy_codebase", project / ".codebase"),
+ ("project_models", project / ".codebase_models"),
+ ]
+
+
+def _clean_refusal(path: Path) -> str:
+ """Why deleting path would be unsafe ('' = safe). Guards the source checkout."""
+ try:
+ resolved = path.resolve()
+ except OSError as e:
+ return f"unresolvable path ({e})"
+ if not resolved.exists():
+ return ""
+ if resolved == PROJECT_ROOT or resolved in PROJECT_ROOT.parents:
+ return "would delete the source checkout"
+ if resolved.parent == resolved:
+ return "filesystem root"
+ if resolved == Path.home().resolve():
+ return "home directory"
+ return ""
+
+
+def _clean_cli(argv: list[str]) -> int:
+ """Remove every artifact created by the installer, layer by layer."""
+ project = Path.cwd()
+ dry_run = "--dry-run" in argv
+ assume_yes = "--yes" in argv or "-y" in argv
+ as_json = "--json" in argv
+
+ plan = []
+ for name, path in _clean_targets(project):
+ size, files = _usage(path)
+ plan.append(
+ {
+ "name": name,
+ "path": str(path),
+ "exists": path.exists(),
+ "bytes": size,
+ "files": files,
+ "refusal": _clean_refusal(path),
+ }
+ )
+
+ existing = [item for item in plan if item["exists"]]
+ removed_bytes = 0
+
+ if not dry_run:
+ blocked = [item for item in existing if item["refusal"]]
+ if blocked:
+ for item in blocked:
+ print(f" ! {item['name']}: {item['refusal']} — {item['path']}", file=sys.stderr)
+ if not assume_yes:
+ if not sys.stdin.isatty():
+ print(
+ "refusing to delete without confirmation: re-run with --yes"
+ " (or use --dry-run to see the list)",
+ file=sys.stderr,
+ )
+ return 2
+ print("This will delete:")
+ for item in existing:
+ print(f" - {item['name']:<22} {_human_bytes(item['bytes']):>9} {item['path']}")
+ answer = input("Type 'yes' to delete everything listed above: ").strip().lower()
+ if answer != "yes":
+ print("aborted: nothing deleted")
+ return 1
+
+ for item in existing:
+ if item["refusal"]:
+ continue
+ path = Path(item["path"])
+ if path.is_dir():
+ _safe_rmtree(path)
+ else:
+ _safe_unlink(path)
+ removed_bytes += item["bytes"]
+
+ remaining = 0
+ remaining_detail = []
+ for name, path in _clean_targets(project):
+ size, _files = _usage(path)
+ remaining += size
+ if size:
+ remaining_detail.append((name, size, str(path)))
+
+ if as_json:
+ print(
+ json.dumps(
+ {
+ "dry_run": dry_run,
+ "targets": plan,
+ "removed_bytes": removed_bytes,
+ "remaining_bytes": remaining,
+ "remaining": [
+ {"name": n, "bytes": s, "path": p} for n, s, p in remaining_detail
+ ],
+ },
+ indent=2,
+ ensure_ascii=False,
+ )
+ )
+ else:
+ mode = "would remove" if dry_run else "removed"
+ for item in existing:
+ suffix = f" SKIPPED: {item['refusal']}" if item["refusal"] else ""
+ print(
+ f" {mode:<12} {item['name']:<22} {_human_bytes(item['bytes']):>9}"
+ f" {item['path']}{suffix}"
+ )
+ print(f"\n {mode}: {_human_bytes(removed_bytes) if not dry_run else _human_bytes(sum(i['bytes'] for i in existing))}")
+ print(f" remaining: {_human_bytes(remaining)}")
+ for name, size, path in remaining_detail:
+ print(f" · {name}: {_human_bytes(size)} {path}")
+
+ if dry_run:
+ return 0
+ # Locked files (a running MCP host) leave residue; that is a real failure.
+ return 0 if remaining == 0 else 1
+
+
+def _print_help() -> int:
+ print(
+ "usage: install.py [mode] [options]\n\n"
+ "Modes (no TUI):\n"
+ " (default) interactive install: venv, pip, optional Zed adapter\n"
+ " --sync-only copy the current sources into the install root\n"
+ " --print-config print a stdio snippet for an MCP host\n"
+ " --client {generic,opencode,zed,claude-code,cursor} (default: generic)\n"
+ " --python PATH interpret the snippet around this interpreter\n"
+ " --doctor versions, paths, sizes, index state (--json)\n"
+ " --clean remove every artifact created by the installer\n"
+ " --dry-run list paths and sizes, delete nothing\n"
+ " --yes skip the interactive confirmation\n"
+ " --json machine-readable report\n\n"
+ "Install options:\n"
+ " --with-models download ONNX/GGUF models (default: text-first, no models)\n"
+ " --no-zed do not touch Zed settings; host-neutral install\n"
+ " --yes accept every step without prompts\n\n"
+ "Environment:\n"
+ " MSCODEBASE_INSTALL_ROOT where the installed copy lives\n"
+ " MSCODEBASE_DATA_DIR where caches/logs/project artifacts live\n"
+ " PROJECT_PATH project the host should index\n"
+ )
+ return 0
+
+
+def _mode_from_argv(argv: list[str]) -> Optional[str]:
+ for mode in ("--print-config", "--doctor", "--clean", "--help"):
+ if mode in argv:
+ return mode
+ if "-h" in argv:
+ return "--help"
+ return None
+
+
# ─── Main ──────────────────────────────────────────────────
def _record_install_meta() -> None:
"""DEV-ONLY: метаданные для детекции рассинхрона source<->extension.
@@ -934,11 +1413,47 @@ def _record_install_meta() -> None:
logger.debug("_record_install_meta failed: %s", e)
+def _utf8_stdout() -> None:
+ """UTF-8 stdout on Windows for every mode (paths and reports contain non-ASCII).
+
+ The TUI already did this; the non-interactive modes used to inherit cp1251
+ and crashed on the first non-ASCII byte (found 2026-11-12 on a Cyrillic path).
+ """
+ if not IS_WINDOWS:
+ return
+ try:
+ sys.stdout = io.TextIOWrapper(
+ sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True
+ )
+ sys.stderr = io.TextIOWrapper(
+ sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True
+ )
+ except Exception as e: # noqa: BLE001 - never fail because of cosmetics
+ logger.debug("utf-8 stdout wrap failed: %s", e)
+
+
def main() -> None:
+ argv = sys.argv[1:]
+ _utf8_stdout()
+ mode = _mode_from_argv(argv)
+ if mode == "--help":
+ sys.exit(_print_help())
+ if mode == "--print-config":
+ client = "generic"
+ if "--client" in argv:
+ idx = argv.index("--client")
+ client = argv[idx + 1] if idx + 1 < len(argv) else ""
+ sys.exit(_print_config(client, Path.cwd(), argv))
+ if mode == "--doctor":
+ sys.exit(_doctor_cli(argv))
+ if mode == "--clean":
+ sys.exit(_clean_cli(argv))
+
_enable_ansi()
lang = _detect_lang()
- if IS_WINDOWS:
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
+ # stdout is already UTF-8: _utf8_stdout() ran at the top of main(). Re-wrapping
+ # here closed the previous wrapper's buffer (ValueError: I/O on closed file,
+ # found 2026-11-12 by the install→doctor→clean roundtrip).
STEPS.sort(key=lambda x: x[0])
diff --git a/scripts/check_docs_vs_config.py b/scripts/check_docs_vs_config.py
new file mode 100644
index 00000000..b4a1f54a
--- /dev/null
+++ b/scripts/check_docs_vs_config.py
@@ -0,0 +1,123 @@
+"""Docs = code, part 2: every environment variable in CONFIGURATION.md must exist.
+
+`check_docs_vs_installer.py` covers installer flags and client names. This guard
+covers the other mechanical class of drift: a documented setting the code never
+reads. It was written after finding `MSCODEBASE_MAX_FILE_SIZE_MB` in
+`docs/*/CONFIGURATION.md` while `src/config/settings.py:425` reads
+`MAX_FILE_SIZE_MB` (no prefix) — a name a user would copy and get nothing from.
+
+Scope: names in the first column of the tables in `docs/en/CONFIGURATION.md`,
+looked up in the code corpus (src/, scripts/, adapters/, .opencode/, .githooks/,
+.env.example, install.py). Defaults are not compared here: they live in lambdas
+and conditional expressions, and a wrong comparison is worse than none.
+
+Exit: 0 clean, 1 violations, 2 nothing to check (a guard that finds no variables
+proves nothing).
+
+Usage:
+ python scripts/check_docs_vs_config.py [--root .]
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+VAR_RE = re.compile(r"`([A-Z][A-Z0-9_]{2,})`")
+
+CORPUS_GLOBS = (
+ "src/**/*.py",
+ "scripts/**/*.py",
+ "adapters/**/*.py",
+ "install.py",
+ ".env.example",
+ ".opencode/**/*.ts",
+ ".githooks/*",
+)
+
+# Tokens that look like variables but are documentation artefacts, not settings.
+IGNORED = {
+ "README",
+ "JSON",
+ "JSONC",
+ "MCP",
+ "LSP",
+ "CI",
+ "API",
+ "HTTP",
+ "SQL",
+ "CPU",
+ "GPU",
+ "OS",
+ "URL",
+ "URI",
+ "ID",
+ "TTL",
+ "LRU",
+ "FTS",
+ "BM25",
+ "RCE",
+ "AST",
+ "VOR",
+}
+
+
+def documented_vars(config_doc: Path) -> list[str]:
+ names: list[str] = []
+ for line in config_doc.read_text(encoding="utf-8", errors="replace").splitlines():
+ if not line.startswith("|"):
+ continue
+ first_cell = line.strip().strip("|").split("|")[0]
+ for name in VAR_RE.findall(first_cell):
+ if name in IGNORED or name in names:
+ continue
+ names.append(name)
+ return names
+
+
+def corpus(root: Path) -> str:
+ chunks: list[str] = []
+ for pattern in CORPUS_GLOBS:
+ for path in root.glob(pattern):
+ if not path.is_file():
+ continue
+ try:
+ chunks.append(path.read_text(encoding="utf-8", errors="replace"))
+ except OSError:
+ continue
+ return "\n".join(chunks)
+
+
+def check(root: Path) -> tuple[list[str], int]:
+ doc = root / "docs" / "en" / "CONFIGURATION.md"
+ if not doc.is_file():
+ return ["docs/en/CONFIGURATION.md not found"], 0
+ names = documented_vars(doc)
+ if not names:
+ return ["no environment variables found in CONFIGURATION.md"], 0
+ blob = corpus(root)
+ missing = [n for n in names if n not in blob]
+ return [f"CONFIGURATION.md documents {n!r}, the code never reads it" for n in missing], len(names)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--root", default=str(Path(__file__).resolve().parents[1]))
+ args = ap.parse_args()
+
+ problems, checked = check(Path(args.root).resolve())
+ if checked == 0 and problems:
+ for problem in problems:
+ print(f" ✗ {problem}")
+ print("docs=config guard: nothing proven")
+ return 2
+ for problem in problems:
+ print(f" ✗ {problem}")
+ print(f"docs=config guard: {checked} variable(s) checked, {len(problems)} problem(s)")
+ return 0 if not problems else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_docs_vs_installer.py b/scripts/check_docs_vs_installer.py
new file mode 100644
index 00000000..a29eb37b
--- /dev/null
+++ b/scripts/check_docs_vs_installer.py
@@ -0,0 +1,101 @@
+"""Docs = code: every installer flag named in documentation must exist.
+
+G7 rule: no sentence in the docs may describe behaviour the code does not have.
+Full semantic verification is a human job; this guard covers the mechanical part
+that already produced a real defect — `docs/*/INSTALL.md` documented
+`install.py --uninstall`, a flag the installer never had.
+
+What it checks:
+ 1. every `install.py --flag` mentioned in docs/ and README.md exists in
+ install.py (source literal or the usage text);
+ 2. every `--client ` mentioned in docs is accepted by
+ `adapters/clients/snippets.CLIENTS`;
+ 3. every documented client has a section in docs/en/CLIENTS.md.
+
+Exit codes: 0 clean, 1 violations, 2 nothing to check (empty corpus is a
+failure, not a pass — a guard that finds no files proves nothing).
+
+Usage:
+ python scripts/check_docs_vs_installer.py [--root .]
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from adapters.clients.snippets import CLIENTS # noqa: E402
+
+FLAG_RE = re.compile(r"install\.py\s+((?:--[a-z][a-z0-9-]*\s*)+)")
+CLIENT_RE = re.compile(r"--client\s+([a-z][a-z0-9-]*)")
+
+DOC_GLOBS = ("README.md", "docs/*/INSTALL.md", "docs/*/CLIENTS.md", "docs/*/DATA_MAP.md", "docs/en/FAQ.md")
+
+
+def install_flags(root: Path) -> set[str]:
+ """Flags the installer actually knows about (string literals in install.py)."""
+ text = (root / "install.py").read_text(encoding="utf-8", errors="replace")
+ return set(re.findall(r'"(--[a-z][a-z0-9-]*)"', text))
+
+
+def doc_files(root: Path) -> list[Path]:
+ files: list[Path] = []
+ for pattern in DOC_GLOBS:
+ files.extend(sorted(root.glob(pattern)))
+ return [p for p in files if p.is_file()]
+
+
+def check(root: Path) -> tuple[list[str], int]:
+ flags = install_flags(root)
+ files = doc_files(root)
+ if not files:
+ return ["no documentation found to check"], 0
+
+ problems: list[str] = []
+ checked_flags = 0
+ for path in files:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ for match in FLAG_RE.finditer(text):
+ for flag in match.group(1).split():
+ checked_flags += 1
+ if flag not in flags:
+ problems.append(f"{path.relative_to(root)}: unknown installer flag {flag}")
+ for match in CLIENT_RE.finditer(text):
+ client = match.group(1)
+ if client not in CLIENTS and client != "":
+ problems.append(
+ f"{path.relative_to(root)}: unknown client {client!r}"
+ f" (known: {', '.join(CLIENTS)})"
+ )
+
+ en_clients = root / "docs" / "en" / "CLIENTS.md"
+ if en_clients.is_file():
+ body = en_clients.read_text(encoding="utf-8", errors="replace")
+ for client in CLIENTS:
+ if f"--client {client}" not in body:
+ problems.append(f"docs/en/CLIENTS.md: no copy-paste section for {client!r}")
+
+ return problems, checked_flags
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--root", default=str(Path(__file__).resolve().parents[1]))
+ args = ap.parse_args()
+
+ problems, checked = check(Path(args.root).resolve())
+ if checked == 0 and not problems:
+ print("docs=code guard: no installer flags referenced in docs — nothing proven")
+ return 2
+ for problem in problems:
+ print(f" ✗ {problem}")
+ print(f"docs=code guard: {checked} flag reference(s) checked, {len(problems)} problem(s)")
+ return 0 if not problems else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/verify_client_snippets.py b/scripts/verify_client_snippets.py
new file mode 100644
index 00000000..71b2dab1
--- /dev/null
+++ b/scripts/verify_client_snippets.py
@@ -0,0 +1,177 @@
+"""Cold-start check for the client snippets printed by `install.py --print-config`.
+
+For every client it renders the snippet exactly as the installer does, launches
+the command from it in a throwaway directory, performs an MCP `initialize`
+handshake and then `tools/list`, and reports PASS/FAIL per client. This is the
+mechanical proof behind the copy-paste sections of docs/CLIENTS.md: the snippet
+is not just valid JSON, it starts a server that answers.
+
+Usage:
+ python scripts/verify_client_snippets.py [--python PATH] [--timeout SEC]
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO))
+
+# ENCODING SAFETY (Windows): the server's stderr tail is Russian, and a cp1252
+# console crashes the report with UnicodeEncodeError (windows-latest, 2026-11-12).
+if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
+ try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ except (AttributeError, ValueError):
+ pass
+
+from adapters.clients.snippets import CLIENTS, snapshot # noqa: E402
+
+_TOP_LEVEL = {
+ "generic": "mcpServers",
+ "claude-code": "mcpServers",
+ "cursor": "mcpServers",
+ "opencode": "mcp",
+ "zed": "context_servers",
+}
+
+INITIALIZE = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "snippet-smoke", "version": "0"},
+ },
+}
+LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
+
+
+def _entry(client: str, command: str, args: list[str], env: dict[str, str]) -> tuple[str, list[str], dict[str, str]]:
+ payload = snapshot(client, command=command, args=args, env=env)[_TOP_LEVEL[client]]["mscodebase"]
+ if client == "opencode":
+ return payload["command"][0], payload["command"][1:], payload["environment"]
+ return payload["command"], payload["args"], payload["env"]
+
+
+def _read_message(stream, timeout: float):
+ """Next JSON object from stdout, or None on timeout/EOF."""
+ import queue
+ import threading
+
+ box: queue.Queue = queue.Queue(maxsize=1)
+
+ def _worker():
+ while True:
+ line = stream.readline()
+ if not line:
+ box.put(None)
+ return
+ line = line.strip()
+ if line.startswith("{"):
+ try:
+ box.put(json.loads(line))
+ return
+ except json.JSONDecodeError:
+ continue
+
+ threading.Thread(target=_worker, daemon=True).start()
+ try:
+ return box.get(timeout=timeout)
+ except queue.Empty:
+ return None
+
+
+def _stderr_drain(stream, sink) -> None:
+ """Keep reading stderr so the child never blocks on a full pipe.
+
+ Found 2026-11-12 on windows-latest: the startup log is larger than the pipe
+ buffer, the server blocked writing stderr and never answered `initialize`.
+ """
+ import threading
+
+ def _worker():
+ try:
+ for line in iter(stream.readline, ""):
+ sink.append(line.rstrip())
+ except (ValueError, OSError):
+ pass
+
+ threading.Thread(target=_worker, daemon=True).start()
+
+
+def _probe(client: str, command: str, args: list[str], env: dict[str, str], timeout: int) -> str:
+ from collections import deque
+
+ exe, cli_args, cli_env = _entry(client, command, args, env)
+ full_env = os.environ.copy()
+ full_env.update(cli_env)
+ # src/main.py:126 reads LOG_LEVEL; a quiet server keeps the stderr pipe from
+ # filling on a cold runner (the probe only needs the protocol answer).
+ full_env.setdefault("LOG_LEVEL", "WARNING")
+ stderr_tail: deque = deque(maxlen=20)
+ with tempfile.TemporaryDirectory(prefix="mcp-snippet-", ignore_cleanup_errors=True) as cwd:
+ proc = subprocess.Popen(
+ [exe, *cli_args],
+ cwd=cwd,
+ env=full_env,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ _stderr_drain(proc.stderr, stderr_tail)
+ try:
+ proc.stdin.write(json.dumps(INITIALIZE) + "\n")
+ proc.stdin.flush()
+ init = _read_message(proc.stdout, timeout)
+ if not init or "result" not in init:
+ tail = " | ".join(stderr_tail) if stderr_tail else ""
+ return f"FAIL: no initialize result ({tail[-300:]})"
+
+ proc.stdin.write(
+ json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n"
+ )
+ proc.stdin.write(json.dumps(LIST_TOOLS) + "\n")
+ proc.stdin.flush()
+ listing = _read_message(proc.stdout, timeout)
+ tools = len(listing.get("result", {}).get("tools", [])) if listing else 0
+ name = init["result"].get("serverInfo", {}).get("name", "?")
+ return f"PASS: server={name} tools={tools}"
+ finally:
+ try:
+ proc.kill()
+ except OSError:
+ pass
+ proc.wait(timeout=30)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--python", default="", help="interpreter/entry point to probe (default: current)")
+ ap.add_argument("--timeout", type=int, default=300)
+ ap.add_argument("--install-root", default=os.environ.get("MSCODEBASE_INSTALL_ROOT", str(REPO)))
+ args = ap.parse_args()
+
+ command = args.python or sys.executable
+ env = {"PYTHONPATH": args.install_root, "PROJECT_PATH": tempfile.gettempdir(), "PYTHONUTF8": "1"}
+
+ failures = 0
+ for client in CLIENTS:
+ result = _probe(client, command, ["-u", "-m", "src.main"], env, args.timeout)
+ failures += 0 if result.startswith("PASS") else 1
+ print(f"{client:<12} {result}")
+ return 0 if failures == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_docs_vs_config.py b/tests/test_docs_vs_config.py
new file mode 100644
index 00000000..743a7a6a
--- /dev/null
+++ b/tests/test_docs_vs_config.py
@@ -0,0 +1,59 @@
+"""Red-first tests for the configuration docs guard (G7).
+
+Written after `docs/*/CONFIGURATION.md` documented `MSCODEBASE_MAX_FILE_SIZE_MB`
+while `src/config/settings.py:425` reads `MAX_FILE_SIZE_MB`: a user copying the
+documented name gets no effect, and nothing noticed for months.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO / "scripts"))
+
+import check_docs_vs_config as guard # noqa: E402
+
+
+def test_real_tree_is_clean_and_checks_many_vars():
+ problems, checked = guard.check(REPO)
+ assert checked >= 50, f"only {checked} variables parsed — the guard is blind"
+ assert problems == [], problems
+
+
+def test_documented_but_unread_variable_is_reported(tmp_path):
+ (tmp_path / "docs" / "en").mkdir(parents=True)
+ (tmp_path / "docs" / "en" / "CONFIGURATION.md").write_text(
+ "| Variable | Default | Effect |\n"
+ "|---|---|---|\n"
+ "| `MSCODEBASE_REAL_FLAG` | `true` | real |\n"
+ "| `MSCODEBASE_INVENTED_FLAG` | `10` | invented |\n",
+ encoding="utf-8",
+ )
+ (tmp_path / "src").mkdir()
+ (tmp_path / "src" / "settings.py").write_text(
+ 'FLAG = os.getenv("MSCODEBASE_REAL_FLAG", "true")\n', encoding="utf-8"
+ )
+
+ problems, checked = guard.check(tmp_path)
+ assert checked == 2
+ assert any("MSCODEBASE_INVENTED_FLAG" in p for p in problems), problems
+ assert not any("MSCODEBASE_REAL_FLAG" in p for p in problems), problems
+
+
+def test_max_file_size_name_matches_the_code():
+ """The historical defect: the doc must not resurrect the prefixed name."""
+ doc = (REPO / "docs" / "en" / "CONFIGURATION.md").read_text(encoding="utf-8")
+ settings = (REPO / "src" / "config" / "settings.py").read_text(encoding="utf-8")
+ assert 'os.getenv("MAX_FILE_SIZE_MB", "10")' in settings
+ assert "MSCODEBASE_MAX_FILE_SIZE_MB" not in doc
+ assert "MAX_FILE_SIZE_MB" in doc
+
+
+def test_empty_corpus_is_not_a_pass(tmp_path):
+ (tmp_path / "docs" / "en").mkdir(parents=True)
+ (tmp_path / "docs" / "en" / "CONFIGURATION.md").write_text("# no tables\n", encoding="utf-8")
+ problems, checked = guard.check(tmp_path)
+ assert checked == 0
+ assert problems
diff --git a/tests/test_docs_vs_installer.py b/tests/test_docs_vs_installer.py
new file mode 100644
index 00000000..f4af410e
--- /dev/null
+++ b/tests/test_docs_vs_installer.py
@@ -0,0 +1,62 @@
+"""Red-first tests for the docs=code guard (G7).
+
+The guard exists because documentation drifted from `install.py` and nobody
+noticed: `docs/*/INSTALL.md` shipped `install.py --uninstall`, a flag that never
+existed. These tests pin both directions: a stale flag must be reported, and the
+real tree must pass.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO / "scripts"))
+
+import check_docs_vs_installer as guard # noqa: E402
+
+
+def test_real_tree_passes_and_checks_something():
+ problems, checked = guard.check(REPO)
+ assert checked > 0, "guard found no flag references — it would pass vacuously"
+ assert problems == [], problems
+
+
+def test_installed_flag_vocabulary_is_complete():
+ flags = guard.install_flags(REPO)
+ for expected in ("--print-config", "--doctor", "--clean", "--dry-run", "--json",
+ "--with-models", "--no-zed", "--sync-only", "--client", "--python"):
+ assert expected in flags, f"{expected} missing from install.py"
+
+
+def test_unknown_flag_in_docs_is_reported(tmp_path):
+ (tmp_path / "install.py").write_text('KNOWN = "--doctor"\n', encoding="utf-8")
+ (tmp_path / "docs" / "en").mkdir(parents=True)
+ (tmp_path / "README.md").write_text("run `install.py --uninstall`\n", encoding="utf-8")
+ (tmp_path / "docs" / "en" / "INSTALL.md").write_text("`install.py --doctor`\n", encoding="utf-8")
+ (tmp_path / "docs" / "en" / "CLIENTS.md").write_text(
+ "\n".join(f"--client {c}" for c in guard.CLIENTS), encoding="utf-8"
+ )
+
+ problems, checked = guard.check(tmp_path)
+ assert checked == 2
+ assert any("--uninstall" in p for p in problems), problems
+
+
+def test_unknown_client_in_docs_is_reported(tmp_path):
+ (tmp_path / "install.py").write_text('KNOWN = "--print-config"\n', encoding="utf-8")
+ (tmp_path / "docs" / "en").mkdir(parents=True)
+ (tmp_path / "docs" / "en" / "CLIENTS.md").write_text(
+ "\n".join(f"--client {c}" for c in guard.CLIENTS) + "\n--client emacs\n",
+ encoding="utf-8",
+ )
+ problems, _checked = guard.check(tmp_path)
+ assert any("emacs" in p for p in problems), problems
+
+
+def test_empty_docs_corpus_is_not_a_pass(tmp_path):
+ (tmp_path / "install.py").write_text("", encoding="utf-8")
+ problems, checked = guard.check(tmp_path)
+ assert checked == 0
+ assert problems == ["no documentation found to check"]
diff --git a/tests/test_install_cli.py b/tests/test_install_cli.py
new file mode 100644
index 00000000..f56b3ee8
--- /dev/null
+++ b/tests/test_install_cli.py
@@ -0,0 +1,395 @@
+"""Red-first tests for the installer's host-neutral CLI (G5/G6).
+
+Every check drives `install.py` as a subprocess with isolated roots
+(`MSCODEBASE_INSTALL_ROOT`, `MSCODEBASE_DATA_DIR`) so nothing touches the real
+user profile. The snippets are not just parsed: the generic snippet's command is
+actually launched and must answer an MCP `initialize` request.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import sqlite3
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[1]
+INSTALL_PY = REPO / "install.py"
+
+SNIPPET_CLIENTS = ["generic", "opencode", "zed", "claude-code", "cursor"]
+
+TOP_LEVEL_KEY = {
+ "generic": "mcpServers",
+ "claude-code": "mcpServers",
+ "cursor": "mcpServers",
+ "opencode": "mcp",
+ "zed": "context_servers",
+}
+
+
+def _env(install_root: Path, data_dir: Path) -> dict[str, str]:
+ env = os.environ.copy()
+ env.update(
+ {
+ "MSCODEBASE_INSTALL_ROOT": str(install_root),
+ "MSCODEBASE_DATA_DIR": str(data_dir),
+ "PYTHONIOENCODING": "utf-8",
+ "PYTHONUTF8": "1",
+ }
+ )
+ return env
+
+
+def run_install(
+ args: list[str],
+ *,
+ install_root: Path,
+ data_dir: Path,
+ cwd: Path,
+ stdin_text: str | None = None,
+ timeout: int = 120,
+) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable, str(INSTALL_PY), *args],
+ cwd=str(cwd),
+ env=_env(install_root, data_dir),
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ input=stdin_text if stdin_text is not None else "",
+ timeout=timeout,
+ )
+
+
+def entry_json(client: str, payload: dict) -> dict:
+ if client == "opencode":
+ # V2: mcp.servers.
+ return payload["mcp"]["servers"]["mscodebase"]
+ return payload[TOP_LEVEL_KEY[client]]["mscodebase"]
+
+
+# ─── G5: --print-config ────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("client", SNIPPET_CLIENTS)
+def test_print_config_is_valid_json_per_client(client, tmp_path):
+ install_root = tmp_path / "install"
+ data_dir = tmp_path / "data"
+ project = tmp_path / "project"
+ project.mkdir()
+
+ proc = run_install(
+ ["--print-config", "--client", client],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ )
+ assert proc.returncode == 0, proc.stderr
+ payload = json.loads(proc.stdout)
+ entry = entry_json(client, payload)
+
+ if client == "opencode":
+ command = entry["command"]
+ assert entry["type"] == "local"
+ env = entry["environment"]
+ assert "servers" in payload["mcp"], "OpenCode V2 keeps servers under mcp.servers"
+ elif client == "zed":
+ command = [entry["command"], *entry["args"]]
+ env = entry["env"]
+ assert entry["enabled"] is True
+ else:
+ command = [entry["command"], *entry["args"]]
+ env = entry["env"]
+
+ name = Path(command[0]).name
+ assert name.startswith(("python", "mscodebase-mcp")), name
+ assert command[1:] == ["-u", "-m", "src.main"]
+ assert Path(env["PYTHONPATH"]).resolve() == install_root.resolve()
+ assert env["PYTHONUTF8"] == "1"
+ assert Path(env["PROJECT_PATH"]).resolve() == project.resolve()
+
+
+def test_print_config_rejects_unknown_client(tmp_path):
+ proc = run_install(
+ ["--print-config", "--client", "not-a-client"],
+ install_root=tmp_path / "install",
+ data_dir=tmp_path / "data",
+ cwd=tmp_path,
+ )
+ assert proc.returncode == 2
+ assert "not-a-client" in (proc.stdout + proc.stderr)
+
+
+def test_print_config_default_client_is_generic(tmp_path):
+ proc = run_install(
+ ["--print-config"],
+ install_root=tmp_path / "install",
+ data_dir=tmp_path / "data",
+ cwd=tmp_path,
+ )
+ assert proc.returncode == 0, proc.stderr
+ assert "mcpServers" in json.loads(proc.stdout)
+
+
+def test_generic_snippet_actually_starts_the_server(tmp_path):
+ """The printed snippet must be copy-paste launchable (real MCP handshake)."""
+ project = tmp_path / "project"
+ project.mkdir()
+
+ proc = run_install(
+ ["--print-config", "--client", "generic", "--python", sys.executable],
+ install_root=REPO,
+ data_dir=tmp_path / "data",
+ cwd=project,
+ )
+ assert proc.returncode == 0, proc.stderr
+ entry = entry_json("generic", json.loads(proc.stdout))
+
+ env = os.environ.copy()
+ env.update(entry["env"])
+ env["MSCODEBASE_DATA_DIR"] = str(tmp_path / "data")
+
+ request = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "snippet-probe", "version": "0"},
+ },
+ }
+ with subprocess.Popen(
+ [entry["command"], *entry["args"]],
+ cwd=str(project),
+ env=env,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ ) as proc2:
+ out, err = proc2.communicate(json.dumps(request) + "\n", timeout=300)
+
+ answer = None
+ for line in out.splitlines():
+ line = line.strip()
+ if line.startswith("{"):
+ try:
+ candidate = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if candidate.get("id") == 1:
+ answer = candidate
+ break
+ assert answer is not None, textwrap.shorten(out + err, 800)
+ assert answer["result"]["serverInfo"]["name"], answer
+
+
+# ─── G6: --doctor ──────────────────────────────────────────────────────
+
+
+def make_install_artifacts(install_root: Path, data_dir: Path, project: Path) -> None:
+ project.mkdir(parents=True, exist_ok=True)
+ (install_root / "venv" / "Scripts").mkdir(parents=True)
+ (install_root / "venv" / "Scripts" / "python.exe").write_bytes(b"x" * 100)
+ (install_root / "src").mkdir()
+ (install_root / "src" / "main.py").write_text("print('hi')\n", encoding="utf-8")
+ (data_dir / "projects" / "deadbeef").mkdir(parents=True)
+ (data_dir / "projects" / "deadbeef" / "graph.db").write_bytes(b"g" * 200)
+ (data_dir / "logs").mkdir()
+ (data_dir / "logs" / "server.log").write_text("line\n" * 5, encoding="utf-8")
+ (project / ".mscodebase").mkdir()
+ conn = sqlite3.connect(project / ".mscodebase" / "chunks.db")
+ conn.execute("CREATE TABLE chunks (id TEXT, text TEXT)")
+ conn.executemany("INSERT INTO chunks VALUES (?, ?)", [("1", "a"), ("2", "b"), ("3", "c")])
+ conn.commit()
+ conn.close()
+
+
+def test_doctor_reports_layers_and_index(tmp_path):
+ install_root = tmp_path / "install"
+ data_dir = tmp_path / "data"
+ project = tmp_path / "project"
+ make_install_artifacts(install_root, data_dir, project)
+
+ proc = run_install(
+ ["--doctor", "--json"],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ )
+ assert proc.returncode == 0, proc.stderr
+ report = json.loads(proc.stdout)
+
+ layers = {layer["name"]: layer for layer in report["layers"]}
+ assert layers["install_root"]["bytes"] > 0
+ assert layers["data_root"]["bytes"] > 0
+ assert layers["project_chunks"]["bytes"] > 0
+ assert report["index"]["chunks"] == 3
+ assert report["total_bytes"] >= 500
+
+
+def test_doctor_human_output_has_paths(tmp_path):
+ install_root = tmp_path / "install"
+ data_dir = tmp_path / "data"
+ project = tmp_path / "project"
+ make_install_artifacts(install_root, data_dir, project)
+
+ proc = run_install(
+ ["--doctor"],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ )
+ assert proc.returncode == 0, proc.stderr
+ assert str(install_root) in proc.stdout
+ assert str(data_dir) in proc.stdout
+ assert "chunks" in proc.stdout.lower()
+
+
+# ─── G6: --clean ───────────────────────────────────────────────────────
+
+
+def test_clean_dry_run_removes_nothing(tmp_path):
+ install_root = tmp_path / "install"
+ data_dir = tmp_path / "data"
+ project = tmp_path / "project"
+ make_install_artifacts(install_root, data_dir, project)
+
+ proc = run_install(
+ ["--clean", "--dry-run"],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ )
+ assert proc.returncode == 0, proc.stderr
+ assert str(install_root) in proc.stdout
+ assert str(data_dir) in proc.stdout
+ assert install_root.exists() and data_dir.exists()
+ assert (project / ".mscodebase" / "chunks.db").exists()
+
+
+def test_clean_without_confirmation_refuses(tmp_path):
+ install_root = tmp_path / "install"
+ data_dir = tmp_path / "data"
+ project = tmp_path / "project"
+ make_install_artifacts(install_root, data_dir, project)
+
+ proc = run_install(
+ ["--clean"],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ stdin_text="",
+ )
+ assert proc.returncode == 2
+ assert "--yes" in (proc.stdout + proc.stderr)
+ assert install_root.exists() and data_dir.exists()
+
+
+def test_clean_yes_leaves_zero_residue(tmp_path):
+ install_root = tmp_path / "install"
+ data_dir = tmp_path / "data"
+ project = tmp_path / "project"
+ make_install_artifacts(install_root, data_dir, project)
+
+ proc = run_install(
+ ["--clean", "--yes", "--json"],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ )
+ assert proc.returncode == 0, proc.stdout + proc.stderr
+ report = json.loads(proc.stdout)
+ assert report["removed_bytes"] > 0
+ assert report["remaining_bytes"] == 0
+ assert not install_root.exists()
+ assert not (project / ".mscodebase").exists()
+ assert not (data_dir / "projects").exists()
+
+ after = run_install(
+ ["--doctor", "--json"],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ )
+ assert after.returncode == 0, after.stderr
+ assert json.loads(after.stdout)["total_bytes"] == 0
+
+
+# ─── G6: models are opt-in (text-first default) ────────────────────────
+
+
+def _install_module():
+ spec = importlib.util.spec_from_file_location("mscodebase_install", INSTALL_PY)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_models_are_opt_in():
+ mod = _install_module()
+ assert mod.models_enabled([]) is False
+ assert mod.models_enabled(["--sync-only"]) is False
+ assert mod.models_enabled(["--with-models"]) is True
+
+
+def test_real_sync_install_then_doctor_then_clean_leaves_zero(tmp_path):
+ """DoD G6: a real install (--sync-only), doctor sees it, clean leaves nothing."""
+ install_root = tmp_path / "install"
+ data_dir = tmp_path / "data"
+ project = tmp_path / "project"
+ project.mkdir()
+
+ install = run_install(
+ ["--sync-only"],
+ install_root=install_root,
+ data_dir=data_dir,
+ cwd=project,
+ timeout=300,
+ )
+ assert install.returncode == 0, install.stderr
+ assert (install_root / "install.py").is_file()
+ assert (install_root / "src" / "main.py").is_file()
+
+ doctor = run_install(["--doctor", "--json"], install_root=install_root, data_dir=data_dir, cwd=project)
+ assert doctor.returncode == 0, doctor.stderr
+ report = json.loads(doctor.stdout)
+ layers = {layer["name"]: layer for layer in report["layers"]}
+ assert layers["install_root"]["files"] > 100, layers["install_root"]
+ assert report["total_bytes"] > 1_000_000
+
+ clean = run_install(
+ ["--clean", "--yes", "--json"], install_root=install_root, data_dir=data_dir, cwd=project
+ )
+ assert clean.returncode == 0, clean.stdout + clean.stderr
+ assert json.loads(clean.stdout)["remaining_bytes"] == 0
+ assert not install_root.exists()
+
+
+@pytest.mark.parametrize("step_name", ["step_gguf", "step_models"])
+def test_text_first_default_skips_downloads(step_name, monkeypatch):
+ mod = _install_module()
+ step = getattr(mod, step_name)
+
+ def _boom(*_a, **_k): # pragma: no cover - must never run
+ raise AssertionError("download attempted without --with-models")
+
+ monkeypatch.setattr(mod.sys, "argv", ["install.py"])
+ if step_name == "step_models":
+ monkeypatch.setattr(mod, "_download_onnx_models", _boom, raising=False)
+
+ lines: list[tuple[str, str]] = []
+ step(lines, "en")
+ joined = " ".join(text for _c, text in lines).lower()
+ assert "skip" in joined, lines
+ assert "--with-models" in joined, lines