diff --git a/Cargo.lock b/Cargo.lock index f2058865..e600e0d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,6 +463,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -762,6 +774,18 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -847,6 +871,7 @@ dependencies = [ "chrono-tz", "clap", "crossterm", + "dialoguer", "dirs", "flate2", "fs2", @@ -894,6 +919,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -3347,6 +3378,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 847fd733..b23d0503 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ uuid = { version = "1", features = ["v4"] } walkdir = "2" metrics = "0.24" metrics-exporter-prometheus = "0.18" +dialoguer = "0.12.0" [dev-dependencies] serial_test = "3.4" diff --git a/docs/commands.md b/docs/commands.md index 3f24186e..d4ff71fe 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -19,7 +19,7 @@ CLI サブコマンドとチャットスラッシュコマンドの完全仕様 | コマンド | 引数 | 説明 | 設定必須 | |---|---|---|:---:| | `egopulse` | なし | ローカル TUI(セッションブラウザ + チャット) | 必須 | -| `egopulse setup` | なし | 対話型設定ウィザード(TUI) | 不要 | +| `egopulse setup` | なし | 対話型設定プロンプト(dialoguer ベースのチャットライク順次プロンプト) | 不要 | | `egopulse ask ` | `[--session ]` | 単発プロンプト、結果を stdout に出力 | 必須 | | `egopulse chat` | `[--session ]` | 永続化 CLI チャットセッション | 必須 | | `egopulse run` | なし | 有効チャネルを一括起動(前景実行) | 必須 | diff --git a/docs/config.md b/docs/config.md index 13525280..b0afe5d1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -617,32 +617,162 @@ SecretRef 解決は以下の 2 層で構成される。 ## 7. セットアップウィザード -`egopulse setup` で起動する対話型 TUI ウィザード。 -プロバイダープリセットから選択し、必要に応じて Discord / Telegram Bot を設定する。 +`egopulse setup` で起動する対話型設定プロンプト(dialoguer ベースのチャットライク順次プロンプト)。 +Agent-First 設計に基づき、エージェント名を最初に問い、LLM と対話するために必要な最小限の項目のみを順次収集する。 -### 設定可能項目 +> 設計の背景・方針・経緯は [setup-redesign.md](./setup-redesign.md) を参照。コマンド仕様は [commands.md §1.1](./commands.md#1-cli-サブコマンド) を参照。 -| 項目 | 必須 | 備考 | -|------|:---:|------| -| プロバイダー選択(25 プリセットから選択) | **必須** | `PROVIDER` → `BASE_URL` → `MODEL` の順に連動入力 | -| API キー | 条件付き | localhost 系(Ollama, LMStudio)では不要 | -| Discord Bot トークン | 任意 | Discord 有効時は必須 | -| Telegram Bot トークン・ユーザー名 | 任意 | Telegram 有効時は必須 | +### 7.1 フロー全体像 -### 動作 +```text +[Welcome] + │ + ▼ +[既存設定の読み込み] ── パースエラー時は警告 + Y/N 確認 + │ + ▼ +[Q1: Agent Label] エージェント名 (text) + │ + ▼ +[Q2: Provider] 26 プリセット + Custom (select) + │ Custom 選択時のみ base_url 入力を追加 + ▼ +[Q3: Model] preset なら select / Custom なら text + │ + ▼ +[Q4: API Key] password。空欄可、非 localhost 系の空欄は Y/N 確認 + │ + ▼ +[Q5: Web Channel] Y/n(デフォルト yes) + │ + ▼ +[Q6: Discord] y/N ── yes のみ Bot Token 入力 + │ + ▼ +[Q7: Telegram] y/N ── yes のみ Bot Token 入力 + │ + ▼ +[Review] 生成内容表示 + Save? (Y/n) + │ no の場合は StartOver / Abort / SaveAnyway の 3 択 + ▼ +[Save] YAML + .env 永続化(上書き前にバックアップ) + │ + ▼ +[Additional Options] 設定対象外項目の案内(情報表示のみ) + │ + ▼ +[Done] 保存先・次ステップ・チャネル案内 +``` + +### 7.2 入力項目仕様(Q1〜Q7) + +| Q | 項目 | 入力種別 | 必須 | デフォルト | 備考 | +|---|---|---|:---:|---|---| +| Q1 | Agent Label | text | ○ | なし | エージェントの表示名。空入力時は `"Default"` にフォールバック | +| — | Agent ID | (自動) | — | label を slugify | lowercase + 英数字以外をハイフン化 + 連続ハイフン圧縮。空結果は `"default"` にフォールバック | +| Q2 | Provider | select | ○ | なし | 26 プリセット(§5 プロバイダープリセット)+ `Custom` | +| Q2' | base_url | text | 条件付き | なし | `Custom` 選択時のみ追加質問。URL 検証あり | +| Q3 | Model | select / text | ○ | preset の `default_model` | preset 選択時は select、`Custom` 選択時は text | +| Q4 | API Key | password | △ | 空文字 | 常に入力ステップを表示。localhost 系は空欄でそのまま通す | +| Q5 | Web Channel | confirm | — | `yes` | 無効時は `channels.web` エントリ自体を YAML に含めない | +| — | Web auth_token | (自動) | — | `generate_auth_token()` | ユーザー入力なし。実値は Review / Done で非表示 | +| Q6 | Discord | confirm | — | `no` | `yes` のみ Bot Token 入力 (password) へ分岐 | +| Q7 | Telegram | confirm | — | `no` | `yes` のみ Bot Token 入力 (password) へ分岐 | + +各質問の分岐仕様: + +- **Q2 Provider**: `Custom` 選択時のみ直後に base_url 入力を追加。preset 選択時は base_url を preset のデフォルトで自動補完 +- **Q3 Model**: preset 選択時は `models` リストからの select(リスト外は選択不可)。`Custom` 選択時は手入力 text モードに切替 +- **Q4 API Key**: ステップ自体は常に表示。非 localhost 系プロバイダで空欄入力時は `Proceed with an empty key? (y/N)` で確認。`no` で再入力、`yes` で警告付きで進行 +- **Q6 / Q7**: `yes` の場合のみ Bot Token (password 入力) を追加質問。空トークンは拒否 + +### 7.3 Review(保存確認) + +収集した入力から生成される設定内容を表示し、保存確認を行う。 -- Web チャネルは常に有効化。`auth_token` は自動生成される -- 秘匿値は YAML に SecretRef、実値は `.env` に保存 -- 既存設定ファイルは上書き前にバックアップ -- 単一プロバイダーのみ生成(複数追加は手動編集) +```text +About to save the configuration file with the following values: + + Agent: Partner (id: partner) + Provider: openai (https://api.openai.com/v1) + Model: gpt-5.2 + API Key: sk-...xxxx + Web: enabled (auth_token: auto-generated, saved to .env) + Discord: disabled + Telegram: disabled + +Save? (Y/n) +``` + +- API Key は先頭 3 文字 + `...` + 末尾 4 文字でマスク。空欄時は `(empty)` +- `Save?` で `no` の場合は 3 択を提示: + +| 選択肢 | 動作 | +|---|---| +| Start over (back to Agent Label) | Q1 に戻り入力をやり直す | +| Abort (exit without saving) | 保存せずに終了(exit code 1) | +| Save anyway | 警告を了承の上、保存へ進む | -### ウィザードで設定できないフィールド +### 7.4 Additional Options(設定対象外項目の案内) -- `log_level`, `compaction_*`, `max_*`, `default_context_window_tokens` -- `channels.web.host`, `channels.web.port`, `channels.web.allowed_origins` -- `channels.voice.*` -- チャネル別アクセス制御(`channels`, `chats`) -- 複数プロバイダーの追加 +保存完了後、セットアップで設定しなかったが YAML 編集で設定可能な項目をカテゴリ別に案内する。入力は受け付けず、Enter で次へ進む情報表示のみ。 + +| カテゴリ | 案内項目 | +|---|---| +| System | `timezone` / `log_level` / `default_context_window_tokens` / `compaction_*` / `max_history_messages` | +| Web UI | `channels.web.host` / `channels.web.port` / `channels.web.allowed_origins` | +| Channels | 追加プロバイダー・エージェント / Discord・Telegram チャネルアクセス制御 / Voice チャネル / エージェント別人格 (`SOUL.md`) | +| Subsystems | `sleep_batch` / `pulse` / `db.backup` / `web_fetch` | + +詳細は `docs/config.md`(本ドキュメント)および [channels.md](./channels.md) へ誘導。 + +### 7.5 Done(完了メッセージ) + +保存完了時に以下を出力する。 + +- **保存先**: `~/.egopulse/egopulse.config.yaml` +- **バックアップ**: 既存設定があった場合のみバックアップファイルパスを表示 +- **次ステップ**: + +| アクション | コマンド | +|---|---| +| すぐチャット開始 | `egopulse chat` | +| systemd サービス登録 | `egopulse gateway install` | +| 設定編集 | `~/.egopulse/egopulse.config.yaml` | +| エージェント追加 | YAML の `agents` セクション編集 | + +- **Web UI 有効時**: アクセス URL (`http://127.0.0.1:10961`) とトークン参照先 (`~/.egopulse/.env` の `WEB_AUTH_TOKEN`) +- **Discord / Telegram 有効時**: DM は即利用可能、サーバー / グループ応答には YAML へのチャネル ID 追加が必要([channels.md](./channels.md) 参照) + +API Key / トークン類の**実値は表示しない**(セキュリティ)。 + +### 7.6 既存設定の再編集(prefill) + +`egopulse setup` を既存設定が存在する状態で実行した場合、各 Q のプロンプトは既存値をデフォルトとして事前入力する。Enter で進めば既存値を維持、入力し直せば上書き。 + +| Q | 事前入力される値 | +|---|---| +| Q1 Agent Label | 既存 `agents..label`(無ければ空) | +| Q2 / Q2' | 既存 `default_provider`(preset 非一致なら `Custom` 扱いで base_url も事前入力) | +| Q3 Model | 既存 `providers..default_model` | +| Q4 API Key | 既存 `.env` から解決した `api_key`(解決不能なら空) | +| Q5 Web | 既存 `channels.web.enabled`(無ければ `yes`) | +| Q6 Discord | 既存 `channels.discord.enabled` | +| Q7 Telegram | 既存 `channels.telegram.enabled` | + +既存 YAML のパースエラー時は Q1 の前に警告表示 + `Continue with empty defaults? (y/N)` で確認する(黙殺は廃止)。`WEB_AUTH_TOKEN` と `state_root` は事前入力対象外だが上書きされない。 + +### 7.7 生成される YAML 構造 + +- `default_agent`: Q1 で生成した agent id +- `default_provider`: Q2 で選んだ provider id +- `agents..label`: Q1 の入力値 +- `providers.`: Q2 / Q3 / Q4 の値(label, base_url, api_key, default_model, models) +- `channels.web`: Q5 の結果。`yes` の場合は `enabled / host=127.0.0.1 / port=10961 / auth_token` を保存。`no` の場合はエントリ自体を含めない +- `channels.discord`: Q6 の結果(enabled, `bots.default.token`) +- `channels.telegram`: Q7 の結果(enabled, `bots.default.token`) +- 秘匿値は `~/.egopulse/.env` に書き出し、YAML には SecretRef で参照([§4](#4-secretrefシークレット参照) 参照) +- 単一プロバイダー・単一エージェントのみ生成(複数追加は手動 YAML 編集) --- @@ -653,7 +783,7 @@ SecretRef 解決は以下の 2 層で構成される。 | インターフェース | 読み取り | 書き込み | 対象 | |---------|:---:|:---:|------| | YAML 手動編集 | 全フィールド | 全フィールド | `~/.egopulse/egopulse.config.yaml` | -| Setup Wizard | — | プロバイダー、Bot トークン | 初回セットアップ | +| Setup Prompt (`egopulse setup`) | — | エージェント・プロバイダー・モデル・チャネル | 初回セットアップ・再設定 ([§7](#7-セットアップウィザード)) | | WebUI (`/api/config`) | 公開フィールド | 公開フィールド | ランタイム中の設定変更 | | スラッシュコマンド (`/provider`, `/model`) | ○ | ○ | プロバイダー・モデルの動的切替 | diff --git a/docs/plan/plan-setup-refresh.md b/docs/plan/plan-setup-refresh.md new file mode 100644 index 00000000..f254f8f6 --- /dev/null +++ b/docs/plan/plan-setup-refresh.md @@ -0,0 +1,816 @@ +# Plan: Setup Wizard Refresh + +`egopulse setup` を ratatui フル TUI から `dialoguer` ベースのチャットライク順次プロンプトへ全面刷新する。設計元: `docs/setup-redesign.md`。 + +> **Note**: 振る舞い (What) は決して変えてはいけないが、より美しい設計があれば実装方法 (HOW) だけは変えてもよい。 + +## 設計方針 + +- **Agent-First**: 最初の質問は Agent Label。続く Provider/Model は「その Agent が使う LLM」として位置づける (`docs/setup-redesign.md §2.1`) +- **Minimum Viable Setup**: LLM と対話するための最低限のみ問い、詳細項目はデフォルト運用 +- **既存資産の流用**: `PROVIDER_PRESETS` / `build_channel_configs` / `generate_auth_token` / `validate_fields` / `save_config` / `backup_config` は残置し、新フローに合わせて拡張する +- **チャットライクプロンプトには `dialoguer` を採用** (AGENTS.md「既存ライブラリ優先」) +- **インタラクションロジックを pure function に切り出し**、dialoguer に依存しない部分をユニットテスト可能にする +- **既存の `ratatui` / `crossterm` 依存は残置** (TUI チャネル `src/channels/tui.rs` が残るため。依存削除は別フェーズ) +- **`run_setup_wizard()` のシグネチャは維持**し、`src/main.rs:97-101` の呼び出し側は変更しない +- 参照: `docs/setup-redesign.md` (設計メモ)、`docs/commands.md §1.1`、`docs/config.md §7`、`docs/channels.md` + +## TDD 方針 + +テストリスト項目 (T1, T2...) と自動テスト (`test_name`) を明確に区別する。1回の Red では自動テストを 1 件だけ追加し、Green・Refactor を混ぜない。1つのテストリスト項目に複数の境界・異常系がある場合は、同じ項目を対象にした Cycle を複数作る。実装中に新たな不安を見つけたらテストリストへ追加し、次の Cycle で扱う。Red→Green→Refactor が終わったら即コミット。 + +## Plan スコープ + +WT作成 → 実装(TDD) → コミット(意味ごとに分離) → PR作成 + +## 対象一覧 + +| 対象 | 種別 | 既存パターン / 参照元 | 備考 | +| -- | -- | -- | -- | +| `src/setup/mod.rs` | 大規模削除 + 一部残置 | 既存 TUI 実装 (`SetupApp`, `init_terminal`, `draw_*`, `handle_*_key`) | 約 900 行削除。`run_setup_wizard()` の再エクスポートのみ残す | +| `src/setup/prompts.rs` | **新規** | なし | `dialoguer` ラッパー。純粋関数 (validation, format) を分離してテスト可能に | +| `src/setup/wizard.rs` | **新規** | なし | Welcome→Q1〜Q7→Review→Save→Additional Options→Done のフロー制御。状態遷移は pure に | +| `src/setup/provider.rs` | 一部削除 + 残置 | `PROVIDER_PRESETS`, `find_provider_preset`, `normalize_provider_id`, `provider_label_for` を残置 | `SelectorItem` / `SelectorState` / `enter_selector` 等 TUI 依存部は削除 | +| `src/setup/channels.rs` | 拡張 + 残置 | `build_channel_configs`, `generate_auth_token`, `extract_existing_state_root` | `build_channel_configs` に `web_enabled: bool` パラメータを追加 (S2 是正) | +| `src/setup/summary.rs` | 拡張 + 残置 + 一部削除 | `validate_fields`, `save_config`, `backup_config`, `cleanup_old_backups`, `mask_secret` を残置 | `draw_completion_summary` は ratatui 依存のため削除。`Field` 構造体廃止に伴い `save_config` シグネチャを新データ型へ | +| `Cargo.toml` | 変更 | 既存依存 | `dialoguer`, `url` を追加 (`url` が未対応の場合) | +| `docs/setup-redesign.md` | 変更 | 本メモ | Status を「実装済み」へ更新 | +| `docs/commands.md §1.1` | 変更 | `egopulse setup` 行 | 「対話型設定プロンプト」へ | +| `docs/config.md §7` | 全面書き換え | 「セットアップウィザード」節 | 新仕様 (チャットライクフロー) へ | +| `README.md` | 変更 | Getting Started | `egopulse setup` の説明整合性確認 | +| `src/main.rs` | 変更不要 | 既存呼び出し `setup::run_setup_wizard(cli.config.clone())` | シグネチャ互換を維持 | + +## テストリスト / 不安リスト + +| ID | 観点 | 期待する振る舞い | 優先 | 対応するCycle | 状態 / 今回対象外理由 | +| -- | -- | -- | -- | -- | -- | +| T1 | 正常系 | `slugify("Lyre")` → `"lyre"` | High | Step 1 | 未着手 | +| T2 | 正常系 | `slugify("My Agent")` → `"my-agent"` | High | Step 1 | 未着手 | +| T3 | 正常系 | `slugify("Vega 2")` → `"vega-2"` (英数字混在) | High | Step 1 | 未着手 | +| T4 | 境界値 | `slugify(" Multi Space ")` → `"multi-space"` (連続非英数圧縮 + 前後ハイフン削除) | High | Step 1 | 未着手 | +| T5 | 空・ゼロ状態 | `slugify("")` / `slugify("!!!")` / `slugify(" ")` → `"default"` フォールバック | High | Step 1 | 未着手 | +| T6 | エッジケース | `slugify("日本語Agent")` → `"agent"` (非 ASCII はハイフン扱い→圧縮) | Medium | Step 1 | 未着手 | +| T7 | 異常系 | `validate_fields` が provider 空を `Err` | High | Step 2 | 未着手 | +| T8 | 異常系 | `validate_fields` が不正 base_url を `Err` | High | Step 2 | 未着手 | +| T9 | 異常系 | `validate_fields` が Discord 有効 + トークン空を `Err` | High | Step 2 | 未着手 | +| T10 | 正常系 | `validate_fields` が最低限セット (provider, base_url, model, api_key 省略/localhost) を許可 | High | Step 2 | 未着手 | +| T11 | 境界値 | `validate_fields` が API key 空 + localhost 系 base_url を許可 | Medium | Step 2 | 未着手 | +| T12 | 正常系 | `build_channel_configs(web_enabled=true)` が `web` エントリを含む | High | Step 3 | 未着手 | +| T13 | 空・ゼロ状態 | `build_channel_configs(web_enabled=false)` が `web` エントリを含まない (S2 是正) | High | Step 3 | 未着手 | +| T14 | 正常系 | `build_channel_configs` が Discord/Telegram 有効時に各エントリを生成 | Medium | Step 3 | 未着手 | +| T15 | 正常系 | `save_config` が agent label を `agents..label` に保存 | High | Step 4 | 未着手 | +| T16 | 正常系 | `save_config` が `default_agent` を ユーザー入力 id に設定 | High | Step 4 | 未着手 | +| T17 | 空・ゼロ状態 | `save_config` が web 無効化時に `channels.web` エントリを**保存しない** (Discord/Telegram と一貫、`enabled:false` 残しではない) | High | Step 4 | 未着手 | +| T18 | 異常系 | `save_config` が既存ファイル存在時に backup を生成 | Medium | Step 4 | 未着手 | +| T19 | 統合 | `save_config` → 再 `Config::load` でラウンドトリップ可能 | High | Step 4 | 未着手 | +| T20 | 異常系 | `save_config` が既存 `WEB_AUTH_TOKEN` を**再利用** (新規生成しない) | High | Step 4 | 未着手 | +| T21 | 正常系 | `save_config` が既存 `state_root` を**保持** (上書きしない) | High | Step 4 | 未着手 | +| T22 | 異常系 | `parse_existing_config` が YAML パースエラー時にエラー情報を保持 (呼び出し側で warn 表示) | Medium | Step 5 | 未着手 | +| T23 | 正常系 | `parse_existing_config` (純粋関数に切り出し) が正常 YAML をフィルドマップへ変換 | Medium | Step 5 | 未着手 | +| T24 | 正常系 | `mask_secret` が短い (≤8 文字) を `********` にマスク (既存挙動維持) | Low | Step 5 | 未着手 | +| T25 | 正常系 | `format_api_key_for_review(api_key)` が `sk-...xxxx` 形式 (末尾4文字) を返す | Medium | Step 6 | 未着手 | +| T26 | 空・ゼロ状態 | `format_api_key_for_review("")` が `"(empty)"` を返す | Medium | Step 6 | 未着手 | +| T27 | 正常系 | `review_decision_from_index(0/1/2)` が `StartOver/Abort/SaveAnyway` を返す | High | Step 7 | 未着手 | +| T28 | 正常系 | `build_review_summary(inputs)` が期待するテキストブロックを生成 | Medium | Step 7 | 未着手 | +| T29 | 正常系 | `build_additional_options_text()` が `docs/setup-redesign.md §4.2` の構成で生成 | Medium | Step 7 | 未着手 | +| T30 | 正常系 | `build_done_message(inputs)` が保存先・次ステップ・Web/Discord/Telegram 案内を含む | Medium | Step 7 | 未着手 | +| T31 | 境界値 | `should_confirm_empty_api_key(provider, base_url)` が **localhost 系では false** を返す (スキップ) | High | Step 7 | 未着手 | +| T32 | 境界値 | `should_confirm_empty_api_key(provider, base_url)` が **非 localhost で true** を返す | High | Step 7 | 未着手 | +| T33 | 正常系 | `is_custom_provider(provider_id)` が `custom` で true、他は false を返す | Medium | Step 7 | 未着手 | +| T34 | 正常系 | `should_ask_model_as_free_text(provider_id)` が Custom で true、preset で false を返す | Medium | Step 7 | 未着手 | +| T35 | 正常系 | 既存 `default_provider` / `agents..label` 等が各 prompt の default に事前入力される (`docs/setup-redesign.md §5.3`) | High | Step 8 | 未着手 | +| T36 | 統合 | wizard Review で no → `StartOver` 選択で Q1 に戻りループする | High | Step 8 | 未着手 | +| T37 | 異常系 | wizard Review で no → `Abort` 選択でファイル未保存のまま終了する | High | Step 8 | 未着手 | +| T38 | 正常系 | wizard Review で no → `SaveAnyway` 選択で保存して Done へ進む | High | Step 8 | 未着手 | +| T39 | 正常系 | wizard Review で `yes` 直接選択で保存して Done へ進む | High | Step 8 | 未着手 | +| T40 | 異常系 | wizard 既存 YAML パースエラー時、ユーザーが N 選択で中断する | High | Step 8 | 未着手 | +| T41 | 正常系 | wizard 既存 YAML パースエラー時、ユーザーが Y 選択で空状態から継続する | High | Step 8 | 未着手 | + +--- + +## Step 0: Worktree 作成 + +- ブランチ名: `feat/setup-wizard-refresh` +- 作成コマンド: + - `git worktree add ./wt-setup-refresh -b feat/setup-wizard-refresh origin/main` +- 作成後、`docs/setup-redesign.md` が最新であることを確認 + +--- + +## Step 1: slugify TDD Cycle - Agent Label → agent id 変換 + +### この Step の目的 + +Agent Label から agent id を生成する純粋関数 `slugify_agent_id(label: &str) -> String` を実装する。 + +### 今回選ぶ項目 + +- 対象: `T1`, `T2`, `T3`, `T4`, `T5`, `T6` +- 選ぶ理由: 後続の `save_config` 拡張 (Step 4) に必要な基盤。入出力が明確で TDD に適し、設計判断の余地が小さい +- この時点では扱わないこと: `save_config` への統合、フロー全体 + +### RED: 失敗する自動テストを書く + +- 追加するテスト名 (複数 Cycle に分割): + - `slugify_lowercases_ascii_letters` (T1) + - `slugify_replaces_whitespace_with_hyphen` (T2) + - `slugify_preserves_alphanumeric` (T3) + - `slugify_compresses_consecutive_separators_and_trims` (T4) + - `slugify_falls_back_to_default_for_empty_or_symbols_only` (T5) + - `slugify_replaces_non_ascii_with_hyphen` (T6) +- Given: 入力文字列 +- When: `slugify_agent_id(input)` を呼ぶ +- Then: 期待の agent id 文字列 +- 失敗理由の想定: 関数が未実装のためコンパイルエラー + +### GREEN: 最小実装 + +`src/setup/mod.rs` または新設の `src/setup/slugify.rs` に private 関数として実装。方針: + +1. lowercase 化 +2. 文字ごとに: ASCII 英数字 → そのまま、それ以外 → ハイフン +3. 連続ハイフンを 1 つに圧縮 +4. 前後のハイフンを削除 +5. 結果が空なら `"default"` を返す + +### REFACTOR: 設計の整理 + +- 重複: 同様の正規化処理が既存コード (`normalize_provider_id`) にないか確認。目的が違うなら混ぜない +- 命名: `slugify_agent_id` とし、`slugify` 単独より目的を明示 +- 責務: 入力→出力の純粋関数。副作用なし +- テストの構造的結合: private fn でも `#[cfg(test)] mod tests` から `use super::*` で呼べる +- 次の項目へ進める身軽さ: `save_config` 側から呼べる状態 + +### テストリスト更新 + +- 完了: `T1`, `T2`, `T3`, `T4`, `T5`, `T6` +- 追加: なし +- 次候補: `T7` (validate_fields 拡張) + +### コミット + +`feat(setup): add slugify_agent_id for agent label normalization` + +--- + +## Step 2: validate_fields 拡張 TDD Cycle - 新入力データ型への対応 + +### この Step の目的 + +`Field` 構造体廃止を見据え、新しい入力データ型 (例: `SetupInputs` 構造体) に対するバリデーションを実装する。`validate_fields` を置き換える `validate_inputs(inputs: &SetupInputs) -> Result<(), String>` を新設。 + +### 今回選ぶ項目 + +- 対象: `T7`, `T8`, `T9`, `T10`, `T11` +- 選ぶ理由: `save_config` 拡張 (Step 4) の前提。異常系を先に固めることで後続 Step の安全性が増す +- この時点では扱わないこと: `save_config`、`build_channel_configs` + +### RED: 失敗する自動テストを書く + +- 追加するテスト名: + - `validate_inputs_rejects_empty_provider` (T7) + - `validate_inputs_rejects_invalid_base_url` (T8) + - `validate_inputs_rejects_discord_enabled_without_token` (T9) + - `validate_inputs_accepts_minimum_set` (T10) + - `validate_inputs_allows_empty_api_key_for_localhost` (T11) +- Given: `SetupInputs` の各種バリエーション +- When: `validate_inputs(&inputs)` +- Then: `Ok(())` または `Err(message)` +- 失敗理由の想定: `SetupInputs` 型および `validate_inputs` 関数が未定義 + +### GREEN: 最小実装 + +- `SetupInputs` 構造体を新設 (`src/setup/summary.rs` 内、または新モジュール)。フィールド: `agent_label: String`, `provider_id: String`, `base_url: String`, `model: String`, `api_key: String`, `web_enabled: bool`, `discord_enabled: bool`, `discord_bot_token: String`, `telegram_enabled: bool`, `telegram_bot_token: String`, 必要に応じて `custom_base_url: Option` +- `validate_inputs` を実装。既存 `validate_fields` ロジックを `SetupInputs` 向けに移植 +- 既存 `validate_fields` はこの Step では**残置** (Step 9 で削除) + +### REFACTOR: 設計の整理 + +- 重複: `validate_fields` と `validate_inputs` が並存する一時的な状態。Step 9 で解消 +- 命名: `validate_inputs` (複数形は避け単数形で) +- 責務: 入力チェックのみ。副作用なし +- テストの構造的結合: 内部の検査順序に依存しない (エラーメッセージで判断しない、`is_err()` と `unwrap_err().contains("...")` で安定) +- 次の項目へ進める身軽さ: Step 3 へ + +### テストリスト更新 + +- 完了: `T7`, `T8`, `T9`, `T10`, `T11` +- 追加: なし +- 次候補: `T12` (build_channel_configs web 無効化対応) + +### コミット + +`feat(setup): add SetupInputs type and validate_inputs for chat-based wizard` + +--- + +## Step 3: build_channel_configs 拡張 TDD Cycle - Web 強制有効化の廃止 + +### この Step の目的 + +`build_channel_configs` に `web_enabled: bool` パラメータを追加し、Web を使わない選択肢を提供する (設計メモ §3.1 / S2 是正)。 + +### 今回選ぶ項目 + +- 対象: `T12`, `T13`, `T14` +- 選ぶ理由: 既存の強制有効化ロジックを安全に切り替える。既存テスト (`build_channel_configs_stores_channel_secrets_as_env_refs`) との整合性も保つ +- この時点では扱わないこと: `save_config`、`wizard` フロー + +### RED: 失敗する自動テストを書く + +- 追加するテスト名: + - `build_channel_configs_includes_web_when_enabled` (T12) + - `build_channel_configs_omits_web_when_disabled` (T13) + - `build_channel_configs_includes_discord_and_telegram_when_enabled` (T14) +- 既存テスト `build_channel_configs_stores_channel_secrets_as_env_refs` は新しいシグネチャに更新 +- Given: `web_enabled` の true/false +- When: `build_channel_configs(web_enabled, discord_enabled, telegram_enabled, ...)` +- Then: `channels` マップのキーに `web` が含まれる/含まれない +- 失敗理由の想定: 既存シグネチャ (`auth_token: String, discord_enabled, telegram_enabled, ...`) と不一致 + +### GREEN: 最小実装 + +- `build_channel_configs` のシグネチャ変更: `web_enabled: bool` を冒頭に追加 +- Web エントリの insert を `if web_enabled { channels.insert(...) }` でガード (**Discord/Telegram と同じパターンで一貫**) +- 無効化時に `channels.web` マップ自体を含めない (`enabled: Some(false)` で残す**ではない**) +- 既存テスト `build_channel_configs_stores_channel_secrets_as_env_refs` は `web_enabled = true` で呼び出すよう修正 + +### REFACTOR: 設計の整理 + +- 重複: なし +- 命名: パラメータ順は `web_enabled, auth_token, discord_enabled, telegram_enabled, ...` (頻度順) +- 責務: ChannelConfig 生成のみ。IO なし +- テストの構造的結合: 戻り値の `HashMap` のキー存在で検証、内部構造に踏み込みすぎない +- 次の項目へ進める身軽さ: Step 4 へ + +### テストリスト更新 + +- 完了: `T12`, `T13`, `T14` +- 追加: なし +- 次候補: `T15` (save_config agent label 対応) + +### コミット + +`feat(setup): allow web channel disablement in build_channel_configs` + +--- + +## Step 4: save_config 拡張 TDD Cycle - 新 SetupInputs 対応 + Agent-First + +### この Step の目的 + +`save_config` を `SetupInputs` 受け取りに変更し、agent label → slugify で id 生成、`default_agent` / `agents..label` を反映、Web 無効化対応。 + +### 今回選ぶ項目 + +- 対象: `T15`, `T16`, `T17`, `T18`, `T19`, `T20`, `T21` +- 選ぶ理由: 永続化ロジックの核心。ラウンドトリップ (T19) で設定ファイル仕様との整合を担保。T20/T21 は既存ユーザーが setup を再実行した際の回帰防止 (WEB_AUTH_TOKEN のローテーション、state_root の上書きを防ぐ) +- この時点では扱わないこと: wizard フロー、Review 表示 + +### RED: 失敗する自動テストを書く + +- 追加するテスト名: + - `save_config_persists_agent_label` (T15) + - `save_config_sets_default_agent_to_user_id` (T16) + - `save_config_omits_web_entry_when_disabled` (T17) + - `save_config_creates_backup_when_existing_file_present` (T18) + - `save_config_roundtrips_with_config_load` (T19) + - `save_config_reuses_existing_web_auth_token` (T20) + - `save_config_preserves_existing_state_root` (T21) +- 既存 `save_config(fields, original_yaml, config_path)` から `save_config(inputs: &SetupInputs, original_yaml: &Option<...>, config_path: &Path)` へ +- Given: `SetupInputs` + 一時ディレクトリ +- When: `save_config(...)` → `Config::load(path)` +- Then: ロードした `Config` の各フィールドが入力と一致 +- 失敗理由の想定: シグネチャ変更で既存呼び出し元が壊れる (Step 9 で `SetupApp::save` も消すため一時的に `#[allow(dead_code)]` は使わず、一時的に旧 `save_config` を残置してもよい) + +### GREEN: 最小実装 + +- 新 `save_config(inputs: &SetupInputs, original_yaml, config_path) -> Result<(Option, Vec), String>` を実装 +- 内部で `slugify_agent_id(&inputs.agent_label)` を呼び出し agent id を生成 +- `agents` マップをユーザー入力 id で上書き (`{id}: { label: inputs.agent_label, ..default }`) +- `default_agent` をその id に +- `build_channel_configs(inputs.web_enabled, ...)` を呼び出し +- **既存設定の保持** (T20/T21): + - 既存 `Config::load_allow_missing_api_key` で `web_auth_token()` が取得できれば再利用、なければ `generate_auth_token()` (既存 `save_config` と同じ挙動) + - `extract_existing_state_root(original_yaml)` で既存 `state_root` を取得し、新 Config に引き継ぎ +- 既存 `save_config(fields, ...)` の呼び出し元 (`SetupApp::save`) は Step 9 で消すので、この Step では新設のみ + +### REFACTOR: 設計の整理 + +- 重複: 旧 `save_config` と新 `save_config` が一時的に並存。Step 9 で旧を削除 +- 命名: 新関数は `save_config_from_inputs` 等にして一時的な衝突を避ける案もあり (How は実装者判断) +- 責務: YAML + `.env` 永続化、バックアップ、completion summary 生成 +- テストの構造的結合: 一時ディレクトリで検証、実環境に依存しない +- 次の項目へ進める身軽さ: Step 5 へ + +### テストリスト更新 + +- 完了: `T15`, `T16`, `T17`, `T18`, `T19`, `T20`, `T21` +- 追加: なし +- 次候補: `T22` (parse_existing_config) + +### コミット + +`feat(setup): support agent label, web disablement and existing value preservation in save_config` + +--- + +## Step 5: load_existing_config 改善 TDD Cycle - パースエラーの黙殺廃止 + +### この Step の目的 + +既存 `SetupApp::load_existing_config` のパースエラー黙殺を廃止し、エラー情報を呼び出し元で扱えるよう pure 関数 `parse_existing_config(yaml_text) -> Result` を切り出す。 + +### 今回選ぶ項目 + +- 対象: `T22`, `T23`, `T24` +- 選ぶ理由: 設計メモ §3.1「既存 Config パースエラーの warn 表示 (Y/N 確認付き)」の実現基盤。Step 8 wizard で呼ぶ +- この時点では扱わないこと: Y/N 確認 UI (Step 8 wizard 側で実装) + +### RED: 失敗する自動テストを書く + +- 追加するテスト名: + - `parse_existing_config_returns_err_for_invalid_yaml` (T22) + - `parse_existing_config_extracts_provider_schema` (T23) + - `mask_secret_fully_masks_short_values` (T24) - ついでに `mask_secret` の回帰テスト +- Given: YAML 文字列 (正常/壊れ) +- When: `parse_existing_config(text)` +- Then: 正常なら `Ok(fields)`、壊れていれば `Err` +- 失敗理由の想定: 関数未実装 + +### GREEN: 最小実装 + +- `src/setup/summary.rs` または `src/setup/mod.rs` に `parse_existing_config` を新設 +- 既存 `SetupApp::load_existing_config` のパース部分を切り出し +- 戻り値は `Result` (既存 `HashMap` と `Option` の組、または新構造体) +- `mask_secret` は既存実装を維持、テスト追加のみ + +### REFACTOR: 設計の整理 + +- 重複: `SetupApp::load_existing_config` と `parse_existing_config` が一時並存。Step 9 で旧を削除 +- 命名: `parse_existing_config` (pure 関数) +- 責務: テキスト→構造化。IO なし +- テストの構造的結合: YAML 文字列を直接渡す、ファイル IO に依存しない +- 次の項目へ進める身軽さ: Step 6 へ + +### テストリスト更新 + +- 完了: `T22`, `T23`, `T24` +- 追加: なし +- 次候補: `T25` (format_api_key_for_review) + +### コミット + +`refactor(setup): extract parse_existing_config as pure function` + +--- + +## Step 6: Review/Done 表示用フォーマット関数 TDD Cycle + +### この Step の目的 + +Review 画面の API Key マスク表示、Done メッセージの構築を pure 関数として実装し、テスト可能にする。 + +### 今回選ぶ項目 + +- 対象: `T25`, `T26` +- 選ぶ理由: dialoguer に依存しない UI 構成要素。先に純粋関数で固める +- この時点では扱わないこと: 実際の描画 (Step 8 wizard) + +### RED: 失敗する自動テストを書く + +- 追加するテスト名: + - `format_api_key_for_review_masks_long_values` (T25) - `sk-xxxxxxxxxxxx` → `sk-...xxxx` (末尾4文字) + - `format_api_key_for_review_shows_empty_for_blank` (T26) +- Given: API key 文字列 +- When: `format_api_key_for_review(key)` +- Then: 期待のマスク文字列 +- 失敗理由の想定: 関数未実装 + +### GREEN: 最小実装 + +- `src/setup/prompts.rs` (新設) または `src/setup/summary.rs` に `format_api_key_for_review` を実装 +- 既存 `mask_secret` との差分: Review 用は「先頭2文字 + `...` + 末尾4文字」形式 (`docs/setup-redesign.md §4.2 Review`) +- 仕様確認: `docs/setup-redesign.md` では `sk-...xxxx` 記載。「先頭 + `...` + 末尾4文字」。先頭何文字かは実装時に確定 (How は実装者判断) + +### REFACTOR: 設計の整理 + +- 重複: `mask_secret` (完全マスク) と `format_api_key_for_review` (部分マスク) は目的が違う。混ぜない +- 命名: 目的を明示 +- 責務: 表示用文字列生成のみ +- テストの構造的結合: 文字列入出力のみ +- 次の項目へ進める身軽さ: Step 7 へ + +### テストリスト更新 + +- 完了: `T25`, `T26` +- 追加: なし +- 次候補: `T27` (review_decision_from_index) + +### コミット + +`feat(setup): add format_api_key_for_review for Review step` + +--- + +## Step 7: wizard フロー純粋関数 TDD Cycle - 状態遷移・メッセージ構築・分岐判断 + +### この Step の目的 + +`wizard.rs` に (a) 状態遷移とメッセージ構築、(b) 分岐判断純粋関数 を実装する。dialoguer に依存しない部分を先に固めることで、Step 8 の統合時に回帰リスクを最小化する。 + +### 今回選ぶ項目 + +- 対象: `T27`, `T28`, `T29`, `T30`, `T31`, `T32`, `T33`, `T34` +- 選ぶ理由: インタラクションロジックのコア。Step 8 の dialoguer 統合だけで回帰を防げない指摘 (codex レビュー指摘2) を受けて、分岐判断も純粋関数化してテスト可能にする +- この時点では扱わないこと: dialoguer 呼び出し、IO + +### RED: 失敗する自動テストを書く + +- 追加するテスト名 (メッセージビルダー系): + - `review_decision_from_index_maps_correctly` (T27) - 0→StartOver, 1→Abort, 2→SaveAnyway + - `build_review_summary_renders_all_fields` (T28) - `docs/setup-redesign.md §4.2 Review` 構成 + - `build_additional_options_text_includes_all_categories` (T29) - System/Web UI/Channels/Subsystems + - `build_done_message_includes_next_steps_and_channel_hints` (T30) +- 追加するテスト名 (分岐判断系): + - `should_confirm_empty_api_key_returns_false_for_localhost` (T31) - Ollama/LMStudio 等 + - `should_confirm_empty_api_key_returns_true_for_remote` (T32) - OpenAI/OpenRouter 等 + - `is_custom_provider_returns_true_only_for_custom` (T33) - base_url 入力の要否 + - `should_ask_model_as_free_text_returns_true_only_for_custom` (T34) - モデル手入力の要否 +- Given: 入力データ (`SetupInputs`, provider_id, base_url 等) +- When: 各ビルド・分岐関数を呼ぶ +- Then: 期待の文字列 (部分一致) または真偽値 +- 失敗理由の想定: 関数未実装 + +### GREEN: 最小実装 + +- `src/setup/wizard.rs` (新規) を作成 +- メッセージビルダー: + - `ReviewDecision` enum (`StartOver`, `Abort`, `SaveAnyway`) + - `review_decision_from_index(usize) -> ReviewDecision` + - `build_review_summary(&SetupInputs) -> String` + - `build_additional_options_text() -> String` (固定テキスト) + - `build_done_message(&SetupInputs, config_path, backup_path: Option) -> String` +- 分岐判断 (pure 関数): + - `should_confirm_empty_api_key(provider_id: &str, base_url: &str) -> bool` - localhost 判定は既存 `codex_auth::provider_allows_empty_api_key` と同じ基準を再利用 + - `is_custom_provider(provider_id: &str) -> bool` - `find_provider_preset` が None を返すかどうか + - `should_ask_model_as_free_text(provider_id: &str) -> bool` - `is_custom_provider` と同等 (Custom には preset models がないため) + +### REFACTOR: 設計の整理 + +- 重複: メッセージ断片の重複を避ける (例: Web 案内の `http://127.0.0.1:10961` は定数化) +- 命名: `build_*` (文字列生成系) / `should_*` (判断系) / `is_*` (分類系) で揃える +- 責務: 表示文字列生成・分岐判断。副作用なし +- テストの構造的結合: 文字列は `contains`、真偽値は `is_true()`/`is_false()` で検証 +- 次の項目へ進める身軽さ: Step 8 (dialoguer 統合) へ。Step 8 はこれら純粋関数を並べて呼ぶだけになるので回帰リスクが下がる + +### テストリスト更新 + +- 完了: `T27`, `T28`, `T29`, `T30`, `T31`, `T32`, `T33`, `T34` +- 追加: なし +- 次候補: なし (純粋関数はここまで) + +### コミット + +`feat(setup): add wizard message builders, review decision and branch predicates` + +--- + +## Step 8: dialoguer 依存追加と wizard 統合 (trait 抽象 + モック駆動テスト付き) + +### この Step の目的 + +`dialoguer` を Cargo.toml に追加し、`prompts.rs` に dialoguer ラッパーを実装、`wizard.rs` にフロー全体を統合する。**重要**: wizard 本体は `PromptSource` / `OutputSink` trait を介して入出力を抽象化し、dialoguer 実装とモック実装を切り替え可能にする。これにより、Step 7 の predicate 単体では守れない「正しい順序・正しい配線」を、wizard 全体の統合テストで機械的に保証する (codex 2回目レビュー指摘2 対応)。また既存設定値を各プロンプトの default として事前入力する (codex 2回目レビュー指摘1 対応、`docs/setup-redesign.md §5.3`)。 + +### 今回選ぶ項目 + +- 対象: `T35`, `T36`, `T37`, `T38`, `T39`, `T40`, `T41` +- 選ぶ理由: リライトで一番壊れやすい wizard 制御 (StartOver/Abort/SaveAnyway/パースエラー時の Y/N) を自動テストで守る。現行実装が持つ「既存設定の事前入力」も回帰させない +- この時点では扱わないこと: 手動確認 (T42) + +### RED: 失敗する自動テストを書く + +`wizard` 本体は trait 抽象を介して駆動する。モック実装 (`MockPromptSource`, `VecOutputSink`) を使ってフロー全体を検証: + +- 追加するテスト名: + - `prefill_defaults_uses_existing_config_values` (T35) - 既存 `default_provider` や `agents..label` が各 prompt の default に事前入力される + - `wizard_review_startover_returns_to_q1` (T36) - Review で no → `StartOver` 選択で Q1 に戻りループ + - `wizard_review_abort_exits_without_save` (T37) - Review で no → `Abort` 選択でファイル未保存のまま終了 + - `wizard_review_save_anyway_writes_config` (T38) - Review で no → `SaveAnyway` 選択で保存して Done へ + - `wizard_review_yes_saves_directly` (T39) - Review で `yes` 直接選択で保存して Done へ + - `wizard_parse_error_decline_aborts` (T40) - 既存 YAML が壊れていてユーザーが N 選択で中断 + - `wizard_parse_error_accept_continues` (T41) - 既存 YAML が壊れていてユーザーが Y 選択で空状態から継続 +- Given: モック prompt source (入力シーケンス) + 一時ディレクトリの config_path +- When: `wizard::run_with_source_and_sink(&source, &sink, config_path)` +- Then: モック sink の出力順序、最終的な config_path の有無・内容、戻り値の Ok/Err + +### GREEN: 最小実装 + +- `Cargo.toml` に `dialoguer = "0.x"` を追加 (`cargo add dialoguer` で最新確認) +- `src/setup/prompts.rs` を新規作成: + - `trait PromptSource` - `fn text(&self, label: &str, default: &str) -> Result`, `fn password(&self, label: &str) -> Result`, `fn select(&self, label: &str, items: &[String]) -> Result`, `fn confirm(&self, label: &str, default: bool) -> Result` + - `trait OutputSink` - `fn print(&self, text: &str)`, `fn println(&self, text: &str)` + - `DialoguerPromptSource` (本番用実装) と `DialoguerOutputSink` + - `MockPromptSource` / `VecOutputSink` (`#[cfg(test)]` 内) +- `src/setup/wizard.rs` に `run_with_source_and_sink(source: &dyn PromptSource, sink: &dyn OutputSink, config_path: Option) -> Result<(), String>` を実装: + - `parse_existing_config` で既存値を取得 → Q1〜Q7 の各 prompt の default へ事前入力 (T35) + - 既存 YAML パースエラー時は warn + `source.confirm` で Y/N、N なら即 `Err` (T40)、Y なら空状態で続行 (T41) + - Review で `source.confirm` が false → `source.select` で3択、`review_decision_from_index` で `StartOver` (T36) / `Abort` (T37) / `SaveAnyway` (T38) へ分岐 + - Review で `source.confirm` が true → 直接 `save_config` 呼び出し (T39) + - `StartOver` はループで Q1 に戻る、`Abort` は `Err("Setup aborted")` で返す + - Welcome / Additional Options / Done は `sink.println` で出力 +- 公開エントリの `wizard::run(config_path)` は `run_with_source_and_sink(&DialoguerPromptSource::new(), &DialoguerOutputSink::new(), config_path)` を呼ぶ thin wrapper + +### REFACTOR: 設計の整理 + +- 重複: `DialoguerPromptSource` 内の dialoguer 呼び出しの重複を避ける (`select` 共通化など) +- 命名: `prompt_*` (関数) / `PromptSource` (trait) / `DialoguerPromptSource` / `MockPromptSource` で一貫 +- 責務: `PromptSource` / `OutputSink` は入出力、`wizard::run_with_source_and_sink` はフロー制御、`summary.rs`/`channels.rs`/`provider.rs` はデータ変換 +- テストの構造的結合: モックの入力シーケンスは `Vec` で表現、順序に依存しすぎないよう「ラベル一致」で消費 +- 次の項目へ進める身軽さ: Step 9 (旧 TUI 削除) へ + +### テストリスト更新 + +- 完了: `T35`, `T36`, `T37`, `T38`, `T39`, `T40`, `T41` +- 追加: なし +- 次候補: なし (ここまでで全振る舞いをカバー) + +### コミット + +`feat(setup): integrate dialoguer prompts with trait abstraction and wizard flow tests` + +--- + +## Step 9: 旧 TUI コード削除 - SetupApp / draw_* / handle_* / init_terminal の一括削除 + +### この Step の目的 + +`src/setup/mod.rs` から ratatui / crossterm に依存する全コードを削除し、`run_setup_wizard` を `wizard::run` に委譲する。 + +### 今回選ぶ項目 + +- 対象: なし (削除 Step) +- 選ぶ理由: 新フローが完成した後なので、旧コードを安全に削除できる +- この時点では扱わないこと: 新機能追加 + +### GREEN: 最小実装 + +- 削除対象 (`src/setup/mod.rs`): + - `Field`, `SetupMode`, `SelectorState`, `SelectorItem`, `SetupApp` 構造体 + - `SetupApp::new`, `load_existing_config` (旧), `visible_fields`, `move_selection`, `current_field*`, `save` + - `init_terminal`, `run_loop`, `run_inner`, `read_setup_key`, `handle_setup_key`, `handle_selector_key`, `handle_edit_key`, `handle_navigate_key`, `enter_navigate_mode`, `finish_editing`, `save_setup`, `open_current_field`, `apply_selector_value`, `move_selector_selection`, `clamp_selector_selection`, `parse_bool` + - `draw`, `max_label_width`, `draw_fields`, `draw_selector_popup` + - crossterm / ratatui の `use` 文 + - 旧テスト (`load_existing_config_reads_*`, `filtered_items_*`, `setup_mode_navigate_default`, `selector_state_holds_original_value`) +- `run_setup_wizard` は残置、`wizard::run(config_path)` へ委譲する thin wrapper に +- 削除対象 (`src/setup/channels.rs`): + - `update_field_visibility` (Field 構造体廃止で不要) +- 削除対象 (`src/setup/provider.rs`): + - `SelectorItem`/`SelectorState` への依存部 (`provider_selector_items`, `model_selector_items`, `enter_selector`, `apply_selector_selection`) + - 残置: `PROVIDER_PRESETS`, `find_provider_preset`, `normalize_provider_id`, `provider_label_for`, `provider_default_base_url`, `provider_default_model` +- 削除対象 (`src/setup/summary.rs`): + - `validate_fields` (旧), `save_config` (旧), `draw_completion_summary` (ratatui 依存), `mask_secret` は `prompts.rs` or `wizard.rs` へ移動してもよい + - 残置: `validate_inputs`, `save_config` (新), `backup_config`, `cleanup_old_backups`, `parse_existing_config`, `extract_existing_state_root` + +### REFACTOR: 設計の整理 + +- `cargo check` / `cargo clippy --all-targets --all-features -- -D warnings` が通ることを確認 +- デッドコード (`#[allow(dead_code)]` は AGENTS.md で禁止) が出ないよう、完全削除 +- 公開範囲: 新設関数は `pub(crate)` または `pub(super)` で最小化 +- 依存: `Cargo.toml` から `ratatui`, `crossterm` は**残置** (`src/channels/tui.rs` が使うため) + +### テストリスト更新 + +- 完了: なし +- 追加: 削除による回帰がないか、Step 1〜7 のテストが全て通ることを確認 +- 次候補: Step 10 (docs) + +### コミット + +`refactor(setup): remove legacy ratatui TUI implementation` + +--- + +## Step 10: docs 更新 + +### この Step の目的 + +`docs/setup-redesign.md`, `docs/commands.md §1.1`, `docs/config.md §7`, `README.md` を更新する。 + +### 今回選ぶ項目 + +- 対象: なし (docs Step) +- 選ぶ理由: 実装完了に伴う文書整合 + +### GREEN: 最小実装 + +- `docs/setup-redesign.md`: Status を「設計段階 (未実装)」→「実装済み」へ。Date 更新 +- `docs/commands.md §1.1`: `egopulse setup` 行の説明を「対話型設定ウィザード (TUI)」→「対話型設定プロンプト」へ +- `docs/config.md §7`: 全面書き換え。新フロー (Welcome → Q1〜Q7 → Review → Save → Additional Options → Done)、新設定可能項目、設定対象外項目 +- `README.md`: Getting Started の `egopulse setup` 記載を確認、TUI 言及があれば「対話型設定プロンプト」へ + +### REFACTOR: 設計の整理 + +- 各 docs 間の相互リンクを確認 (commands.md ↔ config.md ↔ setup-redesign.md) +- 文書スタイルを既存 docs に合わせる (日本語、MECE、テーブル多用) + +### コミット + +`docs(setup): refresh setup wizard docs` + +--- + +## Step 11: 動作確認 + +### 自動テスト・Lint + +- `cargo fmt --check` +- `cargo test` +- `cargo check` +- `cargo clippy --all-targets --all-features -- -D warnings` + +### 失敗時に戻る Step + +該当 TDD Cycle (Step 1〜9)。 + +### E2E 手動確認の扱い + +本 Plan では E2E 手動確認は実施しない。Step 8 の `PromptSource` / `OutputSink` trait 抽象 + モック駆動テスト (T35〜T41) で実質的なフロー検証を機械的に担保済みのため。実機での対話確認 (`egopulse setup` を実際に起動して dialoguer 入力を試すこと) は **ユーザーが自身の環境で実施する**。AI 側では `~/.egopulse/` 配下を一切触らない。 + +--- + +## Step 12: Plan・仕様書との自己チェック + +実装完了後にこの Plan と関連仕様書を最初から読み直し、実装・自動テスト・文書が要求した振る舞いと一致しているかを照合する。未実装、過剰実装、テスト不足、仕様書との齟齬を見つけた場合は、該当する TDD Cycle へ戻って修正し、動作確認を再実行してからこの Step を完了する。 + +- Plan のテストリストと各 Cycle が完了条件を満たしている (T1〜T41 すべて「完了」) +- `docs/setup-redesign.md` の What と実装結果が一致している: + - Q1〜Q7 のフロー順序 + - Agent Label → slugify → agent id の仕様 + - Review の3択 (StartOver / Abort / SaveAnyway) + - Additional Options のカテゴリ (System / Web UI / Channels / Subsystems) + - Done の Web/Discord/Telegram 案内 + - **既存設定の再編集 (§5.3)**: 各 prompt の default 事前入力、`WEB_AUTH_TOKEN` と `state_root` の保持 +- 実装中に変更した設計判断が関連 docs へ反映されている (`docs/commands.md`, `docs/config.md §7`) +- 変更ファイル一覧、コミット分割、自動テスト一覧が実際の変更と一致している +- 禁止事項の確認: `#[allow(dead_code)]` を使っていない、型エラー抑止 (`as any` 等の Rust 版、`unwrap()` 乱用等) がない + +--- + +## Step 13: PR 作成 + +- PR タイトル: `feat: refresh setup wizard to chat-like dialoguer flow` +- PR description (日本語): + - 概要: `egopulse setup` を ratatui フル TUI から dialoguer ベースのチャットライク順次プロンプトへ全面刷新。設計元 `docs/setup-redesign.md` + - 変更ポイント: + - Agent-First フロー (Q1 Agent Label → Q2 Provider → ...) + - Web チャネル強制有効化を廃止、ユーザー選択に + - Review での3択 (StartOver/Abort/SaveAnyway) + - Additional Options ステップ新設 + - 既存資産 (PROVIDER_PRESETS, save_config, backup_config) は流用・拡張 + - 約 900 行削除 (旧 TUI 実装) + - テスト: T1〜T41 のユニットテスト (E2E 手動確認はユーザー側で実施) + - Close # (該当 Issue がある場合) +- レビューは Coderabbit が自動対応 (PR 作成後10分程度)。レビューバックは `pr-review-back-workflow` skill を使用 + +--- + +## 変更ファイル一覧 + +| ファイル | 変更種別 | 内容 | +| ---- | ---- | -- | +| `src/setup/mod.rs` | 大規模削除 + 一部残置 | 約 900 行削除。`run_setup_wizard` の thin wrapper のみ残置 | +| `src/setup/prompts.rs` | **新規** | dialoguer ラッパー、`format_api_key_for_review` 等の純粋関数 | +| `src/setup/wizard.rs` | **新規** | フロー制御、メッセージビルダー、`ReviewDecision` enum | +| `src/setup/provider.rs` | 一部削除 + 残置 | `PROVIDER_PRESETS`, `find_provider_preset` 等は残置。SelectorItem 依存部は削除 | +| `src/setup/channels.rs` | 拡張 + 一部削除 | `build_channel_configs` に `web_enabled` 追加。`update_field_visibility` は削除 | +| `src/setup/summary.rs` | 拡張 + 一部削除 | `validate_inputs`, 新 `save_config`, `parse_existing_config` 追加。旧 `validate_fields`, 旧 `save_config`, `draw_completion_summary` は削除 | +| `Cargo.toml` | 変更 | `dialoguer` 追加 | +| `docs/setup-redesign.md` | 変更 | Status を「実装済み」へ | +| `docs/commands.md` | 変更 | §1.1 `egopulse setup` 行 | +| `docs/config.md` | 変更 | §7 全面書き換え | +| `README.md` | 変更 | Getting Started の説明整合 | + +--- + +## コミット分割 + +1. `feat(setup): add slugify_agent_id for agent label normalization` - `src/setup/mod.rs` or `src/setup/slugify.rs` / Step 1 +2. `feat(setup): add SetupInputs type and validate_inputs for chat-based wizard` - `src/setup/summary.rs` / Step 2 +3. `feat(setup): allow web channel disablement in build_channel_configs` - `src/setup/channels.rs` / Step 3 +4. `feat(setup): support agent label, web disablement and existing value preservation in save_config` - `src/setup/summary.rs` / Step 4 +5. `refactor(setup): extract parse_existing_config as pure function` - `src/setup/summary.rs` or `src/setup/mod.rs` / Step 5 +6. `feat(setup): add format_api_key_for_review for Review step` - `src/setup/prompts.rs` / Step 6 +7. `feat(setup): add wizard message builders, review decision and branch predicates` - `src/setup/wizard.rs` / Step 7 +8. `feat(setup): integrate dialoguer prompts with trait abstraction and wizard flow tests` - `src/setup/prompts.rs`, `src/setup/wizard.rs`, `Cargo.toml` / Step 8 +9. `refactor(setup): remove legacy ratatui TUI implementation` - `src/setup/mod.rs`, `src/setup/provider.rs`, `src/setup/channels.rs`, `src/setup/summary.rs` / Step 9 +10. `docs(setup): refresh setup wizard docs` - `docs/*`, `README.md` / Step 10 + +--- + +## 自動テスト一覧 (全 41 件) + +この一覧は Plan 作成時点で必要と判断した最低限の予定であり、最終テスト件数の上限ではない。実装中に追加された不安には、テストリストと TDD Cycle を追加して対応する。E2E 手動確認は本 Plan スコープ外 (ユーザー側で実施)。 + +### slugify (Step 1、全 6 件) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T1 | `slugify_lowercases_ascii_letters` | Step 1 | `cargo test slugify_lowercases` | +| T2 | `slugify_replaces_whitespace_with_hyphen` | Step 1 | `cargo test slugify_replaces` | +| T3 | `slugify_preserves_alphanumeric` | Step 1 | `cargo test slugify_preserves` | +| T4 | `slugify_compresses_consecutive_separators_and_trims` | Step 1 | `cargo test slugify_compresses` | +| T5 | `slugify_falls_back_to_default_for_empty_or_symbols_only` | Step 1 | `cargo test slugify_falls_back` | +| T6 | `slugify_replaces_non_ascii_with_hyphen` | Step 1 | `cargo test slugify_replaces_non_ascii` | + +### validate_inputs (Step 2、全 5 件) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T7 | `validate_inputs_rejects_empty_provider` | Step 2 | `cargo test validate_inputs_rejects_empty_provider` | +| T8 | `validate_inputs_rejects_invalid_base_url` | Step 2 | `cargo test validate_inputs_rejects_invalid_base_url` | +| T9 | `validate_inputs_rejects_discord_enabled_without_token` | Step 2 | `cargo test validate_inputs_rejects_discord` | +| T10 | `validate_inputs_accepts_minimum_set` | Step 2 | `cargo test validate_inputs_accepts_minimum` | +| T11 | `validate_inputs_allows_empty_api_key_for_localhost` | Step 2 | `cargo test validate_inputs_allows_empty_api_key` | + +### build_channel_configs (Step 3、全 3 件) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T12 | `build_channel_configs_includes_web_when_enabled` | Step 3 | `cargo test build_channel_configs_includes_web` | +| T13 | `build_channel_configs_omits_web_when_disabled` | Step 3 | `cargo test build_channel_configs_omits_web` | +| T14 | `build_channel_configs_includes_discord_and_telegram_when_enabled` | Step 3 | `cargo test build_channel_configs_includes_discord` | + +### save_config (Step 4、全 7 件) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T15 | `save_config_persists_agent_label` | Step 4 | `cargo test save_config_persists_agent_label` | +| T16 | `save_config_sets_default_agent_to_user_id` | Step 4 | `cargo test save_config_sets_default_agent` | +| T17 | `save_config_omits_web_entry_when_disabled` | Step 4 | `cargo test save_config_omits_web_entry` | +| T18 | `save_config_creates_backup_when_existing_file_present` | Step 4 | `cargo test save_config_creates_backup` | +| T19 | `save_config_roundtrips_with_config_load` | Step 4 | `cargo test save_config_roundtrips` | +| T20 | `save_config_reuses_existing_web_auth_token` | Step 4 | `cargo test save_config_reuses_existing_web_auth_token` | +| T21 | `save_config_preserves_existing_state_root` | Step 4 | `cargo test save_config_preserves_existing_state_root` | + +### parse_existing_config / mask_secret (Step 5、全 3 件) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T22 | `parse_existing_config_returns_err_for_invalid_yaml` | Step 5 | `cargo test parse_existing_config_returns_err` | +| T23 | `parse_existing_config_extracts_provider_schema` | Step 5 | `cargo test parse_existing_config_extracts` | +| T24 | `mask_secret_fully_masks_short_values` | Step 5 | `cargo test mask_secret_fully_masks` | + +### format_api_key_for_review (Step 6、全 2 件) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T25 | `format_api_key_for_review_masks_long_values` | Step 6 | `cargo test format_api_key_for_review_masks_long` | +| T26 | `format_api_key_for_review_shows_empty_for_blank` | Step 6 | `cargo test format_api_key_for_review_shows_empty` | + +### wizard メッセージビルダー + 分岐判断 (Step 7、全 8 件) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T27 | `review_decision_from_index_maps_correctly` | Step 7 | `cargo test review_decision_from_index` | +| T28 | `build_review_summary_renders_all_fields` | Step 7 | `cargo test build_review_summary_renders` | +| T29 | `build_additional_options_text_includes_all_categories` | Step 7 | `cargo test build_additional_options_text` | +| T30 | `build_done_message_includes_next_steps_and_channel_hints` | Step 7 | `cargo test build_done_message_includes` | +| T31 | `should_confirm_empty_api_key_returns_false_for_localhost` | Step 7 | `cargo test should_confirm_empty_api_key_returns_false` | +| T32 | `should_confirm_empty_api_key_returns_true_for_remote` | Step 7 | `cargo test should_confirm_empty_api_key_returns_true` | +| T33 | `is_custom_provider_returns_true_only_for_custom` | Step 7 | `cargo test is_custom_provider_returns_true` | +| T34 | `should_ask_model_as_free_text_returns_true_only_for_custom` | Step 7 | `cargo test should_ask_model_as_free_text` | + +### wizard 統合 (Step 8、全 7 件。trait 抽象 + モック駆動) + +| テストリストID | 自動テスト名 | 追加Step | 実行コマンド | +| -- | -- | -- | -- | +| T35 | `prefill_defaults_uses_existing_config_values` | Step 8 | `cargo test prefill_defaults_uses_existing` | +| T36 | `wizard_review_startover_returns_to_q1` | Step 8 | `cargo test wizard_review_startover` | +| T37 | `wizard_review_abort_exits_without_save` | Step 8 | `cargo test wizard_review_abort` | +| T38 | `wizard_review_save_anyway_writes_config` | Step 8 | `cargo test wizard_review_save_anyway` | +| T39 | `wizard_review_yes_saves_directly` | Step 8 | `cargo test wizard_review_yes_saves` | +| T40 | `wizard_parse_error_decline_aborts` | Step 8 | `cargo test wizard_parse_error_decline` | +| T41 | `wizard_parse_error_accept_continues` | Step 8 | `cargo test wizard_parse_error_accept` | + +--- + +## 工数見積もり + +| Step | 内容 | 見積もり | +| -- | -- | -- | +| Step 0 | Worktree 作成 | ~5 min | +| Step 1 | slugify TDD Cycle (6 テスト) | ~40 行 / 0.5h | +| Step 2 | validate_inputs TDD Cycle (5 テスト) | ~50 行 / 1h | +| Step 3 | build_channel_configs 拡張 TDD Cycle (3 テスト) | ~30 行 / 0.5h | +| Step 4 | save_config 拡張 TDD Cycle (7 テスト) | ~100 行 / 2h | +| Step 5 | parse_existing_config 抽出 TDD Cycle (3 テスト) | ~50 行 / 0.5h | +| Step 6 | format_api_key_for_review TDD Cycle (2 テスト) | ~20 行 / 0.5h | +| Step 7 | wizard メッセージビルダー + 分岐判断 TDD Cycle (8 テスト) | ~220 行 / 2.5h | +| Step 8 | dialoguer 統合 + trait 抽象 + wizard モック駆動テスト (7 テスト) | ~400 行 / 4h | +| Step 9 | 旧 TUI コード削除 | -900 行 / 1h | +| Step 10 | docs 更新 | ~200 行 / 1h | +| Step 11 | 動作確認 (自動テスト・Lint のみ、E2E 手動はユーザー側) | ~0.5h | +| Step 12 | Plan・仕様書との自己チェック | ~0.5h | +| Step 13 | PR 作成 | ~0.5h | +| **合計** | | **~1010 行追加 / ~900 行削除 / ~15h (約 2 営業日)** | diff --git a/docs/setup-redesign.md b/docs/setup-redesign.md new file mode 100644 index 00000000..394b45a3 --- /dev/null +++ b/docs/setup-redesign.md @@ -0,0 +1,465 @@ +# Setup Wizard Refresh (設計メモ) + +> **Status**: 実装済み +> **Date**: 2026-06-24 +> **関連**: [commands.md §1.1](./commands.md) `egopulse setup` / [config.md §7](./config.md#7-セットアップウィザード) + +## 目次 + +1. [背景と目的](#1-背景と目的) +2. [設計方針](#2-設計方針) +3. [スコープ](#3-スコープ) +4. [確定フロー](#4-確定フロー) +5. [入力項目仕様](#5-入力項目仕様) +6. [完了メッセージ仕様](#6-完了メッセージ仕様) +7. [コード観点の影響](#7-コード観点の影響) +8. [関連課題 (本メモのスコープ外)](#8-関連課題-本メモのスコープ外) + +--- + +## 1. 背景と目的 + +現状の `egopulse setup` は ratatui + crossterm を用いたフル TUI ウィザードとして実装されている。機能追加・保守性・学習コストの観点で以下の問題を抱えているため、チャットライクな順次プロンプト方式へ全面刷新する。 + +### 1.1 現状の問題点 + +| 分類 | 問題 | +|---|---| +| アーキテクチャ | `src/setup/` 4ファイル 計 2213 行のフル TUI 実装。ratatui 描画・イベントループ・状態遷移を内包し、`src/channels/tui.rs` (ローカル TUI) とスタック重複しながらコンポーネント再利用ゼロ | +| UX | 縦に並んだ 9 フィールドを Navigate / Edit / Selector の 3 モードで埋めるフォーム形式。「ウィザード」のステージ分割なし、戻る/進むなし | +| カバレッジ | `web.host/port/allowed_origins`、`channels.voice.*`、Discord/Telegram の channel access control 等、初回設定で設定不可の項目が多い ([config.md §7](./config.md#7-セットアップウィザード) 参照) | +| 強制設定 | Web チャネルが常に `enabled: true` で host/port 固定。Web を使わない選択肢がない | +| 検証 | モデル名が preset の `models` リストと照合されない。ブール入力が無効値を黙って `false` 扱い。既存 Config のパースエラーを黙殺 | +| エージェント扱い | `agents.default` が永続化時に暗黙生成される。Agent-First アーキテクチャと乖離 | + +### 1.2 目的 + +- 初回セットアップを「**対話型の順次プロンプト**」で行い、ユーザーの学習コストを下げる +- **動かすために最低限必要な設定**のみを問い、詳細項目は全てデフォルト運用にする +- Agent-First 設計に合わせて、**エージェント定義を明示ステップ**にする +- ratatui への依存をセットアップ側から排除し、実装を数百行規模に圧縮する + +--- + +## 2. 設計方針 + +### 2.1 基本方針 + +- **Agent-First**: 最初の質問はエージェントの名前。プロバイダー選択等は「そのエージェントが使う LLM」として位置づける +- **Minimum Viable Setup**: LLM と対話するために必要な項目のみ。ホスト・ポート・タイムゾーン等は全てデフォルト +- **Chat-like Sequential Prompts**: ステージ分割された順次プロンプト。フォーム形式ではなく、1質問1回答の対話 +- **明示的選択**: Web の強制有効化を廃止。Discord / Telegram も含め、ユーザーが明示的に選ぶ + +### 2.2 採用ライブラリ + +- **`dialoguer`** を採用。プロンプト primitives (text / select / confirm / password / multi_select) を標準提供し、依存が少なく `cargo`, `rustup` 等のツール実績もある。AGENTS.md 「既存ライブラリ優先」原則に合致 + +--- + +## 3. スコープ + +### 3.1 対象 (やること) + +- `src/setup/` のチャットライクな順次プロンプト方式への全面刷新 +- エージェント定義ステップの追加 (label 入力) +- Web チャネル有効化の明示的 yes/no 化 (強制有効化の廃止) +- 入力バリデーションの導入 (URL / モデル名 / 必須項目) +- 既存 Config パースエラーの warn 表示 (Y/N 確認付き、黙殺は廃止) +- Additional Options ステップの追加 (設定しなかった項目の案内) + +### 3.2 対象外 (やらないこと) + +- **TUI チャネル (`src/channels/tui.rs`) の刷新** — 別課題 ([§8](#8-関連課題-本メモのスコープ外)) で扱う +- 複数プロバイダー / 複数エージェントの設定 (1つだけ生成、残りは手動 YAML or WebUI) +- Discord / Telegram の channel access control (channels マップ) +- Voice / Sleep Batch / Pulse / DB backup / Web Fetch 等の高度設定 +- `web.host` / `web.port` / `web.allowed_origins` / `timezone` / `log_level` / `compaction_*` / `max_*` 等の詳細項目 +- 人格 (`SOUL.md`) の設定 +- WebUI での設定編集機能 + +### 3.3 過渡期の扱い + +- 本リフレッシュ完了後も `ratatui` / `crossterm` 依存は `src/channels/tui.rs` が残る限り `Cargo.toml` に残置される。依存削除は TUI 廃止と同タイミングで行う + +--- + +## 4. 確定フロー + +### 4.1 ステップ構成 + +``` +[Welcome] + ↓ +[Q1: Agent Label] ── Agent-First のため最初に聞く + ↓ +[Q2: Provider] ── 26 presets から選択 + ↓ (Custom 選択時のみ base_url 追加質問) +[Q3: Model] ── preset から選択 / Custom 時は手入力 + ↓ +[Q4: API Key] ── 空欄可。非 localhost 系で空欄時は Y/N 確認 + ↓ +[Q5: Web Channel] ── デフォルト yes、host/port は固定 + ↓ +[Q6: Discord] ── デフォルト no + ↓ (yes のみ bot token 入力) +[Q7: Telegram] ── デフォルト no + ↓ (yes のみ bot token 入力) +[Review] ── 生成 YAML の内容を表示、保存確認 (no の場合は 戻る/中断/保存 の3択) + ↓ +[Save] ── 設定ファイル永続化 + ↓ +[Additional Options] ── 設定しなかった項目の案内 (情報表示のみ) + ↓ +[Done] ── 保存先パスと次ステップの案内 +``` + +### 4.2 各ステップの疑似プロンプト + +#### [Welcome] + +``` +Welcome to EgoPulse setup. +Answer a few questions to configure the minimum settings to run your AI agent. +``` + +#### [Q1: Agent Label] + +``` +Name your agent (e.g. Partner, Companion, Assistant): +> _ +``` + +- 入力値を **自前 slugify** (lowercase + 英数字以外をハイフン置換 + 連続ハイフン圧縮) で agent id を自動生成 + - 例: `"Lyre"` → `"lyre"`、`"My Agent"` → `"my-agent"`、`"Vega 2"` → `"vega-2"` + - slugify 結果が空になった場合 (label が記号のみ等) は `"default"` にフォールバック +- 以降のプロンプトではこの label を用いて `Choose the LLM provider for {Agent Label}` のように表示 + +#### [Q2: Provider] + +``` +Choose the LLM provider for {Agent Label} (arrow keys to move, Enter to confirm): +> OpenAI + OpenRouter + DeepSeek + Ollama (local) + ... (26 presets) + Custom +``` + +- 選択肢は `PROVIDER_PRESETS` (`src/setup/provider.rs:12-234`) を流用 +- **Custom 選択時のみ Q2 の直後で `base_url` 入力を追加**: + +``` +Enter the base_url (e.g. https://api.example.com/v1): +> _ +``` + + - `url::Url::parse` で検証、不正なら再入力 + +#### [Q3: Model] + +preset 選択時 (選択式): + +``` +Choose the model to use: +> {preset.default_model} + {preset.models[*]} +``` + +Custom 選択時 (手入力テキスト): + +``` +Enter the model name (e.g. gpt-4o, claude-3-opus): +> _ +``` + +- **preset 選択時は手入力不可** (validation で preset 外を拒否、再選択) +- **Custom 選択時は Q3 も表示するが手入力テキストモードに切り替え**。空文字拒否、再入力 + +#### [Q4: API Key] + +``` +Enter the API key for {Provider} (input is hidden). +For local endpoints (Ollama/LMStudio), leave it empty and press Enter: +******** +``` + +- **ステップ自体は常に表示**。空欄 Enter で進める。プロバイダーごとの分岐なし +- **非 localhost 系プロバイダで空欄入力時は Y/N で確認**: + +``` +WARNING: {Provider} usually requires an API key. Proceed with an empty key? (y/N) +``` + + - `no` の場合は Q4 に戻り再入力 + - `yes` の場合は警告付きでそのまま進める (ローカルプロキシ等の例外ケースを想定) + +#### [Q5: Web Channel] + +``` +Enable the Web UI? (Y/n) +You can access it at http://127.0.0.1:10961 from your browser. +> Y +``` + +- デフォルト `yes` +- `auth_token` は `generate_auth_token()` で自動生成、ユーザーには聞かない +- トークン実値は Review / Done いずれでも**表示しない** (`.env` 参照を案内するのみ) + +#### [Q6: Discord] + +``` +Configure a Discord bot? (y/N) +> N +``` + +- デフォルト `no` +- `yes` の場合のみ追加で bot token 入力: + +``` +Enter the Discord bot token (input is hidden): +******** +``` + +#### [Q7: Telegram] + +``` +Configure a Telegram bot? (y/N) +> N +``` + +- デフォルト `no` +- `yes` の場合のみ追加で bot token 入力 (Discord と同様) + +#### [Review] + +``` +About to save the configuration file with the following values: + + Agent: Partner (id: partner) + Provider: openai (https://api.openai.com/v1) + Model: gpt-5.2 + API Key: sk-...xxxx + Web: enabled (auth_token: auto-generated, saved to .env) + Discord: disabled + Telegram: disabled + +Save? (Y/n) +``` + +- API Key は末尾 4 文字のみ表示、それ以外は `...` でマスク +- API Key 空欄時は `(empty)` と明示 +- **`no` の場合は `dialoguer::Select` で 3択を提示**: + +``` +What would you like to do? +> Start over (back to Agent Label) + Abort (exit without saving) + Save anyway +``` + + - 「Start over」→ Q1 に戻る + - 「Abort」→ 保存せずに終了 (exit code 1) + - 「Save anyway」→ 保存処理へ進む + +#### [Additional Options] + +保存完了後、セットアップで設定しなかったが YAML 編集で設定可能な項目を案内する。入力は受け付けず、Enter で次へ進む。 + +``` +The configuration has been saved. The following options were not configured in +this setup, but can be set by editing ~/.egopulse/egopulse.config.yaml: + +System: + - timezone (default: UTC) + - log_level (default: info) + - default_context_window_tokens (default: 32768) + - compaction_threshold_ratio / compaction_target_ratio / compact_keep_recent + - max_history_messages + +Web UI: + - channels.web.host (default: 127.0.0.1) + - channels.web.port (default: 10961) + - channels.web.allowed_origins (default: []) + +Channels: + - Additional providers and agents (add entries under "providers" / "agents") + - Discord/Telegram channel access control (see docs/channels.md) + - Voice channel (channels.voice.*) + - Per-agent persona (SOUL.md) + +Subsystems: + - sleep_batch (long-term memory processing) + - pulse (attention activation) + - db.backup (SQLite backup settings) + - web_fetch (built-in tool settings) + +See docs/config.md for the full reference. + +Press Enter to continue. +``` + +- 情報表示のみ。入力フィールドなし、Enter のみで次へ +- カテゴリ分けして概要を提示、詳細は `docs/config.md` へ誘導 +- セットアップで終わりではなく、YAML 編集で拡張できることを教育する役割 + +#### [Done] + +``` +Configuration saved: ~/.egopulse/egopulse.config.yaml +Backup: (shown only if an existing config was backed up) + +Next steps: + - Start chatting now: egopulse chat + - Install as a systemd service: egopulse gateway install + - Edit configuration: ~/.egopulse/egopulse.config.yaml + - Add more agents: edit the "agents" section in the YAML + +If Web UI is enabled: + - URL: http://127.0.0.1:10961 + - Token: see WEB_AUTH_TOKEN in ~/.egopulse/.env + +If Discord or Telegram is enabled: + - The bot responds to DMs out of the box. + - To enable server/group responses, add channel/chat IDs to the YAML. + See docs/channels.md for details. +``` + +--- + +## 5. 入力項目仕様 + +### 5.1 一覧 + +| Q | 項目 | 必須 | デフォルト | バリデーション | +|---|---|:---:|---|---| +| 1 | Agent Label | ○ | なし | 空入力時は `"Default"` (prefill なし) または既存デフォルト値にフォールバック。表示名として妥当な長さ (1〜64 文字程度) | +| 1' | Agent ID | (自動) | label を自前 slugify | 英数字・ハイフンのみ、連続ハイフン圧縮、空結果拒否 (フォールバックで `"default"`) | +| 2 | Provider | ○ | なし | `PROVIDER_PRESETS` いずれか、または `Custom` | +| 2' | base_url | 条件付き | なし | Custom 選択時のみ Q2 の直後に聞く。`url::Url::parse` で検証、再入力 | +| 3 | Model | ○ | preset の `default_model` | preset 選択時: `models` リスト内であること。Custom 選択時: 手入力テキスト、空文字拒否 | +| 4 | API Key | △ | 空文字 | 常に入力ステップ表示。localhost 系は空欄でそのまま通す。非 localhost 系で空欄時は Y/N 確認 (no で再入力、yes で警告付きで進行) | +| 5 | Web Channel enabled | — | `yes` | 真偽値。無効時は `channels.web` エントリ自体を YAML に含めない (Discord/Telegram と一貫) | +| 5' | Web auth_token | (自動) | `generate_auth_token()` | ユーザー入力なし、実値は Review/Done で非表示 | +| 6 | Discord enabled | — | `no` | 真偽値 | +| 6' | Discord bot token | 条件付き | なし | `yes` 時は必須、空拒否 | +| 7 | Telegram enabled | — | `no` | 真偽値 | +| 7' | Telegram bot token | 条件付き | なし | `yes` 時は必須、空拒否 | + +### 5.2 生成される YAML の構造 + +- `default_agent`: Q1 で生成した agent id +- `default_provider`: Q2 で選んだ provider id +- `agents..label`: Q1 の入力値 +- `providers.`: Q2/Q3/Q4 の値 (label, base_url, api_key, default_model, models) +- `channels.web`: Q5 の結果。`yes` の場合は `enabled: true, host=127.0.0.1, port=10961, auth_token` を保存。`no` の場合はエントリ自体を含めない (Discord/Telegram と一貫) +- `channels.discord`: Q6 の結果 (enabled, bots.default.token) +- `channels.telegram`: Q7 の結果 (enabled, bots.default.token) +- 秘匿値は `.env` に書き出し、YAML には `SecretRef` で参照 (現状仕様を維持) + +### 5.3 既存設定の再編集時の挙動 + +`egopulse setup` を**既存設定が存在する状態**で実行した場合、各 Q のプロンプトは既存値を**デフォルトとして事前入力**する (現行仕様を維持)。 + +| Q | デフォルトとして事前入力される値 | +|---|---| +| Q1 Agent Label | 既存 `agents..label` (無ければ空) | +| Q2 Provider | 既存 `default_provider` (preset に一致しない場合は `Custom` 扱いで `base_url` も事前入力) | +| Q3 Model | 既存 `providers..default_model` またはグローバル `default_model` | +| Q4 API Key | 既存 `.env` から解決した `providers..api_key` (解決不能なら空) | +| Q5 Web | 既存 `channels.web.enabled` (無ければ `yes`) | +| Q6 Discord | 既存 `channels.discord.enabled` と `bots.default.token` | +| Q7 Telegram | 既存 `channels.telegram.enabled` と `bots.default.token` | + +- 既存 YAML のパースエラー時は Q1 の前に warn 表示 + Y/N 確認 (§3.1) +- ユーザーが Enter でそのまま進めば既存値を維持、入力し直せば上書き +- `WEB_AUTH_TOKEN` と `state_root` は事前入力の対象外だが、上書きされない (Plan テストリスト T20/T21 で保証) + +--- + +## 6. 完了メッセージ仕様 + +### 6.1 表示項目 + +- 設定ファイル保存先 (`~/.egopulse/egopulse.config.yaml`) +- 既存設定があった場合、バックアップファイルパス +- 次ステップの案内: + - `egopulse chat` — すぐチャット開始 + - `egopulse gateway install` — systemd サービス登録 + - `~/.egopulse/egopulse.config.yaml` 編集 — 詳細設定 + - `agents` セクション編集 — エージェント追加 +- Web UI 有効化時: + - アクセス URL (`http://127.0.0.1:10961`) + - 認証トークンの参照先 (`~/.egopulse/.env` の `WEB_AUTH_TOKEN`) +- Discord / Telegram 有効化時: + - DM は即利用可能 + - サーバー / グループで応答させるには YAML にチャンネル/チャット ID を追加が必要 + - 詳細は `docs/channels.md` を参照 + +### 6.2 明示しない項目 + +- API Key / トークン類の**実値** (セキュリティ)。Review でのマスク表示、Done での .env 参照案内のみ + +--- + +## 7. コード観点の影響 + +### 7.1 削除対象 + +| 対象 | 場所 | 備考 | +|---|---|---| +| `SetupApp` 構造体と関連メソッド | `src/setup/mod.rs:86-338` | Navigate/Edit/Selector モード含む全 TUI 状態管理 | +| `init_terminal()` / `restore_terminal()` | `src/setup/mod.rs:711-778` | ratatui `Terminal` 初期化・イベントループ | +| `draw_*()` 系描画関数 | `src/setup/mod.rs:362-708` | `draw_fields`, `draw_selector_popup` 等 | +| `handle_*_key()` 系キーハンドラ | `src/setup/mod.rs:779-956` | `handle_navigate_key`, `handle_edit_key`, `handle_selector_key` | +| `SetupMode` enum | `src/setup/mod.rs` | Navigate / Edit / Selector の 3 モード | +| `read_setup_key()` | `src/setup/mod.rs` | crossterm event poll | +| `load_existing_config()` の TUI 的扱い | `src/setup/mod.rs:207-276` | パースエラー黙殺を廃止し warn 表示へ (下記残置で改修) | + +推定削減: 約 900 行。 + +### 7.2 残置対象 (情報資産として流用) + +| 対象 | 場所 | 用途 | +|---|---|---| +| `PROVIDER_PRESETS` 配列 | `src/setup/provider.rs:12-234` | 26 preset のデータ。そのまま参照 | +| `build_channel_configs()` | `src/setup/channels.rs:88-130` | ChannelConfig 生成ロジック。Web 強制有効化を廃止して `enabled: Some(user_choice)` へ | +| `generate_auth_token()` | `src/setup/channels.rs:135-139` | 32 bytes ランダム base64。そのまま流用 | +| `validate_fields()` | `src/setup/summary.rs:31-90` | バリデーションロジック。新しい入力項目に合わせて拡張 | +| `save_config()` | `src/setup/summary.rs:92-350` | YAML + `.env` 永続化。そのまま流用 | +| `backup_config()` | `src/setup/summary.rs:395-419` | 上書き前バックアップ。そのまま流用 | +| `MAX_CONFIG_BACKUPS = 50` | `src/setup/summary.rs:29` | バックアップ世代数。そのまま流用 | + +### 7.3 新規追加 + +| 対象 | 場所 | 内容 | +|---|---|---| +| prompts 層 | `src/setup/prompts.rs` (新設) | `dialoguer` を用いたラッパー。provider 選択、model 選択、api key 入力、バリデーション等をカプセル化 | +| wizard フロー | `src/setup/wizard.rs` (新設) | Welcome → Q1〜Q7 → Review → Save → Additional Options → Done の順次制御。`run_setup_wizard()` の新本体 | +| slugify ユーティリティ | `src/setup/mod.rs` or 共通ユーティリティ | Agent Label から agent id を生成 | + +### 7.4 既存エントリポイントの互換性 + +- `src/main.rs:97-101` の `setup::run_setup_wizard()` 呼び出しはそのまま維持 (シグネチャ互換) +- `docs/commands.md §1.1` の `egopulse setup` 行は説明更新のみ (「対話型設定ウィザード (TUI)」→「対話型設定プロンプト」) +- `docs/config.md §7` は刷新後に全面書き換え + +--- + +## 8. 関連課題 (本メモのスコープ外) + +以下は本リフレッシュとは独立に扱う。別途メモ / Plan を起す予定。 + +### 8.1 ローカル TUI チャネルの廃止と再構築 + +- `src/channels/tui.rs` (961 行) はアーキテクチャ上の限界 (ストリーミング非対応 / ツールコール不可視 / マークダウン非対応 / 画像破棄 / 単行入力 / セッション管理貧弱) があり、刷新ではなく**一度廃止して別ライブラリで再構築**する方針 +- ratatui 以外の候補 (cursive / tui-realm / crossterm 直叩き / 他) を比較検討する必要あり +- 完了後に `ratatui` 依存を `Cargo.toml` から削除可能 + +### 8.2 docs 整備 + +本リフレッシュ実装完了に伴い、以下を更新済み: + +- `docs/commands.md §1.1` — `egopulse setup` 行の説明を「対話型設定プロンプト (dialoguer)」へ +- `docs/config.md §7` — 「セットアップウィザード」節を新仕様へ全面書き換え +- `README.md` — Getting Started の `egopulse setup` 記載の整合性確認 (TUI 言及なし、変更不要) \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index 12a3fd0b..b35d9825 100644 --- a/src/error.rs +++ b/src/error.rs @@ -27,6 +27,9 @@ pub enum EgoPulseError { #[error(transparent)] #[allow(private_interfaces)] Pulse(#[from] crate::pulse::definition::PulseParseError), + #[error(transparent)] + #[allow(private_interfaces)] + SetupWizard(#[from] crate::setup::SetupWizardError), #[error("shutdown_requested")] ShutdownRequested, #[error("internal_error: {0}")] @@ -45,6 +48,7 @@ impl EgoPulseError { Self::Channel(_) => "channel", Self::Mcp(_) => "mcp", Self::Pulse(_) => "pulse", + Self::SetupWizard(_) => "setup", Self::ShutdownRequested => "shutdown", Self::Internal(_) => "internal", } diff --git a/src/main.rs b/src/main.rs index 0beece76..5bf0541b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,9 +95,7 @@ async fn run() -> Result<(), EgoPulseError> { // setup は設定ファイル未作成でも実行できるよう、通常の設定解決フローに入る前に分岐する。 if matches!(cli.command, Some(Command::Setup)) { - return setup::run_setup_wizard(cli.config.clone()) - .await - .map_err(EgoPulseError::Internal); + return setup::run_setup_wizard(cli.config.clone()).await; } match cli.command { diff --git a/src/setup/channels.rs b/src/setup/channels.rs index 4b7e6b70..ee32176f 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -1,38 +1,9 @@ use std::collections::HashMap; -use std::path::Path; use base64::Engine; use base64::engine::general_purpose::STANDARD; use rand::RngExt; -use super::Field; - -pub(crate) fn update_field_visibility(fields: &mut [Field]) { - let discord_enabled = fields - .iter() - .find(|f| f.key == "DISCORD_ENABLED") - .map(|f| super::parse_bool(&f.value).unwrap_or(false)) - .unwrap_or(false); - - let telegram_enabled = fields - .iter() - .find(|f| f.key == "TELEGRAM_ENABLED") - .map(|f| super::parse_bool(&f.value).unwrap_or(false)) - .unwrap_or(false); - - for field in fields.iter_mut() { - match field.key.as_str() { - "DISCORD_BOT_TOKEN" => { - field.required = discord_enabled; - } - "TELEGRAM_BOT_TOKEN" => { - field.required = telegram_enabled; - } - _ => {} - } - } -} - pub(crate) fn load_channel_fields( channels: &yaml_serde::Value, result: &mut HashMap, @@ -48,19 +19,6 @@ pub(crate) fn load_channel_fields( insert_telegram_bot_field(ch_map, "token", result, "TELEGRAM_BOT_TOKEN"); } -pub(crate) fn load_discord_default_bot_token(config_path: &Path) -> Option { - let config = crate::config::Config::load_allow_missing_api_key(Some(config_path)).ok()?; - let token = config - .channels - .get("discord")? - .discord_bots - .as_ref()? - .get("default")? - .token - .as_ref()?; - Some(token.value().to_string()) -} - pub(crate) fn extract_existing_state_root( original_yaml: &Option, ) -> Option { @@ -73,6 +31,7 @@ pub(crate) fn extract_existing_state_root( } pub(crate) fn build_channel_configs( + web_enabled: bool, auth_token: String, discord_enabled: bool, telegram_enabled: bool, @@ -85,17 +44,19 @@ pub(crate) fn build_channel_configs( let mut channels = HashMap::new(); - channels.insert( - ChannelName::new("web"), - ChannelConfig { - enabled: Some(true), - host: Some("127.0.0.1".to_string()), - port: Some(10961), - auth_token: Some(env_resolved_value(WEB_AUTH_TOKEN_ENV_NAME, auth_token)), - file_auth_token: Some(env_yaml_value(WEB_AUTH_TOKEN_ENV_NAME)), - ..Default::default() - }, - ); + if web_enabled { + channels.insert( + ChannelName::new("web"), + ChannelConfig { + enabled: Some(true), + host: Some("127.0.0.1".to_string()), + port: Some(10961), + auth_token: Some(env_resolved_value(WEB_AUTH_TOKEN_ENV_NAME, auth_token)), + file_auth_token: Some(env_yaml_value(WEB_AUTH_TOKEN_ENV_NAME)), + ..Default::default() + }, + ); + } if discord_enabled { channels.insert( @@ -212,6 +173,7 @@ mod tests { #[test] fn build_channel_configs_stores_channel_secrets_as_env_refs() { let channels = build_channel_configs( + true, "web-token".to_string(), true, true, @@ -237,4 +199,35 @@ mod tests { .expect("serialize telegram file"); assert!(telegram_file.contains("id: TELEGRAM_BOT_TOKEN")); } + + #[test] + fn build_channel_configs_includes_web_when_enabled() { + let channels = + build_channel_configs(true, "web-token".to_string(), false, false, String::new()); + assert!(channels.contains_key("web")); + } + + #[test] + fn build_channel_configs_omits_web_when_disabled() { + let channels = + build_channel_configs(false, "web-token".to_string(), false, false, String::new()); + assert!( + !channels.contains_key("web"), + "web entry must be absent when web_enabled is false" + ); + } + + #[test] + fn build_channel_configs_includes_discord_and_telegram_when_enabled() { + let channels = build_channel_configs( + false, + "web-token".to_string(), + true, + true, + "telegram-token".to_string(), + ); + assert!(channels.contains_key("discord")); + assert!(channels.contains_key("telegram")); + assert!(!channels.contains_key("web")); + } } diff --git a/src/setup/error.rs b/src/setup/error.rs new file mode 100644 index 00000000..d2fdea2f --- /dev/null +++ b/src/setup/error.rs @@ -0,0 +1,23 @@ +//! Setup wizard 固有のエラー型。 +//! +//! `String` ベースのアドホックエラーを thiserror で構造化し、 +//! 呼び出し元 ([`crate::error::EgoPulseError`]) への `#[from]` 変換を可能にする。 + +use std::io; + +use thiserror::Error; + +/// セットアップウィザードの実行中に発生しうる構造化エラー。 +#[derive(Debug, Error)] +pub(crate) enum SetupWizardError { + #[error("setup aborted by user")] + Aborted, + #[error("prompt error: {0}")] + Prompt(String), + #[error("failed to save config: {0}")] + Save(String), + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("config path resolution failed: {0}")] + ConfigResolve(String), +} diff --git a/src/setup/inputs.rs b/src/setup/inputs.rs new file mode 100644 index 00000000..35683c96 --- /dev/null +++ b/src/setup/inputs.rs @@ -0,0 +1,149 @@ +//! チャットライクウィザード向けの入力データ型と検証ロジック。 + +use crate::config::is_valid_base_url; +use crate::llm::codex_auth; +use crate::setup::provider::{provider_default_base_url, provider_default_model}; + +/// チャットライクセットアップウィザードが収集する全入力フィールド。 +/// +/// `Field` 構造体廃止後の後続 Step で `save_config` の入力データ型として使用される。 +/// 各フィールドはプロンプト (Q1〜Q7) に 1:1 対応する。 +#[derive(Clone)] +pub(crate) struct SetupInputs { + pub agent_label: String, + pub provider_id: String, + pub base_url: String, + pub model: String, + pub api_key: String, + pub web_enabled: bool, + pub discord_enabled: bool, + pub discord_bot_token: String, + pub telegram_enabled: bool, + pub telegram_bot_token: String, +} + +/// `SetupInputs` の内容を検証する。 +/// +/// 検証項目 (いずれかでも違反なら `Err`): +/// - `provider_id` が空でない +/// - `base_url` が空でない (空なら provider preset のデフォルトで補完) +/// - `base_url` が有効な URL である +/// - `model` が空でない (空なら provider preset のデフォルトで補完) +/// - 非 localhost 系プロバイダーで `api_key` が空でない +/// - Discord 有効時は `discord_bot_token` が必須 +/// - Telegram 有効時は `telegram_bot_token` が必須 +/// +/// # Errors +/// +/// 上記いずれかの検証に失敗した場合、人間が読めるエラーメッセージを返す。 +pub(crate) fn validate_inputs(inputs: &SetupInputs) -> Result<(), String> { + let provider = inputs.provider_id.trim(); + if provider.is_empty() { + return Err("Provider profile ID is required".into()); + } + + let effective_base_url = + effective_value(&inputs.base_url, || provider_default_base_url(provider)); + if effective_base_url.is_empty() { + return Err(format!( + "API base URL is required for provider '{provider}'" + )); + } + if !is_valid_base_url(effective_base_url) { + return Err(format!("Invalid API base URL: {effective_base_url}")); + } + + let effective_model = effective_value(&inputs.model, || provider_default_model(provider)); + if effective_model.is_empty() { + return Err(format!("LLM model is required for provider '{provider}'")); + } + + if !codex_auth::provider_allows_empty_api_key(provider, effective_base_url) + && inputs.api_key.trim().is_empty() + { + return Err( + "API key is required for non-local endpoints. Use a local URL (localhost/127.0.0.1) to skip.".into(), + ); + } + + if inputs.discord_enabled && inputs.discord_bot_token.trim().is_empty() { + return Err("Discord bot token is required when Discord is enabled".into()); + } + + if inputs.telegram_enabled && inputs.telegram_bot_token.trim().is_empty() { + return Err("Telegram bot token is required when Telegram is enabled".into()); + } + + Ok(()) +} + +fn effective_value(value: &str, fallback: impl FnOnce() -> Option<&'static str>) -> &str { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback().unwrap_or("") + } else { + trimmed + } +} + +#[cfg(test)] +mod tests { + use super::SetupInputs; + use super::validate_inputs; + + fn valid_inputs() -> SetupInputs { + SetupInputs { + agent_label: "Partner".into(), + provider_id: "openai".into(), + base_url: "https://api.openai.com/v1".into(), + model: "gpt-4o".into(), + api_key: "sk-test-key".into(), + web_enabled: true, + discord_enabled: false, + discord_bot_token: String::new(), + telegram_enabled: false, + telegram_bot_token: String::new(), + } + } + + #[test] + fn validate_inputs_rejects_empty_provider() { + let mut inputs = valid_inputs(); + inputs.provider_id = String::new(); + let err = validate_inputs(&inputs).unwrap_err(); + assert!(err.to_lowercase().contains("provider")); + } + + #[test] + fn validate_inputs_rejects_invalid_base_url() { + let mut inputs = valid_inputs(); + inputs.base_url = "not a valid url".into(); + let err = validate_inputs(&inputs).unwrap_err(); + assert!(err.to_lowercase().contains("base url") || err.to_lowercase().contains("url")); + } + + #[test] + fn validate_inputs_rejects_discord_enabled_without_token() { + let mut inputs = valid_inputs(); + inputs.discord_enabled = true; + inputs.discord_bot_token = String::new(); + let err = validate_inputs(&inputs).unwrap_err(); + assert!(err.to_lowercase().contains("discord")); + } + + #[test] + fn validate_inputs_accepts_minimum_set() { + let inputs = valid_inputs(); + validate_inputs(&inputs).expect("valid inputs should pass"); + } + + #[test] + fn validate_inputs_allows_empty_api_key_for_localhost() { + let mut inputs = valid_inputs(); + inputs.provider_id = "ollama".into(); + inputs.base_url = "http://127.0.0.1:11434/v1".into(); + inputs.model = "llama3.2".into(); + inputs.api_key = String::new(); + validate_inputs(&inputs).expect("localhost with empty api key should pass"); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 4a14268e..b4de2ca3 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,1112 +1,37 @@ -//! 対話型セットアップウィザード。 +//! 対話型セットアップウィザードのエントリポイント。 //! -//! Ratatui ベースのローカル UI で設定値を収集し、既存 YAML を必要最小限だけ保ちながら -//! `egopulse.config.yaml` を生成・更新する。 +//! 実際のフロー実装は `wizard` モジュールを参照。本モジュールは +//! `main.rs` から呼ばれる後方互換エントリポイントのみを公開する。 mod channels; +mod error; +pub(crate) mod inputs; +pub(crate) mod prompts; mod provider; +pub(crate) mod slugify; mod summary; +pub(crate) mod wizard; -use std::collections::HashMap; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; +pub(crate) use error::SetupWizardError; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use crossterm::execute; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; -use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; -use ratatui::{Terminal, backend::CrosstermBackend}; +use std::path::PathBuf; -use crate::config::default_config_path; - -pub(crate) use channels::*; -pub(crate) use provider::*; -pub(crate) use summary::*; - -#[derive(Clone)] -pub(crate) struct Field { - pub key: String, - pub label: String, - pub value: String, - pub required: bool, - pub secret: bool, - pub help: Option, -} - -pub(crate) enum SetupMode { - Navigate, - Edit, - Selector(SelectorState), -} - -pub(crate) struct SelectorState { - pub field_key: String, - pub filter: String, - pub items: Vec, - pub selected: usize, - pub original_value: String, -} - -pub(crate) struct SelectorItem { - pub display: String, - pub value: String, -} - -impl Field { - fn display_value(&self, editing: bool) -> String { - if editing || !self.secret { - return self.value.clone(); - } - if self.value.is_empty() { - String::new() - } else { - mask_secret(&self.value) - } - } -} - -pub(crate) struct SetupApp { - pub fields: Vec, - pub selected: usize, - pub mode: SetupMode, - pub status: String, - pub completed: bool, - pub backup_path: Option, - pub completion_summary: Vec, - pub config_path: PathBuf, - pub original_yaml: Option, -} - -impl SetupApp { - fn new(config_path: Option) -> Result { - let config_path = match config_path { - Some(path) => path, - None => default_config_path().map_err(|e| e.to_string())?, - }; - let (existing, original_yaml) = Self::load_existing_config(&config_path); - let provider_id = existing - .get("PROVIDER") - .cloned() - .unwrap_or_else(|| "openai".into()); - let provider_model = existing - .get("MODEL") - .cloned() - .or_else(|| provider_default_model(&provider_id).map(|value| value.to_string())) - .unwrap_or_default(); - let provider_base_url = existing - .get("BASE_URL") - .cloned() - .or_else(|| provider_default_base_url(&provider_id).map(|value| value.to_string())) - .unwrap_or_default(); - - let mut fields = vec![ - Field { - key: "PROVIDER".into(), - label: "Provider profile ID".into(), - value: provider_id.clone(), - required: true, - secret: false, - help: Some(format!( - "Profile id used as default_provider ({})", - provider_choices() - )), - }, - Field { - key: "MODEL".into(), - label: "LLM model".into(), - value: provider_model, - required: false, - secret: false, - help: Some("Model name for the selected provider profile".into()), - }, - Field { - key: "BASE_URL".into(), - label: "API base URL".into(), - value: provider_base_url, - required: true, - secret: false, - help: Some( - "OpenAI-compatible API endpoint for the selected provider profile".into(), - ), - }, - Field { - key: "API_KEY".into(), - label: "API key".into(), - value: existing.get("API_KEY").cloned().unwrap_or_default(), - required: true, - secret: true, - help: Some("Leave empty for local endpoints (localhost/127.0.0.1)".into()), - }, - Field { - key: "DISCORD_ENABLED".into(), - label: "Enable Discord channel".into(), - value: existing - .get("DISCORD_ENABLED") - .cloned() - .unwrap_or_else(|| "false".into()), - required: false, - secret: false, - help: Some("true/false".into()), - }, - Field { - key: "DISCORD_BOT_TOKEN".into(), - label: "Discord bot token".into(), - value: existing - .get("DISCORD_BOT_TOKEN") - .cloned() - .unwrap_or_default(), - required: false, - secret: true, - help: Some("From Discord Developer Portal".into()), - }, - Field { - key: "TELEGRAM_ENABLED".into(), - label: "Enable Telegram channel".into(), - value: existing - .get("TELEGRAM_ENABLED") - .cloned() - .unwrap_or_else(|| "false".into()), - required: false, - secret: false, - help: Some("true/false".into()), - }, - Field { - key: "TELEGRAM_BOT_TOKEN".into(), - label: "Telegram bot token".into(), - value: existing - .get("TELEGRAM_BOT_TOKEN") - .cloned() - .unwrap_or_default(), - required: false, - secret: true, - help: Some("From @BotFather on Telegram".into()), - }, - ]; - - update_field_visibility(&mut fields); - - Ok(Self { - fields, - selected: 0, - mode: SetupMode::Navigate, - status: "Enter: edit | Up/Down: navigate | Ctrl+S: save & exit | Ctrl+C: cancel".into(), - completed: false, - backup_path: None, - completion_summary: Vec::new(), - config_path, - original_yaml, - }) - } - - fn load_existing_config( - config_path: &Path, - ) -> (HashMap, Option) { - let mut result = HashMap::new(); - - let contents = match fs::read_to_string(config_path) { - Ok(c) => c, - Err(_) => return (result, None), - }; - - let parsed: yaml_serde::Value = match yaml_serde::from_str(&contents) { - Ok(v) => v, - Err(_) => return (result, None), - }; - - if let Some(map) = parsed.as_mapping() { - if let Some(default_provider) = map - .get(yaml_serde::Value::String("default_provider".into())) - .and_then(|value| value.as_str()) - { - let provider_id = normalize_provider_id(default_provider); - result.insert("PROVIDER".into(), provider_id.clone()); - if let Some(top_level_model) = map - .get(yaml_serde::Value::String("default_model".into())) - .and_then(|v| v.as_str()) - { - result.insert("MODEL".into(), top_level_model.to_string()); - } else if let Some(providers) = map - .get(yaml_serde::Value::String("providers".into())) - .and_then(|value| value.as_mapping()) - && let Some(provider) = providers - .get(yaml_serde::Value::String(default_provider.into())) - .and_then(|value| value.as_mapping()) - { - if let Some(model) = provider - .get(yaml_serde::Value::String("default_model".into())) - .and_then(|value| value.as_str()) - { - result.insert("MODEL".into(), model.to_string()); - } else if let Some(model) = provider_default_model(&provider_id) { - result.insert("MODEL".into(), model.to_string()); - } - if let Some(base_url) = provider - .get(yaml_serde::Value::String("base_url".into())) - .and_then(|value| value.as_str()) - { - result.insert("BASE_URL".into(), base_url.to_string()); - } else if let Some(base_url) = provider_default_base_url(&provider_id) { - result.insert("BASE_URL".into(), base_url.to_string()); - } - if let Some(api_key) = provider - .get(yaml_serde::Value::String("api_key".into())) - .and_then(|value| value.as_str()) - { - result.insert("API_KEY".into(), api_key.to_string()); - } - } - } - - if let Some(channels) = map.get(yaml_serde::Value::String("channels".into())) { - load_channel_fields(channels, &mut result); - } - - if let Some(token) = load_discord_default_bot_token(config_path) { - result.insert("DISCORD_BOT_TOKEN".into(), token); - } - } - - (result, Some(parsed)) - } - - fn visible_fields(&self) -> Vec { - let mut indices = Vec::new(); - - for field in self.fields.iter().enumerate() { - let should_skip = match field.1.key.as_str() { - "DISCORD_BOT_TOKEN" => !self - .fields - .iter() - .find(|f| f.key == "DISCORD_ENABLED") - .map(|f| parse_bool(&f.value).unwrap_or(false)) - .unwrap_or(false), - "TELEGRAM_BOT_TOKEN" => !self - .fields - .iter() - .find(|f| f.key == "TELEGRAM_ENABLED") - .map(|f| parse_bool(&f.value).unwrap_or(false)) - .unwrap_or(false), - _ => false, - }; - - if !should_skip { - indices.push(field.0); - } - } - - indices - } - - fn move_selection(&mut self, delta: isize) { - let visible = self.visible_fields(); - if visible.is_empty() { - return; - } - - let current_pos = visible - .iter() - .position(|&idx| idx == self.selected) - .unwrap_or(0); - - let next_pos = (current_pos as isize + delta).clamp(0, visible.len() as isize - 1) as usize; - - self.selected = visible[next_pos]; - } - - fn current_field(&self) -> Option<&Field> { - self.fields.get(self.selected) - } - - fn current_field_mut(&mut self) -> Option<&mut Field> { - self.fields.get_mut(self.selected) - } - - fn save(&mut self) -> Result<(), String> { - let (backup_path, completion_summary) = - save_config(&self.fields, &self.original_yaml, &self.config_path)?; - self.backup_path = backup_path; - self.completion_summary = completion_summary; - self.completed = true; - Ok(()) - } -} - -fn parse_bool(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "true" | "1" | "yes" | "on" => Some(true), - "false" | "0" | "no" | "off" => Some(false), - _ => None, - } -} - -fn filtered_items<'a>(items: &'a [SelectorItem], filter: &str) -> Vec<&'a SelectorItem> { - if filter.is_empty() { - return items.iter().collect(); - } - let lower = filter.to_ascii_lowercase(); - items - .iter() - .filter(|item| { - item.display.to_ascii_lowercase().contains(&lower) - || item.value.to_ascii_lowercase().contains(&lower) - }) - .collect() -} - -fn draw(terminal: &mut Terminal>, app: &SetupApp) { - let _ = terminal.draw(|frame| { - let area = frame.area(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(area); - - let header = Paragraph::new(vec![ - Line::from(vec![Span::styled( - "EgoPulse Setup Wizard", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )]), - Line::from("Configure egopulse.config.yaml interactively"), - ]) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - frame.render_widget(header, chunks[0]); - - if app.completed { - draw_completion_summary(frame, app, chunks[1]); - } else { - draw_fields(frame, app, chunks[1]); - } - - if let SetupMode::Selector(ref state) = app.mode { - draw_selector_popup(frame, state, area); - } - - let footer_text = if app.completed { - vec![Line::from( - "Setup complete. Run egopulse for the TUI, or egopulse run for channels.", - )] - } else { - vec![ - Line::from(app.status.clone()), - if let Some(field) = app.current_field() { - if let Some(ref help) = field.help { - Line::from(format!("hint: {help}")) - } else { - Line::from("") - } - } else { - Line::from("") - }, - ] - }; - - let footer = Paragraph::new(footer_text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - frame.render_widget(footer, chunks[2]); - - if matches!(app.mode, SetupMode::Edit) && !app.completed { - if let Some(field) = app.current_field() { - let visible = app.visible_fields(); - let field_pos = visible - .iter() - .position(|&idx| idx == app.selected) - .unwrap_or(0); - - let content_height = chunks[1].height.saturating_sub(2) as usize; - let mut window_start = 0usize; - if field_pos < window_start { - window_start = field_pos; - } else if field_pos >= window_start + content_height { - window_start = field_pos - content_height + 1; - } - let window_end = window_start + content_height; - - if (window_start..window_end).contains(&field_pos) { - let row = chunks[1].y + 1 + (field_pos - window_start) as u16; - let label_width = max_label_width(&app.fields, &visible); - let displayed_len = if field.value.is_empty() { - "(type value...)".chars().count() - } else { - field.value.chars().count() - }; - let cursor_x = chunks[1].x + label_width + 3 + displayed_len as u16; - let cursor_y = row; - frame.set_cursor_position(Position::new(cursor_x, cursor_y)); - } - } - } - }); -} - -fn max_label_width(fields: &[Field], visible: &[usize]) -> u16 { - let mut max = 0; - for &idx in visible { - if let Some(f) = fields.get(idx) { - let len = f.label.chars().count(); - if len > max { - max = len; - } - } - } - (max + 2) as u16 -} - -fn draw_fields(frame: &mut ratatui::Frame<'_>, app: &SetupApp, area: Rect) { - let visible = app.visible_fields(); - if visible.is_empty() { - return; - } - - let content_height = area.height.saturating_sub(2) as usize; - if content_height == 0 { - return; - } - - let field_pos = visible - .iter() - .position(|&idx| idx == app.selected) - .unwrap_or(0); - - let mut window_start = 0usize; - if field_pos < window_start { - window_start = field_pos; - } else if field_pos >= window_start + content_height { - window_start = field_pos - content_height + 1; - } - - let label_width = max_label_width(&app.fields, &visible); - let window_end = (window_start + content_height).min(visible.len()); - - let is_selector_active = matches!(app.mode, SetupMode::Selector(_)); - - let mut lines = Vec::new(); - for &idx in visible.iter().take(window_end).skip(window_start) { - let field = &app.fields[idx]; - let is_selected = idx == app.selected; - let is_editing = is_selected && matches!(app.mode, SetupMode::Edit); - - let display = field.display_value(is_editing); - let prefix = if is_selected { "> " } else { " " }; - - let base_style = if is_selector_active { - Style::default().fg(Color::DarkGray) - } else { - Style::default() - }; - - let mut spans = vec![ - Span::styled(prefix, base_style), - Span::styled( - &field.label, - if is_selector_active { - base_style - } else if is_selected { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::White) - }, - ), - ]; - - let sep_len = label_width.saturating_sub(field.label.chars().count() as u16); - if sep_len > 0 { - spans.push(Span::raw(" ".repeat(sep_len as usize))); - } - - spans.push(Span::raw(" ")); - - if is_editing { - spans.push(Span::styled( - if display.is_empty() { - "(type value...)".into() - } else { - display - }, - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::UNDERLINED), - )); - } else if field.secret && !display.is_empty() { - spans.push(Span::styled( - display, - if is_selector_active { - base_style - } else { - Style::default().fg(Color::DarkGray) - }, - )); - } else if display.is_empty() { - spans.push(Span::styled( - "(empty)", - if is_selector_active { - base_style - } else { - Style::default().fg(Color::DarkGray) - }, - )); - } else { - spans.push(Span::styled(display, base_style)); - } - - if field.required && !is_editing { - spans.push(Span::styled( - " *", - if is_selector_active { - base_style - } else { - Style::default().fg(Color::Red) - }, - )); - } - - lines.push(Line::from(spans)); - } - - let body = Paragraph::new(lines) - .block( - Block::default() - .title("Configuration Fields") - .borders(Borders::ALL), - ) - .wrap(Wrap { trim: true }); - frame.render_widget(body, area); -} - -fn draw_selector_popup(frame: &mut ratatui::Frame<'_>, state: &SelectorState, area: Rect) { - let title = match state.field_key.as_str() { - "PROVIDER" => "Select Provider", - "MODEL" => "Select Model", - _ => "Select", - }; - - let filtered = filtered_items(&state.items, &state.filter); - - let popup_width = (area.width as usize).clamp(40, 70); - let popup_height = (7 + filtered.len()).clamp(10, 20) as u16; - let max_list_height = (popup_height as usize).saturating_sub(7); - - let popup_x = (area.width as usize).saturating_sub(popup_width) / 2; - let popup_y = (area.height as usize).saturating_sub(popup_height as usize) / 2; - - let popup_area = Rect::new( - popup_x as u16, - popup_y as u16, - popup_width as u16, - popup_height, - ); - - let inner_width = popup_width.saturating_sub(2); - - let mut lines: Vec> = Vec::new(); - - lines.push(Line::from(vec![Span::styled( - title, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])); - lines.push(Line::from(vec![Span::styled( - "─".repeat(inner_width), - Style::default().fg(Color::DarkGray), - )])); - - let filter_display = format!("Filter: {}", state.filter); - lines.push(Line::from(vec![Span::styled( - filter_display, - Style::default().fg(Color::White), - )])); - - lines.push(Line::from(vec![Span::styled( - "─".repeat(inner_width), - Style::default().fg(Color::DarkGray), - )])); - - if filtered.is_empty() { - lines.push(Line::from(vec![Span::styled( - "No matches. Enter to use as free input.", - Style::default().fg(Color::Yellow), - )])); - } else { - let mut window_start = 0usize; - if state.selected >= max_list_height { - window_start = state.selected - max_list_height + 1; - } - let window_end = (window_start + max_list_height).min(filtered.len()); - - for (i, item) in filtered - .iter() - .enumerate() - .skip(window_start) - .take(window_end - window_start) - { - let is_selected = i == state.selected; - let prefix = if is_selected { "▸ " } else { " " }; - let display_text = if item.display.chars().count() > inner_width.saturating_sub(4) { - let truncated: String = item - .display - .chars() - .take(inner_width.saturating_sub(5)) - .collect(); - format!("{truncated}…") - } else { - item.display.clone() - }; - - if is_selected { - lines.push(Line::from(vec![Span::styled( - format!("{prefix}{display_text}"), - Style::default() - .fg(Color::Black) - .bg(Color::White) - .add_modifier(Modifier::BOLD), - )])); - } else { - lines.push(Line::from(vec![Span::styled( - format!("{prefix}{display_text}"), - Style::default().fg(Color::White), - )])); - } - } - } - - let remaining = (popup_height as usize).saturating_sub(lines.len() + 3); - for _ in 0..remaining { - lines.push(Line::from("")); - } - - let match_info = format!( - "{} matches │ Esc:cancel Enter:select ↑↓:navigate", - filtered.len() - ); - lines.push(Line::from(vec![Span::styled( - match_info, - Style::default().fg(Color::DarkGray), - )])); - - let popup = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - frame.render_widget(popup, popup_area); - - let filter_cursor_x = popup_area.x + 1 + 8 + state.filter.chars().count() as u16; - let filter_cursor_y = popup_area.y + 3; - frame.set_cursor_position(Position::new(filter_cursor_x, filter_cursor_y)); -} +use crate::error::EgoPulseError; /// Runs the interactive setup wizard and writes the resulting configuration file. -pub async fn run_setup_wizard(config_path: Option) -> Result<(), String> { - let mut app = SetupApp::new(config_path)?; - let terminal = init_terminal()?; - - run_loop(terminal, &mut app).await -} - -fn init_terminal() -> Result>, String> { - enable_raw_mode().map_err(|e| e.to_string())?; - let mut stdout = io::stdout(); - if let Err(e) = execute!(stdout, EnterAlternateScreen) { - let _ = disable_raw_mode(); - return Err(e.to_string()); - } - let backend = CrosstermBackend::new(stdout); - match Terminal::new(backend) { - Ok(t) => Ok(t), - Err(e) => { - let _ = execute!(io::stdout(), LeaveAlternateScreen); - let _ = disable_raw_mode(); - Err(e.to_string()) - } - } -} - -async fn run_loop( - mut terminal: Terminal>, - app: &mut SetupApp, -) -> Result<(), String> { - let result = run_inner(&mut terminal, app).await; - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - result -} - -async fn run_inner( - terminal: &mut Terminal>, - app: &mut SetupApp, -) -> Result<(), String> { - loop { - draw(terminal, app); - - let Some(key) = read_setup_key()? else { - continue; - }; - - if app.completed { - return Ok(()); - } - - if handle_setup_key(app, key)? { - return Ok(()); - } - } -} - -fn read_setup_key() -> Result, String> { - if !event::poll(std::time::Duration::from_millis(200)).map_err(|e| e.to_string())? { - return Ok(None); - } - - let Event::Key(key) = event::read().map_err(|e| e.to_string())? else { - return Ok(None); - }; - - Ok((key.kind == KeyEventKind::Press).then_some(key)) -} - -fn handle_setup_key(app: &mut SetupApp, key: KeyEvent) -> Result { - let mode = std::mem::replace(&mut app.mode, SetupMode::Navigate); - match mode { - SetupMode::Selector(state) => handle_selector_key(app, key, state), - SetupMode::Edit => handle_edit_key(app, key), - SetupMode::Navigate => handle_navigate_key(app, key), - } -} - -fn handle_selector_key( - app: &mut SetupApp, - key: KeyEvent, - mut state: SelectorState, -) -> Result { - match key.code { - KeyCode::Esc => { - if let Some(field) = app.fields.iter_mut().find(|f| f.key == state.field_key) { - field.value = state.original_value.clone(); - } - enter_navigate_mode(app); - } - KeyCode::Enter => { - apply_selector_value(app, &mut state); - let field_key = state.field_key.clone(); - app.apply_selector_selection(&field_key); - enter_navigate_mode(app); - } - KeyCode::Up | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => { - move_selector_selection(&mut state, -1); - app.mode = SetupMode::Selector(state); - } - KeyCode::Down | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => { - move_selector_selection(&mut state, 1); - app.mode = SetupMode::Selector(state); - } - KeyCode::Backspace => { - state.filter.pop(); - clamp_selector_selection(&mut state); - app.mode = SetupMode::Selector(state); - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - state.filter.push(c); - clamp_selector_selection(&mut state); - app.mode = SetupMode::Selector(state); - } - _ => app.mode = SetupMode::Selector(state), - } - Ok(false) -} - -fn handle_edit_key(app: &mut SetupApp, key: KeyEvent) -> Result { - match key.code { - KeyCode::Esc | KeyCode::Enter => { - finish_editing(app); - } - KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { - save_setup(app); - app.mode = SetupMode::Navigate; - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return Err("Setup cancelled".into()); - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(field) = app.current_field_mut() { - field.value.push(c); - } - app.mode = SetupMode::Edit; - } - KeyCode::Backspace => { - if let Some(field) = app.current_field_mut() { - field.value.pop(); - } - app.mode = SetupMode::Edit; - } - _ => app.mode = SetupMode::Edit, - } - Ok(false) -} - -fn handle_navigate_key(app: &mut SetupApp, key: KeyEvent) -> Result { - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return Err("Setup cancelled".into()); - } - KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => save_setup(app), - KeyCode::Enter => open_current_field(app), - KeyCode::Up | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => { - app.move_selection(-1); - app.mode = SetupMode::Navigate; - } - KeyCode::Down | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => { - app.move_selection(1); - app.mode = SetupMode::Navigate; - } - _ => app.mode = SetupMode::Navigate, - } - Ok(false) -} - -fn enter_navigate_mode(app: &mut SetupApp) { - app.mode = SetupMode::Navigate; - app.status = "Enter: edit | Up/Down: navigate | Ctrl+S: save & exit | Ctrl+C: cancel".into(); -} - -fn finish_editing(app: &mut SetupApp) { - if let Some(field) = app.current_field() - && (field.key == "DISCORD_ENABLED" || field.key == "TELEGRAM_ENABLED") - { - update_field_visibility(&mut app.fields); - } - enter_navigate_mode(app); -} - -fn save_setup(app: &mut SetupApp) { - match app.save() { - Ok(()) => { - app.status = "Config saved successfully!".into(); - } - Err(e) => { - app.status = format!("Save failed: {e}"); - } - } -} - -fn open_current_field(app: &mut SetupApp) { - let Some(field) = app.current_field() else { - app.mode = SetupMode::Navigate; - return; - }; - let key_name = field.key.clone(); - match key_name.as_str() { - "PROVIDER" | "MODEL" => { - app.mode = SetupMode::Selector(app.enter_selector(&key_name)); - app.status = "Selector: type to filter, Enter: select, Esc: cancel".into(); - } - _ => { - app.mode = SetupMode::Edit; - app.status = "Editing... (Enter/Esc to finish)".into(); - } - } -} - -fn apply_selector_value(app: &mut SetupApp, state: &mut SelectorState) { - let filtered = filtered_items(&state.items, &state.filter); - let selected_value = if filtered.is_empty() { - state.filter.clone() - } else { - state.selected = state.selected.min(filtered.len() - 1); - filtered[state.selected].value.clone() - }; - - if let Some(field) = app - .fields - .iter_mut() - .find(|field| field.key == state.field_key) - { - field.value = selected_value; - } -} - -fn move_selector_selection(state: &mut SelectorState, delta: isize) { - let filtered = filtered_items(&state.items, &state.filter); - if filtered.is_empty() { - state.selected = 0; - return; - } - let len = filtered.len() as isize; - state.selected = (state.selected as isize + delta).clamp(0, len - 1) as usize; -} - -fn clamp_selector_selection(state: &mut SelectorState) { - let filtered = filtered_items(&state.items, &state.filter); - if filtered.is_empty() { - state.selected = 0; - } else { - state.selected = state.selected.min(filtered.len() - 1); - } -} - -#[cfg(test)] -mod tests { - use super::{SelectorItem, SelectorState, SetupMode}; - use super::{SetupApp, filtered_items}; - - #[test] - fn load_existing_config_reads_current_provider_schema() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let config_path = temp_dir.path().join("egopulse.config.yaml"); - std::fs::write( - &config_path, - r#"default_provider: openai -providers: - openai: - label: OpenAI - base_url: https://api.openai.com/v1 - api_key: sk-openai - default_model: gpt-4o-mini - models: - gpt-4o-mini: {} - gpt-5: {} -channels: - web: - enabled: true - auth_token: web-token -"#, - ) - .expect("write config"); - - let (existing, _) = SetupApp::load_existing_config(&config_path); - - assert_eq!(existing.get("PROVIDER"), Some(&"openai".to_string())); - assert_eq!(existing.get("MODEL"), Some(&"gpt-4o-mini".to_string())); - assert_eq!( - existing.get("BASE_URL"), - Some(&"https://api.openai.com/v1".to_string()) - ); - assert_eq!(existing.get("API_KEY"), Some(&"sk-openai".to_string())); - assert_eq!( - existing.get("WEB_AUTH_TOKEN"), - Some(&"web-token".to_string()) - ); - } - - #[test] - fn load_existing_config_reads_discord_default_bot_token() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let config_path = temp_dir.path().join("egopulse.config.yaml"); - std::fs::write( - temp_dir.path().join(".env"), - "DISCORD_BOT_TOKEN=discord-secret\n", - ) - .expect("write .env"); - std::fs::write( - &config_path, - r#"default_provider: openai -providers: - openai: - label: OpenAI - base_url: https://api.openai.com/v1 - api_key: sk-openai - default_model: gpt-4o-mini -default_agent: default -agents: - default: - label: Default -channels: - discord: - enabled: true - bots: - default: - token: - source: env - id: DISCORD_BOT_TOKEN - "#, - ) - .expect("write config"); - - let (existing, _) = SetupApp::load_existing_config(&config_path); - - assert_eq!( - existing.get("DISCORD_BOT_TOKEN"), - Some(&"discord-secret".to_string()) - ); - } - - #[test] - fn filtered_items_returns_all_when_filter_empty() { - let items = vec![ - SelectorItem { - display: "openai (gpt-5.2, gpt-5)".into(), - value: "openai".into(), - }, - SelectorItem { - display: "ollama (llama3.2)".into(), - value: "ollama".into(), - }, - ]; - let result = filtered_items(&items, ""); - assert_eq!(result.len(), 2); - } - - #[test] - fn filtered_items_matches_substring_case_insensitive() { - let items = vec![ - SelectorItem { - display: "openai (gpt-5.2, gpt-5)".into(), - value: "openai".into(), - }, - SelectorItem { - display: "Ollama (local)".into(), - value: "ollama".into(), - }, - SelectorItem { - display: "OpenRouter".into(), - value: "openrouter".into(), - }, - ]; - let result = filtered_items(&items, "OPEN"); - assert_eq!(result.len(), 2); - assert_eq!(result[0].value, "openai"); - assert_eq!(result[1].value, "openrouter"); - } - - #[test] - fn filtered_items_returns_none_when_no_match() { - let items = vec![SelectorItem { - display: "openai".into(), - value: "openai".into(), - }]; - let result = filtered_items(&items, "zzzzz"); - assert!(result.is_empty()); - } - - #[test] - fn setup_mode_navigate_default() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let config_path = temp_dir.path().join("egopulse.config.yaml"); - let app = SetupApp::new(Some(config_path)).expect("setup app"); - assert!(matches!(app.mode, SetupMode::Navigate)); - } - - #[test] - fn selector_state_holds_original_value() { - let state = SelectorState { - field_key: "PROVIDER".into(), - filter: String::new(), - items: vec![], - selected: 0, - original_value: "openai".into(), - }; - assert_eq!(state.field_key, "PROVIDER"); - assert_eq!(state.original_value, "openai"); +/// +/// Thin wrapper around `wizard::run` for backwards-compatible entrypoint. +/// A user-initiated abort is treated as a successful cancellation and returns +/// `Ok(())`; genuine setup failures are surfaced via [`EgoPulseError`]. +/// +/// # Errors +/// +/// Returns `Err(EgoPulseError::SetupWizard(_))` when the wizard fails due to +/// prompt I/O, validation, config save, or config path resolution errors. +pub async fn run_setup_wizard(config_path: Option) -> Result<(), EgoPulseError> { + match wizard::run(config_path) { + Ok(()) => Ok(()), + Err(SetupWizardError::Aborted) => Ok(()), + Err(error) => Err(EgoPulseError::from(error)), } } diff --git a/src/setup/prompts.rs b/src/setup/prompts.rs new file mode 100644 index 00000000..097f7b12 --- /dev/null +++ b/src/setup/prompts.rs @@ -0,0 +1,337 @@ +//! Setup wizard のプロンプト入出力抽象化と Review 表示用フォーマット関数。 +//! +//! [`PromptSource`] / [`OutputSink`] trait により、wizard 本体 ([`crate::setup::wizard`]) +//! への入出力を抽象化する。本番環境では [`DialoguerPromptSource`] / +//! [`DialoguerOutputSink`] を使用し、テストではモック実装に差し替える。 + +use std::io::Write; + +/// API key を Review 画面向けに部分マスクして返す。 +/// +/// 空文字列の場合は `"(empty)"` を返す。 +/// それ以外は先頭3文字 + `...` + 末尾4文字の形式 (`docs/setup-redesign.md §4.2 Review`)。 +/// `sk-` 等のプレフィックスを保持しつつ、実値を秘匿する。 +pub(crate) fn format_api_key_for_review(api_key: &str) -> String { + if api_key.is_empty() { + return "(empty)".to_string(); + } + let chars: Vec = api_key.chars().collect(); + if chars.len() <= 7 { + return "********".to_string(); + } + let head: String = chars.iter().take(3).collect(); + let tail: String = chars + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{head}...{tail}") +} + +/// セットアップウィザードのプロンプト入力を抽象化する trait。 +/// +/// wizard 本体はこの trait を介してユーザー入力を取得する。 +/// 本番環境では [`DialoguerPromptSource`] を、テストでは `MockPromptSource` を使用する。 +/// +/// 全メソッドは入力エラー時に `Err(String)` を返す。 +pub(crate) trait PromptSource { + /// テキスト入力を求める。`default` は事前入力値 (空文字列可)。 + fn text(&self, label: &str, default: &str) -> Result; + + /// パスワード入力 (hidden) を求める。 + fn password(&self, label: &str) -> Result; + + /// 選択肢からインデックスを選ぶ。`items` は表示文字列のリスト。 + fn select(&self, label: &str, items: &[String], default: usize) -> Result; + + /// Yes/No 確認。`default` は Enter 押下時の値。 + fn confirm(&self, label: &str, default: bool) -> Result; +} + +/// セットアップウィザードの出力先を抽象化する trait。 +pub(crate) trait OutputSink { + /// 改行付きでテキストを出力する。 + fn println(&self, text: &str); +} + +/// [`dialoguer`] を用いた本番用 [`PromptSource`] 実装。 +pub(crate) struct DialoguerPromptSource; + +impl DialoguerPromptSource { + /// 新しいインスタンスを生成する。 + pub(crate) fn new() -> Self { + Self + } +} + +impl Default for DialoguerPromptSource { + fn default() -> Self { + Self::new() + } +} + +impl PromptSource for DialoguerPromptSource { + fn text(&self, label: &str, default: &str) -> Result { + dialoguer::Input::::new() + .with_prompt(label) + .with_initial_text(default) + .allow_empty(true) + .interact_text() + .map_err(|e| format!("Input error: {e}")) + } + + fn password(&self, label: &str) -> Result { + dialoguer::Password::new() + .with_prompt(label) + .allow_empty_password(true) + .interact() + .map_err(|e| format!("Password input error: {e}")) + } + + fn select(&self, label: &str, items: &[String], default: usize) -> Result { + dialoguer::Select::new() + .with_prompt(label) + .items(items) + .default(default) + .interact() + .map_err(|e| format!("Select error: {e}")) + } + + fn confirm(&self, label: &str, default: bool) -> Result { + dialoguer::Confirm::new() + .with_prompt(label) + .default(default) + .interact() + .map_err(|e| format!("Confirm error: {e}")) + } +} + +/// 標準出力への [`OutputSink`] 実装。 +pub(crate) struct DialoguerOutputSink; + +impl DialoguerOutputSink { + /// 新しいインスタンスを生成する。 + pub(crate) fn new() -> Self { + Self + } +} + +impl Default for DialoguerOutputSink { + fn default() -> Self { + Self::new() + } +} + +impl OutputSink for DialoguerOutputSink { + fn println(&self, text: &str) { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = handle.write_all(text.as_bytes()); + let _ = handle.write_all(b"\n"); + let _ = handle.flush(); + } +} + +/// テスト用 [`PromptSource`] / [`OutputSink`] モック実装。 +/// +/// wizard 統合テスト (T35〜T41) が [`crate::setup::test_mocks`] 経由で使用する。 +#[cfg(test)] +pub(crate) mod test_mocks { + use std::cell::RefCell; + use std::collections::VecDeque; + + use super::OutputSink; + use super::PromptSource; + + /// テスト用 [`PromptSource`] モック。 + /// + /// 入力はタイプ別 (text / password / select / confirm) のキューで管理し、 + /// 各呼び出しでラベルが部分一致する先頭エントリを消費する。 + /// `text_defaults` / `confirm_defaults` に渡されたデフォルト値を記録し、 + /// prefill テスト (T35) でアサーションに使用する。 + pub(crate) struct MockPromptSource { + text: RefCell>, + password: RefCell>, + select: RefCell>, + confirm: RefCell>, + text_defaults: RefCell>, + select_defaults: RefCell>, + confirm_defaults: RefCell>, + } + + impl MockPromptSource { + pub(crate) fn new() -> Self { + Self { + text: RefCell::new(VecDeque::new()), + password: RefCell::new(VecDeque::new()), + select: RefCell::new(VecDeque::new()), + confirm: RefCell::new(VecDeque::new()), + text_defaults: RefCell::new(Vec::new()), + select_defaults: RefCell::new(Vec::new()), + confirm_defaults: RefCell::new(Vec::new()), + } + } + + pub(crate) fn expect_text(&self, label_sub: &str, value: &str) -> &Self { + self.text + .borrow_mut() + .push_back((label_sub.to_string(), value.to_string())); + self + } + + pub(crate) fn expect_password(&self, label_sub: &str, value: &str) -> &Self { + self.password + .borrow_mut() + .push_back((label_sub.to_string(), value.to_string())); + self + } + + pub(crate) fn expect_select(&self, label_sub: &str, index: usize) -> &Self { + self.select + .borrow_mut() + .push_back((label_sub.to_string(), index)); + self + } + + pub(crate) fn expect_confirm(&self, label_sub: &str, value: bool) -> &Self { + self.confirm + .borrow_mut() + .push_back((label_sub.to_string(), value)); + self + } + + /// wizard から渡された text デフォルト値の (label, default) ペアを返す。 + pub(crate) fn text_defaults(&self) -> Vec<(String, String)> { + self.text_defaults.borrow().clone() + } + + pub(crate) fn select_defaults(&self) -> Vec<(String, usize)> { + self.select_defaults.borrow().clone() + } + + /// wizard から渡された confirm デフォルト値の (label, default) ペアを返す。 + pub(crate) fn confirm_defaults(&self) -> Vec<(String, bool)> { + self.confirm_defaults.borrow().clone() + } + + fn consume_text(&self, label: &str, default: &str) -> Result { + self.text_defaults + .borrow_mut() + .push((label.to_string(), default.to_string())); + let mut queue = self.text.borrow_mut(); + consume_entry(&mut queue, label, "text") + } + + fn consume_password(&self, label: &str) -> Result { + let mut queue = self.password.borrow_mut(); + consume_entry(&mut queue, label, "password") + } + + fn consume_select(&self, label: &str, default: usize) -> Result { + self.select_defaults + .borrow_mut() + .push((label.to_string(), default)); + let mut queue = self.select.borrow_mut(); + consume_entry(&mut queue, label, "select") + } + + fn consume_confirm(&self, label: &str, default: bool) -> Result { + self.confirm_defaults + .borrow_mut() + .push((label.to_string(), default)); + let mut queue = self.confirm.borrow_mut(); + consume_entry(&mut queue, label, "confirm") + } + } + + impl Default for MockPromptSource { + fn default() -> Self { + Self::new() + } + } + + impl PromptSource for MockPromptSource { + fn text(&self, label: &str, default: &str) -> Result { + self.consume_text(label, default) + } + + fn password(&self, label: &str) -> Result { + self.consume_password(label) + } + + fn select(&self, label: &str, _items: &[String], default: usize) -> Result { + self.consume_select(label, default) + } + + fn confirm(&self, label: &str, default: bool) -> Result { + self.consume_confirm(label, default) + } + } + + /// テスト用 [`OutputSink`]。出力を `Vec` へ蓄積する。 + pub(crate) struct VecOutputSink { + lines: RefCell>, + } + + impl VecOutputSink { + pub(crate) fn new() -> Self { + Self { + lines: RefCell::new(Vec::new()), + } + } + + pub(crate) fn joined(&self) -> String { + self.lines.borrow().join("\n") + } + } + + impl Default for VecOutputSink { + fn default() -> Self { + Self::new() + } + } + + impl OutputSink for VecOutputSink { + fn println(&self, text: &str) { + self.lines.borrow_mut().push(text.to_string()); + } + } + + fn consume_entry( + queue: &mut VecDeque<(String, T)>, + label: &str, + kind: &str, + ) -> Result { + let pos = queue + .iter() + .position(|(expected, _)| label.contains(expected.as_str())) + .ok_or_else(|| format!("no mock {kind} input matching '{label}'"))?; + let (_, value) = queue.remove(pos).expect("position was found"); + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::format_api_key_for_review; + + #[test] + fn format_api_key_for_review_masks_long_values() { + let result = format_api_key_for_review("sk-abcdef123456"); + assert_eq!(result, "sk-...3456"); + } + + #[test] + fn format_api_key_for_review_shows_empty_for_blank() { + assert_eq!(format_api_key_for_review(""), "(empty)"); + } + + #[test] + fn format_api_key_for_review_fully_masks_short_values() { + assert_eq!(format_api_key_for_review("abc"), "********"); + assert_eq!(format_api_key_for_review("sk-1234"), "********"); + } +} diff --git a/src/setup/provider.rs b/src/setup/provider.rs index dde1e6cf..022c39e3 100644 --- a/src/setup/provider.rs +++ b/src/setup/provider.rs @@ -1,5 +1,3 @@ -use super::{SelectorItem, SelectorState, SetupApp}; - #[derive(Clone, Copy)] pub(crate) struct ProviderPreset { pub id: &'static str, @@ -255,20 +253,6 @@ pub(crate) fn provider_label_for(provider: &str) -> String { .unwrap_or_else(|| provider.to_string()) } -pub(crate) fn provider_choices() -> String { - PROVIDER_PRESETS - .iter() - .map(|preset| { - if preset.models.is_empty() { - preset.id.to_string() - } else { - format!("{} (e.g. {})", preset.id, preset.models.join(", ")) - } - }) - .collect::>() - .join(", ") -} - pub(crate) fn normalize_provider_id(raw: &str) -> String { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -280,113 +264,21 @@ pub(crate) fn normalize_provider_id(raw: &str) -> String { trimmed.to_string() } -pub(crate) fn provider_selector_items() -> Vec { - PROVIDER_PRESETS - .iter() - .map(|preset| SelectorItem { - display: format!("{} ({})", preset.id, preset_models_preview(preset)), - value: preset.id.to_string(), - }) - .collect() -} - -fn preset_models_preview(preset: &ProviderPreset) -> String { - if preset.models.len() <= 2 { - return preset.models.join(", "); - } - - format!( - "{}, ... ({} total)", - preset.models[..2].join(", "), - preset.models.len() - ) -} - -pub(crate) fn model_selector_items(provider_id: &str) -> Vec { - find_provider_preset(provider_id) - .map(|preset| { - preset - .models - .iter() - .map(|model| SelectorItem { - display: model.to_string(), - value: model.to_string(), - }) - .collect() - }) - .unwrap_or_default() -} - -impl SetupApp { - pub(crate) fn enter_selector(&self, field_key: &str) -> SelectorState { - let items = match field_key { - "PROVIDER" => provider_selector_items(), - "MODEL" => model_selector_items(self.provider_field_value()), - _ => Vec::new(), - }; - let original_value = self - .fields - .iter() - .find(|f| f.key == field_key) - .map(|f| f.value.clone()) - .unwrap_or_default(); - SelectorState { - field_key: field_key.to_string(), - filter: String::new(), - items, - selected: 0, - original_value, - } - } - - pub(crate) fn apply_selector_selection(&mut self, field_key: &str) { - if field_key != "PROVIDER" { - return; - } - - let Some(preset) = find_provider_preset(self.provider_field_value()) else { - return; - }; - - if let Some(model_field) = self.fields.iter_mut().find(|f| f.key == "MODEL") { - model_field.value = preset.default_model.to_string(); - } - if let Some(url_field) = self.fields.iter_mut().find(|f| f.key == "BASE_URL") { - url_field.value = preset.default_base_url.to_string(); - } - } - - fn provider_field_value(&self) -> &str { - self.fields - .iter() - .find(|f| f.key == "PROVIDER") - .map(|f| f.value.as_str()) - .unwrap_or("") - } -} - #[cfg(test)] mod tests { - use super::{model_selector_items, provider_selector_items}; - #[test] - fn provider_selector_items_includes_key_presets() { - let items = provider_selector_items(); - assert!(!items.is_empty()); - assert!(items.iter().any(|i| i.value == "openai")); - assert!(items.iter().any(|i| i.value == "lmstudio")); + fn find_provider_preset_matches_known_id() { + assert!(super::find_provider_preset("openai").is_some()); + assert!(super::find_provider_preset("lmstudio").is_some()); } #[test] - fn model_selector_items_returns_models_for_known_provider() { - let items = model_selector_items("openai"); - assert!(!items.is_empty()); - assert!(items.iter().any(|i| i.value == "gpt-5.2")); + fn find_provider_preset_returns_none_for_unknown() { + assert!(super::find_provider_preset("nonexistent").is_none()); } #[test] - fn model_selector_items_returns_empty_for_unknown_provider() { - let items = model_selector_items("nonexistent"); - assert!(items.is_empty()); + fn normalize_provider_id_lowercases_known_preset() { + assert_eq!(super::normalize_provider_id("OpenAI"), "openai"); } } diff --git a/src/setup/slugify.rs b/src/setup/slugify.rs new file mode 100644 index 00000000..b84dc9d1 --- /dev/null +++ b/src/setup/slugify.rs @@ -0,0 +1,76 @@ +//! Agent label から agent id を生成する slugify ユーティリティ。 + +/// Agent label を agent id へ正規化する。 +/// +/// ASCII 英小文字化 → 非ASCII英数字をハイフンへ置換 → 連続ハイフンを圧縮 → +/// 前後のハイフンを削除し、結果が空文字列になった場合は `"default"` を返す。 +pub(crate) fn slugify_agent_id(label: &str) -> String { + let slug = compress_and_trim( + label + .to_ascii_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }), + ); + if slug.is_empty() { + "default".to_string() + } else { + slug + } +} + +fn compress_and_trim>(chars: I) -> String { + let mut result = String::new(); + let mut prev_hyphen = false; + for c in chars { + if c == '-' { + if !prev_hyphen && !result.is_empty() { + result.push('-'); + } + prev_hyphen = true; + } else { + result.push(c); + prev_hyphen = false; + } + } + while result.ends_with('-') { + result.pop(); + } + result +} + +#[cfg(test)] +mod tests { + use super::slugify_agent_id; + + #[test] + fn slugify_lowercases_ascii_letters() { + assert_eq!(slugify_agent_id("Lyre"), "lyre"); + } + + #[test] + fn slugify_replaces_whitespace_with_hyphen() { + assert_eq!(slugify_agent_id("My Agent"), "my-agent"); + } + + #[test] + fn slugify_preserves_alphanumeric() { + assert_eq!(slugify_agent_id("Vega 2"), "vega-2"); + } + + #[test] + fn slugify_compresses_consecutive_separators_and_trims() { + assert_eq!(slugify_agent_id(" Multi Space "), "multi-space"); + } + + #[test] + fn slugify_falls_back_to_default_for_empty_or_symbols_only() { + assert_eq!(slugify_agent_id(""), "default"); + assert_eq!(slugify_agent_id("!!!"), "default"); + assert_eq!(slugify_agent_id(" "), "default"); + } + + #[test] + fn slugify_replaces_non_ascii_with_hyphen() { + assert_eq!(slugify_agent_id("日本語Agent"), "agent"); + } +} diff --git a/src/setup/summary.rs b/src/setup/summary.rs index bfa63ca2..b1c31c41 100644 --- a/src/setup/summary.rs +++ b/src/setup/summary.rs @@ -3,111 +3,63 @@ use std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; - -use super::channels::{build_channel_configs, extract_existing_state_root, generate_auth_token}; +use super::channels::{ + build_channel_configs, extract_existing_state_root, generate_auth_token, load_channel_fields, +}; +use super::inputs::{SetupInputs, validate_inputs}; use super::provider::{ find_provider_preset, normalize_provider_id, provider_default_base_url, provider_default_model, provider_label_for, }; -use super::{Field, SetupApp}; +use super::slugify::slugify_agent_id; use crate::config::secret_ref::{ DISCORD_BOT_TOKEN_ENV_NAME, env_resolved_value, env_yaml_value as yaml_value, provider_api_key_env_name, }; -use crate::config::{ - Config, ProviderConfig, ProviderId, default_state_root, default_workspace_dir, - is_valid_base_url, -}; +use crate::config::{Config, ProviderConfig, ProviderId, default_state_root}; use crate::error::EgoPulseError; -use crate::llm::codex_auth; const CONFIG_BACKUP_DIR: &str = "egopulse.config.backups"; const MAX_CONFIG_BACKUPS: usize = 50; -pub(crate) fn validate_fields(fields: &[Field]) -> Result<(), String> { - let provider = field_value(fields, "PROVIDER"); - - if provider.is_empty() { - return Err("Provider profile ID is required".into()); - } - - let model = field_value(fields, "MODEL"); - let effective_model = if model.is_empty() { - provider_default_model(provider).unwrap_or("") - } else { - model - }; - - let base_url = field_value(fields, "BASE_URL"); - let effective_base_url = if base_url.is_empty() { - provider_default_base_url(provider).unwrap_or("") - } else { - base_url - }; - - if effective_base_url.is_empty() { - return Err(format!( - "API base URL is required for provider '{provider}'" - )); - } - - if !is_valid_base_url(effective_base_url) { - return Err(format!("Invalid API base URL: {effective_base_url}")); - } - - if effective_model.is_empty() { - return Err(format!("LLM model is required for provider '{provider}'")); - } - - let api_key = field_value(fields, "API_KEY"); - - if !codex_auth::provider_allows_empty_api_key(provider, effective_base_url) - && api_key.is_empty() - { - return Err( - "API key is required for non-local endpoints. Use a local URL (localhost/127.0.0.1) to skip.".into(), - ); - } - - validate_enabled_token( - fields, - "DISCORD_ENABLED", - "DISCORD_BOT_TOKEN", - "Discord bot token is required when Discord is enabled", - )?; - validate_enabled_token( - fields, - "TELEGRAM_ENABLED", - "TELEGRAM_BOT_TOKEN", - "Telegram bot token is required when Telegram is enabled", - )?; - - Ok(()) -} - +/// Saves configuration derived from [`SetupInputs`], writing the YAML file and +/// associated `.env`, and generating a backup when an existing config is present. +/// +/// Agent-First 設計に基づき以下を行う: +/// +/// - `inputs.agent_label` を [`slugify_agent_id`] で agent id へ正規化し、 +/// `agents..label` に label を保存、`default_agent` をその id に設定 +/// - [`build_channel_configs`] に `inputs.web_enabled` を渡し、Web 無効化時に +/// `channels.web` エントリを保存しない (Discord/Telegram と一貫) +/// - 既存 `WEB_AUTH_TOKEN` を再利用 (新規生成しない) +/// - 既存 `state_root` および compaction 系パラメータを保持 +/// +/// # Errors +/// +/// - [`validate_inputs`] が `Err` の場合 +/// - 設定ディレクトリ / state_root / workspace ディレクトリの作成に失敗した場合 +/// - 既存設定ファイルのバックアップ作成に失敗した場合 +/// - 設定ファイル (YAML + `.env`) の保存に失敗した場合 pub(crate) fn save_config( - fields: &[Field], - original_yaml: &Option, + inputs: &SetupInputs, + original_yaml: Option<&yaml_serde::Value>, config_path: &Path, ) -> Result<(Option, Vec), String> { - validate_fields(fields)?; + validate_inputs(inputs)?; - let provider_id = normalize_provider_id(field_value(fields, "PROVIDER")); + let provider_id = normalize_provider_id(&inputs.provider_id); let provider_label = provider_label_for(&provider_id); + let agent_id = slugify_agent_id(&inputs.agent_label); - let model = non_empty_owned(field_value(fields, "MODEL")) + let model = non_empty_owned(&inputs.model) .or_else(|| provider_default_model(&provider_id).map(|value| value.to_string())) .unwrap_or_default(); - let base_url = non_empty_owned(field_value(fields, "BASE_URL")) + let base_url = non_empty_owned(&inputs.base_url) .or_else(|| provider_default_base_url(&provider_id).map(|value| value.to_string())) .unwrap_or_default(); - let api_key = field_value(fields, "API_KEY").to_string(); + let api_key = inputs.api_key.trim().to_string(); let existing_config = Config::load_allow_missing_api_key(Some(config_path)).ok(); @@ -117,20 +69,27 @@ pub(crate) fn save_config( let has_existing_token = existing_token.is_some(); let auth_token = existing_token.unwrap_or_else(generate_auth_token); - let existing_state_root = extract_existing_state_root(original_yaml); + let owned_yaml = original_yaml.cloned(); + let existing_state_root = extract_existing_state_root(&owned_yaml); - let discord_enabled = field_bool(fields, "DISCORD_ENABLED"); - let discord_bot_token = field_value(fields, "DISCORD_BOT_TOKEN").to_string(); - let telegram_enabled = field_bool(fields, "TELEGRAM_ENABLED"); - let telegram_bot_token = field_value(fields, "TELEGRAM_BOT_TOKEN").to_string(); + let discord_enabled = inputs.discord_enabled; + let discord_bot_token = inputs.discord_bot_token.trim().to_string(); + let telegram_enabled = inputs.telegram_enabled; + let telegram_bot_token = inputs.telegram_bot_token.trim().to_string(); if let Some(config_dir) = config_path.parent() { fs::create_dir_all(config_dir) .map_err(|e| format!("Failed to create config directory: {e}"))?; } - fs::create_dir_all(default_state_root().map_err(|e| e.to_string())?) + let default_root = + default_state_root().map_err(|e| format!("Failed to resolve state root: {e}"))?; + let resolved_state_root = existing_state_root + .clone() + .unwrap_or_else(|| default_root.to_string_lossy().into_owned()); + fs::create_dir_all(&resolved_state_root) .map_err(|e| format!("Failed to create state root directory: {e}"))?; - fs::create_dir_all(default_workspace_dir().map_err(|e| e.to_string())?) + let workspace_dir = Path::new(&resolved_state_root).join("workspace"); + fs::create_dir_all(&workspace_dir) .map_err(|e| format!("Failed to create workspace directory: {e}"))?; let backup_path = if config_path.exists() { @@ -143,7 +102,7 @@ pub(crate) fn save_config( let preset_default_model = preset .map(|p| p.default_model.to_string()) .unwrap_or_else(|| model.clone()); - let mut preset_models: std::collections::HashMap = preset + let mut preset_models: HashMap = preset .map(|p| { p.models .iter() @@ -151,7 +110,7 @@ pub(crate) fn save_config( .collect() }) .unwrap_or_else(|| { - let mut m = std::collections::HashMap::new(); + let mut m = HashMap::new(); m.insert(model.clone(), crate::config::ModelConfig::default()); if !m.contains_key(&preset_default_model) { m.insert( @@ -164,7 +123,10 @@ pub(crate) fn save_config( // Ensure the user's chosen model is always in the models map. preset_models.entry(model.clone()).or_default(); - let mut providers = HashMap::new(); + let mut providers = existing_config + .as_ref() + .map(|c| c.providers.clone()) + .unwrap_or_default(); providers.insert( ProviderId::new(&provider_id), ProviderConfig { @@ -183,60 +145,87 @@ pub(crate) fn save_config( }, ); - let discord_bots: Option< - std::collections::HashMap, - > = if discord_enabled && !discord_bot_token.is_empty() { - let mut bots = std::collections::HashMap::new(); - bots.insert( - crate::config::BotId::new("default"), - crate::config::DiscordBotConfig { - token: Some(env_resolved_value( - DISCORD_BOT_TOKEN_ENV_NAME, - discord_bot_token, - )), - file_token: Some(yaml_value(DISCORD_BOT_TOKEN_ENV_NAME)), - }, - ); - Some(bots) - } else { - None - }; + let discord_bots: Option> = + if discord_enabled && !discord_bot_token.is_empty() { + let mut bots = HashMap::new(); + bots.insert( + crate::config::BotId::new("default"), + crate::config::DiscordBotConfig { + token: Some(env_resolved_value( + DISCORD_BOT_TOKEN_ENV_NAME, + discord_bot_token, + )), + file_token: Some(yaml_value(DISCORD_BOT_TOKEN_ENV_NAME)), + }, + ); + Some(bots) + } else { + None + }; - let mut channels = build_channel_configs( + let new_channels = build_channel_configs( + inputs.web_enabled, auth_token, discord_enabled, telegram_enabled, telegram_bot_token, ); - if let Some(bots) = discord_bots { - channels - .entry(crate::config::ChannelName::new("discord")) - .or_default() - .discord_bots = Some(bots); + let mut channels = existing_config + .as_ref() + .map(|c| c.channels.clone()) + .unwrap_or_default(); + + let web_key = crate::config::ChannelName::new("web"); + if inputs.web_enabled { + if let Some(web) = new_channels.get(&web_key) { + channels.insert(web_key.clone(), web.clone()); + } + } else { + channels.remove(&web_key); } - let agents: std::collections::HashMap = - std::collections::HashMap::from([( - crate::config::AgentId::new("default"), - crate::config::AgentConfig { - label: "Default Agent".to_string(), - ..Default::default() - }, - )]); + let discord_key = crate::config::ChannelName::new("discord"); + if discord_enabled { + if let Some(new_discord) = new_channels.get(&discord_key) { + channels.insert(discord_key.clone(), new_discord.clone()); + } + if let Some(bots) = discord_bots { + channels + .entry(discord_key.clone()) + .or_default() + .discord_bots = Some(bots); + } + } else { + channels.remove(&discord_key); + } + + let telegram_key = crate::config::ChannelName::new("telegram"); + if telegram_enabled { + if let Some(telegram) = new_channels.get(&telegram_key) { + channels.insert(telegram_key.clone(), telegram.clone()); + } + } else { + channels.remove(&telegram_key); + } + + let mut agents = existing_config + .as_ref() + .map(|c| c.agents.clone()) + .unwrap_or_default(); + let agent_key = crate::config::AgentId::new(&agent_id); + let agent = agents.entry(agent_key).or_default(); + agent.label = inputs.agent_label.clone(); let config = Config { default_provider: ProviderId::new(&provider_id), default_model: Some(model.clone()), providers, - state_root: existing_state_root.unwrap_or_else(|| { - default_state_root() - .map_err(|e| e.to_string()) - .unwrap() - .to_string_lossy() - .into_owned() - }), - log_level: "info".to_string(), + state_root: resolved_state_root.clone(), + log_level: existing_config + .as_ref() + .map(|c| c.log_level.clone()) + .unwrap_or_else(|| "info".to_string()), compaction_timeout_secs: existing_config .as_ref() .map(|c| c.compaction_timeout_secs) @@ -262,7 +251,7 @@ pub(crate) fn save_config( .map(|c| c.compaction_target_ratio) .unwrap_or(0.40), channels, - default_agent: crate::config::AgentId::new("default"), + default_agent: crate::config::AgentId::new(&agent_id), agents, timezone: existing_config .as_ref() @@ -292,6 +281,7 @@ pub(crate) fn save_config( let mut completion_summary = vec![ format!("Config saved to: {}", config_path.display()), + format!("Agent: {} (id: {agent_id})", inputs.agent_label), format!("Provider: {provider_label} ({provider_id})"), format!("Model: {model}"), format!("Base URL: {base_url}"), @@ -300,7 +290,9 @@ pub(crate) fn save_config( } else { format!("API key: {}", mask_secret(&api_key)) }, - if has_existing_token { + if !inputs.web_enabled { + "Web channel: disabled".into() + } else if has_existing_token { "Web channel: enabled (auth_token reused)".into() } else { "Web channel: enabled (auth_token auto-generated)".into() @@ -327,25 +319,6 @@ pub(crate) fn save_config( completion_summary.push(format!("Previous config backed up to: {backup}")); } - let existing_non_default = original_yaml - .as_ref() - .and_then(|yaml| yaml.as_mapping()) - .and_then(|m| m.get(yaml_serde::Value::String("agents".into()))) - .and_then(|a| a.as_mapping()) - .map(|m| { - m.keys() - .filter_map(|k| k.as_str()) - .filter(|id| *id != "default") - .count() - }) - .unwrap_or(0); - if existing_non_default > 0 { - completion_summary.push(format!( - "⚠ Existing {existing_non_default} custom agent(s) preserved in backup; \ - re-add them to agents in config YAML if needed" - )); - } - Ok((backup_path, completion_summary)) } @@ -357,37 +330,6 @@ pub(crate) fn mask_secret(value: &str) -> String { format!("{visible}********") } -fn field_value<'a>(fields: &'a [Field], key: &str) -> &'a str { - fields - .iter() - .find(|f| f.key == key) - .map(|f| f.value.trim()) - .unwrap_or("") -} - -fn field_bool(fields: &[Field], key: &str) -> bool { - fields - .iter() - .find(|f| f.key == key) - .and_then(|f| super::parse_bool(&f.value)) - .unwrap_or(false) -} - -fn validate_enabled_token( - fields: &[Field], - enabled_key: &str, - token_key: &str, - error_message: &str, -) -> Result<(), String> { - if !field_bool(fields, enabled_key) { - return Ok(()); - } - if !field_value(fields, token_key).is_empty() { - return Ok(()); - } - Err(error_message.into()) -} - fn non_empty_owned(value: &str) -> Option { (!value.is_empty()).then(|| value.to_string()) } @@ -445,25 +387,531 @@ pub(crate) fn cleanup_old_backups(backup_dir: &Path, file_name: &str) -> Result< Ok(()) } -pub(crate) fn draw_completion_summary(frame: &mut ratatui::Frame<'_>, app: &SetupApp, area: Rect) { - let mut lines = Vec::new(); - lines.push(Line::from(vec![Span::styled( - "Setup Complete!", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )])); - lines.push(Line::from("")); - - for item in &app.completion_summary { - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::raw(item), - ])); +/// 既存設定 YAML のパース結果。 +/// +/// `fields` はウィザードプロンプトのデフォルト値として事前入力に使用する平坦なマップ。 +/// `root` は元の YAML ルートノード (state_root 抽出等に使用)。 +pub(crate) struct ExistingConfig { + pub fields: HashMap, + pub root: Option, +} + +/// YAML 文字列をパースし、プロバイダー・チャネル情報を抽出する純粋関数。 +/// +/// ファイル IO (`.env` からのトークン読み込み等) は行わず、YAML テキストのみを入力とする。 +/// 既存 `SetupApp::load_existing_config` の YAML パース部分を切り出したもので、 +/// パースエラーを黙殺せず `Err` で返す。 +/// +/// # Errors +/// +/// YAML 構文エラーの場合、パースエラーの内容を含むメッセージを返す。 +pub(crate) fn parse_existing_config(yaml_text: &str) -> Result { + let parsed: yaml_serde::Value = yaml_serde::from_str(yaml_text) + .map_err(|e| format!("Failed to parse existing config YAML: {e}"))?; + + let mut fields = HashMap::new(); + + if let Some(map) = parsed.as_mapping() { + extract_provider_fields(map, &mut fields); + if let Some(channels) = map.get(yaml_string_key("channels")) { + load_channel_fields(channels, &mut fields); + } + } + + Ok(ExistingConfig { + fields, + root: Some(parsed), + }) +} + +fn extract_provider_fields(map: &yaml_serde::Mapping, fields: &mut HashMap) { + let Some(default_provider) = map + .get(yaml_string_key("default_provider")) + .and_then(|v| v.as_str()) + else { + return; + }; + + let provider_id = normalize_provider_id(default_provider); + fields.insert("PROVIDER".into(), provider_id.clone()); + + let provider_map = map + .get(yaml_string_key("providers")) + .and_then(|v| v.as_mapping()) + .and_then(|providers| providers.get(yaml_string_key(&provider_id))) + .and_then(|v| v.as_mapping()); + + let model = map + .get(yaml_string_key("default_model")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + provider_map + .and_then(|pm| pm.get(yaml_string_key("default_model"))) + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .or_else(|| provider_default_model(&provider_id).map(str::to_string)); + if let Some(model) = model { + fields.insert("MODEL".into(), model); + } + + let base_url = provider_map + .and_then(|pm| pm.get(yaml_string_key("base_url"))) + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| provider_default_base_url(&provider_id).map(str::to_string)); + if let Some(base_url) = base_url { + fields.insert("BASE_URL".into(), base_url); + } + + if let Some(api_key) = provider_map + .and_then(|pm| pm.get(yaml_string_key("api_key"))) + .and_then(|v| v.as_str()) + { + fields.insert("API_KEY".into(), api_key.to_string()); + } +} + +fn yaml_string_key(value: &str) -> yaml_serde::Value { + yaml_serde::Value::String(value.to_string()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{mask_secret, parse_existing_config, save_config}; + use crate::config::{AgentId, Config, ProviderId}; + use crate::setup::inputs::SetupInputs; + use serial_test::serial; + + fn ollama_inputs(agent_label: &str) -> SetupInputs { + SetupInputs { + agent_label: agent_label.into(), + provider_id: "ollama".into(), + base_url: "http://127.0.0.1:11434/v1".into(), + model: "llama3.2".into(), + api_key: String::new(), + web_enabled: false, + discord_enabled: false, + discord_bot_token: String::new(), + telegram_enabled: false, + telegram_bot_token: String::new(), + } + } + + #[test] + fn parse_existing_config_returns_err_for_invalid_yaml() { + let result = parse_existing_config("default_provider: [unclosed"); + assert!(result.is_err()); + } + + #[test] + fn parse_existing_config_extracts_provider_schema() { + let yaml = r#"default_provider: openai +providers: + openai: + label: OpenAI + base_url: https://api.openai.com/v1 + api_key: sk-openai + default_model: gpt-4o-mini +channels: + web: + enabled: true + auth_token: web-token +"#; + let config = parse_existing_config(yaml).expect("valid yaml"); + assert_eq!(config.fields.get("PROVIDER"), Some(&"openai".to_string())); + assert_eq!(config.fields.get("MODEL"), Some(&"gpt-4o-mini".to_string())); + assert_eq!( + config.fields.get("BASE_URL"), + Some(&"https://api.openai.com/v1".to_string()) + ); + assert_eq!( + config.fields.get("WEB_AUTH_TOKEN"), + Some(&"web-token".to_string()) + ); + } + + #[test] + #[serial] + fn save_config_persists_agent_label() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let inputs = ollama_inputs("Partner"); + save_config(&inputs, None, &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + let agent = loaded + .agents + .get(&AgentId::new("partner")) + .expect("agent 'partner' should exist"); + assert_eq!(agent.label, "Partner"); + } + + #[test] + #[serial] + fn save_config_sets_default_agent_to_user_id() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let inputs = ollama_inputs("My Companion"); + save_config(&inputs, None, &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + assert_eq!(loaded.default_agent, AgentId::new("my-companion")); } - let body = Paragraph::new(lines) - .block(Block::default().title("Summary").borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - frame.render_widget(body, area); + #[test] + #[serial] + fn save_config_omits_web_entry_when_disabled() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let inputs = ollama_inputs("Partner"); + save_config(&inputs, None, &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + assert!( + !loaded.channels.contains_key("web"), + "channels.web must be absent when web_enabled is false" + ); + } + + #[test] + #[serial] + fn save_config_creates_backup_when_existing_file_present() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + std::fs::write( + &config_path, + "default_provider: ollama\nproviders:\n ollama:\n label: Ollama\n base_url: http://127.0.0.1:11434/v1\n default_model: llama3.2\ndefault_agent: default\nagents:\n default:\n label: Default\n", + ) + .expect("write existing config"); + + let inputs = ollama_inputs("Partner"); + let (backup_path, _) = save_config(&inputs, None, &config_path).expect("save config"); + + let backup = backup_path.expect("backup path should be Some when file existed"); + assert!( + Path::new(&backup).exists(), + "backup file should exist on disk" + ); + } + + #[test] + #[serial] + fn save_config_roundtrips_with_config_load() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let inputs = SetupInputs { + agent_label: "Partner".into(), + provider_id: "openai".into(), + base_url: "https://api.openai.com/v1".into(), + model: "gpt-4o".into(), + api_key: "sk-test-roundtrip-key".into(), + web_enabled: true, + discord_enabled: false, + discord_bot_token: String::new(), + telegram_enabled: false, + telegram_bot_token: String::new(), + }; + save_config(&inputs, None, &config_path).expect("save config"); + + let loaded = Config::load(Some(&config_path)).expect("load config via Config::load"); + assert_eq!(loaded.default_agent, AgentId::new("partner")); + assert!(loaded.agents.contains_key(&AgentId::new("partner"))); + assert_eq!( + loaded.agents.get(&AgentId::new("partner")).unwrap().label, + "Partner" + ); + assert_eq!(loaded.default_provider.as_str(), "openai"); + assert!(loaded.providers.contains_key("openai")); + assert_eq!(loaded.default_model, Some("gpt-4o".to_string())); + } + + #[test] + #[serial] + fn save_config_reuses_existing_web_auth_token() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let web_token_env = crate::config::secret_ref::WEB_AUTH_TOKEN_ENV_NAME; + let existing_yaml = format!( + "default_provider: ollama\n\ + providers:\n\ + \x20 ollama:\n\ + \x20 label: Ollama\n\ + \x20 base_url: http://127.0.0.1:11434/v1\n\ + \x20 default_model: llama3.2\n\ + default_agent: default\n\ + agents:\n\ + \x20 default:\n\ + \x20 label: Default\n\ + channels:\n\ + \x20 web:\n\ + \x20 enabled: true\n\ + \x20 auth_token:\n\ + \x20 source: env\n\ + \x20 id: {web_token_env}\n" + ); + std::fs::write(&config_path, &existing_yaml).expect("write existing config"); + std::fs::write( + temp_dir.path().join(".env"), + format!("{web_token_env}=existing-token-from-previous-setup\n"), + ) + .expect("write .env"); + + let parsed_yaml: yaml_serde::Value = + yaml_serde::from_str(&existing_yaml).expect("parse existing yaml"); + let inputs = SetupInputs { + web_enabled: true, + ..ollama_inputs("Partner") + }; + save_config(&inputs, Some(&parsed_yaml), &config_path).expect("save config"); + + let dotenv = + std::fs::read_to_string(temp_dir.path().join(".env")).expect("read .env after save"); + assert!( + dotenv.contains("existing-token-from-previous-setup"), + "existing WEB_AUTH_TOKEN must be reused, not regenerated" + ); + } + + #[test] + #[serial] + fn save_config_preserves_existing_state_root() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let custom_state_root = temp_dir.path().join("custom-state"); + let root_str = custom_state_root.to_string_lossy(); + let existing_yaml = format!( + "state_root: {root_str}\n\ + default_provider: ollama\n\ + providers:\n\ + \x20 ollama:\n\ + \x20 label: Ollama\n\ + \x20 base_url: http://127.0.0.1:11434/v1\n\ + \x20 default_model: llama3.2\n\ + default_agent: default\n\ + agents:\n\ + \x20 default:\n\ + \x20 label: Default\n" + ); + std::fs::write(&config_path, &existing_yaml).expect("write existing config"); + + let parsed_yaml: yaml_serde::Value = + yaml_serde::from_str(&existing_yaml).expect("parse existing yaml"); + let inputs = ollama_inputs("Partner"); + save_config(&inputs, Some(&parsed_yaml), &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + assert_eq!( + loaded.state_root, + root_str.to_string(), + "existing state_root must be preserved" + ); + } + + #[test] + fn mask_secret_fully_masks_short_values() { + let short_secret = "sk-1234"; + + let masked = mask_secret(short_secret); + + assert_eq!(masked, "********"); + } + + #[test] + #[serial] + fn save_config_preserves_existing_non_default_agents() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + let existing_yaml = "default_provider: ollama\n\ + default_agent: partner\n\ + providers:\n\ + \x20 ollama:\n\ + \x20 label: Ollama\n\ + \x20 base_url: http://127.0.0.1:11434/v1\n\ + \x20 default_model: llama3.2\n\ + agents:\n\ + \x20 partner:\n\ + \x20 label: Partner\n\ + \x20 assistant:\n\ + \x20 label: Assistant\n\ + \x20 researcher:\n\ + \x20 label: Researcher\n"; + std::fs::write(&config_path, existing_yaml).expect("write existing config"); + let parsed_yaml: yaml_serde::Value = + yaml_serde::from_str(existing_yaml).expect("parse existing yaml"); + + let inputs = ollama_inputs("Partner"); + save_config(&inputs, Some(&parsed_yaml), &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + assert!(loaded.agents.contains_key(&AgentId::new("partner"))); + assert!( + loaded.agents.contains_key(&AgentId::new("assistant")), + "existing non-default agent must be preserved" + ); + assert!( + loaded.agents.contains_key(&AgentId::new("researcher")), + "existing non-default agent must be preserved" + ); + } + + #[test] + #[serial] + fn save_config_preserves_existing_log_level() { + // SAFETY: テストは #[serial] で直列実行され、他テストは LOG_LEVEL に依存しない。 + unsafe { + std::env::remove_var("LOG_LEVEL"); + } + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + let existing_yaml = "default_provider: ollama\n\ + log_level: debug\n\ + providers:\n\ + \x20 ollama:\n\ + \x20 label: Ollama\n\ + \x20 base_url: http://127.0.0.1:11434/v1\n\ + \x20 default_model: llama3.2\n\ + agents:\n\ + \x20 default:\n\ + \x20 label: Default\n"; + std::fs::write(&config_path, existing_yaml).expect("write existing config"); + let parsed_yaml: yaml_serde::Value = + yaml_serde::from_str(existing_yaml).expect("parse existing yaml"); + + let inputs = ollama_inputs("Partner"); + save_config(&inputs, Some(&parsed_yaml), &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + assert_eq!( + loaded.log_level, "debug", + "existing log_level must be preserved across re-setup" + ); + } + + #[test] + #[serial] + fn save_config_preserves_existing_non_default_providers() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + let existing_yaml = "default_provider: ollama\n\ + providers:\n\ + \x20 ollama:\n\ + \x20 label: Ollama\n\ + \x20 base_url: http://127.0.0.1:11434/v1\n\ + \x20 default_model: llama3.2\n\ + \x20 openai:\n\ + \x20 label: OpenAI\n\ + \x20 base_url: https://api.openai.com/v1\n\ + \x20 default_model: gpt-4o\n\ + agents:\n\ + \x20 default:\n\ + \x20 label: Default\n"; + std::fs::write(&config_path, existing_yaml).expect("write existing config"); + let parsed_yaml: yaml_serde::Value = + yaml_serde::from_str(existing_yaml).expect("parse existing yaml"); + + let inputs = ollama_inputs("Partner"); + save_config(&inputs, Some(&parsed_yaml), &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + assert!(loaded.providers.contains_key(&ProviderId::new("ollama"))); + assert!( + loaded.providers.contains_key(&ProviderId::new("openai")), + "existing non-default provider must be preserved" + ); + } + + #[test] + #[serial] + fn save_config_preserves_existing_agent_provider_and_model() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + let existing_yaml = "default_provider: openai\n\ + default_agent: partner\n\ + providers:\n\ + \x20 openai:\n\ + \x20 label: OpenAI\n\ + \x20 base_url: https://api.openai.com/v1\n\ + \x20 default_model: gpt-4o\n\ + agents:\n\ + \x20 partner:\n\ + \x20 label: Partner\n\ + \x20 provider: openai\n\ + \x20 model: gpt-4o\n"; + std::fs::write(&config_path, existing_yaml).expect("write existing config"); + let parsed_yaml: yaml_serde::Value = + yaml_serde::from_str(existing_yaml).expect("parse existing yaml"); + + let mut inputs = ollama_inputs("Partner"); + inputs.provider_id = "openai".into(); + inputs.base_url = "https://api.openai.com/v1".into(); + inputs.model = "gpt-4o".into(); + inputs.api_key = "sk-test".into(); + save_config(&inputs, Some(&parsed_yaml), &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + let partner = loaded + .agents + .get(&AgentId::new("partner")) + .expect("partner exists"); + assert_eq!( + partner.provider, + Some("openai".to_string()), + "existing agent provider must be preserved across re-setup" + ); + assert_eq!( + partner.model, + Some("gpt-4o".to_string()), + "existing agent model must be preserved across re-setup" + ); + } + + #[test] + #[serial] + fn save_config_removes_disabled_discord_and_telegram() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + let existing_yaml = "default_provider: ollama\n\ + providers:\n\ + \x20 ollama:\n\ + \x20 label: Ollama\n\ + \x20 base_url: http://127.0.0.1:11434/v1\n\ + \x20 default_model: llama3.2\n\ + agents:\n\ + \x20 default:\n\ + \x20 label: Default\n\ + channels:\n\ + \x20 discord:\n\ + \x20 enabled: true\n\ + \x20 telegram:\n\ + \x20 enabled: true\n"; + std::fs::write(&config_path, existing_yaml).expect("write existing config"); + let parsed_yaml: yaml_serde::Value = + yaml_serde::from_str(existing_yaml).expect("parse existing yaml"); + + let inputs = ollama_inputs("Partner"); + save_config(&inputs, Some(&parsed_yaml), &config_path).expect("save config"); + + let loaded = Config::load_allow_missing_api_key(Some(&config_path)).expect("load config"); + let discord_key = crate::config::ChannelName::new("discord"); + let telegram_key = crate::config::ChannelName::new("telegram"); + assert!( + !loaded.channels.contains_key(&discord_key), + "disabled discord channel must be removed" + ); + assert!( + !loaded.channels.contains_key(&telegram_key), + "disabled telegram channel must be removed" + ); + } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs new file mode 100644 index 00000000..2b4e7144 --- /dev/null +++ b/src/setup/wizard.rs @@ -0,0 +1,934 @@ +//! Setup wizard のフロー制御、メッセージビルダー、分岐判断純粋関数。 +//! +//! Step 7 の純粋関数群と Step 8 の trait 抽象化されたフロー統合を統合する。 +//! wizard 本体 ([`run_with_source_and_sink`]) は [`PromptSource`] / [`OutputSink`] +//! trait を介して入出力を行い、本番では dialoguer、テストではモックを使用する。 + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::default_config_path; +use crate::llm::codex_auth::provider_allows_empty_api_key; +use crate::setup::error::SetupWizardError; +use crate::setup::inputs::{SetupInputs, validate_inputs}; +use crate::setup::prompts::format_api_key_for_review; +use crate::setup::prompts::{DialoguerOutputSink, DialoguerPromptSource, OutputSink, PromptSource}; +use crate::setup::provider::{PROVIDER_PRESETS, find_provider_preset, provider_label_for}; +use crate::setup::slugify::slugify_agent_id; +use crate::setup::summary::{ExistingConfig, parse_existing_config, save_config}; + +/// Web UI のデフォルトアクセス URL。 +const WEB_UI_URL: &str = "http://127.0.0.1:10961"; + +/// Review 画面でユーザーが "no" を選択した際の次アクション。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReviewDecision { + /// Q1 に戻り、入力をやり直す。 + StartOver, + /// 保存せずに終了する。 + Abort, + /// 警告を了承の上、保存へ進む。 + SaveAnyway, +} + +/// Select インデックスを `ReviewDecision` へ変換する。 +/// +/// `dialoguer::Select` が返すインデックス (0=Start over, 1=Abort, 2=Save anyway) +/// を対応する列挙子へマッピングする。範囲外のインデックスは `SaveAnyway` になる。 +pub(crate) fn review_decision_from_index(index: usize) -> ReviewDecision { + match index { + 0 => ReviewDecision::StartOver, + 1 => ReviewDecision::Abort, + _ => ReviewDecision::SaveAnyway, + } +} + +/// Review 画面に表示する設定内容サマリーを構築する (`docs/setup-redesign.md §4.2 Review`)。 +/// +/// API Key 行は `format_api_key_for_review` でマスクした値を表示する。 +pub(crate) fn build_review_summary(inputs: &SetupInputs) -> String { + let agent_id = slugify_agent_id(&inputs.agent_label); + let masked_key = format_api_key_for_review(&inputs.api_key); + let web_line = if inputs.web_enabled { + "enabled (auth_token: auto-generated, saved to .env)" + } else { + "disabled" + }; + + [ + "About to save the configuration file with the following values:".to_string(), + String::new(), + format!(" Agent: {} (id: {})", inputs.agent_label, agent_id), + format!(" Provider: {} ({})", inputs.provider_id, inputs.base_url), + format!(" Model: {}", inputs.model), + format!(" API Key: {}", masked_key), + format!(" Web: {}", web_line), + format!(" Discord: {}", enabled_label(inputs.discord_enabled)), + format!(" Telegram: {}", enabled_label(inputs.telegram_enabled)), + String::new(), + "Save? (Y/n)".to_string(), + ] + .join("\n") +} + +/// Additional Options ステップの固定案内テキストを返す (`docs/setup-redesign.md §4.2`)。 +pub(crate) fn build_additional_options_text() -> String { + [ + "The configuration has been saved. The following options were not configured in", + "this setup, but can be set by editing ~/.egopulse/egopulse.config.yaml:", + "", + "System:", + " - timezone (default: UTC)", + " - log_level (default: info)", + " - default_context_window_tokens (default: 32768)", + " - compaction_threshold_ratio / compaction_target_ratio / compact_keep_recent", + " - max_history_messages", + "", + "Web UI:", + " - channels.web.host (default: 127.0.0.1)", + " - channels.web.port (default: 10961)", + " - channels.web.allowed_origins (default: [])", + "", + "Channels:", + " - Additional providers and agents (add entries under \"providers\" / \"agents\")", + " - Discord/Telegram channel access control (see docs/channels.md)", + " - Voice channel (channels.voice.*)", + " - Per-agent persona (SOUL.md)", + "", + "Subsystems:", + " - sleep_batch (long-term memory processing)", + " - pulse (attention activation)", + " - db.backup (SQLite backup settings)", + " - web_fetch (built-in tool settings)", + "", + "See docs/config.md for the full reference.", + "", + "Press Enter to continue.", + ] + .join("\n") +} + +/// Done メッセージを構築する (`docs/setup-redesign.md §4.2 Done`)。 +/// +/// 保存先パス、バックアップパス (存在する場合のみ)、次ステップの案内、 +/// および有効化されたチャネル (Web / Discord / Telegram) の案内を含む。 +pub(crate) fn build_done_message( + inputs: &SetupInputs, + config_path: &str, + backup_path: Option<&str>, +) -> String { + let mut lines = vec![format!("Configuration saved: {config_path}")]; + + if let Some(backup) = backup_path { + lines.push(format!("Backup: {backup}")); + } + + lines.push(String::new()); + lines.push("Next steps:".into()); + lines.push(" - Start chatting now: egopulse chat".into()); + lines.push(" - Install as a systemd service: egopulse gateway install".into()); + lines.push(format!(" - Edit configuration: {config_path}")); + lines.push(" - Add more agents: edit the \"agents\" section in the YAML".into()); + + if inputs.web_enabled { + lines.push(String::new()); + lines.push("If Web UI is enabled:".into()); + lines.push(format!(" - URL: {WEB_UI_URL}")); + lines.push(" - Token: see WEB_AUTH_TOKEN in ~/.egopulse/.env".into()); + } + + if inputs.discord_enabled || inputs.telegram_enabled { + lines.push(String::new()); + lines.push("If Discord or Telegram is enabled:".into()); + lines.push(" - The bot responds to DMs out of the box.".into()); + lines + .push(" - To enable server/group responses, add channel/chat IDs to the YAML.".into()); + lines.push(" See docs/channels.md for details.".into()); + } + + lines.join("\n") +} + +/// API key 空欄時に確認ダイアログを表示すべきか判定する。 +/// +/// localhost 系 URL (Ollama / LMStudio 等) および `openai-codex` は +/// API key 不要のため `false` を返す。それ以外のリモートエンドポイントは `true`。 +pub(crate) fn should_confirm_empty_api_key(provider_id: &str, base_url: &str) -> bool { + !provider_allows_empty_api_key(provider_id, base_url) +} + +/// 指定したプロバイダー ID が preset に存在しない (Custom 扱い) か判定する。 +pub(crate) fn is_custom_provider(provider_id: &str) -> bool { + find_provider_preset(provider_id).is_none() +} + +/// モデルをフリーテキスト入力すべきか判定する。 +/// +/// Custom プロバイダーには preset models リストがないため、`is_custom_provider` と同等。 +pub(crate) fn should_ask_model_as_free_text(provider_id: &str) -> bool { + is_custom_provider(provider_id) +} + +fn enabled_label(enabled: bool) -> &'static str { + if enabled { "enabled" } else { "disabled" } +} + +struct PrefillValues { + agent_label: String, + provider_id: String, + base_url: String, + model: String, + web_enabled: bool, + discord_enabled: bool, + telegram_enabled: bool, +} + +fn extract_prefill(existing: &ExistingConfig) -> PrefillValues { + PrefillValues { + agent_label: existing + .root + .as_ref() + .and_then(root_agent_label) + .unwrap_or_default(), + provider_id: existing.fields.get("PROVIDER").cloned().unwrap_or_default(), + base_url: existing.fields.get("BASE_URL").cloned().unwrap_or_default(), + model: existing.fields.get("MODEL").cloned().unwrap_or_default(), + web_enabled: existing + .root + .as_ref() + .and_then(root_web_enabled) + .unwrap_or(true), + discord_enabled: existing + .fields + .get("DISCORD_ENABLED") + .map(|v| is_truthy(v.as_str())) + .unwrap_or(false), + telegram_enabled: existing + .fields + .get("TELEGRAM_ENABLED") + .map(|v| is_truthy(v.as_str())) + .unwrap_or(false), + } +} + +fn root_agent_label(root: &yaml_serde::Value) -> Option { + let map = root.as_mapping()?; + let default_agent = map.get(yaml_str("default_agent"))?.as_str()?; + map.get(yaml_str("agents"))? + .as_mapping()? + .get(yaml_str(default_agent))? + .as_mapping()? + .get(yaml_str("label"))? + .as_str() + .map(str::to_string) +} + +fn root_web_enabled(root: &yaml_serde::Value) -> Option { + root.as_mapping()? + .get(yaml_str("channels"))? + .as_mapping()? + .get(yaml_str("web"))? + .as_mapping()? + .get(yaml_str("enabled"))? + .as_bool() +} + +fn yaml_str(key: &str) -> yaml_serde::Value { + yaml_serde::Value::String(key.to_string()) +} + +fn is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "1" | "yes" | "on" + ) +} + +fn provider_default_index(provider_id: &str) -> usize { + if provider_id.is_empty() { + return 0; + } + PROVIDER_PRESETS + .iter() + .position(|p| p.id == provider_id) + .unwrap_or(PROVIDER_PRESETS.len()) +} + +fn provider_select_items() -> Vec { + PROVIDER_PRESETS + .iter() + .map(|p| p.label.to_string()) + .chain(std::iter::once("Custom".to_string())) + .collect() +} + +fn model_select_items(provider_id: &str) -> Vec { + find_provider_preset(provider_id) + .map(|p| p.models.iter().map(|m| m.to_string()).collect()) + .unwrap_or_default() +} + +fn load_existing( + source: &dyn PromptSource, + sink: &dyn OutputSink, + config_path: &Path, +) -> Result { + let yaml_text = match fs::read_to_string(config_path) { + Ok(text) => text, + Err(_) => { + return Ok(ExistingConfig { + fields: HashMap::new(), + root: None, + }); + } + }; + + match parse_existing_config(&yaml_text) { + Ok(config) => Ok(config), + Err(e) => { + sink.println(&format!("WARNING: {e}")); + if source + .confirm("Continue with empty defaults? (y/N)", false) + .map_err(SetupWizardError::Prompt)? + { + Ok(ExistingConfig { + fields: HashMap::new(), + root: None, + }) + } else { + Err(SetupWizardError::Aborted) + } + } + } +} + +fn collect_inputs( + source: &dyn PromptSource, + prefill: &PrefillValues, +) -> Result { + let agent_label = prompt_agent_label(source, &prefill.agent_label)?; + let (provider_id, base_url) = prompt_provider(source, &agent_label, prefill)?; + let model = prompt_model(source, &provider_id, &prefill.model)?; + let api_key = prompt_api_key(source, &provider_id, &base_url)?; + let web_enabled = prompt_web(source, prefill.web_enabled)?; + let (discord_enabled, discord_bot_token) = prompt_discord(source, prefill.discord_enabled)?; + let (telegram_enabled, telegram_bot_token) = prompt_telegram(source, prefill.telegram_enabled)?; + + Ok(SetupInputs { + agent_label, + provider_id, + base_url, + model, + api_key, + web_enabled, + discord_enabled, + discord_bot_token, + telegram_enabled, + telegram_bot_token, + }) +} + +fn prompt_agent_label( + source: &dyn PromptSource, + default: &str, +) -> Result { + let prompt = "Name your agent (e.g. Partner, Companion, Assistant):"; + let input = source + .text(prompt, default) + .map_err(SetupWizardError::Prompt)?; + let trimmed = input.trim(); + if trimmed.is_empty() { + if default.is_empty() { + Ok("Default".to_string()) + } else { + Ok(default.to_string()) + } + } else { + Ok(trimmed.to_string()) + } +} + +fn prompt_provider( + source: &dyn PromptSource, + agent_label: &str, + prefill: &PrefillValues, +) -> Result<(String, String), SetupWizardError> { + let items = provider_select_items(); + let label = format!("Choose the LLM provider for {agent_label}:"); + let default_idx = provider_default_index(&prefill.provider_id); + let idx = source + .select(&label, &items, default_idx) + .map_err(SetupWizardError::Prompt)?; + + if idx >= PROVIDER_PRESETS.len() { + let url = source + .text( + "Enter the base_url (e.g. https://api.example.com/v1):", + &prefill.base_url, + ) + .map_err(SetupWizardError::Prompt)?; + Ok(("custom".to_string(), url.trim().to_string())) + } else { + let preset = &PROVIDER_PRESETS[idx]; + Ok((preset.id.to_string(), preset.default_base_url.to_string())) + } +} + +fn prompt_model( + source: &dyn PromptSource, + provider_id: &str, + default: &str, +) -> Result { + if should_ask_model_as_free_text(provider_id) { + let input = source + .text( + "Enter the model name (e.g. gpt-4o, claude-3-opus):", + default, + ) + .map_err(SetupWizardError::Prompt)?; + let trimmed = input.trim(); + if trimmed.is_empty() && !default.is_empty() { + Ok(default.to_string()) + } else { + Ok(trimmed.to_string()) + } + } else { + let items = model_select_items(provider_id); + if items.is_empty() { + let input = source + .text("Enter the model name (e.g. gpt-4o):", default) + .map_err(SetupWizardError::Prompt)?; + return Ok(input.trim().to_string()); + } + let default_idx = items.iter().position(|m| m == default).unwrap_or(0); + let idx = source + .select("Choose the model to use:", &items, default_idx) + .map_err(SetupWizardError::Prompt)?; + Ok(items[idx].clone()) + } +} + +fn prompt_api_key( + source: &dyn PromptSource, + provider_id: &str, + base_url: &str, +) -> Result { + let provider_label = provider_label_for(provider_id); + let prompt = format!( + "Enter the API key for {provider_label} (input is hidden).\n\ + For local endpoints (Ollama/LMStudio), leave it empty and press Enter:" + ); + loop { + let key = source.password(&prompt).map_err(SetupWizardError::Prompt)?; + if key.trim().is_empty() && should_confirm_empty_api_key(provider_id, base_url) { + let confirm = format!( + "WARNING: {provider_label} usually requires an API key. \ + Proceed with an empty key? (y/N)" + ); + if !source + .confirm(&confirm, false) + .map_err(SetupWizardError::Prompt)? + { + continue; + } + } + return Ok(key); + } +} + +fn prompt_web(source: &dyn PromptSource, default: bool) -> Result { + source + .confirm( + &format!( + "Enable the Web UI? (Y/n)\n\ + You can access it at {WEB_UI_URL} from your browser." + ), + default, + ) + .map_err(SetupWizardError::Prompt) +} + +fn prompt_discord( + source: &dyn PromptSource, + default: bool, +) -> Result<(bool, String), SetupWizardError> { + let enabled = source + .confirm("Configure a Discord bot? (y/N)", default) + .map_err(SetupWizardError::Prompt)?; + if enabled { + let token = source + .password("Enter the Discord bot token (input is hidden):") + .map_err(SetupWizardError::Prompt)?; + Ok((true, token)) + } else { + Ok((false, String::new())) + } +} + +fn prompt_telegram( + source: &dyn PromptSource, + default: bool, +) -> Result<(bool, String), SetupWizardError> { + let enabled = source + .confirm("Configure a Telegram bot? (y/N)", default) + .map_err(SetupWizardError::Prompt)?; + if enabled { + let token = source + .password("Enter the Telegram bot token (input is hidden):") + .map_err(SetupWizardError::Prompt)?; + Ok((true, token)) + } else { + Ok((false, String::new())) + } +} + +fn save_and_finish( + sink: &dyn OutputSink, + inputs: &SetupInputs, + existing: &ExistingConfig, + config_path: &Path, +) -> Result<(), SetupWizardError> { + let (backup_path, _summary) = + save_config(inputs, existing.root.as_ref(), config_path).map_err(SetupWizardError::Save)?; + + sink.println(&build_additional_options_text()); + let path_str = config_path.to_string_lossy(); + sink.println(&build_done_message( + inputs, + &path_str, + backup_path.as_deref(), + )); + + Ok(()) +} + +/// trait 抽象化されたプロンプト/シンクを用いて wizard フロー全体を実行する。 +/// +/// Welcome → Q1〜Q7 → Review → Save → Additional Options → Done の順次制御を行う。 +/// Review で no 選択時は StartOver / Abort / SaveAnyway の3択へ分岐する。 +/// 既存 YAML のパースエラー時は warn 表示 + Y/N 確認を行う。 +/// +/// # Errors +/// +/// - ユーザーが Abort を選択した場合 +/// - 既存 YAML パースエラー時にユーザーが N を選択した場合 +/// - プロンプト入力エラー +/// - 設定保存エラー +pub(crate) fn run_with_source_and_sink( + source: &dyn PromptSource, + sink: &dyn OutputSink, + config_path: Option, +) -> Result<(), SetupWizardError> { + let resolved_path = match config_path { + Some(path) => path, + None => { + default_config_path().map_err(|e| SetupWizardError::ConfigResolve(e.to_string()))? + } + }; + + sink.println("Welcome to EgoPulse setup."); + sink.println("Answer a few questions to configure the minimum settings to run your AI agent."); + + let existing = load_existing(source, sink, &resolved_path)?; + let prefill = extract_prefill(&existing); + + loop { + let inputs = collect_inputs(source, &prefill)?; + + if let Err(message) = validate_inputs(&inputs) { + sink.println(&format!("Invalid input: {message}")); + sink.println("Please answer the questions again."); + continue; + } + + sink.println(&build_review_summary(&inputs)); + + if source + .confirm("Save? (Y/n)", true) + .map_err(SetupWizardError::Prompt)? + { + return save_and_finish(sink, &inputs, &existing, &resolved_path); + } + + let choices = vec![ + "Start over (back to Agent Label)".to_string(), + "Abort (exit without saving)".to_string(), + "Save anyway".to_string(), + ]; + let idx = source + .select("What would you like to do?", &choices, 0) + .map_err(SetupWizardError::Prompt)?; + match review_decision_from_index(idx) { + ReviewDecision::StartOver => continue, + ReviewDecision::Abort => { + sink.println("Setup aborted. No configuration was saved."); + return Err(SetupWizardError::Aborted); + } + ReviewDecision::SaveAnyway => { + return save_and_finish(sink, &inputs, &existing, &resolved_path); + } + } + } +} + +/// dialoguer ベースのプロンプト/シンクを使用して wizard を実行する thin wrapper。 +/// +/// # Errors +/// +/// [`run_with_source_and_sink`] に準ずる。 +pub(crate) fn run(config_path: Option) -> Result<(), SetupWizardError> { + run_with_source_and_sink( + &DialoguerPromptSource::new(), + &DialoguerOutputSink::new(), + config_path, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_inputs() -> SetupInputs { + SetupInputs { + agent_label: "Partner".into(), + provider_id: "openai".into(), + base_url: "https://api.openai.com/v1".into(), + model: "gpt-5.2".into(), + api_key: "sk-test-key-1234".into(), + web_enabled: true, + discord_enabled: false, + discord_bot_token: String::new(), + telegram_enabled: false, + telegram_bot_token: String::new(), + } + } + + #[test] + fn review_decision_from_index_maps_correctly() { + assert_eq!(review_decision_from_index(0), ReviewDecision::StartOver); + assert_eq!(review_decision_from_index(1), ReviewDecision::Abort); + assert_eq!(review_decision_from_index(2), ReviewDecision::SaveAnyway); + } + + #[test] + fn build_review_summary_renders_all_fields() { + let inputs = sample_inputs(); + let summary = build_review_summary(&inputs); + let agent_id = slugify_agent_id(&inputs.agent_label); + let masked_key = format_api_key_for_review(&inputs.api_key); + + assert!(summary.contains(&inputs.agent_label)); + assert!(summary.contains(&agent_id)); + assert!(summary.contains(&inputs.provider_id)); + assert!(summary.contains(&inputs.base_url)); + assert!(summary.contains(&inputs.model)); + assert!(summary.contains(&masked_key)); + assert!(summary.contains("Web:")); + assert!(summary.contains("Discord:")); + assert!(summary.contains("Telegram:")); + } + + #[test] + fn build_additional_options_text_includes_all_categories() { + let text = build_additional_options_text(); + + assert!(text.contains("System:")); + assert!(text.contains("Web UI:")); + assert!(text.contains("Channels:")); + assert!(text.contains("Subsystems:")); + } + + #[test] + fn build_done_message_includes_next_steps_and_channel_hints() { + let inputs = sample_inputs(); + let config_path = "~/.egopulse/egopulse.config.yaml"; + let message = build_done_message(&inputs, config_path, None); + + assert!(message.contains(config_path)); + assert!(message.contains("egopulse chat")); + assert!(message.contains("egopulse gateway install")); + assert!(message.contains("agents")); + assert!(message.contains(WEB_UI_URL)); + assert!(!message.contains("Backup:")); + + let backup_message = build_done_message(&inputs, config_path, Some("~/backup.yaml")); + assert!(backup_message.contains("Backup:")); + assert!(backup_message.contains("~/backup.yaml")); + + let mut discord_inputs = sample_inputs(); + discord_inputs.discord_enabled = true; + let discord_message = build_done_message(&discord_inputs, config_path, None); + assert!(discord_message.contains("docs/channels.md")); + } + + #[test] + fn should_confirm_empty_api_key_returns_false_for_localhost() { + assert!(!should_confirm_empty_api_key( + "ollama", + "http://127.0.0.1:11434/v1" + )); + assert!(!should_confirm_empty_api_key( + "lmstudio", + "http://localhost:1234/v1" + )); + } + + #[test] + fn should_confirm_empty_api_key_returns_true_for_remote() { + assert!(should_confirm_empty_api_key( + "openai", + "https://api.openai.com/v1" + )); + assert!(should_confirm_empty_api_key( + "deepseek", + "https://api.deepseek.com/v1" + )); + } + + #[test] + fn is_custom_provider_returns_true_only_for_custom() { + assert!(is_custom_provider("custom")); + assert!(!is_custom_provider("openai")); + assert!(!is_custom_provider("ollama")); + } + + #[test] + fn should_ask_model_as_free_text_returns_true_only_for_custom() { + assert!(should_ask_model_as_free_text("custom")); + assert!(!should_ask_model_as_free_text("openai")); + assert!(!should_ask_model_as_free_text("deepseek")); + } + + use crate::setup::prompts::test_mocks::{MockPromptSource, VecOutputSink}; + use serial_test::serial; + + /// Ollama is at index 3 in PROVIDER_PRESETS. + const OLLAMA_INDEX: usize = 3; + + fn setup_happy_path(source: &MockPromptSource) { + source + .expect_text("Name your agent", "Partner") + .expect_select("Choose the LLM provider", OLLAMA_INDEX) + .expect_select("Choose the model", 0) + .expect_password("API key", "") + .expect_confirm("Web UI", true) + .expect_confirm("Discord", false) + .expect_confirm("Telegram", false); + } + + fn assert_config_saved(config_path: &std::path::Path) { + assert!(config_path.exists(), "config file must be created"); + let content = std::fs::read_to_string(config_path).expect("read saved config"); + assert!( + content.contains("default_provider"), + "saved config must contain default_provider" + ); + } + + #[test] + #[serial] + fn prefill_defaults_uses_existing_config_values() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + std::fs::write( + &config_path, + "default_provider: ollama\n\ + default_agent: partner\n\ + providers:\n\ + \x20 ollama:\n\ + \x20 label: Ollama\n\ + \x20 base_url: http://127.0.0.1:11434/v1\n\ + \x20 default_model: llama3.2\n\ + agents:\n\ + \x20 partner:\n\ + \x20 label: Partner\n\ + channels:\n\ + \x20 web:\n\ + \x20 enabled: true\n\ + \x20 discord:\n\ + \x20 enabled: false\n\ + \x20 telegram:\n\ + \x20 enabled: false\n", + ) + .expect("write existing config"); + + let source = MockPromptSource::new(); + let sink = VecOutputSink::new(); + + setup_happy_path(&source); + source.expect_confirm("Save?", true); + + run_with_source_and_sink(&source, &sink, Some(config_path.clone())) + .expect("wizard should succeed"); + + let text_defs = source.text_defaults(); + let label_default = text_defs + .iter() + .find(|(l, _)| l.contains("Name your agent")) + .map(|(_, d)| d.clone()) + .expect("agent label text default must be recorded"); + assert_eq!(label_default, "Partner"); + + let confirm_defs = source.confirm_defaults(); + let web_default = confirm_defs + .iter() + .find(|(l, _)| l.contains("Web UI")) + .map(|(_, d)| *d) + .expect("web confirm default must be recorded"); + assert!(web_default); + + let select_defs = source.select_defaults(); + let provider_default = select_defs + .iter() + .find(|(l, _)| l.contains("Choose the LLM provider")) + .map(|(_, d)| *d) + .expect("provider select default must be recorded"); + assert_eq!( + provider_default, OLLAMA_INDEX, + "provider select default must point at the existing provider preset" + ); + + assert_config_saved(&config_path); + } + + #[test] + #[serial] + fn wizard_review_startover_returns_to_q1() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let source = MockPromptSource::new(); + let sink = VecOutputSink::new(); + + // Round 1: review -> no -> StartOver + setup_happy_path(&source); + source + .expect_confirm("Save?", false) + .expect_select("What would you like to do?", 0); + + // Round 2: review -> yes -> save + setup_happy_path(&source); + source.expect_confirm("Save?", true); + + run_with_source_and_sink(&source, &sink, Some(config_path.clone())) + .expect("wizard should succeed after StartOver loop"); + + assert_config_saved(&config_path); + } + + #[test] + #[serial] + fn wizard_review_abort_exits_without_save() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let source = MockPromptSource::new(); + let sink = VecOutputSink::new(); + + setup_happy_path(&source); + source + .expect_confirm("Save?", false) + .expect_select("What would you like to do?", 1); + + let result = run_with_source_and_sink(&source, &sink, Some(config_path.clone())); + + assert!(result.is_err(), "Abort must return Err"); + assert!( + !config_path.exists(), + "config file must NOT be created on Abort" + ); + } + + #[test] + #[serial] + fn wizard_review_save_anyway_writes_config() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let source = MockPromptSource::new(); + let sink = VecOutputSink::new(); + + setup_happy_path(&source); + source + .expect_confirm("Save?", false) + .expect_select("What would you like to do?", 2); + + run_with_source_and_sink(&source, &sink, Some(config_path.clone())) + .expect("wizard should succeed via SaveAnyway"); + + assert_config_saved(&config_path); + } + + #[test] + #[serial] + fn wizard_review_yes_saves_directly() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + + let source = MockPromptSource::new(); + let sink = VecOutputSink::new(); + + setup_happy_path(&source); + source.expect_confirm("Save?", true); + + run_with_source_and_sink(&source, &sink, Some(config_path.clone())) + .expect("wizard should succeed on direct yes"); + + assert_config_saved(&config_path); + + let output = sink.joined(); + assert!( + output.contains("Configuration saved"), + "Done message must appear in output" + ); + } + + #[test] + #[serial] + fn wizard_parse_error_decline_aborts() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + std::fs::write(&config_path, "default_provider: [unclosed").expect("write broken yaml"); + + let source = MockPromptSource::new(); + let sink = VecOutputSink::new(); + + source.expect_confirm("Continue with empty defaults", false); + + let result = run_with_source_and_sink(&source, &sink, Some(config_path.clone())); + + assert!(result.is_err(), "decline on parse error must return Err"); + assert_eq!( + std::fs::read_to_string(&config_path).unwrap(), + "default_provider: [unclosed", + "original broken config must remain untouched" + ); + + let output = sink.joined(); + assert!( + output.contains("WARNING"), + "warning must be displayed on parse error" + ); + } + + #[test] + #[serial] + fn wizard_parse_error_accept_continues() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config_path = temp_dir.path().join("egopulse.config.yaml"); + std::fs::write(&config_path, "default_provider: [unclosed").expect("write broken yaml"); + + let source = MockPromptSource::new(); + let sink = VecOutputSink::new(); + + source.expect_confirm("Continue with empty defaults", true); + setup_happy_path(&source); + source.expect_confirm("Save?", true); + + run_with_source_and_sink(&source, &sink, Some(config_path.clone())) + .expect("wizard should continue after accepting parse error"); + + assert_config_saved(&config_path); + } +}