@pheem49/mint 1.5.5 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/.codex +0 -0
  2. package/.github/FUNDING.yml +2 -0
  3. package/.github/workflows/ci.yml +45 -0
  4. package/.github/workflows/release.yml +79 -0
  5. package/Cargo.lock +5792 -0
  6. package/Cargo.toml +32 -0
  7. package/README.md +387 -353
  8. package/assets/icon.png +0 -0
  9. package/bin/mint +0 -0
  10. package/crates/mint-cli/Cargo.toml +23 -0
  11. package/crates/mint-cli/src/agent.rs +851 -0
  12. package/crates/mint-cli/src/gmail.rs +216 -0
  13. package/crates/mint-cli/src/image.rs +142 -0
  14. package/crates/mint-cli/src/main.rs +2837 -0
  15. package/crates/mint-cli/src/mcp.rs +63 -0
  16. package/crates/mint-cli/src/onboard.rs +1149 -0
  17. package/crates/mint-cli/src/setup.rs +390 -0
  18. package/crates/mint-cli/src/skills.rs +8 -0
  19. package/crates/mint-cli/src/updater.rs +279 -0
  20. package/crates/mint-core/Cargo.toml +22 -0
  21. package/crates/mint-core/src/agent_loop.rs +94 -0
  22. package/crates/mint-core/src/api_server.rs +991 -0
  23. package/crates/mint-core/src/channels.rs +248 -0
  24. package/crates/mint-core/src/chat.rs +895 -0
  25. package/crates/mint-core/src/code_tools.rs +729 -0
  26. package/crates/mint-core/src/config.rs +368 -0
  27. package/crates/mint-core/src/files.rs +159 -0
  28. package/crates/mint-core/src/knowledge.rs +541 -0
  29. package/crates/mint-core/src/lib.rs +84 -0
  30. package/crates/mint-core/src/mcp.rs +273 -0
  31. package/crates/mint-core/src/memory.rs +673 -0
  32. package/crates/mint-core/src/orchestration.rs +2157 -0
  33. package/crates/mint-core/src/pictures.rs +314 -0
  34. package/crates/mint-core/src/plugins.rs +727 -0
  35. package/crates/mint-core/src/safety.rs +416 -0
  36. package/crates/mint-core/src/semantic.rs +254 -0
  37. package/crates/mint-core/src/shell.rs +317 -0
  38. package/crates/mint-core/src/skills.rs +71 -0
  39. package/crates/mint-core/src/symbols.rs +157 -0
  40. package/crates/mint-core/src/tasks.rs +308 -0
  41. package/crates/mint-core/src/tts.rs +92 -0
  42. package/crates/mint-core/src/weather.rs +93 -0
  43. package/crates/mint-core/src/web_search.rs +200 -0
  44. package/crates/mint-core/src/workflows.rs +81 -0
  45. package/crates/mint-core/tests/mcp_stdio.rs +45 -0
  46. package/crates/mint-core/tests/memory_persistence.rs +172 -0
  47. package/crates/mint-core/tests/pictures_storage.rs +14 -0
  48. package/crates/mint-core/tests/task_lifecycle.rs +87 -0
  49. package/package.json +35 -99
  50. package/src/bin/index.js +16 -0
  51. package/src/renderer/index-web.html +17 -0
  52. package/src/renderer/index.html +17 -0
  53. package/src/renderer/public/Live2DCubismCore.js +9 -0
  54. package/src/renderer/public/assets/icon.png +0 -0
  55. package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
  56. package/src/renderer/src/App.tsx +33 -0
  57. package/src/renderer/src/calculator.ts +47 -0
  58. package/src/renderer/src/components/ChatPanel.tsx +1598 -0
  59. package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
  60. package/src/renderer/src/components/Live2DStage.tsx +374 -0
  61. package/src/renderer/src/components/MintDashboard.tsx +950 -0
  62. package/src/renderer/src/components/ModelPanel.tsx +154 -0
  63. package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
  64. package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
  65. package/src/renderer/src/components/ScreenPicker.tsx +579 -0
  66. package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
  67. package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
  68. package/src/renderer/src/components/WidgetWindow.tsx +36 -0
  69. package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
  70. package/src/{UI → renderer/src/css}/settings.css +69 -16
  71. package/src/renderer/src/css/spotlight.css +113 -0
  72. package/src/renderer/src/css/styles.css +3722 -0
  73. package/src/renderer/src/css/widget.css +185 -0
  74. package/src/renderer/src/env.d.ts +116 -0
  75. package/src/renderer/src/index.css +379 -0
  76. package/src/renderer/src/main.tsx +13 -0
  77. package/src/renderer/src/tauri.ts +996 -0
  78. package/src/renderer/src-web/App.tsx +25 -0
  79. package/src/renderer/src-web/calculator.ts +47 -0
  80. package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
  81. package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
  82. package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
  83. package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
  84. package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
  85. package/src/renderer/src-web/css/settings.css +1100 -0
  86. package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
  87. package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
  88. package/src/{UI → renderer/src-web/css}/widget.css +2 -2
  89. package/src/renderer/src-web/env.d.ts +107 -0
  90. package/src/renderer/src-web/index.css +379 -0
  91. package/src/renderer/src-web/main.tsx +13 -0
  92. package/src/renderer/src-web/tauri.ts +983 -0
  93. package/tsconfig.json +30 -0
  94. package/vite.config.ts +33 -0
  95. package/vite.config.web.ts +51 -0
  96. package/GUIDE_TH.md +0 -125
  97. package/assets/Agent_Mint.png +0 -0
  98. package/assets/CLI_Screen.png +0 -0
  99. package/assets/Settings.png +0 -0
  100. package/benchmark_ai.js +0 -71
  101. package/install.ps1 +0 -64
  102. package/install.sh +0 -54
  103. package/main.js +0 -139
  104. package/mint-cli-logic.js +0 -3
  105. package/mint-cli.js +0 -410
  106. package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
  107. package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
  108. package/preload-picker.js +0 -11
  109. package/preload-settings.js +0 -11
  110. package/preload.js +0 -41
  111. package/scripts/install_linux_desktop_entry.js +0 -48
  112. package/src/AI_Brain/Gemini_API.js +0 -813
  113. package/src/AI_Brain/agent_orchestrator.js +0 -73
  114. package/src/AI_Brain/autonomous_brain.js +0 -179
  115. package/src/AI_Brain/behavior_memory.js +0 -135
  116. package/src/AI_Brain/headless_agent.js +0 -143
  117. package/src/AI_Brain/knowledge_base.js +0 -349
  118. package/src/AI_Brain/memory_store.js +0 -662
  119. package/src/AI_Brain/proactive_engine.js +0 -172
  120. package/src/AI_Brain/provider_adapter.js +0 -365
  121. package/src/Automation_Layer/browser_automation.js +0 -149
  122. package/src/Automation_Layer/file_operations.js +0 -286
  123. package/src/Automation_Layer/open_app.js +0 -85
  124. package/src/Automation_Layer/open_website.js +0 -38
  125. package/src/CLI/approval_handler.js +0 -47
  126. package/src/CLI/chat_router.js +0 -247
  127. package/src/CLI/chat_ui.js +0 -1159
  128. package/src/CLI/cli_colors.js +0 -115
  129. package/src/CLI/cli_formatters.js +0 -94
  130. package/src/CLI/code_agent.js +0 -1667
  131. package/src/CLI/code_session_memory.js +0 -62
  132. package/src/CLI/gmail_auth.js +0 -210
  133. package/src/CLI/image_input.js +0 -90
  134. package/src/CLI/intent_detectors.js +0 -181
  135. package/src/CLI/interactive_chat.js +0 -658
  136. package/src/CLI/list_features.js +0 -64
  137. package/src/CLI/onboarding.js +0 -416
  138. package/src/CLI/repo_summarizer.js +0 -282
  139. package/src/CLI/semantic_code_search.js +0 -312
  140. package/src/CLI/skill_manager.js +0 -41
  141. package/src/CLI/slash_command_handler.js +0 -418
  142. package/src/CLI/symbol_indexer.js +0 -231
  143. package/src/CLI/updater.js +0 -230
  144. package/src/CLI/workspace_manager.js +0 -90
  145. package/src/Channels/brave_search_bridge.js +0 -35
  146. package/src/Channels/discord_bridge.js +0 -66
  147. package/src/Channels/google_search_bridge.js +0 -38
  148. package/src/Channels/line_bridge.js +0 -60
  149. package/src/Channels/slack_bridge.js +0 -48
  150. package/src/Channels/telegram_bridge.js +0 -41
  151. package/src/Channels/whatsapp_bridge.js +0 -57
  152. package/src/Command_Parser/parser.js +0 -45
  153. package/src/Plugins/dev_tools.js +0 -41
  154. package/src/Plugins/discord.js +0 -20
  155. package/src/Plugins/docker.js +0 -47
  156. package/src/Plugins/gmail.js +0 -251
  157. package/src/Plugins/google_calendar.js +0 -252
  158. package/src/Plugins/mcp_manager.js +0 -95
  159. package/src/Plugins/notion.js +0 -256
  160. package/src/Plugins/obsidian.js +0 -54
  161. package/src/Plugins/plugin_manager.js +0 -81
  162. package/src/Plugins/spotify.js +0 -173
  163. package/src/Plugins/system_metrics.js +0 -31
  164. package/src/Plugins/system_monitor.js +0 -72
  165. package/src/System/action_executor.js +0 -178
  166. package/src/System/bridge_manager.js +0 -76
  167. package/src/System/chat_history_manager.js +0 -83
  168. package/src/System/config_manager.js +0 -194
  169. package/src/System/custom_workflows.js +0 -163
  170. package/src/System/daemon_manager.js +0 -67
  171. package/src/System/google_tts_urls.js +0 -51
  172. package/src/System/granular_automation.js +0 -157
  173. package/src/System/ipc_handlers.js +0 -332
  174. package/src/System/notifications.js +0 -23
  175. package/src/System/optional_require.js +0 -23
  176. package/src/System/picture_store.js +0 -109
  177. package/src/System/proactive_loop.js +0 -153
  178. package/src/System/safety_manager.js +0 -273
  179. package/src/System/sandbox_runner.js +0 -182
  180. package/src/System/screen_capture.js +0 -175
  181. package/src/System/smart_context.js +0 -227
  182. package/src/System/system_automation.js +0 -162
  183. package/src/System/system_events.js +0 -79
  184. package/src/System/system_info.js +0 -125
  185. package/src/System/task_manager.js +0 -222
  186. package/src/System/tool_registry.js +0 -293
  187. package/src/System/window_manager.js +0 -220
  188. package/src/UI/floating.css +0 -80
  189. package/src/UI/floating.html +0 -17
  190. package/src/UI/floating.js +0 -67
  191. package/src/UI/live2d_manager.js +0 -600
  192. package/src/UI/preload-floating.js +0 -7
  193. package/src/UI/preload-spotlight.js +0 -11
  194. package/src/UI/preload-widget.js +0 -5
  195. package/src/UI/proactive-glow.html +0 -42
  196. package/src/UI/renderer.js +0 -2127
  197. package/src/UI/screenPicker.html +0 -214
  198. package/src/UI/screenPicker.js +0 -262
  199. package/src/UI/settings.html +0 -577
  200. package/src/UI/settings.js +0 -770
  201. package/src/UI/spotlight.html +0 -23
  202. package/src/UI/spotlight.js +0 -185
  203. package/src/UI/widget.html +0 -29
  204. package/src/UI/widget.js +0 -10
  205. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
  206. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
  207. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
  208. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
  209. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
  210. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
  211. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
  212. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
  213. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
  214. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
  215. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
  216. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
  217. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
  218. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
  219. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
  220. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
  221. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
  222. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
@@ -0,0 +1,1149 @@
1
+ use anyhow::{Result, bail};
2
+ use crossterm::event::{self, Event, KeyCode};
3
+ use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
4
+ use mint_core::{load_config, save_config};
5
+ use std::io::{self, Write};
6
+ use std::net::TcpStream;
7
+ use std::process::Command;
8
+
9
+ struct OnboardService {
10
+ category: &'static str,
11
+ name: &'static str,
12
+ key: &'static str,
13
+ enabled: bool,
14
+ }
15
+
16
+ const GEMINI_MODEL_PRESETS: &[&str] = &[
17
+ "gemini-2.5-flash",
18
+ "gemini-2.5-pro",
19
+ "gemini-2.0-flash",
20
+ "gemini-2.0-flash-lite",
21
+ ];
22
+
23
+ const ANTHROPIC_MODEL_PRESETS: &[&str] = &[
24
+ "claude-sonnet-4-20250514",
25
+ "claude-opus-4-20250514",
26
+ "claude-haiku-35-20241022",
27
+ ];
28
+
29
+ const OPENAI_MODEL_PRESETS: &[&str] = &[
30
+ "gpt-4.1",
31
+ "gpt-4.1-mini",
32
+ "gpt-4.1-nano",
33
+ "gpt-4o",
34
+ "gpt-4o-mini",
35
+ "o3",
36
+ "o4-mini",
37
+ ];
38
+
39
+ const OPENROUTER_MODEL_PRESETS: &[&str] = &[
40
+ "openai/gpt-4o-mini",
41
+ "openai/gpt-4o",
42
+ "anthropic/claude-sonnet-4",
43
+ "anthropic/claude-haiku-3.5",
44
+ "google/gemini-2.5-flash",
45
+ "meta-llama/llama-3.3-70b-instruct",
46
+ "mistralai/mistral-large",
47
+ ];
48
+
49
+ const DEEPSEEK_MODEL_PRESETS: &[&str] = &[
50
+ "deepseek-v4-flash",
51
+ "deepseek-v4-pro",
52
+ "deepseek-chat",
53
+ "deepseek-reasoner",
54
+ ];
55
+
56
+ const HUGGINGFACE_MODEL_PRESETS: &[&str] = &[
57
+ "meta-llama/Llama-3.3-70B-Instruct",
58
+ "Qwen/Qwen3-235B-A22B",
59
+ "mistralai/Mistral-Small-24B-Instruct-2501",
60
+ "google/gemma-3-27b-it",
61
+ ];
62
+
63
+ pub async fn run() -> Result<()> {
64
+ let mut config = load_config()?;
65
+
66
+ println!("\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m");
67
+ println!("\x1b[32m Mint CLI Onboarding Wizard\x1b[0m");
68
+ println!("\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m");
69
+ println!("Welcome to Mint! Let's get your workspace configured.");
70
+ println!();
71
+
72
+ // ────────────────────────────────────────────────────────────────
73
+ // Step 1: Core AI Activation (Gemini)
74
+ // ────────────────────────────────────────────────────────────────
75
+ println!("\x1b[33mStep 1: Core AI Activation (Gemini)\x1b[0m");
76
+ println!("Mint is powered primarily by Google Gemini.");
77
+ config.api_key = prompt_sensitive("Gemini API Key", &config.api_key)?;
78
+ config.gemini_model = prompt_select_or_custom(
79
+ "Gemini Model",
80
+ static_model_options(GEMINI_MODEL_PRESETS),
81
+ Some(&config.gemini_model),
82
+ "Custom Gemini model...",
83
+ )?;
84
+ println!();
85
+
86
+ // ────────────────────────────────────────────────────────────────
87
+ // Step 2: QuickStart Provider Selection
88
+ // ────────────────────────────────────────────────────────────────
89
+ let mut services = vec![
90
+ OnboardService {
91
+ category: "AI Providers",
92
+ name: "Anthropic (Claude) API",
93
+ key: "anthropic",
94
+ enabled: !config.anthropic_api_key.is_empty(),
95
+ },
96
+ OnboardService {
97
+ category: "AI Providers",
98
+ name: "OpenAI API",
99
+ key: "openai",
100
+ enabled: !config.openai_api_key.is_empty(),
101
+ },
102
+ OnboardService {
103
+ category: "AI Providers",
104
+ name: "OpenRouter API",
105
+ key: "openrouter",
106
+ enabled: !config.openrouter_api_key.is_empty(),
107
+ },
108
+ OnboardService {
109
+ category: "AI Providers",
110
+ name: "DeepSeek API",
111
+ key: "deepseek",
112
+ enabled: !config.deepseek_api_key.is_empty(),
113
+ },
114
+ OnboardService {
115
+ category: "AI Providers",
116
+ name: "Hugging Face API",
117
+ key: "huggingface",
118
+ enabled: !config.hf_api_key.is_empty(),
119
+ },
120
+ OnboardService {
121
+ category: "AI Providers",
122
+ name: "Local OpenAI (e.g. LM Studio)",
123
+ key: "local_openai",
124
+ enabled: !config.local_api_base_url.is_empty(),
125
+ },
126
+ OnboardService {
127
+ category: "AI Providers",
128
+ name: "Ollama",
129
+ key: "ollama",
130
+ enabled: !config.ollama_model.is_empty(),
131
+ },
132
+ OnboardService {
133
+ category: "Search",
134
+ name: "Google Search API",
135
+ key: "google_search",
136
+ enabled: !config
137
+ .extra
138
+ .get("googleSearchApiKey")
139
+ .and_then(|v| v.as_str())
140
+ .unwrap_or("")
141
+ .is_empty(),
142
+ },
143
+ OnboardService {
144
+ category: "Search",
145
+ name: "Brave Search API",
146
+ key: "brave_search",
147
+ enabled: !config
148
+ .extra
149
+ .get("braveSearchApiKey")
150
+ .and_then(|v| v.as_str())
151
+ .unwrap_or("")
152
+ .is_empty(),
153
+ },
154
+ OnboardService {
155
+ category: "Messaging Bridges",
156
+ name: "Telegram Bot Bridge",
157
+ key: "telegram",
158
+ enabled: config
159
+ .extra
160
+ .get("enableTelegramBridge")
161
+ .and_then(|v| v.as_bool())
162
+ .unwrap_or(false),
163
+ },
164
+ OnboardService {
165
+ category: "Messaging Bridges",
166
+ name: "Discord Bot Bridge",
167
+ key: "discord",
168
+ enabled: config
169
+ .extra
170
+ .get("enableDiscordBridge")
171
+ .and_then(|v| v.as_bool())
172
+ .unwrap_or(false),
173
+ },
174
+ OnboardService {
175
+ category: "Messaging Bridges",
176
+ name: "Slack Bot Bridge",
177
+ key: "slack",
178
+ enabled: config
179
+ .extra
180
+ .get("enableSlackBridge")
181
+ .and_then(|v| v.as_bool())
182
+ .unwrap_or(false),
183
+ },
184
+ OnboardService {
185
+ category: "Messaging Bridges",
186
+ name: "LINE Bot Bridge",
187
+ key: "line",
188
+ enabled: config
189
+ .extra
190
+ .get("enableLineBridge")
191
+ .and_then(|v| v.as_bool())
192
+ .unwrap_or(false),
193
+ },
194
+ OnboardService {
195
+ category: "Messaging Bridges",
196
+ name: "WhatsApp Cloud Bridge",
197
+ key: "whatsapp",
198
+ enabled: config
199
+ .extra
200
+ .get("enableWhatsappBridge")
201
+ .and_then(|v| v.as_bool())
202
+ .unwrap_or(false),
203
+ },
204
+ OnboardService {
205
+ category: "Productivity",
206
+ name: "Gmail Plugin",
207
+ key: "gmail",
208
+ enabled: config
209
+ .extra
210
+ .get("pluginGmailEnabled")
211
+ .and_then(|v| v.as_bool())
212
+ .unwrap_or(false),
213
+ },
214
+ OnboardService {
215
+ category: "Productivity",
216
+ name: "Google Calendar Plugin",
217
+ key: "calendar",
218
+ enabled: config
219
+ .extra
220
+ .get("pluginCalendarEnabled")
221
+ .and_then(|v| v.as_bool())
222
+ .unwrap_or(false),
223
+ },
224
+ OnboardService {
225
+ category: "Productivity",
226
+ name: "Notion Plugin",
227
+ key: "notion",
228
+ enabled: config
229
+ .extra
230
+ .get("pluginNotionEnabled")
231
+ .and_then(|v| v.as_bool())
232
+ .unwrap_or(false),
233
+ },
234
+ ];
235
+
236
+ let mut cursor = 0;
237
+ println!("\x1b[33mStep 2: QuickStart Provider Selection\x1b[0m");
238
+ println!("Select which plugins or bridges you would like to configure:");
239
+ println!(
240
+ " \x1b[90m[Keyboard Controls: ↑/↓: Navigate | Space: Toggle | a: All | i: Invert | Enter: Confirm]\x1b[0m"
241
+ );
242
+ println!();
243
+
244
+ print_services(&services, cursor);
245
+ enable_raw_mode()?;
246
+
247
+ loop {
248
+ match event::poll(std::time::Duration::from_millis(100)) {
249
+ Ok(true) => {
250
+ if let Event::Key(key_event) = event::read()? {
251
+ if key_event.kind == event::KeyEventKind::Press {
252
+ let is_ctrl_c = matches!(key_event.code, KeyCode::Char('c'))
253
+ && key_event
254
+ .modifiers
255
+ .contains(crossterm::event::KeyModifiers::CONTROL);
256
+ if is_ctrl_c {
257
+ disable_raw_mode()?;
258
+ println!("\n\x1b[31mOnboarding cancelled.\x1b[0m");
259
+ return Ok(());
260
+ }
261
+
262
+ match key_event.code {
263
+ KeyCode::Up => {
264
+ if cursor > 0 {
265
+ cursor -= 1;
266
+ } else {
267
+ cursor = services.len() - 1;
268
+ }
269
+ disable_raw_mode()?;
270
+ print!("\x1b[{}A\x1b[J", service_display_lines(&services));
271
+ print_services(&services, cursor);
272
+ enable_raw_mode()?;
273
+ }
274
+ KeyCode::Down => {
275
+ if cursor < services.len() - 1 {
276
+ cursor += 1;
277
+ } else {
278
+ cursor = 0;
279
+ }
280
+ disable_raw_mode()?;
281
+ print!("\x1b[{}A\x1b[J", service_display_lines(&services));
282
+ print_services(&services, cursor);
283
+ enable_raw_mode()?;
284
+ }
285
+ KeyCode::Char(' ') => {
286
+ services[cursor].enabled = !services[cursor].enabled;
287
+ disable_raw_mode()?;
288
+ print!("\x1b[{}A\x1b[J", service_display_lines(&services));
289
+ print_services(&services, cursor);
290
+ enable_raw_mode()?;
291
+ }
292
+ KeyCode::Char('a') => {
293
+ for svc in &mut services {
294
+ svc.enabled = true;
295
+ }
296
+ disable_raw_mode()?;
297
+ print!("\x1b[{}A\x1b[J", service_display_lines(&services));
298
+ print_services(&services, cursor);
299
+ enable_raw_mode()?;
300
+ }
301
+ KeyCode::Char('i') => {
302
+ for svc in &mut services {
303
+ svc.enabled = !svc.enabled;
304
+ }
305
+ disable_raw_mode()?;
306
+ print!("\x1b[{}A\x1b[J", service_display_lines(&services));
307
+ print_services(&services, cursor);
308
+ enable_raw_mode()?;
309
+ }
310
+ KeyCode::Enter => {
311
+ break;
312
+ }
313
+ _ => {}
314
+ }
315
+ }
316
+ }
317
+ }
318
+ Ok(false) => {}
319
+ Err(_) => {
320
+ break;
321
+ }
322
+ }
323
+ }
324
+ disable_raw_mode()?;
325
+ println!();
326
+
327
+ // ────────────────────────────────────────────────────────────────
328
+ // Step 3: Categorized Services Detail Entry
329
+ // ────────────────────────────────────────────────────────────────
330
+ println!("\x1b[33mStep 3: Service Configurations\x1b[0m");
331
+
332
+ // Anthropic
333
+ if is_selected("anthropic", &services) {
334
+ println!("\n\x1b[36m--- Anthropic (Claude) API ---\x1b[0m");
335
+ config.anthropic_api_key =
336
+ prompt_sensitive("Anthropic API Key", &config.anthropic_api_key)?;
337
+ config.anthropic_model = prompt_select_or_custom(
338
+ "Anthropic Model",
339
+ static_model_options(ANTHROPIC_MODEL_PRESETS),
340
+ Some(&config.anthropic_model),
341
+ "Custom Anthropic model...",
342
+ )?;
343
+ } else {
344
+ config.anthropic_api_key = String::new();
345
+ }
346
+
347
+ // OpenAI
348
+ if is_selected("openai", &services) {
349
+ println!("\n\x1b[36m--- OpenAI API ---\x1b[0m");
350
+ config.openai_api_key = prompt_sensitive("OpenAI API Key", &config.openai_api_key)?;
351
+ config.openai_model = prompt_select_or_custom(
352
+ "OpenAI Model",
353
+ static_model_options(OPENAI_MODEL_PRESETS),
354
+ Some(&config.openai_model),
355
+ "Custom OpenAI model...",
356
+ )?;
357
+ } else {
358
+ config.openai_api_key = String::new();
359
+ }
360
+
361
+ // OpenRouter
362
+ if is_selected("openrouter", &services) {
363
+ println!("\n\x1b[36m--- OpenRouter API ---\x1b[0m");
364
+ println!(
365
+ "\x1b[90mOpenRouter model uses a provider/model slug, for example: openai/gpt-4o-mini, anthropic/claude-3.5-sonnet, google/gemini-2.5-flash, meta-llama/llama-3.3-70b-instruct, mistralai/mistral-large\x1b[0m"
366
+ );
367
+ config.openrouter_api_key =
368
+ prompt_sensitive("OpenRouter API Key", &config.openrouter_api_key)?;
369
+ config.openrouter_model = prompt_select_or_custom(
370
+ "OpenRouter Model Slug",
371
+ static_model_options(OPENROUTER_MODEL_PRESETS),
372
+ Some(&config.openrouter_model),
373
+ "Custom model slug...",
374
+ )?;
375
+ } else {
376
+ config.openrouter_api_key = String::new();
377
+ }
378
+
379
+ // DeepSeek
380
+ if is_selected("deepseek", &services) {
381
+ println!("\n\x1b[36m--- DeepSeek API ---\x1b[0m");
382
+ println!(
383
+ "\x1b[90mDeepSeek uses OpenAI-compatible model names. Prefer deepseek-v4-flash or deepseek-v4-pro; deepseek-chat and deepseek-reasoner are compatibility aliases scheduled for deprecation on 2026-07-24.\x1b[0m"
384
+ );
385
+ config.deepseek_api_key = prompt_sensitive("DeepSeek API Key", &config.deepseek_api_key)?;
386
+ config.deepseek_model = prompt_select_or_custom(
387
+ "DeepSeek Model",
388
+ static_model_options(DEEPSEEK_MODEL_PRESETS),
389
+ Some(&config.deepseek_model),
390
+ "Custom DeepSeek model...",
391
+ )?;
392
+ } else {
393
+ config.deepseek_api_key = String::new();
394
+ }
395
+
396
+ // Hugging Face
397
+ if is_selected("huggingface", &services) {
398
+ println!("\n\x1b[36m--- Hugging Face API ---\x1b[0m");
399
+ config.hf_api_key = prompt_sensitive("Hugging Face API Key", &config.hf_api_key)?;
400
+ config.hf_model = prompt_select_or_custom(
401
+ "Hugging Face Model",
402
+ static_model_options(HUGGINGFACE_MODEL_PRESETS),
403
+ Some(&config.hf_model),
404
+ "Custom Hugging Face model...",
405
+ )?;
406
+ } else {
407
+ config.hf_api_key = String::new();
408
+ }
409
+
410
+ // Local OpenAI
411
+ if is_selected("local_openai", &services) {
412
+ println!("\n\x1b[36m--- Local OpenAI (e.g. LM Studio) ---\x1b[0m");
413
+ config.local_api_base_url =
414
+ prompt_input("Local OpenAI Base URL", Some(&config.local_api_base_url))?;
415
+ config.local_model_name = prompt_input("Local Model Name", Some(&config.local_model_name))?;
416
+ } else {
417
+ config.local_api_base_url = String::new();
418
+ }
419
+
420
+ // Ollama
421
+ if is_selected("ollama", &services) {
422
+ println!("\n\x1b[36m--- Ollama ---\x1b[0m");
423
+ config.ollama_host = prompt_input("Ollama Host", Some(&config.ollama_host))?;
424
+ let ollama_models = installed_ollama_models();
425
+ if ollama_models.is_empty() {
426
+ println!(
427
+ "\x1b[90mNo local Ollama models found. Run `ollama pull <model>` to install one, or type a model name manually.\x1b[0m"
428
+ );
429
+ config.ollama_model = prompt_input("Ollama Model Name", Some(&config.ollama_model))?;
430
+ } else {
431
+ println!(
432
+ "\x1b[90mFound {} local Ollama model(s).\x1b[0m",
433
+ ollama_models.len()
434
+ );
435
+ config.ollama_model = prompt_select_or_custom(
436
+ "Ollama Local Model Name",
437
+ ollama_models,
438
+ Some(&config.ollama_model),
439
+ "Custom local model name...",
440
+ )?;
441
+ }
442
+ ensure_ollama_serving(&config.ollama_host);
443
+ } else {
444
+ config.ollama_host = String::new();
445
+ config.ollama_model = String::new();
446
+ }
447
+
448
+ // Google Search
449
+ if is_selected("google_search", &services) {
450
+ println!("\n\x1b[36m--- Google Search API ---\x1b[0m");
451
+ let current_key = config
452
+ .extra
453
+ .get("googleSearchApiKey")
454
+ .and_then(|v| v.as_str())
455
+ .unwrap_or("");
456
+ let current_cx = config
457
+ .extra
458
+ .get("googleSearchCx")
459
+ .and_then(|v| v.as_str())
460
+ .unwrap_or("");
461
+ let key = prompt_sensitive("Google Search API Key", current_key)?;
462
+ let cx = prompt_input("Google Search Engine ID (Cx)", Some(current_cx))?;
463
+ config.extra.insert(
464
+ "googleSearchApiKey".to_string(),
465
+ serde_json::Value::String(key),
466
+ );
467
+ config
468
+ .extra
469
+ .insert("googleSearchCx".to_string(), serde_json::Value::String(cx));
470
+ } else {
471
+ config.extra.insert(
472
+ "googleSearchApiKey".to_string(),
473
+ serde_json::Value::String(String::new()),
474
+ );
475
+ config.extra.insert(
476
+ "googleSearchCx".to_string(),
477
+ serde_json::Value::String(String::new()),
478
+ );
479
+ }
480
+
481
+ // Brave Search
482
+ if is_selected("brave_search", &services) {
483
+ println!("\n\x1b[36m--- Brave Search API ---\x1b[0m");
484
+ let current_key = config
485
+ .extra
486
+ .get("braveSearchApiKey")
487
+ .and_then(|v| v.as_str())
488
+ .unwrap_or("");
489
+ let key = prompt_sensitive("Brave Search API Key", current_key)?;
490
+ config.extra.insert(
491
+ "braveSearchApiKey".to_string(),
492
+ serde_json::Value::String(key),
493
+ );
494
+ } else {
495
+ config.extra.insert(
496
+ "braveSearchApiKey".to_string(),
497
+ serde_json::Value::String(String::new()),
498
+ );
499
+ }
500
+
501
+ // Telegram Bot
502
+ if is_selected("telegram", &services) {
503
+ println!("\n\x1b[36m--- Telegram Bot Bridge ---\x1b[0m");
504
+ let current_token = config
505
+ .extra
506
+ .get("telegramBotToken")
507
+ .and_then(|v| v.as_str())
508
+ .unwrap_or("");
509
+ let token = prompt_sensitive("Telegram Bot Token", current_token)?;
510
+ config.extra.insert(
511
+ "telegramBotToken".to_string(),
512
+ serde_json::Value::String(token),
513
+ );
514
+ config.extra.insert(
515
+ "enableTelegramBridge".to_string(),
516
+ serde_json::Value::Bool(true),
517
+ );
518
+ } else {
519
+ config.extra.insert(
520
+ "enableTelegramBridge".to_string(),
521
+ serde_json::Value::Bool(false),
522
+ );
523
+ }
524
+
525
+ // Discord Bot
526
+ if is_selected("discord", &services) {
527
+ println!("\n\x1b[36m--- Discord Bot Bridge ---\x1b[0m");
528
+ let current_token = config
529
+ .extra
530
+ .get("discordBotToken")
531
+ .and_then(|v| v.as_str())
532
+ .unwrap_or("");
533
+ let current_id = config
534
+ .extra
535
+ .get("discordApplicationId")
536
+ .and_then(|v| v.as_str())
537
+ .unwrap_or("");
538
+ let token = prompt_sensitive("Discord Bot Token", current_token)?;
539
+ let app_id = prompt_input("Discord Application ID", Some(current_id))?;
540
+ config.extra.insert(
541
+ "discordBotToken".to_string(),
542
+ serde_json::Value::String(token),
543
+ );
544
+ config.extra.insert(
545
+ "discordApplicationId".to_string(),
546
+ serde_json::Value::String(app_id),
547
+ );
548
+ config.extra.insert(
549
+ "enableDiscordBridge".to_string(),
550
+ serde_json::Value::Bool(true),
551
+ );
552
+ } else {
553
+ config.extra.insert(
554
+ "enableDiscordBridge".to_string(),
555
+ serde_json::Value::Bool(false),
556
+ );
557
+ }
558
+
559
+ // Slack Bot
560
+ if is_selected("slack", &services) {
561
+ println!("\n\x1b[36m--- Slack Bot Bridge ---\x1b[0m");
562
+ let current_token = config
563
+ .extra
564
+ .get("slackBotToken")
565
+ .and_then(|v| v.as_str())
566
+ .unwrap_or("");
567
+ let current_app_token = config
568
+ .extra
569
+ .get("slackAppToken")
570
+ .and_then(|v| v.as_str())
571
+ .unwrap_or("");
572
+ let token = prompt_sensitive("Slack Bot Token", current_token)?;
573
+ let app_token = prompt_sensitive("Slack App Token (xapp-...)", current_app_token)?;
574
+ config.extra.insert(
575
+ "slackBotToken".to_string(),
576
+ serde_json::Value::String(token),
577
+ );
578
+ config.extra.insert(
579
+ "slackAppToken".to_string(),
580
+ serde_json::Value::String(app_token),
581
+ );
582
+ config.extra.insert(
583
+ "enableSlackBridge".to_string(),
584
+ serde_json::Value::Bool(true),
585
+ );
586
+ } else {
587
+ config.extra.insert(
588
+ "enableSlackBridge".to_string(),
589
+ serde_json::Value::Bool(false),
590
+ );
591
+ }
592
+
593
+ // LINE Bot
594
+ if is_selected("line", &services) {
595
+ println!("\n\x1b[36m--- LINE Bot Bridge ---\x1b[0m");
596
+ let current_token = config
597
+ .extra
598
+ .get("lineChannelAccessToken")
599
+ .and_then(|v| v.as_str())
600
+ .unwrap_or("");
601
+ let current_secret = config
602
+ .extra
603
+ .get("lineChannelSecret")
604
+ .and_then(|v| v.as_str())
605
+ .unwrap_or("");
606
+ let token = prompt_sensitive("LINE Channel Access Token", current_token)?;
607
+ let secret = prompt_sensitive("LINE Channel Secret", current_secret)?;
608
+ config.extra.insert(
609
+ "lineChannelAccessToken".to_string(),
610
+ serde_json::Value::String(token),
611
+ );
612
+ config.extra.insert(
613
+ "lineChannelSecret".to_string(),
614
+ serde_json::Value::String(secret),
615
+ );
616
+ config.extra.insert(
617
+ "enableLineBridge".to_string(),
618
+ serde_json::Value::Bool(true),
619
+ );
620
+ } else {
621
+ config.extra.insert(
622
+ "enableLineBridge".to_string(),
623
+ serde_json::Value::Bool(false),
624
+ );
625
+ }
626
+
627
+ // WhatsApp Cloud
628
+ if is_selected("whatsapp", &services) {
629
+ println!("\n\x1b[36m--- WhatsApp Cloud Bridge ---\x1b[0m");
630
+ let current_token = config
631
+ .extra
632
+ .get("whatsappCloudAccessToken")
633
+ .and_then(|v| v.as_str())
634
+ .unwrap_or("");
635
+ let current_phone = config
636
+ .extra
637
+ .get("whatsappPhoneNumberId")
638
+ .and_then(|v| v.as_str())
639
+ .unwrap_or("");
640
+ let current_verify = config
641
+ .extra
642
+ .get("whatsappVerifyToken")
643
+ .and_then(|v| v.as_str())
644
+ .unwrap_or("");
645
+ let current_secret = config
646
+ .extra
647
+ .get("whatsappAppSecret")
648
+ .and_then(|v| v.as_str())
649
+ .unwrap_or("");
650
+ let token = prompt_sensitive("WhatsApp Access Token", current_token)?;
651
+ let phone = prompt_input("WhatsApp Phone Number ID", Some(current_phone))?;
652
+ let verify = prompt_input("WhatsApp Verify Token", Some(current_verify))?;
653
+ let secret = prompt_sensitive("WhatsApp App Secret", current_secret)?;
654
+ config.extra.insert(
655
+ "whatsappCloudAccessToken".to_string(),
656
+ serde_json::Value::String(token),
657
+ );
658
+ config.extra.insert(
659
+ "whatsappPhoneNumberId".to_string(),
660
+ serde_json::Value::String(phone),
661
+ );
662
+ config.extra.insert(
663
+ "whatsappVerifyToken".to_string(),
664
+ serde_json::Value::String(verify),
665
+ );
666
+ config.extra.insert(
667
+ "whatsappAppSecret".to_string(),
668
+ serde_json::Value::String(secret),
669
+ );
670
+ config.extra.insert(
671
+ "enableWhatsappBridge".to_string(),
672
+ serde_json::Value::Bool(true),
673
+ );
674
+ } else {
675
+ config.extra.insert(
676
+ "enableWhatsappBridge".to_string(),
677
+ serde_json::Value::Bool(false),
678
+ );
679
+ }
680
+
681
+ // Gmail
682
+ if is_selected("gmail", &services) {
683
+ println!("\n\x1b[36m--- Gmail Plugin ---\x1b[0m");
684
+ let current_client_id = config
685
+ .extra
686
+ .get("gmailClientId")
687
+ .and_then(|v| v.as_str())
688
+ .unwrap_or("");
689
+ let current_client_secret = config
690
+ .extra
691
+ .get("gmailClientSecret")
692
+ .and_then(|v| v.as_str())
693
+ .unwrap_or("");
694
+ let current_refresh_token = config
695
+ .extra
696
+ .get("gmailRefreshToken")
697
+ .and_then(|v| v.as_str())
698
+ .unwrap_or("");
699
+ let current_user_id = config
700
+ .extra
701
+ .get("gmailUserId")
702
+ .and_then(|v| v.as_str())
703
+ .unwrap_or("me");
704
+ let client_id = prompt_input("Gmail Client ID", Some(current_client_id))?;
705
+ let client_secret = prompt_sensitive("Gmail Client Secret", current_client_secret)?;
706
+ let refresh_token = prompt_sensitive("Gmail Refresh Token", current_refresh_token)?;
707
+ let user_id = prompt_input("Gmail User ID", Some(current_user_id))?;
708
+ config.extra.insert(
709
+ "gmailClientId".to_string(),
710
+ serde_json::Value::String(client_id),
711
+ );
712
+ config.extra.insert(
713
+ "gmailClientSecret".to_string(),
714
+ serde_json::Value::String(client_secret),
715
+ );
716
+ config.extra.insert(
717
+ "gmailRefreshToken".to_string(),
718
+ serde_json::Value::String(refresh_token),
719
+ );
720
+ config.extra.insert(
721
+ "gmailUserId".to_string(),
722
+ serde_json::Value::String(user_id),
723
+ );
724
+ config.extra.insert(
725
+ "pluginGmailEnabled".to_string(),
726
+ serde_json::Value::Bool(true),
727
+ );
728
+ } else {
729
+ config.extra.insert(
730
+ "pluginGmailEnabled".to_string(),
731
+ serde_json::Value::Bool(false),
732
+ );
733
+ }
734
+
735
+ // Google Calendar
736
+ if is_selected("calendar", &services) {
737
+ println!("\n\x1b[36m--- Google Calendar Plugin ---\x1b[0m");
738
+ let current_client_id = config
739
+ .extra
740
+ .get("googleCalendarClientId")
741
+ .and_then(|v| v.as_str())
742
+ .unwrap_or("");
743
+ let current_client_secret = config
744
+ .extra
745
+ .get("googleCalendarClientSecret")
746
+ .and_then(|v| v.as_str())
747
+ .unwrap_or("");
748
+ let current_refresh_token = config
749
+ .extra
750
+ .get("googleCalendarRefreshToken")
751
+ .and_then(|v| v.as_str())
752
+ .unwrap_or("");
753
+ let current_cal_id = config
754
+ .extra
755
+ .get("googleCalendarId")
756
+ .and_then(|v| v.as_str())
757
+ .unwrap_or("primary");
758
+ let client_id = prompt_input("Google Calendar Client ID", Some(current_client_id))?;
759
+ let client_secret =
760
+ prompt_sensitive("Google Calendar Client Secret", current_client_secret)?;
761
+ let refresh_token =
762
+ prompt_sensitive("Google Calendar Refresh Token", current_refresh_token)?;
763
+ let cal_id = prompt_input("Google Calendar ID", Some(current_cal_id))?;
764
+ config.extra.insert(
765
+ "googleCalendarClientId".to_string(),
766
+ serde_json::Value::String(client_id),
767
+ );
768
+ config.extra.insert(
769
+ "googleCalendarClientSecret".to_string(),
770
+ serde_json::Value::String(client_secret),
771
+ );
772
+ config.extra.insert(
773
+ "googleCalendarRefreshToken".to_string(),
774
+ serde_json::Value::String(refresh_token),
775
+ );
776
+ config.extra.insert(
777
+ "googleCalendarId".to_string(),
778
+ serde_json::Value::String(cal_id),
779
+ );
780
+ config.extra.insert(
781
+ "pluginCalendarEnabled".to_string(),
782
+ serde_json::Value::Bool(true),
783
+ );
784
+ } else {
785
+ config.extra.insert(
786
+ "pluginCalendarEnabled".to_string(),
787
+ serde_json::Value::Bool(false),
788
+ );
789
+ }
790
+
791
+ // Notion
792
+ if is_selected("notion", &services) {
793
+ println!("\n\x1b[36m--- Notion Plugin ---\x1b[0m");
794
+ let current_api_key = config
795
+ .extra
796
+ .get("notionApiKey")
797
+ .and_then(|v| v.as_str())
798
+ .unwrap_or("");
799
+ let current_db_id = config
800
+ .extra
801
+ .get("notionDatabaseId")
802
+ .and_then(|v| v.as_str())
803
+ .unwrap_or("");
804
+ let current_page_id = config
805
+ .extra
806
+ .get("notionPageId")
807
+ .and_then(|v| v.as_str())
808
+ .unwrap_or("");
809
+ let current_title = config
810
+ .extra
811
+ .get("notionTitleProperty")
812
+ .and_then(|v| v.as_str())
813
+ .unwrap_or("Name");
814
+ let api_key = prompt_sensitive("Notion API Key", current_api_key)?;
815
+ let db_id = prompt_input("Notion Database ID", Some(current_db_id))?;
816
+ let page_id = prompt_input("Notion Page ID", Some(current_page_id))?;
817
+ let title_prop = prompt_input("Notion Title Property", Some(current_title))?;
818
+ config.extra.insert(
819
+ "notionApiKey".to_string(),
820
+ serde_json::Value::String(api_key),
821
+ );
822
+ config.extra.insert(
823
+ "notionDatabaseId".to_string(),
824
+ serde_json::Value::String(db_id),
825
+ );
826
+ config.extra.insert(
827
+ "notionPageId".to_string(),
828
+ serde_json::Value::String(page_id),
829
+ );
830
+ config.extra.insert(
831
+ "notionTitleProperty".to_string(),
832
+ serde_json::Value::String(title_prop),
833
+ );
834
+ config.extra.insert(
835
+ "pluginNotionEnabled".to_string(),
836
+ serde_json::Value::Bool(true),
837
+ );
838
+ } else {
839
+ config.extra.insert(
840
+ "pluginNotionEnabled".to_string(),
841
+ serde_json::Value::Bool(false),
842
+ );
843
+ }
844
+
845
+ println!();
846
+ save_config(&config)?;
847
+ println!("\x1b[32m✅ Configuration saved successfully!\x1b[0m");
848
+ Ok(())
849
+ }
850
+
851
+ fn is_selected(key: &str, services: &[OnboardService]) -> bool {
852
+ services.iter().any(|s| s.key == key && s.enabled)
853
+ }
854
+
855
+ fn print_services(services: &[OnboardService], cursor: usize) {
856
+ let mut current_category = "";
857
+ for (i, svc) in services.iter().enumerate() {
858
+ if svc.category != current_category {
859
+ current_category = svc.category;
860
+ println!(" \x1b[1;32m{}:\x1b[0m", svc.category);
861
+ }
862
+ let checkbox = if svc.enabled {
863
+ "\x1b[32m◉\x1b[0m"
864
+ } else {
865
+ "\x1b[90m○\x1b[0m"
866
+ };
867
+ if i == cursor {
868
+ println!(
869
+ " \x1b[36m❯\x1b[0m {} \x1b[36m{}\x1b[0m",
870
+ checkbox, svc.name
871
+ );
872
+ } else {
873
+ println!(" {} {}", checkbox, svc.name);
874
+ }
875
+ }
876
+ let _ = io::stdout().flush();
877
+ }
878
+
879
+ fn service_display_lines(services: &[OnboardService]) -> usize {
880
+ let categories = services
881
+ .iter()
882
+ .fold(Vec::<&str>::new(), |mut categories, service| {
883
+ if categories.last().copied() != Some(service.category) {
884
+ categories.push(service.category);
885
+ }
886
+ categories
887
+ });
888
+ services.len() + categories.len()
889
+ }
890
+
891
+ fn prompt_select_or_custom(
892
+ label: &str,
893
+ presets: Vec<String>,
894
+ current: Option<&str>,
895
+ custom_label: &str,
896
+ ) -> Result<String> {
897
+ let current = current.unwrap_or("").trim();
898
+ let mut options: Vec<String> = Vec::new();
899
+ if !current.is_empty() && !presets.iter().any(|preset| preset == current) {
900
+ options.push(current.to_string());
901
+ }
902
+ options.extend(presets);
903
+ options.push(custom_label.to_string());
904
+
905
+ let mut cursor = if current.is_empty() {
906
+ 0
907
+ } else {
908
+ options
909
+ .iter()
910
+ .position(|option| option == current)
911
+ .unwrap_or(0)
912
+ };
913
+
914
+ println!("{}", label);
915
+ println!(" \x1b[90m[Keyboard Controls: ↑/↓: Navigate | Enter: Select]\x1b[0m");
916
+ print_select_options(&options, cursor);
917
+ enable_raw_mode()?;
918
+
919
+ loop {
920
+ match event::poll(std::time::Duration::from_millis(100)) {
921
+ Ok(true) => {
922
+ if let Event::Key(key_event) = event::read()? {
923
+ if key_event.kind == event::KeyEventKind::Press {
924
+ let is_ctrl_c = matches!(key_event.code, KeyCode::Char('c'))
925
+ && key_event
926
+ .modifiers
927
+ .contains(crossterm::event::KeyModifiers::CONTROL);
928
+ if is_ctrl_c {
929
+ disable_raw_mode()?;
930
+ println!("\n\x1b[31mOnboarding cancelled.\x1b[0m");
931
+ bail!("onboarding cancelled");
932
+ }
933
+
934
+ match key_event.code {
935
+ KeyCode::Up => {
936
+ if cursor > 0 {
937
+ cursor -= 1;
938
+ } else {
939
+ cursor = options.len() - 1;
940
+ }
941
+ disable_raw_mode()?;
942
+ print!("\x1b[{}A\x1b[J", options.len());
943
+ print_select_options(&options, cursor);
944
+ enable_raw_mode()?;
945
+ }
946
+ KeyCode::Down => {
947
+ if cursor < options.len() - 1 {
948
+ cursor += 1;
949
+ } else {
950
+ cursor = 0;
951
+ }
952
+ disable_raw_mode()?;
953
+ print!("\x1b[{}A\x1b[J", options.len());
954
+ print_select_options(&options, cursor);
955
+ enable_raw_mode()?;
956
+ }
957
+ KeyCode::Enter => {
958
+ disable_raw_mode()?;
959
+ println!();
960
+ let selected = options[cursor].clone();
961
+ if selected == custom_label {
962
+ return prompt_input(label, Some(current));
963
+ }
964
+ return Ok(selected);
965
+ }
966
+ _ => {}
967
+ }
968
+ }
969
+ }
970
+ }
971
+ Ok(false) => {}
972
+ Err(error) => {
973
+ disable_raw_mode()?;
974
+ return Err(error.into());
975
+ }
976
+ }
977
+ }
978
+ }
979
+
980
+ fn static_model_options(presets: &[&str]) -> Vec<String> {
981
+ presets.iter().map(|value| value.to_string()).collect()
982
+ }
983
+
984
+ fn installed_ollama_models() -> Vec<String> {
985
+ let output = match Command::new("ollama").arg("list").output() {
986
+ Ok(output) if output.status.success() => output,
987
+ _ => return Vec::new(),
988
+ };
989
+ let stdout = String::from_utf8_lossy(&output.stdout);
990
+ stdout
991
+ .lines()
992
+ .skip(1)
993
+ .filter_map(|line| line.split_whitespace().next())
994
+ .filter(|name| !name.trim().is_empty())
995
+ .map(|name| name.to_string())
996
+ .collect()
997
+ }
998
+
999
+ fn ensure_ollama_serving(host: &str) {
1000
+ let host = if host.trim().is_empty() {
1001
+ "http://localhost:11434"
1002
+ } else {
1003
+ host.trim_end_matches('/')
1004
+ };
1005
+
1006
+ // Parse host:port for TCP check
1007
+ let addr = host
1008
+ .strip_prefix("http://")
1009
+ .or_else(|| host.strip_prefix("https://"))
1010
+ .unwrap_or(host);
1011
+ let addr = if !addr.contains(':') {
1012
+ format!("{}:11434", addr)
1013
+ } else {
1014
+ addr.to_string()
1015
+ };
1016
+
1017
+ // Check if Ollama is already serving
1018
+ if TcpStream::connect_timeout(
1019
+ &addr
1020
+ .parse()
1021
+ .unwrap_or_else(|_| "127.0.0.1:11434".parse().unwrap()),
1022
+ std::time::Duration::from_secs(1),
1023
+ )
1024
+ .is_ok()
1025
+ {
1026
+ println!(
1027
+ "\x1b[32m✔ Ollama server is already running at {}\x1b[0m",
1028
+ host
1029
+ );
1030
+ return;
1031
+ }
1032
+
1033
+ // Not running — try to start it
1034
+ print!("\x1b[33m⏳ Starting Ollama server...\x1b[0m");
1035
+ let _ = io::stdout().flush();
1036
+
1037
+ match Command::new("ollama")
1038
+ .arg("serve")
1039
+ .stdout(std::process::Stdio::null())
1040
+ .stderr(std::process::Stdio::null())
1041
+ .spawn()
1042
+ {
1043
+ Ok(_) => {
1044
+ // Wait for server to become ready (up to 10 seconds)
1045
+ let mut ready = false;
1046
+ for _ in 0..20 {
1047
+ std::thread::sleep(std::time::Duration::from_millis(500));
1048
+ if TcpStream::connect_timeout(
1049
+ &addr
1050
+ .parse()
1051
+ .unwrap_or_else(|_| "127.0.0.1:11434".parse().unwrap()),
1052
+ std::time::Duration::from_secs(1),
1053
+ )
1054
+ .is_ok()
1055
+ {
1056
+ ready = true;
1057
+ break;
1058
+ }
1059
+ }
1060
+ if ready {
1061
+ println!(
1062
+ "\r\x1b[32m✔ Ollama server started successfully at {}\x1b[0m ",
1063
+ host
1064
+ );
1065
+ } else {
1066
+ println!(
1067
+ "\r\x1b[31m✘ Ollama server started but not responding yet. It may need more time.\x1b[0m"
1068
+ );
1069
+ }
1070
+ }
1071
+ Err(e) => {
1072
+ println!(
1073
+ "\r\x1b[31m✘ Failed to start Ollama server: {}. Please run `ollama serve` manually.\x1b[0m",
1074
+ e
1075
+ );
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ fn print_select_options(options: &[String], cursor: usize) {
1081
+ for (i, option) in options.iter().enumerate() {
1082
+ if i == cursor {
1083
+ println!(" \x1b[36m❯\x1b[0m \x1b[36m{}\x1b[0m", option);
1084
+ } else {
1085
+ println!(" {}", option);
1086
+ }
1087
+ }
1088
+ let _ = io::stdout().flush();
1089
+ }
1090
+
1091
+ fn prompt_input(label: &str, default: Option<&str>) -> Result<String> {
1092
+ print!("{}", label);
1093
+ if let Some(d) = default {
1094
+ if !d.is_empty() {
1095
+ print!(" [\x1b[90m{}\x1b[0m]", d);
1096
+ }
1097
+ }
1098
+ print!(": ");
1099
+ io::stdout().flush()?;
1100
+
1101
+ let mut input = String::new();
1102
+ io::stdin().read_line(&mut input)?;
1103
+ let trimmed = input.trim();
1104
+ if trimmed.is_empty() {
1105
+ if let Some(d) = default {
1106
+ return Ok(d.to_string());
1107
+ }
1108
+ }
1109
+ Ok(trimmed.to_string())
1110
+ }
1111
+
1112
+ fn format_masked_key(key: &str) -> String {
1113
+ let key = key.trim();
1114
+ if key.is_empty() {
1115
+ return "none".to_string();
1116
+ }
1117
+ let len = key.chars().count();
1118
+ if len <= 10 {
1119
+ "***".to_string()
1120
+ } else {
1121
+ let first: String = key.chars().take(6).collect();
1122
+ let last: String = key.chars().skip(len - 4).collect();
1123
+ format!("{}...****...{}", first, last)
1124
+ }
1125
+ }
1126
+
1127
+ fn prompt_sensitive(label: &str, existing: &str) -> Result<String> {
1128
+ if !existing.is_empty() {
1129
+ print!(
1130
+ "{} [keep existing ({})]: ",
1131
+ label,
1132
+ format_masked_key(existing)
1133
+ );
1134
+ io::stdout().flush()?;
1135
+ let mut input = String::new();
1136
+ io::stdin().read_line(&mut input)?;
1137
+ let trimmed = input.trim();
1138
+ if trimmed.is_empty() {
1139
+ return Ok(existing.to_string());
1140
+ }
1141
+ Ok(trimmed.to_string())
1142
+ } else {
1143
+ print!("{}: ", label);
1144
+ io::stdout().flush()?;
1145
+ let mut input = String::new();
1146
+ io::stdin().read_line(&mut input)?;
1147
+ Ok(input.trim().to_string())
1148
+ }
1149
+ }