mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-27 07:56:46 +00:00
feat(analog): add claw-analog minimal harness
Adds claw-analog minimal harness for lean, predictable tool execution.
This commit is contained in:
389
how_to_run.md
Normal file
389
how_to_run.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# claw-analog — как запускать и как это устроено
|
||||||
|
|
||||||
|
Минимальный агент поверх того же стека API, что и основной CLI [`claw`](rust/README.md): провайдеры Anthropic / OpenAI‑совместимые / xAI выбираются по модели и переменным окружения (см. [USAGE.md](USAGE.md)).
|
||||||
|
|
||||||
|
Дальше в примерах **рабочий каталог** — папка **`claw-code-main\rust`** (внутри клона репозитория). Если приглашение PowerShell уже `…\claw-code-main\rust>`, **не** выполняйте второй раз `cd rust` (иначе будет `rust\rust` и ошибка пути).
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Установленный **Rust** и **cargo** (в PATH: обычно `%USERPROFILE%\.cargo\bin` на Windows).
|
||||||
|
- Ключ API для выбранного провайдера (например `ANTHROPIC_API_KEY`).
|
||||||
|
|
||||||
|
## Сборка и справка
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\path\to\claw-code-main\rust
|
||||||
|
cargo build -p claw-analog
|
||||||
|
cargo run -p claw-analog -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Диагностика (`doctor`)
|
||||||
|
|
||||||
|
Подкоманда **`claw-analog doctor`** (у неё свой `--help`, отдельно от основного режима):
|
||||||
|
|
||||||
|
- **превью конфигурации** — итог после слияния **`.claw-analog.toml`** (путь `<workspace>/.claw-analog.toml` или **`--config`**) и **тех же флагов**, что у основного run: **`--model`**, **`--permission`**, **`--preset`**, **`--output-format`**, **`--stream`**, **`--no-stream`**, **`--no-runtime-enforcer`**, **`--accept-danger-non-interactive`**, плюс **`--profile`** для отображения пути к профилю. Печатаются контракт NDJSON (`schema`, `format_version`), эффективные поля и строки **provenance** (что победило: CLI, TOML или default);
|
||||||
|
- статус типовых переменных (**без** значений: только `set` / `unset` и длина строки);
|
||||||
|
- поиск workspace вверх от cwd (или **`--manifest-dir`**) и по умолчанию **`cargo check -p claw-analog`** (только компиляция, **не** перезаписывает `target\debug\claw-analog.exe` — иначе на Windows при `cargo run … doctor` часто «Отказано в доступе» при вложенном `cargo build`);
|
||||||
|
- **`--release-build`** — **`cargo build --release -p claw-analog`** (бинарь в `target\release\`, не конфликтует с запущенным debug‑exe);
|
||||||
|
- **`--no-build`** — пропустить cargo;
|
||||||
|
- **`--tcp-ping`** (алиас **`--mock`**) — TCP **`connect`** к хосту:порту из **`ANTHROPIC_BASE_URL`** (или к дефолтному `https://api.anthropic.com`); не проверяет HTTP/TLS и тело ответа.
|
||||||
|
|
||||||
|
Примеры (из каталога `…\claw-code-main\rust`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p claw-analog -- doctor
|
||||||
|
cargo run -p claw-analog -- doctor --no-build
|
||||||
|
cargo run -p claw-analog -- doctor --tcp-ping
|
||||||
|
cargo run -p claw-analog -- doctor -w D:\path\to\repo --preset implement
|
||||||
|
cargo run -p claw-analog -- doctor --release-build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка конфигурации без API (`config validate`)
|
||||||
|
|
||||||
|
Подкоманда **`claw-analog config validate`**:
|
||||||
|
|
||||||
|
- парсит **`.claw-analog.toml`** (по умолчанию `<workspace>/.claw-analog.toml`, переопределение **`--config`**) и выводит краткий **merge preview** (как у `doctor`, но **только TOML + defaults**, без флагов основного run);
|
||||||
|
- проверяет **`profile.toml`**: тот же порядок, что у run (`--profile`, поле `profile` в TOML, иначе дефолтный `~/.claw-analog/profile.toml` при наличии файла);
|
||||||
|
- **никаких** запросов к LLM и сети API.
|
||||||
|
|
||||||
|
**`--strict`** — ошибка (код выхода 1), если файла конфигурации нет или профиль не читается.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p claw-analog -- config validate -w D:\path\to\repo
|
||||||
|
cargo run -p claw-analog -- config validate --strict -w .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Дополнение оболочки (`complete`)
|
||||||
|
|
||||||
|
Скрипт автодополнения в **stdout** (перенаправьте в файл из документации вашей оболочки):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p claw-analog -- complete powershell >> $PROFILE
|
||||||
|
# bash:zsh:fish — см. вывод `complete --help`
|
||||||
|
```
|
||||||
|
|
||||||
|
Доступные значения: **`bash`**, **`zsh`**, **`fish`**, **`powershell`** (алиас **`pwsh`**).
|
||||||
|
|
||||||
|
## Основные команды
|
||||||
|
|
||||||
|
Одна задача в аргументе (или текст с **stdin**):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# из ...\claw-code-main\rust
|
||||||
|
cargo run -p claw-analog -- -w D:\path\to\repo "Кратко опиши структуру rust/crates"
|
||||||
|
```
|
||||||
|
|
||||||
|
С **живым выводом** (SSE через `stream_message`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p claw-analog -- --stream -w . "Объясни claw-analog в двух предложениях"
|
||||||
|
```
|
||||||
|
|
||||||
|
Разрешить **запись файлов** в workspace:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p claw-analog -- --permission workspace-write -w . "Добавь комментарий в начало crates/claw-analog/Cargo.toml"
|
||||||
|
```
|
||||||
|
|
||||||
|
Отключить проверку через **`runtime::PermissionEnforcer`** (только своя тюрьма путей; не рекомендуется):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p claw-analog -- --no-runtime-enforcer -w . "…"
|
||||||
|
```
|
||||||
|
|
||||||
|
Полезные лимиты (CLI **перекрывает** значения из `.claw-analog.toml`, см. ниже):
|
||||||
|
|
||||||
|
| Флаг | Значение по умолчанию | Назначение |
|
||||||
|
|------|------------------------|------------|
|
||||||
|
| `--max-read-bytes` | 262144 | Максимум байт для `read_file` / `grep_workspace` / `git_diff` / `git_log` |
|
||||||
|
| `--max-turns` | 24 | Максимум раундов «модель → инструменты → модель» |
|
||||||
|
| `--max-list-entries` | 500 | Лимит строк `list_dir` |
|
||||||
|
| `--grep-max-lines` | 200 | Верхняя граница **суммарных** строк совпадений в `grep_workspace` (в т.ч. по нескольким файлам; в одном файле можно задать меньше через `max_lines`) |
|
||||||
|
| `--glob-max-paths` | 2000 | Максимум путей, возвращаемых `glob_workspace` и при расширении `glob` внутри `grep_workspace` |
|
||||||
|
| `--glob-max-depth` | 32 | Глубина обхода каталогов для glob (через `walkdir`), без бесконечной рекурсии |
|
||||||
|
| `--output-format` | `rich` | `json` — NDJSON на stdout для скриптов и агентов |
|
||||||
|
| `--print-tools` | — | Список эффективных инструментов для итоговых `permission` / enforcer, затем выход (**без** промпта и API) |
|
||||||
|
| `--lang` | `en` | Подсказка в system: `en` или `ru` (язык ответов; **не** меняет id модели в API) |
|
||||||
|
| `--preset` | — | `none` \| `audit` \| `explain` \| `implement` — см. раздел ниже |
|
||||||
|
| `--session` | — | Путь к JSON-сессии (относительно `-w`, если не абсолютный): сохранение истории и resume |
|
||||||
|
| `--save-session` | — | Дополнительный путь: тот же снимок сессии пишется сюда при каждом сохранении (можно **без** `--session`, чтобы только экспортировать JSON после прогона) |
|
||||||
|
| `--profile` | — | TOML с полем `line` (подмешивается в system). Без флага: пробуется `%USERPROFILE%\.claw-analog\profile.toml` (Windows) / `~/.claw-analog/profile.toml` |
|
||||||
|
| `--permission` | `read-only` | см. ниже: `read-only`, `workspace-write`, `prompt`, `danger-full-access`, `allow` |
|
||||||
|
| `--accept-danger-non-interactive` | — | Разрешить `danger-full-access` / `allow`, когда stdin **не** TTY (CI; осознанный риск). В TOML: `accept_danger_non_interactive = true` |
|
||||||
|
|
||||||
|
Конфиг по умолчанию читается из **`<workspace>/.claw-analog.toml`**, если файл существует. Другой путь: **`--config PATH`**. Неизвестные ключи в TOML — ошибка парсинга (строгая схема).
|
||||||
|
|
||||||
|
Пример `.claw-analog.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
model = "sonnet"
|
||||||
|
stream = true
|
||||||
|
output_format = "rich"
|
||||||
|
permission = "read-only"
|
||||||
|
language = "en"
|
||||||
|
preset = "audit"
|
||||||
|
session = ".claw-analog.session.json"
|
||||||
|
profile = "~/.claw-analog/profile.toml"
|
||||||
|
no_runtime_enforcer = false
|
||||||
|
accept_danger_non_interactive = false
|
||||||
|
max_read_bytes = 262144
|
||||||
|
max_turns = 24
|
||||||
|
max_list_entries = 500
|
||||||
|
grep_max_lines = 200
|
||||||
|
glob_max_paths = 2000
|
||||||
|
glob_max_depth = 32
|
||||||
|
# Опционально: RAG (`claw-rag-service`) — см. раздел про RAG ниже
|
||||||
|
# rag_base_url = "http://127.0.0.1:8787"
|
||||||
|
# rag_timeout_secs = 30
|
||||||
|
# rag_top_k_max = 32
|
||||||
|
```
|
||||||
|
|
||||||
|
**RAG (`retrieve_context`):** если заданы **`RAG_BASE_URL`** (per-env) или непустой **`rag_base_url`** в `.claw-analog.toml`, в набор инструментов добавляется **`retrieve_context`** (семантический поиск по уже проиндексированному воркспейсу). Значение — корень HTTP сервиса, без суффикса `/v1` (запрос идёт на `{base}/v1/query`). Таймаут и верхняя граница **`top_k`** задаются **`rag_timeout_secs`** и **`rag_top_k_max`** (по умолчанию 30 с и 32; «жёсткий» потолок 256). Индексация по-прежнему отдельной командой **`claw-rag-service`**, см. [`docs/rag-web-ui.md`](docs/rag-web-ui.md).
|
||||||
|
|
||||||
|
**`permission`** (как у полного `claw`, те же строки в TOML):
|
||||||
|
|
||||||
|
| Значение | Инструмент `write_file` | Неинтерактив (stdin не TTY) |
|
||||||
|
|----------|-------------------------|------------------------------|
|
||||||
|
| `read-only` | нет | OK |
|
||||||
|
| `workspace-write` | да (в пределах `-w`) | OK |
|
||||||
|
| `prompt` | нет (в этом harness Enforcer не даёт писать без подтверждений) | предупреждение в stderr; для автозаписи используйте `workspace-write` |
|
||||||
|
| `danger-full-access`, `allow` | да | **запрещено**, пока не задан `--accept-danger-non-interactive` или `accept_danger_non_interactive = true` в TOML |
|
||||||
|
|
||||||
|
**`--stream`** в командной строке включает стриминг; **`--no-stream`** явно выключает (полезно поверх `stream = true` в файле).
|
||||||
|
|
||||||
|
**`language`** в TOML: `en` или `ru` (те же значения, что у **`--lang`**); CLI имеет приоритет.
|
||||||
|
|
||||||
|
### Сессия (`--session`)
|
||||||
|
|
||||||
|
Файл JSON (версия `1`): метаданные `workspace`, `model`, опционально `preset`, массив `messages` в формате API (`role` + `content`). При запуске с существующим файлом история **догружается**, текущий текст запроса (аргумент или stdin) добавляется как **новое** пользовательское сообщение. Состояние сохраняется после каждого полного раунда с инструментами и при завершении без `tool_use`.
|
||||||
|
|
||||||
|
**`--save-session`** — тот же формат файла, что и у `--session`: при каждом шаге, где обновлялся бы файл сессии, запись дублируется (если путь совпадает с `--session`, вторая запись не выполняется). Без **`--session`** можно собрать историю одного прогона в JSON для скриптов или последующего **`--session`** без ручной сборки `messages`.
|
||||||
|
|
||||||
|
**Риски:** в файле могут оказаться **секреты** (вывод `read_file`, ключи из логов), файл не шифруется; длинная история **дороже** по токенам API. В stderr печатается напоминание при **`--session`** или **`--save-session`**. Несовпадение `workspace` / `model` / `preset` с текущим запуском даёт **предупреждение**, но прогон продолжается.
|
||||||
|
|
||||||
|
### Пресеты (`--preset`)
|
||||||
|
|
||||||
|
Добавляют краткий абзац к system prompt (аудит / обучение / правки). Набор инструментов по-прежнему задаётся **permission**: для **`implement`**, если ни CLI, ни файл не задали `permission`, по умолчанию подставляется **workspace-write** (чтобы был `write_file`). Явный `permission = "read-only"` в файле или `--permission read-only` в CLI имеет приоритет.
|
||||||
|
|
||||||
|
### Профиль (`profile.toml`)
|
||||||
|
|
||||||
|
Мини-файл:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
line = "Короткая подсказка стиля (одна строка в system)."
|
||||||
|
```
|
||||||
|
|
||||||
|
Ограничения: размер файла не больше **2048** байт; длина строки после trim — не больше **512** символов Unicode (иначе усечение с предупреждением). Содержимое добавляется в system одной строкой: `Learner hint: …`.
|
||||||
|
|
||||||
|
## Инструменты (без произвольного shell)
|
||||||
|
|
||||||
|
| Имя | Режим | Описание |
|
||||||
|
|-----|--------|----------|
|
||||||
|
| `read_file` | read-only+ | Чтение UTF‑8 файла под `-w` |
|
||||||
|
| `list_dir` | read-only+ | Список каталога (не рекурсивно) |
|
||||||
|
| `glob_workspace` | read-only+ | Список **путей файлов** под `-w`: аргументы `pattern` (glob относительно `root`, слэши `/`), опционально `root` (по умолчанию `.`), `max_paths` (урезается лимитом CLI). В шаблоне нельзя `..`. |
|
||||||
|
| `grep_workspace` | read-only+ | Та же **литеральная** подстрока по строкам, что и раньше; ровно один из селекторов: `path`, массив `paths` или `glob` (+ опционально `glob_root`). Общий бюджет строк — `max_lines` и `--grep-max-lines`. В нескольких файлах формат строк: `относительный/путь:номер_строки:содержимое`. |
|
||||||
|
| `grep_search` | read-only+ | Тот же обработчик, что у `grep_workspace` (совместимость промптов с полным `claw`). |
|
||||||
|
| `git_diff` | read-only+ | `git diff` (без цвета) внутри репозитория в `-w`. Опционально `cached` (staged), `rev_range`, `context_lines`, `paths`. Вывод ограничен `--max-read-bytes`. |
|
||||||
|
| `git_log` | read-only+ | `git log` (без цвета) внутри репозитория в `-w`. Опционально `max_count` (по умолчанию 20), `rev_range`, `paths`. Вывод ограничен `--max-read-bytes`. |
|
||||||
|
| `retrieve_context` | read-only+ | Только если задан **`RAG_BASE_URL`** или **`rag_base_url`** в TOML: HTTP **`POST {base}/v1/query`** к `claw-rag-service`, ответ — пути и сниппеты чанков (лимиты см. выше). |
|
||||||
|
| `write_file` | `workspace-write`, `danger-full-access` или `allow` | Запись файла; родительские каталоги создаются при необходимости (`prompt` не даёт записать через Enforcer) |
|
||||||
|
|
||||||
|
## Принципы работы
|
||||||
|
|
||||||
|
1. **Корень workspace** (`-w`) приводится к каноническому пути; все пути в инструментах **относительные**, без `..` и без абсолютных сегментов.
|
||||||
|
2. Перед доступом к файлу проверяется, что реальный путь остаётся **внутри** корня (symlink/`canonicalize`).
|
||||||
|
3. **Политика прав** (если не отключена `--no-runtime-enforcer`): те же сущности, что у основного CLI — `PermissionPolicy` + `PermissionEnforcer::check` для инструмента и `check_file_write` для записи.
|
||||||
|
4. **Цикл агента**: запрос к провайдеру → если `stop_reason == tool_use`, выполняются вызовы, результаты уходят в историю как `tool_result` → следующий раунд.
|
||||||
|
5. **Стриминг**: при `--stream` текст ассистента печатается по мере прихода дельт; история для следующего раунда собирается из SSE так же, как в полном пайплайне (индексы блоков + JSON tool input). Отключить стриминг при настройке из файла можно флагом **`--no-stream`**.
|
||||||
|
|
||||||
|
Логи вида `[claw-analog] ...` пишутся в **stderr**. В режиме **rich** ответ модели — обычный текст в **stdout**; в режиме **json** в **stdout** идёт только **NDJSON** (см. ниже).
|
||||||
|
|
||||||
|
## Вывод JSON (CI и внешние агенты)
|
||||||
|
|
||||||
|
Флаг **`--output-format json`** переключает stdout на **поток строк JSON** (один объект = одна строка). Поля стабильны по смыслу, но набор может расширяться.
|
||||||
|
|
||||||
|
Основные `type`:
|
||||||
|
|
||||||
|
| `type` | Когда |
|
||||||
|
|--------|--------|
|
||||||
|
| `run_start` | Старт прогона: **`schema`** (`claw-analog-ndjson`), **`format_version`**, далее `workspace`, `model`, `stream`, `permission`, опционально `preset`, `session`, опционально `session_save`, булево **`rag_enabled`** (есть ли база для `retrieve_context`) |
|
||||||
|
| `turn_start` | Начало раунда с моделью (`turn`) |
|
||||||
|
| `assistant_text_delta` | Только при `--stream`: фрагмент текста ассистента |
|
||||||
|
| `assistant_turn` | Итог раунда: `stop_reason`, `usage`, полный `text`, массив `tool_calls` |
|
||||||
|
| `tool_result` | После выполнения инструмента: `name`, `tool_use_id`, `is_error`, `output` (может быть усечён), `truncated`, `output_len_chars` |
|
||||||
|
| `run_end` | Успешное завершение (`ok: true`) |
|
||||||
|
| `error` | Ошибка (печатается отдельной строкой при падении или пустом промпте) |
|
||||||
|
|
||||||
|
Пример (PowerShell): разбор потока построчно удобен **`jq`** или любом JSON‑парсере.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# из ...\claw-code-main\rust
|
||||||
|
$env:ANTHROPIC_API_KEY = "sk-ant-..."
|
||||||
|
cargo run -p claw-analog -- --output-format json -w . "Summarize rust/README.md" 2>$null | ForEach-Object { $_ | ConvertFrom-Json | Select-Object -ExpandProperty type }
|
||||||
|
```
|
||||||
|
|
||||||
|
С **`--stream`** в stdout сначала идут события `assistant_text_delta`, затем для того же раунда — одна строка `assistant_turn` с полным собранным `text` (удобно для воспроизводимых логов).
|
||||||
|
|
||||||
|
### Ограничения и риски для агентов
|
||||||
|
|
||||||
|
- В **`tool_result.output`** большие файлы обрезаются (~32 KiB UTF‑8), поле **`truncated`: true**.
|
||||||
|
- **Секреты**: не перенаправляйте stderr сырьём в публичные логи без фильтра; в `output` теоретически может попасть содержимое прочитанных файлов.
|
||||||
|
- Контракт для оркестраторов: NDJSON из stdout, диагностика из stderr; код возврата ≠ 0 при ошибке. На первой строке **`run_start`** имеет смысл сверять **`schema`** и **`format_version`**; **`run_start`** также раскрывает путь workspace и модель — учитывайте при шаринге логов.
|
||||||
|
|
||||||
|
## Автотесты без реальной сети
|
||||||
|
|
||||||
|
Юнит‑тесты и интеграция с локальным **mock-anthropic-service**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# из ...\claw-code-main\rust
|
||||||
|
cargo test -p claw-analog
|
||||||
|
```
|
||||||
|
|
||||||
|
В **GitHub Actions** отдельный job **`claw-analog (test + clippy -p)`** гоняет `cargo test -p claw-analog` и `cargo clippy -p claw-analog --no-deps` (в дополнение к полному `cargo test` / `clippy` по workspace).
|
||||||
|
|
||||||
|
При параллельном запуске тестов переменные окружения Anthropic изолированы **mutex**‑ом только для mock‑сценария; при сбоях можно запустить `cargo test -p claw-analog -- --test-threads=1`.
|
||||||
|
|
||||||
|
## Отдельно: `claw-rag-service` (RAG)
|
||||||
|
|
||||||
|
Индексация воркспейса и HTTP API живут в **`cargo run -p claw-rag-service`** (`ingest` + `serve`). После `serve` откройте **`http://127.0.0.1:8787/`** — лёгкий UI (stats + поиск). К `claw-analog` подключается через **`RAG_BASE_URL`** / `retrieve_context`. Подробности и env: [`docs/rag-web-ui.md`](docs/rag-web-ui.md).
|
||||||
|
|
||||||
|
### Ingest (один или несколько репозиториев)
|
||||||
|
|
||||||
|
`ingest` принимает **повторяемый** `--workspace` — это позволяет сделать **cross-repo RAG** (несколько реп в одну БД/коллекцию).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# из ...\claw-code-main\rust
|
||||||
|
|
||||||
|
# один workspace
|
||||||
|
cargo run -p claw-rag-service -- ingest --workspace "D:\v\kria\s6"
|
||||||
|
|
||||||
|
# несколько workspace (cross-repo)
|
||||||
|
cargo run -p claw-rag-service -- ingest --workspace "D:\repo1" --workspace "D:\repo2"
|
||||||
|
```
|
||||||
|
|
||||||
|
В ответах `path` будет вида `repoId:relative/path` (чтобы не было коллизий одинаковых путей между репозиториями).
|
||||||
|
|
||||||
|
### Mock embeddings (без ключей / без сети)
|
||||||
|
|
||||||
|
Для локальных прогонов/тестов можно включить mock-эмбеддинги:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CLAW_RAG_MOCK_PROVIDERS = "1"
|
||||||
|
cargo run -p claw-rag-service -- ingest --workspace "D:\v\kria\s6"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qdrant (рекомендуемый локальный вариант) через Docker
|
||||||
|
|
||||||
|
Для больших репозиториев лучше поднять локальный Qdrant: это снимает нагрузку с линейного сканирования `SQLite` и ускоряет запросы.
|
||||||
|
|
||||||
|
Запуск Qdrant (gRPC на 6334):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker run --rm -p 6333:6333 -p 6334:6334 -e QDRANT__SERVICE__GRPC_PORT=6334 qdrant/qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Qdrant с persist volume (чтобы индекс сохранялся)
|
||||||
|
|
||||||
|
Вариант через именованный volume Docker:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker volume create claw-qdrant-data
|
||||||
|
docker run --rm -p 6333:6333 -p 6334:6334 `
|
||||||
|
-e QDRANT__SERVICE__GRPC_PORT=6334 `
|
||||||
|
-v claw-qdrant-data:/qdrant/storage `
|
||||||
|
qdrant/qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
Вариант через bind-mount (путь на хосте):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
mkdir .claw-qdrant | Out-Null
|
||||||
|
docker run --rm -p 6333:6333 -p 6334:6334 `
|
||||||
|
-e QDRANT__SERVICE__GRPC_PORT=6334 `
|
||||||
|
-v "${PWD}/.claw-qdrant:/qdrant/storage" `
|
||||||
|
qdrant/qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем включите env и запускайте ingest с фичей `qdrant-index`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CLAW_RAG_QDRANT_URL = "http://127.0.0.1:6334"
|
||||||
|
$env:CLAW_RAG_QDRANT_COLLECTION = "claw_rag_chunks"
|
||||||
|
|
||||||
|
# (опционально) без реального API для эмбеддингов
|
||||||
|
$env:CLAW_RAG_MOCK_PROVIDERS = "1"
|
||||||
|
|
||||||
|
cargo run -p claw-rag-service --features qdrant-index -- ingest --workspace "D:\v\kria\s6"
|
||||||
|
```
|
||||||
|
|
||||||
|
`ingest` сам создаст коллекцию, если её ещё нет (по размерности эмбеддингов).
|
||||||
|
|
||||||
|
### Запуск через Docker (Qdrant + claw-rag-service)
|
||||||
|
|
||||||
|
Если хочется поднимать всё одной командой, удобнее использовать `docker compose`.
|
||||||
|
|
||||||
|
1) Запуск сервисов:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\path\to\claw-code-main
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Примечание: образ `rag-serve`/`rag-ingest` собирается на достаточно свежем Rust (см. `rust/crates/claw-rag-service/Dockerfile`), потому что `qdrant-client` может требовать более новую версию Rust, чем старые pinned-теги.
|
||||||
|
|
||||||
|
Если сборка Docker падает и вы видите строки вроде `transferring context: 21.02GB`, проверьте что:
|
||||||
|
|
||||||
|
- вы запускаете compose из корня репозитория (где лежит `docker-compose.yml`)
|
||||||
|
- используется `.dockerignore` (уменьшает build-context, особенно если есть `target/` и локальные индексы)
|
||||||
|
|
||||||
|
Если сборка падает сразу с `EOF` на шаге `load local bake definitions`, попробуйте:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:COMPOSE_BAKE = "0"
|
||||||
|
$env:DOCKER_BUILDKIT = "0"
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Ingest (запускать отдельно, т.к. это batch job). Пример для одного workspace:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose run --rm rag-ingest ingest --workspace "/workspaces/main"
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию `rag-ingest` пишет индекс в общий volume, так что `rag-serve` сразу увидит чанки.
|
||||||
|
|
||||||
|
### Подключение к `claw-analog`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:RAG_BASE_URL = "http://127.0.0.1:8787"
|
||||||
|
cargo run -p claw-analog -- -w "D:\v\kria\s6" "Найди где реализован ingest в RAG сервисе"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto‑TDD (автопроверки после `write_file`/`edit_file`)
|
||||||
|
|
||||||
|
В полном `claw` (и в других потребителях `runtime`) можно включить автозапуск линтера/тестов после успешных write-инструментов через `.claw/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"autoTdd": {
|
||||||
|
"enabled": true,
|
||||||
|
"tools": ["write_file", "edit_file"],
|
||||||
|
"commands": [
|
||||||
|
"cd rust && cargo fmt",
|
||||||
|
"cd rust && cargo clippy --workspace --all-targets -- -D warnings",
|
||||||
|
"cd rust && cargo test --workspace"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Отличия от полного `claw`
|
||||||
|
|
||||||
|
- Узкий набор инструментов (нет bash/MCP/плагинов).
|
||||||
|
- Проще аудировать и ограничивать по `--permission` и лимитам.
|
||||||
|
- Основной продукт по-прежнему `cargo run -p rusty-claude-cli` → бинарь `claw`.
|
||||||
|
|
||||||
|
## Дальнейшая разработка
|
||||||
|
|
||||||
|
План и чеклист идей (в т.ч. заимствованные из продуктового слоя вроде DeepTutor): [`futute.md`](futute.md) в корне репозитория.
|
||||||
33
rust/crates/claw-analog/Cargo.toml
Normal file
33
rust/crates/claw-analog/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[package]
|
||||||
|
name = "claw-analog"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "Minimal agent harness: tool loop with explicit permissions and workspace jail."
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "claw_analog"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "claw-analog"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
api = { path = "../api" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap_complete = "4"
|
||||||
|
globset = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
runtime = { path = "../runtime" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
toml = "0.8"
|
||||||
|
walkdir = "2"
|
||||||
|
ignore = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||||
|
tempfile = "3"
|
||||||
489
rust/crates/claw-analog/src/agents.rs
Normal file
489
rust/crates/claw-analog/src/agents.rs
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
//! `claw-analog agents` — run multiple specialized sub-agents sequentially.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use api::InputMessage;
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use claw_analog::{
|
||||||
|
enforce_non_interactive_permission_rules, load_analog_toml, resolve_analog_options,
|
||||||
|
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogDoctorOverrides,
|
||||||
|
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEF_MAX_READ: u64 = 256 * 1024;
|
||||||
|
const DEF_MAX_TURNS: u32 = 24;
|
||||||
|
const DEF_MAX_LIST: usize = 500;
|
||||||
|
const DEF_GREP_MAX: usize = 200;
|
||||||
|
const DEF_GLOB_PATHS: usize = 2000;
|
||||||
|
const DEF_GLOB_DEPTH: usize = 32;
|
||||||
|
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
|
||||||
|
const DEF_RAG_TOP_K_MAX: u32 = 32;
|
||||||
|
const RAG_TOP_K_ABS_CAP: u32 = 256;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
pub enum AgentsPresetArg {
|
||||||
|
Audit,
|
||||||
|
Explain,
|
||||||
|
Implement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AgentsPresetArg> for Preset {
|
||||||
|
fn from(p: AgentsPresetArg) -> Self {
|
||||||
|
match p {
|
||||||
|
AgentsPresetArg::Audit => Preset::Audit,
|
||||||
|
AgentsPresetArg::Explain => Preset::Explain,
|
||||||
|
AgentsPresetArg::Implement => Preset::Implement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
pub enum AgentsPermissionArg {
|
||||||
|
ReadOnly,
|
||||||
|
WorkspaceWrite,
|
||||||
|
Prompt,
|
||||||
|
#[value(name = "danger-full-access")]
|
||||||
|
DangerFullAccess,
|
||||||
|
Allow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AgentsPermissionArg> for PermissionMode {
|
||||||
|
fn from(p: AgentsPermissionArg) -> Self {
|
||||||
|
match p {
|
||||||
|
AgentsPermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||||
|
AgentsPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||||
|
AgentsPermissionArg::Prompt => PermissionMode::Prompt,
|
||||||
|
AgentsPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||||
|
AgentsPermissionArg::Allow => PermissionMode::Allow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgentSpec {
|
||||||
|
pub name: String,
|
||||||
|
pub preset: Preset,
|
||||||
|
pub permission: PermissionMode,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_permission_for_preset(p: Preset) -> PermissionMode {
|
||||||
|
match p {
|
||||||
|
Preset::Audit | Preset::Explain => PermissionMode::ReadOnly,
|
||||||
|
Preset::Implement => PermissionMode::WorkspaceWrite,
|
||||||
|
Preset::None => PermissionMode::ReadOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_agent_spec(s: &str) -> Result<AgentSpec, String> {
|
||||||
|
// Allowed forms:
|
||||||
|
// - "audit" | "explain" | "implement"
|
||||||
|
// - "name=audit,preset=audit,permission=read-only,model=...,prompt=..."
|
||||||
|
let raw = s.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err("empty --agent spec".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !raw.contains('=') {
|
||||||
|
let preset = match raw.to_ascii_lowercase().as_str() {
|
||||||
|
"audit" => Preset::Audit,
|
||||||
|
"explain" => Preset::Explain,
|
||||||
|
"implement" | "fix" => Preset::Implement,
|
||||||
|
other => return Err(format!("unknown agent shorthand: {other}")),
|
||||||
|
};
|
||||||
|
return Ok(AgentSpec {
|
||||||
|
name: raw.to_string(),
|
||||||
|
preset,
|
||||||
|
permission: default_permission_for_preset(preset),
|
||||||
|
model: None,
|
||||||
|
prompt: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut name: Option<String> = None;
|
||||||
|
let mut preset: Option<Preset> = None;
|
||||||
|
let mut permission: Option<PermissionMode> = None;
|
||||||
|
let mut model: Option<String> = None;
|
||||||
|
let mut prompt: Option<String> = None;
|
||||||
|
|
||||||
|
for part in raw.split(',') {
|
||||||
|
let (k, v) = part
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| format!("invalid agent spec part {part:?} (expected k=v)"))?;
|
||||||
|
let k = k.trim().to_ascii_lowercase();
|
||||||
|
let v = v.trim();
|
||||||
|
if v.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match k.as_str() {
|
||||||
|
"name" => name = Some(v.to_string()),
|
||||||
|
"preset" => {
|
||||||
|
let p = match v.to_ascii_lowercase().as_str() {
|
||||||
|
"audit" => Preset::Audit,
|
||||||
|
"explain" => Preset::Explain,
|
||||||
|
"implement" | "fix" => Preset::Implement,
|
||||||
|
"none" => Preset::None,
|
||||||
|
other => return Err(format!("unknown preset {other:?}")),
|
||||||
|
};
|
||||||
|
preset = Some(p);
|
||||||
|
}
|
||||||
|
"permission" => {
|
||||||
|
let pm = match v.to_ascii_lowercase().replace('_', "-").as_str() {
|
||||||
|
"read-only" | "readonly" => PermissionMode::ReadOnly,
|
||||||
|
"workspace-write" | "write" => PermissionMode::WorkspaceWrite,
|
||||||
|
"prompt" => PermissionMode::Prompt,
|
||||||
|
"danger-full-access" | "danger" => PermissionMode::DangerFullAccess,
|
||||||
|
"allow" => PermissionMode::Allow,
|
||||||
|
other => return Err(format!("unknown permission {other:?}")),
|
||||||
|
};
|
||||||
|
permission = Some(pm);
|
||||||
|
}
|
||||||
|
"model" => model = Some(v.to_string()),
|
||||||
|
"prompt" => prompt = Some(v.to_string()),
|
||||||
|
other => return Err(format!("unknown agent spec key {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let preset = preset.unwrap_or(Preset::Audit);
|
||||||
|
let permission = permission.unwrap_or_else(|| default_permission_for_preset(preset));
|
||||||
|
let name = name.unwrap_or_else(|| preset.label().unwrap_or("agent").to_string());
|
||||||
|
|
||||||
|
Ok(AgentSpec {
|
||||||
|
name,
|
||||||
|
preset,
|
||||||
|
permission,
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
pub struct AgentsCli {
|
||||||
|
/// Workspace root.
|
||||||
|
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||||
|
pub workspace: PathBuf,
|
||||||
|
|
||||||
|
/// Config path (default: `<workspace>/.claw-analog.toml`).
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Base session path. If missing, it will be created from the base prompt.
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub base_session: PathBuf,
|
||||||
|
|
||||||
|
/// Base prompt. If omitted, reads from stdin.
|
||||||
|
#[arg(long)]
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
|
||||||
|
/// Repeatable agent specs, e.g. `--agent audit` or `--agent name=fix,preset=implement,permission=workspace-write`.
|
||||||
|
#[arg(long, required = true)]
|
||||||
|
pub agent: Vec<String>,
|
||||||
|
|
||||||
|
/// If set, each agent writes its own session file next to base session.
|
||||||
|
#[arg(long, default_value_t = true)]
|
||||||
|
pub split_sessions: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_file_config(path: &Path) -> AnalogFileConfig {
|
||||||
|
if !path.is_file() {
|
||||||
|
return AnalogFileConfig::default();
|
||||||
|
}
|
||||||
|
load_analog_toml(path).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path(args: &AgentsCli) -> PathBuf {
|
||||||
|
args.config
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| args.workspace.join(".claw-analog.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_agent_session_path(base: &Path, agent_name: &str) -> PathBuf {
|
||||||
|
let base_s = base.to_string_lossy();
|
||||||
|
PathBuf::from(format!("{base_s}.agent-{agent_name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_stdin_prompt() -> Result<String, String> {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut buf = String::new();
|
||||||
|
std::io::stdin()
|
||||||
|
.read_to_string(&mut buf)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let t = buf.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
return Err("empty prompt (pass --prompt or stdin)".to_string());
|
||||||
|
}
|
||||||
|
Ok(t.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_base_session(base_session: &Path, workspace: &Path, prompt: &str) -> Result<(), String> {
|
||||||
|
if base_session.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ws_s = workspace.display().to_string();
|
||||||
|
let model = "base".to_string();
|
||||||
|
let messages = if prompt.trim().is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![InputMessage::user_text(prompt.to_string())]
|
||||||
|
};
|
||||||
|
claw_analog::session_save(base_session, &ws_s, &model, Preset::None, &messages)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_agents(args: AgentsCli) -> Result<(), String> {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rt.block_on(async { run_agents_async(args).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_agents_async(args: AgentsCli) -> Result<(), String> {
|
||||||
|
run_agents_inner(args, |cfg, out| {
|
||||||
|
Box::pin(async move {
|
||||||
|
claw_analog::run(cfg, out)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>>;
|
||||||
|
|
||||||
|
async fn run_agents_inner<F>(args: AgentsCli, mut run_one: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
for<'a> F: FnMut(AnalogConfig, &'a mut Vec<u8>) -> RunFuture<'a>,
|
||||||
|
{
|
||||||
|
let workspace = if args.workspace.is_absolute() {
|
||||||
|
args.workspace.clone()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.join(&args.workspace)
|
||||||
|
};
|
||||||
|
let cfg_path = config_path(&args);
|
||||||
|
let file_cfg = load_file_config(&cfg_path);
|
||||||
|
|
||||||
|
let base_prompt = match args.prompt.clone() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => read_stdin_prompt()?,
|
||||||
|
};
|
||||||
|
ensure_base_session(&args.base_session, &workspace, base_prompt.as_str())?;
|
||||||
|
|
||||||
|
let mut specs = Vec::new();
|
||||||
|
for a in &args.agent {
|
||||||
|
specs.push(parse_agent_spec(a)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("claw-analog agents (sequential)\n");
|
||||||
|
println!(" workspace: {}", workspace.display());
|
||||||
|
println!(" base_session: {}", args.base_session.display());
|
||||||
|
println!(" agents: {}", specs.len());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for (i, spec) in specs.into_iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
"== Agent {} / {}: {} ==",
|
||||||
|
i + 1,
|
||||||
|
args.agent.len(),
|
||||||
|
spec.name
|
||||||
|
);
|
||||||
|
println!(" preset: {}", spec.preset.label().unwrap_or("none"));
|
||||||
|
println!(" permission: {}", spec.permission.as_str());
|
||||||
|
if let Some(m) = &spec.model {
|
||||||
|
println!(" model: {m}");
|
||||||
|
}
|
||||||
|
|
||||||
|
enforce_non_interactive_permission_rules(spec.permission, false)?;
|
||||||
|
|
||||||
|
let agent_session = if args.split_sessions {
|
||||||
|
derive_agent_session_path(&args.base_session, spec.name.as_str())
|
||||||
|
} else {
|
||||||
|
args.base_session.clone()
|
||||||
|
};
|
||||||
|
if args.split_sessions {
|
||||||
|
std::fs::copy(&args.base_session, &agent_session).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let overrides = AnalogDoctorOverrides {
|
||||||
|
model: spec.model.clone(),
|
||||||
|
permission: Some(spec.permission),
|
||||||
|
preset: Some(spec.preset),
|
||||||
|
output_format: Some(OutputFormat::Rich),
|
||||||
|
stream: StreamOverride::ForceOff,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let resolved = resolve_analog_options(&file_cfg, &overrides);
|
||||||
|
|
||||||
|
let profile_path =
|
||||||
|
resolve_analog_profile_path(&workspace, None, file_cfg.profile.as_deref());
|
||||||
|
let profile_hint = if let Some(ref p) = profile_path {
|
||||||
|
claw_analog::load_profile_hint(p).unwrap_or(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let rag_base_url = resolve_rag_base_url(&file_cfg);
|
||||||
|
|
||||||
|
let agent_prompt = spec.prompt.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Agent {}: run preset {}",
|
||||||
|
spec.name,
|
||||||
|
resolved.preset.label().unwrap_or("none")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = AnalogConfig {
|
||||||
|
model: resolved.model,
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
permission_mode: resolved.permission_mode,
|
||||||
|
accept_danger_non_interactive: false,
|
||||||
|
use_stream: false,
|
||||||
|
output_format: resolved.output_format,
|
||||||
|
use_runtime_enforcer: resolved.use_runtime_enforcer,
|
||||||
|
max_read_bytes: file_cfg.max_read_bytes.unwrap_or(DEF_MAX_READ),
|
||||||
|
max_turns: file_cfg.max_turns.unwrap_or(DEF_MAX_TURNS),
|
||||||
|
max_list_entries: file_cfg.max_list_entries.unwrap_or(DEF_MAX_LIST),
|
||||||
|
grep_max_lines: file_cfg.grep_max_lines.unwrap_or(DEF_GREP_MAX),
|
||||||
|
glob_max_paths: file_cfg.glob_max_paths.unwrap_or(DEF_GLOB_PATHS),
|
||||||
|
glob_max_depth: file_cfg.glob_max_depth.unwrap_or(DEF_GLOB_DEPTH),
|
||||||
|
preset: resolved.preset,
|
||||||
|
language: file_cfg
|
||||||
|
.language
|
||||||
|
.as_deref()
|
||||||
|
.and_then(claw_analog::AnalogLanguage::from_toml_str)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
session_path: Some(agent_session.clone()),
|
||||||
|
session_save_path: None,
|
||||||
|
profile_hint,
|
||||||
|
prompt: agent_prompt,
|
||||||
|
rag_base_url,
|
||||||
|
rag_http_timeout: std::time::Duration::from_secs(
|
||||||
|
file_cfg.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS),
|
||||||
|
),
|
||||||
|
rag_top_k_max: file_cfg
|
||||||
|
.rag_top_k_max
|
||||||
|
.unwrap_or(DEF_RAG_TOP_K_MAX)
|
||||||
|
.clamp(1, RAG_TOP_K_ABS_CAP),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
let run_res = run_one(cfg, &mut buf).await;
|
||||||
|
match run_res {
|
||||||
|
Ok(()) => {
|
||||||
|
let text = String::from_utf8_lossy(&buf);
|
||||||
|
let summary = tail_chars(text.as_ref(), 1600);
|
||||||
|
println!(" result: OK");
|
||||||
|
if args.split_sessions {
|
||||||
|
println!(" session: {}", agent_session.display());
|
||||||
|
}
|
||||||
|
println!(" summary_tail:\n{}\n", indent_lines(&summary, 4));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!(" result: FAIL — {e}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tail_chars(s: &str, n: usize) -> String {
|
||||||
|
let total = s.chars().count();
|
||||||
|
if total <= n {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
s.chars().skip(total - n).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indent_lines(s: &str, spaces: usize) -> String {
|
||||||
|
let pad = " ".repeat(spaces);
|
||||||
|
s.lines()
|
||||||
|
.map(|l| format!("{pad}{l}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
fn mock_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_agent_shorthand() {
|
||||||
|
let a = parse_agent_spec("audit").unwrap();
|
||||||
|
assert_eq!(a.preset, Preset::Audit);
|
||||||
|
assert_eq!(a.permission, PermissionMode::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_agent_kv() {
|
||||||
|
let a = parse_agent_spec("name=fix,preset=implement,permission=workspace-write").unwrap();
|
||||||
|
assert_eq!(a.name, "fix");
|
||||||
|
assert_eq!(a.preset, Preset::Implement);
|
||||||
|
assert_eq!(a.permission, PermissionMode::WorkspaceWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runs_two_agents_sequentially_with_stub_runner() {
|
||||||
|
let _g = mock_env_lock();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let workspace = dir.path().canonicalize().unwrap();
|
||||||
|
std::fs::write(workspace.join("fixture.txt"), "hello parity fixture\n").unwrap();
|
||||||
|
|
||||||
|
let base_session = workspace.join(".claw").join("agents-base.json");
|
||||||
|
std::fs::create_dir_all(base_session.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&base_session,
|
||||||
|
format!(
|
||||||
|
"{{\n \"version\": 1,\n \"workspace\": \"{}\",\n \"model\": \"base\",\n \"messages\": []\n}}\n",
|
||||||
|
workspace.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let args = AgentsCli {
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
config: None,
|
||||||
|
base_session: base_session.clone(),
|
||||||
|
prompt: Some(String::new()),
|
||||||
|
agent: vec![
|
||||||
|
"name=audit,preset=audit,permission=read-only,prompt=check 1".to_string(),
|
||||||
|
"name=explain,preset=explain,permission=read-only,prompt=check 2".to_string(),
|
||||||
|
],
|
||||||
|
split_sessions: true,
|
||||||
|
};
|
||||||
|
let called = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let called2 = called.clone();
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(1)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
run_agents_inner(args, move |_cfg, out| {
|
||||||
|
let called3 = called2.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
called3.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
out.extend_from_slice(b"stub ok");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("agents should run");
|
||||||
|
});
|
||||||
|
assert_eq!(called.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||||
|
|
||||||
|
assert!(derive_agent_session_path(&base_session, "audit").is_file());
|
||||||
|
assert!(derive_agent_session_path(&base_session, "explain").is_file());
|
||||||
|
}
|
||||||
|
}
|
||||||
144
rust/crates/claw-analog/src/config_cmd.rs
Normal file
144
rust/crates/claw-analog/src/config_cmd.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
//! `claw-analog config validate` — parse TOML and profile without calling the API.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use claw_analog::{
|
||||||
|
load_analog_toml, load_profile_hint, resolve_analog_options, resolve_analog_profile_path,
|
||||||
|
AnalogDoctorOverrides, AnalogFileConfig, AnalogLanguage, OutputFormat,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct ValidateCli {
|
||||||
|
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||||
|
pub workspace: PathBuf,
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
/// Require `<workspace>/.claw-analog.toml` (or `--config`) to exist and parse.
|
||||||
|
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||||
|
pub strict: bool,
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub profile: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_validate(cli: ValidateCli) -> i32 {
|
||||||
|
let cfg_path = cli
|
||||||
|
.config
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"));
|
||||||
|
|
||||||
|
let file_cfg = if cfg_path.is_file() {
|
||||||
|
match load_analog_toml(&cfg_path) {
|
||||||
|
Ok(c) => {
|
||||||
|
println!("OK: {} parses", cfg_path.display());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("ERROR: {}: {e}", cfg_path.display());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if cli.strict {
|
||||||
|
eprintln!(
|
||||||
|
"ERROR: --strict: config file missing: {}",
|
||||||
|
cfg_path.display()
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Note: {} absent — using empty TOML defaults for preview",
|
||||||
|
cfg_path.display()
|
||||||
|
);
|
||||||
|
AnalogFileConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let prof_path = resolve_analog_profile_path(
|
||||||
|
&cli.workspace,
|
||||||
|
cli.profile.clone(),
|
||||||
|
file_cfg.profile.as_deref(),
|
||||||
|
);
|
||||||
|
let mut ok = true;
|
||||||
|
match &prof_path {
|
||||||
|
None => println!(
|
||||||
|
"Profile: (none — no CLI/TOML path and no default ~/.claw-analog/profile.toml)"
|
||||||
|
),
|
||||||
|
Some(p) => match load_profile_hint(p) {
|
||||||
|
Ok(Some(line)) => println!(
|
||||||
|
"OK: profile {} (line: {} chars)",
|
||||||
|
p.display(),
|
||||||
|
line.chars().count()
|
||||||
|
),
|
||||||
|
Ok(None) => println!("OK: profile {} (empty `line`)", p.display()),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("ERROR: profile {}: {e}", p.display());
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let lang = file_cfg
|
||||||
|
.language
|
||||||
|
.as_deref()
|
||||||
|
.and_then(AnalogLanguage::from_toml_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let r = resolve_analog_options(&file_cfg, &AnalogDoctorOverrides::default());
|
||||||
|
println!("\nMerge preview (TOML + defaults only; main-run CLI flags not applied):");
|
||||||
|
println!(" language (TOML): {}", lang.as_str());
|
||||||
|
println!(" model: {}", r.model);
|
||||||
|
println!(" permission: {}", r.permission_mode.as_str());
|
||||||
|
println!(" preset: {}", r.preset.label().unwrap_or("none"));
|
||||||
|
println!(
|
||||||
|
" output_format: {}",
|
||||||
|
match r.output_format {
|
||||||
|
OutputFormat::Rich => "rich",
|
||||||
|
OutputFormat::Json => "json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(" stream: {}", r.use_stream);
|
||||||
|
println!(
|
||||||
|
" runtime_enforcer: {}",
|
||||||
|
if r.use_runtime_enforcer { "on" } else { "off" }
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" accept_danger_non_interactive: {}",
|
||||||
|
r.accept_danger_non_interactive
|
||||||
|
);
|
||||||
|
println!(" Provenance:");
|
||||||
|
for line in &r.provenance {
|
||||||
|
println!(" - {line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
i32::from(!ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_fails_when_config_missing() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let code = run_validate(ValidateCli {
|
||||||
|
workspace: dir.path().to_path_buf(),
|
||||||
|
config: None,
|
||||||
|
strict: true,
|
||||||
|
profile: None,
|
||||||
|
});
|
||||||
|
assert_eq!(code, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_when_config_present() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join(".claw-analog.toml");
|
||||||
|
std::fs::write(&p, r#"model = "sonnet""#).unwrap();
|
||||||
|
let code = run_validate(ValidateCli {
|
||||||
|
workspace: dir.path().to_path_buf(),
|
||||||
|
config: None,
|
||||||
|
strict: true,
|
||||||
|
profile: None,
|
||||||
|
});
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
733
rust/crates/claw-analog/src/doctor.rs
Normal file
733
rust/crates/claw-analog/src/doctor.rs
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
//! `claw-analog doctor` — environment and Cargo sanity checks.
|
||||||
|
|
||||||
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use claw_analog::{
|
||||||
|
load_analog_toml, load_profile_hint, resolve_analog_options, AnalogDoctorOverrides,
|
||||||
|
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride, NDJSON_FORMAT_VERSION,
|
||||||
|
NDJSON_SCHEMA,
|
||||||
|
};
|
||||||
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||||
|
|
||||||
|
const ENV_CHECK: &[&str] = &[
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_BASE_URL",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"OPENAI_BASE_URL",
|
||||||
|
"XAI_API_KEY",
|
||||||
|
"RAG_BASE_URL",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
pub enum DoctorPermissionArg {
|
||||||
|
ReadOnly,
|
||||||
|
WorkspaceWrite,
|
||||||
|
Prompt,
|
||||||
|
#[value(name = "danger-full-access")]
|
||||||
|
DangerFullAccess,
|
||||||
|
Allow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DoctorPermissionArg> for PermissionMode {
|
||||||
|
fn from(p: DoctorPermissionArg) -> Self {
|
||||||
|
match p {
|
||||||
|
DoctorPermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||||
|
DoctorPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||||
|
DoctorPermissionArg::Prompt => PermissionMode::Prompt,
|
||||||
|
DoctorPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||||
|
DoctorPermissionArg::Allow => PermissionMode::Allow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
pub enum DoctorOutputArg {
|
||||||
|
Rich,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DoctorOutputArg> for OutputFormat {
|
||||||
|
fn from(o: DoctorOutputArg) -> Self {
|
||||||
|
match o {
|
||||||
|
DoctorOutputArg::Rich => OutputFormat::Rich,
|
||||||
|
DoctorOutputArg::Json => OutputFormat::Json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
pub enum DoctorPresetCli {
|
||||||
|
None,
|
||||||
|
Audit,
|
||||||
|
Explain,
|
||||||
|
Implement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DoctorPresetCli> for Preset {
|
||||||
|
fn from(p: DoctorPresetCli) -> Self {
|
||||||
|
match p {
|
||||||
|
DoctorPresetCli::None => Preset::None,
|
||||||
|
DoctorPresetCli::Audit => Preset::Audit,
|
||||||
|
DoctorPresetCli::Explain => Preset::Explain,
|
||||||
|
DoctorPresetCli::Implement => Preset::Implement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct DoctorCli {
|
||||||
|
/// Workspace root (same as `claw-analog -w`; config defaults to `<workspace>/.claw-analog.toml`).
|
||||||
|
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||||
|
pub workspace: PathBuf,
|
||||||
|
/// Config path (default: `<workspace>/.claw-analog.toml`).
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
/// Override model (same precedence as main CLI).
|
||||||
|
#[arg(long)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub permission: Option<DoctorPermissionArg>,
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub preset: Option<DoctorPresetCli>,
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub output_format: Option<DoctorOutputArg>,
|
||||||
|
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
|
||||||
|
pub stream: bool,
|
||||||
|
#[arg(long, default_value_t = false, conflicts_with = "stream")]
|
||||||
|
pub no_stream: bool,
|
||||||
|
/// Disable `runtime::PermissionEnforcer` (same as main CLI).
|
||||||
|
#[arg(
|
||||||
|
long = "no-runtime-enforcer",
|
||||||
|
default_value_t = false,
|
||||||
|
action = clap::ArgAction::SetTrue
|
||||||
|
)]
|
||||||
|
pub no_runtime_enforcer: bool,
|
||||||
|
#[arg(
|
||||||
|
long = "accept-danger-non-interactive",
|
||||||
|
default_value_t = false,
|
||||||
|
action = clap::ArgAction::SetTrue
|
||||||
|
)]
|
||||||
|
pub accept_danger_non_interactive: bool,
|
||||||
|
/// Profile TOML path (optional; if omitted, uses TOML `profile` or default `~/.claw-analog/profile.toml`).
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub profile: Option<PathBuf>,
|
||||||
|
/// TCP connect to host:port from `ANTHROPIC_BASE_URL` (or default API URL); not a full HTTP check.
|
||||||
|
#[arg(long, visible_alias = "mock")]
|
||||||
|
pub tcp_ping: bool,
|
||||||
|
/// Skip HTTPS/TLS + auth + quota header checks against configured providers.
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
pub no_http_check: bool,
|
||||||
|
/// Also probe the embeddings endpoint for OpenAI-compatible providers (may incur minimal cost).
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
pub embeddings_check: bool,
|
||||||
|
/// Skip compile check (`cargo check` / `build --release`).
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_build: bool,
|
||||||
|
/// Run `cargo build --release -p claw-analog` (writes `target/release/…`, safe while `cargo run` holds `target/debug/…` on Windows).
|
||||||
|
#[arg(long, conflicts_with = "no_build")]
|
||||||
|
pub release_build: bool,
|
||||||
|
/// Directory containing the repo workspace `Cargo.toml` (default: search upward from cwd).
|
||||||
|
#[arg(long, value_name = "DIR")]
|
||||||
|
pub manifest_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_doctor(args: DoctorCli) -> i32 {
|
||||||
|
println!("claw-analog doctor — environment and build checks\n");
|
||||||
|
|
||||||
|
let workspace = args.workspace.clone();
|
||||||
|
let canon_ws = std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone());
|
||||||
|
let cfg_path = args
|
||||||
|
.config
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| workspace.join(".claw-analog.toml"));
|
||||||
|
let (file_cfg, cfg_note) = if cfg_path.is_file() {
|
||||||
|
match load_analog_toml(&cfg_path) {
|
||||||
|
Ok(c) => (c, "loaded"),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[claw-analog] doctor: failed to parse {}: {e} (using empty TOML defaults)",
|
||||||
|
cfg_path.display()
|
||||||
|
);
|
||||||
|
(AnalogFileConfig::default(), "parse error (defaults)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(AnalogFileConfig::default(), "file missing (defaults only)")
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream_ov = if args.no_stream {
|
||||||
|
StreamOverride::ForceOff
|
||||||
|
} else if args.stream {
|
||||||
|
StreamOverride::ForceOn
|
||||||
|
} else {
|
||||||
|
StreamOverride::FromFile
|
||||||
|
};
|
||||||
|
let overrides = AnalogDoctorOverrides {
|
||||||
|
model: args.model.clone(),
|
||||||
|
permission: args.permission.map(Into::into),
|
||||||
|
preset: args.preset.map(Into::into),
|
||||||
|
output_format: args.output_format.map(Into::into),
|
||||||
|
stream: stream_ov,
|
||||||
|
no_runtime_enforcer: args.no_runtime_enforcer,
|
||||||
|
accept_danger_non_interactive: args.accept_danger_non_interactive,
|
||||||
|
};
|
||||||
|
let resolved = resolve_analog_options(&file_cfg, &overrides);
|
||||||
|
|
||||||
|
println!("NDJSON contract (for `--output-format json` runs):");
|
||||||
|
println!(" schema: {NDJSON_SCHEMA}");
|
||||||
|
println!(" format_version: {NDJSON_FORMAT_VERSION}\n");
|
||||||
|
|
||||||
|
println!("Effective config (merge of `.claw-analog.toml` + flags below):");
|
||||||
|
println!(" workspace: {}", canon_ws.display());
|
||||||
|
println!(" config: {} ({cfg_note})", cfg_path.display());
|
||||||
|
println!(" model: {}", resolved.model);
|
||||||
|
println!(" permission: {}", resolved.permission_mode.as_str());
|
||||||
|
println!(" preset: {}", resolved.preset.label().unwrap_or("none"));
|
||||||
|
println!(
|
||||||
|
" output_format: {}",
|
||||||
|
match resolved.output_format {
|
||||||
|
OutputFormat::Rich => "rich",
|
||||||
|
OutputFormat::Json => "json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(" stream: {}", resolved.use_stream);
|
||||||
|
println!(
|
||||||
|
" runtime_enforcer: {}",
|
||||||
|
if resolved.use_runtime_enforcer {
|
||||||
|
"on"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" accept_danger_non_interactive: {}",
|
||||||
|
resolved.accept_danger_non_interactive
|
||||||
|
);
|
||||||
|
println!(" Provenance (which side won src ← …):");
|
||||||
|
for line in &resolved.provenance {
|
||||||
|
println!(" - {line}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let prof = resolve_profile_path_doctor(
|
||||||
|
args.profile.as_ref(),
|
||||||
|
file_cfg.profile.as_deref(),
|
||||||
|
&workspace,
|
||||||
|
);
|
||||||
|
print_profile_hint_section(&prof);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
check_env();
|
||||||
|
println!();
|
||||||
|
let build_ok = if args.no_build {
|
||||||
|
println!("cargo: skipped (--no-build)");
|
||||||
|
true
|
||||||
|
} else if args.release_build {
|
||||||
|
run_cargo_release_build(args.manifest_dir.as_deref())
|
||||||
|
} else {
|
||||||
|
run_cargo_check(args.manifest_dir.as_deref())
|
||||||
|
};
|
||||||
|
println!();
|
||||||
|
if args.tcp_ping {
|
||||||
|
ping_print();
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
if !args.no_http_check {
|
||||||
|
http_checks_print(args.embeddings_check);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
if build_ok {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_dir() -> Option<PathBuf> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
std::env::var_os("USERPROFILE").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
std::env::var_os("HOME").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_user_path(raw: &str) -> PathBuf {
|
||||||
|
if let Some(rest) = raw.strip_prefix("~/") {
|
||||||
|
home_dir()
|
||||||
|
.map(|h| h.join(rest))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(raw))
|
||||||
|
} else {
|
||||||
|
PathBuf::from(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_profile_path_doctor(
|
||||||
|
cli: Option<&PathBuf>,
|
||||||
|
file: Option<&str>,
|
||||||
|
workspace: &Path,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
if let Some(p) = cli {
|
||||||
|
return Some(if p.is_absolute() {
|
||||||
|
p.clone()
|
||||||
|
} else {
|
||||||
|
workspace.join(p)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(s) = file {
|
||||||
|
let p = expand_user_path(s.trim());
|
||||||
|
return Some(if p.is_absolute() {
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
workspace.join(p)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let def = home_dir()?.join(".claw-analog").join("profile.toml");
|
||||||
|
if def.is_file() {
|
||||||
|
Some(def)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_profile_hint_section(path: &Option<PathBuf>) {
|
||||||
|
println!("Profile (system prompt snippet):");
|
||||||
|
match path {
|
||||||
|
None => println!(" (none — no --profile, no `profile` in TOML, default file absent)"),
|
||||||
|
Some(p) => {
|
||||||
|
print!(" path: {}", p.display());
|
||||||
|
match load_profile_hint(p) {
|
||||||
|
Ok(Some(h)) => println!(" — loaded, {} chars", h.chars().count()),
|
||||||
|
Ok(None) => println!(" — file ok, empty `line`"),
|
||||||
|
Err(e) => println!(" — error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_env_line(name: &str) {
|
||||||
|
match std::env::var(name) {
|
||||||
|
Ok(v) if !v.trim().is_empty() => {
|
||||||
|
println!(" {name}: set ({} chars)", v.chars().count());
|
||||||
|
}
|
||||||
|
Ok(_) => println!(" {name}: set but empty"),
|
||||||
|
Err(_) => println!(" {name}: unset"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_env() {
|
||||||
|
println!("Environment (values are not printed):");
|
||||||
|
for name in ENV_CHECK {
|
||||||
|
mask_env_line(name);
|
||||||
|
}
|
||||||
|
let anthro_ok = std::env::var("ANTHROPIC_API_KEY")
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let openai_ok = std::env::var("OPENAI_API_KEY")
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
println!();
|
||||||
|
if anthro_ok {
|
||||||
|
println!("Anthropic credentials: OK (API key and/or auth token).");
|
||||||
|
} else {
|
||||||
|
println!("Anthropic credentials: not set — needed for default Claude/Anthropic models.");
|
||||||
|
}
|
||||||
|
if openai_ok {
|
||||||
|
println!("OpenAI API key: set — use `openai/...` model prefix for that provider.");
|
||||||
|
} else {
|
||||||
|
println!("OpenAI API key: unset — only relevant for `openai/` models.");
|
||||||
|
}
|
||||||
|
if !anthro_ok && !openai_ok {
|
||||||
|
println!("\nNote: neither Anthropic nor OpenAI keys are set; live runs will fail until you export credentials (see USAGE.md).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk upward from `start` for a `Cargo.toml` that defines `[workspace]`.
|
||||||
|
pub fn discover_cargo_workspace(start: &Path) -> Option<PathBuf> {
|
||||||
|
let mut dir = start.to_path_buf();
|
||||||
|
for _ in 0..32 {
|
||||||
|
let manifest = dir.join("Cargo.toml");
|
||||||
|
if manifest.is_file() {
|
||||||
|
if let Ok(txt) = std::fs::read_to_string(&manifest) {
|
||||||
|
if txt.contains("[workspace]") {
|
||||||
|
return Some(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir = dir.parent()?.to_path_buf();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root_or_eprint(manifest_dir: Option<&Path>) -> Option<PathBuf> {
|
||||||
|
let start = manifest_dir
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.or_else(|| std::env::current_dir().ok())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
discover_cargo_workspace(&start).or_else(|| {
|
||||||
|
eprintln!(
|
||||||
|
"cargo: could not find a [workspace] Cargo.toml above {}.\n Pass --manifest-dir pointing at the `rust` folder of claw-code.",
|
||||||
|
start.display()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cargo check` does not replace `target/debug/claw-analog.exe`, so `cargo run … doctor` works on Windows.
|
||||||
|
fn run_cargo_check(manifest_dir: Option<&Path>) -> bool {
|
||||||
|
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
println!("cargo check -p claw-analog (workspace {})", root.display());
|
||||||
|
println!(" (compile-only; avoids “access denied” replacing the running debug exe on Windows)");
|
||||||
|
let status = Command::new("cargo")
|
||||||
|
.args(["check", "-p", "claw-analog"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.status();
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
println!("cargo check: OK");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(s) => {
|
||||||
|
eprintln!("cargo check: failed ({s})");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("cargo check: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_cargo_release_build(manifest_dir: Option<&Path>) -> bool {
|
||||||
|
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"cargo build --release -p claw-analog (workspace {})",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
println!(" (output in target/release/; does not overwrite a running target/debug/ binary)");
|
||||||
|
let status = Command::new("cargo")
|
||||||
|
.args(["build", "--release", "-p", "claw-analog"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.status();
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
println!("cargo build --release: OK");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(s) => {
|
||||||
|
eprintln!("cargo build --release: failed ({s})");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("cargo build --release: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_anthropic_base() -> String {
|
||||||
|
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| "https://api.anthropic.com".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_host_port(url: &str) -> Result<(String, u16), String> {
|
||||||
|
let url = url.trim().trim_end_matches('/');
|
||||||
|
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
|
||||||
|
("https", r)
|
||||||
|
} else if let Some(r) = url.strip_prefix("http://") {
|
||||||
|
("http", r)
|
||||||
|
} else {
|
||||||
|
return Err("URL must start with http:// or https://".into());
|
||||||
|
};
|
||||||
|
let host_part = rest
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| "missing host".to_string())?;
|
||||||
|
if let Some((host, port_s)) = host_part.rsplit_once(':') {
|
||||||
|
if let Ok(p) = port_s.parse::<u16>() {
|
||||||
|
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||||
|
return Ok((host.to_string(), p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let default_port = if scheme == "https" { 443 } else { 80 };
|
||||||
|
Ok((host_part.to_string(), default_port))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ping_print() {
|
||||||
|
let url = default_anthropic_base();
|
||||||
|
println!("TCP check for ANTHROPIC_BASE_URL (default if unset): {url}");
|
||||||
|
match parse_host_port(&url) {
|
||||||
|
Ok((host, port)) => match tcp_ping(&host, port) {
|
||||||
|
Ok(()) => println!(" reachability: OK ({host}:{port})"),
|
||||||
|
Err(e) => println!(" reachability: FAIL ({host}:{port}) — {e}"),
|
||||||
|
},
|
||||||
|
Err(e) => println!(" could not parse URL: {e}"),
|
||||||
|
}
|
||||||
|
println!(" (HTTP/TLS application data is not validated; this is connect() only.)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tcp_ping(host: &str, port: u16) -> Result<(), String> {
|
||||||
|
let addr = (host, port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "no resolved addresses".to_string())?;
|
||||||
|
TcpStream::connect_timeout(&addr, Duration::from_secs(3)).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_checks_print(embeddings_check: bool) {
|
||||||
|
println!("HTTP/TLS checks (auth + TLS validation + quota headers when available):");
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build();
|
||||||
|
let Ok(rt) = rt else {
|
||||||
|
println!(" runtime: FAIL (could not build tokio runtime)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
// OpenAI-compatible providers (OPENAI_BASE_URL, OPENAI_API_KEY)
|
||||||
|
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
|
||||||
|
if !key.trim().is_empty() {
|
||||||
|
let base = std::env::var("OPENAI_BASE_URL")
|
||||||
|
.ok()
|
||||||
|
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
|
||||||
|
let url = openai_models_url(base.as_str());
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str()) {
|
||||||
|
headers.insert(reqwest::header::AUTHORIZATION, v);
|
||||||
|
}
|
||||||
|
let _ = http_check_and_print("openai", url.as_str(), headers).await;
|
||||||
|
|
||||||
|
if embeddings_check {
|
||||||
|
let model = std::env::var("OPENAI_EMBEDDING_MODEL")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| std::env::var("CLAW_RAG_EMBEDDING_MODEL").ok())
|
||||||
|
.unwrap_or_else(|| "text-embedding-3-small".to_string());
|
||||||
|
let eurl = openai_embeddings_url(base.as_str());
|
||||||
|
let mut eheaders = HeaderMap::new();
|
||||||
|
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str())
|
||||||
|
{
|
||||||
|
eheaders.insert(reqwest::header::AUTHORIZATION, v);
|
||||||
|
}
|
||||||
|
let _ = openai_embeddings_probe(
|
||||||
|
"openai embeddings",
|
||||||
|
eurl.as_str(),
|
||||||
|
&model,
|
||||||
|
eheaders,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
println!(" openai embeddings: skipped (pass --embeddings-check to enable)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(" openai: skipped (OPENAI_API_KEY empty)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(" openai: skipped (OPENAI_API_KEY unset)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anthropic (ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY/AUTH_TOKEN)
|
||||||
|
let a_key = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||||
|
let a_tok = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
|
||||||
|
let a_base = std::env::var("ANTHROPIC_BASE_URL")
|
||||||
|
.ok()
|
||||||
|
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
|
||||||
|
if a_key.as_deref().is_some_and(|s| !s.trim().is_empty())
|
||||||
|
|| a_tok.as_deref().is_some_and(|s| !s.trim().is_empty())
|
||||||
|
{
|
||||||
|
let url = anthropic_models_url(a_base.as_str());
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
HeaderName::from_static("anthropic-version"),
|
||||||
|
HeaderValue::from_static("2023-06-01"),
|
||||||
|
);
|
||||||
|
if let Some(k) = a_key.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
if let Ok(v) = HeaderValue::from_str(k) {
|
||||||
|
headers.insert(HeaderName::from_static("x-api-key"), v);
|
||||||
|
}
|
||||||
|
} else if let Some(t) = a_tok.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
if let Ok(v) = HeaderValue::from_str(format!("Bearer {t}").as_str()) {
|
||||||
|
headers.insert(reqwest::header::AUTHORIZATION, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = http_check_and_print("anthropic", url.as_str(), headers).await;
|
||||||
|
} else {
|
||||||
|
println!(" anthropic: skipped (no API key/token)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAG service (RAG_BASE_URL) — just basic health + stats.
|
||||||
|
if let Ok(base) = std::env::var("RAG_BASE_URL") {
|
||||||
|
let base = base.trim().trim_end_matches('/');
|
||||||
|
if !base.is_empty() {
|
||||||
|
let headers = HeaderMap::new();
|
||||||
|
let _ =
|
||||||
|
http_check_and_print("rag health", &format!("{base}/health"), headers.clone())
|
||||||
|
.await;
|
||||||
|
let _ =
|
||||||
|
http_check_and_print("rag stats", &format!("{base}/v1/stats"), headers).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
println!(" (TLS validation is performed by the HTTP client; certificate errors surface as request failures.)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_models_url(base: &str) -> String {
|
||||||
|
let b = base.trim().trim_end_matches('/');
|
||||||
|
if b.ends_with("/v1") {
|
||||||
|
format!("{b}/models")
|
||||||
|
} else {
|
||||||
|
format!("{b}/v1/models")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_embeddings_url(base: &str) -> String {
|
||||||
|
let b = base.trim().trim_end_matches('/');
|
||||||
|
if b.ends_with("/v1") {
|
||||||
|
format!("{b}/embeddings")
|
||||||
|
} else {
|
||||||
|
format!("{b}/v1/embeddings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anthropic_models_url(base: &str) -> String {
|
||||||
|
let b = base.trim().trim_end_matches('/');
|
||||||
|
format!("{b}/v1/models?limit=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn http_check_and_print(label: &str, url: &str, headers: HeaderMap) -> Result<(), ()> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(8))
|
||||||
|
.build();
|
||||||
|
let Ok(client) = client else {
|
||||||
|
println!(" {label}: FAIL (client build)");
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = client.get(url).headers(headers).send().await;
|
||||||
|
match resp {
|
||||||
|
Ok(r) => {
|
||||||
|
let status = r.status();
|
||||||
|
println!(" {label}: {status} ({url})");
|
||||||
|
print_quota_headers(r.headers());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.to_ascii_lowercase().contains("certificate")
|
||||||
|
|| msg.to_ascii_lowercase().contains("tls")
|
||||||
|
{
|
||||||
|
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
|
||||||
|
} else {
|
||||||
|
println!(" {label}: FAIL ({url}) — {msg}");
|
||||||
|
}
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_quota_headers(headers: &HeaderMap) {
|
||||||
|
let mut out: Vec<(String, String)> = Vec::new();
|
||||||
|
for (k, v) in headers.iter() {
|
||||||
|
let name = k.as_str().to_ascii_lowercase();
|
||||||
|
if name.contains("ratelimit") || name.contains("quota") {
|
||||||
|
if let Ok(s) = v.to_str() {
|
||||||
|
out.push((k.as_str().to_string(), s.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OpenAI-compatible common headers:
|
||||||
|
if name.starts_with("x-ratelimit-") {
|
||||||
|
if let Ok(s) = v.to_str() {
|
||||||
|
out.push((k.as_str().to_string(), s.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out.dedup();
|
||||||
|
for (k, v) in out {
|
||||||
|
println!(" {k}: {v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn openai_embeddings_probe(
|
||||||
|
label: &str,
|
||||||
|
url: &str,
|
||||||
|
model: &str,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<(), ()> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(12))
|
||||||
|
.build();
|
||||||
|
let Ok(client) = client else {
|
||||||
|
println!(" {label}: FAIL (client build)");
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal request: one short string. We don't parse the embedding content.
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"input": ["ping"]
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client.post(url).headers(headers).json(&body).send().await;
|
||||||
|
match resp {
|
||||||
|
Ok(r) => {
|
||||||
|
let status = r.status();
|
||||||
|
println!(" {label}: {status} ({url}) model={model}");
|
||||||
|
print_quota_headers(r.headers());
|
||||||
|
if !status.is_success() {
|
||||||
|
let t = r.text().await.unwrap_or_default();
|
||||||
|
if !t.trim().is_empty() {
|
||||||
|
println!(" body: {}", t.chars().take(400).collect::<String>());
|
||||||
|
}
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.to_ascii_lowercase().contains("certificate")
|
||||||
|
|| msg.to_ascii_lowercase().contains("tls")
|
||||||
|
{
|
||||||
|
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
|
||||||
|
} else {
|
||||||
|
println!(" {label}: FAIL ({url}) — {msg}");
|
||||||
|
}
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_base_url_host_port() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_host_port("http://127.0.0.1:8080/v1").unwrap(),
|
||||||
|
("127.0.0.1".into(), 8080)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_host_port("https://api.anthropic.com").unwrap(),
|
||||||
|
("api.anthropic.com".into(), 443)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
2889
rust/crates/claw-analog/src/lib.rs
Normal file
2889
rust/crates/claw-analog/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
522
rust/crates/claw-analog/src/main.rs
Normal file
522
rust/crates/claw-analog/src/main.rs
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
//! Binary wrapper for `claw_analog::run` — see `how_to_run.md` in repo root.
|
||||||
|
|
||||||
|
mod agents;
|
||||||
|
mod config_cmd;
|
||||||
|
mod doctor;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
|
||||||
|
use clap_complete::{generate, Shell};
|
||||||
|
use claw_analog::{
|
||||||
|
load_analog_toml, load_profile_hint, permission_mode_from_toml_str, print_tools_dry_run,
|
||||||
|
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogFileConfig,
|
||||||
|
AnalogLanguage, OutputFormat, PermissionMode, Preset, ANALOG_DEFAULT_MODEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
enum PermissionArg {
|
||||||
|
ReadOnly,
|
||||||
|
WorkspaceWrite,
|
||||||
|
Prompt,
|
||||||
|
#[value(name = "danger-full-access")]
|
||||||
|
DangerFullAccess,
|
||||||
|
/// Same unrestricted posture as danger-full-access for this narrow tool set.
|
||||||
|
Allow,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
enum OutputFormatArg {
|
||||||
|
Rich,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
enum LangArg {
|
||||||
|
En,
|
||||||
|
Ru,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LangArg> for AnalogLanguage {
|
||||||
|
fn from(a: LangArg) -> Self {
|
||||||
|
match a {
|
||||||
|
LangArg::En => AnalogLanguage::En,
|
||||||
|
LangArg::Ru => AnalogLanguage::Ru,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
enum PresetCli {
|
||||||
|
None,
|
||||||
|
/// Automatically infer a preset from the initial prompt.
|
||||||
|
Auto,
|
||||||
|
Audit,
|
||||||
|
Explain,
|
||||||
|
Implement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PresetCli> for Preset {
|
||||||
|
fn from(p: PresetCli) -> Self {
|
||||||
|
match p {
|
||||||
|
PresetCli::None => Preset::None,
|
||||||
|
PresetCli::Auto => Preset::None,
|
||||||
|
PresetCli::Audit => Preset::Audit,
|
||||||
|
PresetCli::Explain => Preset::Explain,
|
||||||
|
PresetCli::Implement => Preset::Implement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "claw-analog",
|
||||||
|
version,
|
||||||
|
about = "Lean tool-agent loop (read/list/grep/write) on claw-code `api` providers"
|
||||||
|
)]
|
||||||
|
#[command(args_conflicts_with_subcommands = true)]
|
||||||
|
struct RootCli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
#[command(flatten)]
|
||||||
|
run: RunCli,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum Commands {
|
||||||
|
/// Verify credentials, `cargo check -p claw-analog` (or `--release-build`), config merge preview, optional `--tcp-ping`.
|
||||||
|
Doctor(doctor::DoctorCli),
|
||||||
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ConfigSub,
|
||||||
|
},
|
||||||
|
/// Print shell completion script for this binary (redirect to a file or `source` it).
|
||||||
|
Complete(CompleteCli),
|
||||||
|
/// Run multiple specialized sub-agents sequentially (shared base session).
|
||||||
|
Agents(agents::AgentsCli),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum ConfigSub {
|
||||||
|
/// Parse `.claw-analog.toml` and profile; print a merge preview (no API calls).
|
||||||
|
Validate(config_cmd::ValidateCli),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
struct CompleteCli {
|
||||||
|
#[arg(value_enum)]
|
||||||
|
shell: ShellKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
enum ShellKind {
|
||||||
|
Bash,
|
||||||
|
Zsh,
|
||||||
|
Fish,
|
||||||
|
#[value(name = "powershell", alias = "pwsh")]
|
||||||
|
Powershell,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
struct RunCli {
|
||||||
|
/// Config file (default: `<workspace>/.claw-analog.toml` if that path exists).
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[arg(short = 'w', long, default_value = ".")]
|
||||||
|
workspace: PathBuf,
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
permission: Option<PermissionArg>,
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
preset: Option<PresetCli>,
|
||||||
|
/// Reply language hint for the assistant (`en` or `ru` in system prompt; not the API model id).
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
lang: Option<LangArg>,
|
||||||
|
/// Print effective tools for merged `permission` / enforcer, then exit (no prompt, no API).
|
||||||
|
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||||
|
print_tools: bool,
|
||||||
|
/// Persist message history for resume (JSON). See `how_to_run.md` for risks.
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
session: Option<PathBuf>,
|
||||||
|
/// Write session JSON to this path on each snapshot (export without `--session`, or an extra copy).
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
save_session: Option<PathBuf>,
|
||||||
|
/// Profile snippet TOML (`line = "..."`). Default: `~/.claw-analog/profile.toml` if it exists.
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
profile: Option<PathBuf>,
|
||||||
|
/// Stream assistant text to stdout as tokens arrive (uses `stream_message`).
|
||||||
|
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
|
||||||
|
stream: bool,
|
||||||
|
/// Turn streaming off (overrides `stream` in config).
|
||||||
|
#[arg(long, default_value_t = false, conflicts_with = "stream")]
|
||||||
|
no_stream: bool,
|
||||||
|
/// Newline-delimited JSON events on stdout (for agents / CI). Diagnostics stay on stderr.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
output_format: Option<OutputFormatArg>,
|
||||||
|
/// Disable `runtime::PermissionEnforcer` (paths are still jailed; policy checks are weakened).
|
||||||
|
#[arg(long = "no-runtime-enforcer", default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||||
|
no_runtime_enforcer: bool,
|
||||||
|
/// Allow `danger-full-access` / `allow` when stdin is not a TTY (CI/automation; use with care).
|
||||||
|
#[arg(long = "accept-danger-non-interactive", default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||||
|
accept_danger_non_interactive: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
max_read_bytes: Option<u64>,
|
||||||
|
#[arg(long)]
|
||||||
|
max_turns: Option<u32>,
|
||||||
|
#[arg(long)]
|
||||||
|
max_list_entries: Option<usize>,
|
||||||
|
#[arg(long)]
|
||||||
|
grep_max_lines: Option<usize>,
|
||||||
|
#[arg(long)]
|
||||||
|
glob_max_paths: Option<usize>,
|
||||||
|
#[arg(long)]
|
||||||
|
glob_max_depth: Option<usize>,
|
||||||
|
prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEF_MAX_READ: u64 = 256 * 1024;
|
||||||
|
const DEF_MAX_TURNS: u32 = 24;
|
||||||
|
const DEF_MAX_LIST: usize = 500;
|
||||||
|
const DEF_GREP_MAX: usize = 200;
|
||||||
|
const DEF_GLOB_PATHS: usize = 2000;
|
||||||
|
const DEF_GLOB_DEPTH: usize = 32;
|
||||||
|
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
|
||||||
|
const DEF_RAG_TOP_K_MAX: u32 = 32;
|
||||||
|
const RAG_TOP_K_ABS_CAP: u32 = 256;
|
||||||
|
|
||||||
|
fn config_file_path(cli: &RunCli) -> PathBuf {
|
||||||
|
cli.config
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_file_config(path: &Path) -> AnalogFileConfig {
|
||||||
|
if !path.is_file() {
|
||||||
|
return AnalogFileConfig::default();
|
||||||
|
}
|
||||||
|
match load_analog_toml(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[claw-analog] warning: failed to read {}: {e}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
AnalogFileConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_format_from_toml(s: &str) -> Option<OutputFormat> {
|
||||||
|
match s.to_ascii_lowercase().as_str() {
|
||||||
|
"json" => Some(OutputFormat::Json),
|
||||||
|
"rich" => Some(OutputFormat::Rich),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_session_path(
|
||||||
|
cli: Option<PathBuf>,
|
||||||
|
file: Option<&str>,
|
||||||
|
workspace: &Path,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
let p = cli.or_else(|| file.map(PathBuf::from))?;
|
||||||
|
Some(if p.is_absolute() {
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
workspace.join(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_language(cli: Option<LangArg>, file: Option<&str>) -> AnalogLanguage {
|
||||||
|
if let Some(l) = cli {
|
||||||
|
return l.into();
|
||||||
|
}
|
||||||
|
file.and_then(AnalogLanguage::from_toml_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_preset(cli: Option<PresetCli>, file: Option<&str>, prompt: &str) -> Preset {
|
||||||
|
if let Some(p) = cli {
|
||||||
|
return match p {
|
||||||
|
PresetCli::Auto => claw_analog::infer_preset_from_prompt(prompt),
|
||||||
|
other => Preset::from(other),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if file.is_some_and(|s| s.trim().eq_ignore_ascii_case("auto")) {
|
||||||
|
return claw_analog::infer_preset_from_prompt(prompt);
|
||||||
|
}
|
||||||
|
if let Some(s) = file.and_then(Preset::from_toml_str) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
claw_analog::infer_preset_from_prompt(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_permission(
|
||||||
|
cli: Option<PermissionArg>,
|
||||||
|
file_perm: Option<String>,
|
||||||
|
preset: Preset,
|
||||||
|
) -> PermissionMode {
|
||||||
|
if let Some(p) = cli {
|
||||||
|
return match p {
|
||||||
|
PermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||||
|
PermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||||
|
PermissionArg::Prompt => PermissionMode::Prompt,
|
||||||
|
PermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||||
|
PermissionArg::Allow => PermissionMode::Allow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(s) = file_perm.as_deref().and_then(permission_mode_from_toml_str) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
match preset {
|
||||||
|
Preset::Implement => PermissionMode::WorkspaceWrite,
|
||||||
|
_ => PermissionMode::ReadOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_config(
|
||||||
|
cli: &RunCli,
|
||||||
|
file: &AnalogFileConfig,
|
||||||
|
prompt: String,
|
||||||
|
profile_hint: Option<String>,
|
||||||
|
session_path: Option<PathBuf>,
|
||||||
|
preset: Preset,
|
||||||
|
permission_mode: PermissionMode,
|
||||||
|
) -> AnalogConfig {
|
||||||
|
let model = cli
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.or_else(|| file.model.clone())
|
||||||
|
.unwrap_or_else(|| ANALOG_DEFAULT_MODEL.into());
|
||||||
|
|
||||||
|
let output_format = cli
|
||||||
|
.output_format
|
||||||
|
.map(|o| match o {
|
||||||
|
OutputFormatArg::Rich => OutputFormat::Rich,
|
||||||
|
OutputFormatArg::Json => OutputFormat::Json,
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
file.output_format
|
||||||
|
.as_deref()
|
||||||
|
.and_then(output_format_from_toml)
|
||||||
|
})
|
||||||
|
.unwrap_or(OutputFormat::Rich);
|
||||||
|
|
||||||
|
let use_stream = if cli.no_stream {
|
||||||
|
false
|
||||||
|
} else if cli.stream {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
file.stream.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let use_runtime_enforcer =
|
||||||
|
!cli.no_runtime_enforcer && !file.no_runtime_enforcer.unwrap_or(false);
|
||||||
|
|
||||||
|
let accept_danger_non_interactive =
|
||||||
|
cli.accept_danger_non_interactive || file.accept_danger_non_interactive.unwrap_or(false);
|
||||||
|
|
||||||
|
let max_read_bytes = cli
|
||||||
|
.max_read_bytes
|
||||||
|
.or(file.max_read_bytes)
|
||||||
|
.unwrap_or(DEF_MAX_READ);
|
||||||
|
let max_turns = cli.max_turns.or(file.max_turns).unwrap_or(DEF_MAX_TURNS);
|
||||||
|
let max_list_entries = cli
|
||||||
|
.max_list_entries
|
||||||
|
.or(file.max_list_entries)
|
||||||
|
.unwrap_or(DEF_MAX_LIST);
|
||||||
|
let grep_max_lines = cli
|
||||||
|
.grep_max_lines
|
||||||
|
.or(file.grep_max_lines)
|
||||||
|
.unwrap_or(DEF_GREP_MAX);
|
||||||
|
let glob_max_paths = cli
|
||||||
|
.glob_max_paths
|
||||||
|
.or(file.glob_max_paths)
|
||||||
|
.unwrap_or(DEF_GLOB_PATHS);
|
||||||
|
let glob_max_depth = cli
|
||||||
|
.glob_max_depth
|
||||||
|
.or(file.glob_max_depth)
|
||||||
|
.unwrap_or(DEF_GLOB_DEPTH);
|
||||||
|
|
||||||
|
let rag_base_url = resolve_rag_base_url(file);
|
||||||
|
let rag_http_timeout =
|
||||||
|
Duration::from_secs(file.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS).max(1));
|
||||||
|
let rag_top_k_max = file
|
||||||
|
.rag_top_k_max
|
||||||
|
.unwrap_or(DEF_RAG_TOP_K_MAX)
|
||||||
|
.clamp(1, RAG_TOP_K_ABS_CAP);
|
||||||
|
|
||||||
|
let session_save_path = cli.save_session.as_ref().map(|p| {
|
||||||
|
if p.is_absolute() {
|
||||||
|
p.clone()
|
||||||
|
} else {
|
||||||
|
cli.workspace.join(p)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let language = merge_language(cli.lang, file.language.as_deref());
|
||||||
|
|
||||||
|
AnalogConfig {
|
||||||
|
model,
|
||||||
|
workspace: cli.workspace.clone(),
|
||||||
|
permission_mode,
|
||||||
|
accept_danger_non_interactive,
|
||||||
|
use_stream,
|
||||||
|
output_format,
|
||||||
|
use_runtime_enforcer,
|
||||||
|
max_read_bytes,
|
||||||
|
max_turns,
|
||||||
|
max_list_entries,
|
||||||
|
grep_max_lines,
|
||||||
|
glob_max_paths,
|
||||||
|
glob_max_depth,
|
||||||
|
preset,
|
||||||
|
language,
|
||||||
|
session_path,
|
||||||
|
session_save_path,
|
||||||
|
profile_hint,
|
||||||
|
prompt,
|
||||||
|
rag_base_url,
|
||||||
|
rag_http_timeout,
|
||||||
|
rag_top_k_max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let root = RootCli::parse();
|
||||||
|
match root.command {
|
||||||
|
Some(Commands::Doctor(d)) => {
|
||||||
|
let code = doctor::run_doctor(d);
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
Some(Commands::Agents(a)) => {
|
||||||
|
let code = match agents::run_agents(a) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("agents: {e}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
Some(Commands::Config { command }) => {
|
||||||
|
let code = match command {
|
||||||
|
ConfigSub::Validate(v) => config_cmd::run_validate(v),
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
Some(Commands::Complete(co)) => {
|
||||||
|
let shell = match co.shell {
|
||||||
|
ShellKind::Bash => Shell::Bash,
|
||||||
|
ShellKind::Zsh => Shell::Zsh,
|
||||||
|
ShellKind::Fish => Shell::Fish,
|
||||||
|
ShellKind::Powershell => Shell::PowerShell,
|
||||||
|
};
|
||||||
|
let mut cmd = RootCli::command();
|
||||||
|
generate(shell, &mut cmd, "claw-analog", &mut std::io::stdout());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
let cli = root.run;
|
||||||
|
let cfg_path = config_file_path(&cli);
|
||||||
|
let file_cfg = load_file_config(&cfg_path);
|
||||||
|
|
||||||
|
if cli.print_tools {
|
||||||
|
let preset = merge_preset(
|
||||||
|
cli.preset,
|
||||||
|
file_cfg.preset.as_deref(),
|
||||||
|
&cli.prompt.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
|
||||||
|
let use_runtime_enforcer =
|
||||||
|
!cli.no_runtime_enforcer && !file_cfg.no_runtime_enforcer.unwrap_or(false);
|
||||||
|
let rag_url = resolve_rag_base_url(&file_cfg);
|
||||||
|
print_tools_dry_run(
|
||||||
|
permission_mode,
|
||||||
|
use_runtime_enforcer,
|
||||||
|
rag_url.as_deref(),
|
||||||
|
&mut std::io::stdout(),
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pre_output_format = cli
|
||||||
|
.output_format
|
||||||
|
.map(|o| match o {
|
||||||
|
OutputFormatArg::Rich => OutputFormat::Rich,
|
||||||
|
OutputFormatArg::Json => OutputFormat::Json,
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
file_cfg
|
||||||
|
.output_format
|
||||||
|
.as_deref()
|
||||||
|
.and_then(output_format_from_toml)
|
||||||
|
})
|
||||||
|
.unwrap_or(OutputFormat::Rich);
|
||||||
|
|
||||||
|
let prompt = if let Some(p) = cli.prompt.clone() {
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut buf = String::new();
|
||||||
|
std::io::stdin().read_to_string(&mut buf)?;
|
||||||
|
if buf.trim().is_empty() {
|
||||||
|
if matches!(pre_output_format, OutputFormat::Json) {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"type": "error", "message": "empty prompt (pass as arg or stdin)"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err("empty prompt (pass as arg or stdin)".into());
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
|
||||||
|
let preset = merge_preset(cli.preset, file_cfg.preset.as_deref(), &prompt);
|
||||||
|
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
|
||||||
|
|
||||||
|
let session_path = resolve_session_path(
|
||||||
|
cli.session.clone(),
|
||||||
|
file_cfg.session.as_deref(),
|
||||||
|
&cli.workspace,
|
||||||
|
);
|
||||||
|
|
||||||
|
let profile_path = resolve_analog_profile_path(
|
||||||
|
&cli.workspace,
|
||||||
|
cli.profile.clone(),
|
||||||
|
file_cfg.profile.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let profile_hint = if let Some(ref p) = profile_path {
|
||||||
|
load_profile_hint(p)?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = build_config(
|
||||||
|
&cli,
|
||||||
|
&file_cfg,
|
||||||
|
prompt,
|
||||||
|
profile_hint,
|
||||||
|
session_path,
|
||||||
|
preset,
|
||||||
|
permission_mode,
|
||||||
|
);
|
||||||
|
let output_format = config.output_format;
|
||||||
|
|
||||||
|
let mut out = std::io::stdout();
|
||||||
|
if let Err(e) = claw_analog::run(config, &mut out).await {
|
||||||
|
if matches!(output_format, OutputFormat::Json) {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"type": "error", "message": e.to_string()})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user