\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/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/README.md b/docs/ru/README.md
index c257ab84..9587c17d 100644
--- a/docs/ru/README.md
+++ b/docs/ru/README.md
@@ -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/zh/CLIENTS.md b/docs/zh/CLIENTS.md
index b0a94f1a..4e87c37e 100644
--- a/docs/zh/CLIENTS.md
+++ b/docs/zh/CLIENTS.md
@@ -36,7 +36,7 @@ Linux : ~/.local/share/zed/extensions/mscodebase-intelligence
用 `MSCODEBASE_INSTALL_ROOT=` 可放到任意位置。缓存、日志与项目产物在别处
(`MSCODEBASE_DATA_DIR`,默认 `%LOCALAPPDATA%\mscodebase`)。完整清单见
-
+[DATA_MAP.md](DATA_MAP.md)。
不用安装器的替代方式(任何提供 console scripts 的包管理器):
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/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/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/install.py b/install.py
index 050f6c9e..90f06807 100644
--- a/install.py
+++ b/install.py
@@ -12,6 +12,9 @@
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:
@@ -46,7 +49,10 @@
машине без Zed и без adapters/ — адаптер подключается лениво внутри шагов.
2. INSTALL_ROOT отвязан от Zed: MSCODEBASE_INSTALL_ROOT переопределяет каталог
установки (историческое имя папки расширения остаётся только default).
- 3. Добавлены --print-config/--help: готовый stdio-сниппет для любого MCP-клиента.
+ 3. Добавлены --print-config/--doctor/--clean/--help: сниппет для любого MCP-
+ клиента, инвентарь «что стоит и сколько весит», полное удаление по слоям.
+ 4. Модели стали opt-in (--with-models): по умолчанию text-first, ни одного
+ сетевого запроса за модели во время установки.
"""
import io
@@ -711,7 +717,12 @@ def step_llama(lines, lang):
def step_gguf(lines, lang):
"""Скачивает 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
@@ -764,7 +775,12 @@ def step_models(lines, lang):
Проверяет по порядку: 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),
@@ -951,15 +967,70 @@ def _build_uninstaller() -> str:
)
-# ─── Non-interactive modes: config snippet (--print-config) ────────────────
+# ─── 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]]:
@@ -1007,10 +1078,258 @@ def _print_config(client: str, project: Path, argv: list[str]) -> int:
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:
@@ -1022,7 +1341,13 @@ def _print_help() -> int:
" --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"
@@ -1034,7 +1359,7 @@ def _print_help() -> int:
def _mode_from_argv(argv: list[str]) -> Optional[str]:
- for mode in ("--print-config", "--help"):
+ for mode in ("--print-config", "--doctor", "--clean", "--help"):
if mode in argv:
return mode
if "-h" in argv:
@@ -1119,6 +1444,10 @@ def main() -> None:
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()
diff --git a/tests/test_install_cli.py b/tests/test_install_cli.py
index e398dae5..c3a74b55 100644
--- a/tests/test_install_cli.py
+++ b/tests/test_install_cli.py
@@ -1,4 +1,4 @@
-"""Red-first tests for the installer's host-neutral CLI (G5).
+"""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
@@ -11,6 +11,7 @@
import importlib.util
import json
import os
+import sqlite3
import subprocess
import sys
import textwrap
@@ -200,6 +201,207 @@ def test_generic_snippet_actually_starts_the_server(tmp_path):
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
+
+
# ─── G5: --yes must run the steps, not skip all of them ────────────────
@@ -226,4 +428,3 @@ def _fake_step(lines, lang):
mod.main()
assert calls == ["ran"], "auto-yes marked the step failed instead of running it"
-