@pheem49/mint 1.7.1 → 1.8.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 (66) hide show
  1. package/.github/workflows/release.yml +1 -1
  2. package/Cargo.lock +34 -3
  3. package/Cargo.toml +2 -2
  4. package/README.md +40 -0
  5. package/Release_Note.md +95 -0
  6. package/assets/Mint_CLI.png +0 -0
  7. package/crates/mint-cli/Cargo.toml +1 -0
  8. package/crates/mint-cli/src/agent.rs +142 -48
  9. package/crates/mint-cli/src/main.rs +231 -22
  10. package/crates/mint-core/src/api_server.rs +71 -4
  11. package/crates/mint-core/src/browser.rs +401 -0
  12. package/crates/mint-core/src/chat.rs +79 -0
  13. package/crates/mint-core/src/config.rs +69 -1
  14. package/crates/mint-core/src/lib.rs +6 -1
  15. package/crates/mint-core/src/orchestration.rs +137 -1
  16. package/crates/mint-core/src/skills.rs +113 -4
  17. package/crates/mint-core/src/workflows.rs +9 -22
  18. package/package.json +2 -1
  19. package/src/renderer/src/App.tsx +1 -1
  20. package/src/renderer/src/components/ChatPanel.tsx +197 -47
  21. package/src/renderer/src/components/DashboardSidebar.tsx +71 -12
  22. package/src/renderer/src/components/ImageStudioPanel.tsx +18 -4
  23. package/src/renderer/src/components/MintDashboard.tsx +239 -7
  24. package/src/renderer/src/components/PicturesLibrary.tsx +1 -3
  25. package/src/renderer/src/components/Settings/AudioTab.tsx +97 -0
  26. package/src/renderer/src/components/Settings/AutomationTab.tsx +195 -0
  27. package/src/renderer/src/components/Settings/GeneralTab.tsx +950 -0
  28. package/src/renderer/src/components/Settings/MemoryTab.tsx +60 -0
  29. package/src/renderer/src/components/Settings/PluginsTab.tsx +632 -0
  30. package/src/renderer/src/components/Settings/ShortcutsTab.tsx +30 -0
  31. package/src/renderer/src/components/Settings/ThemeTab.tsx +129 -0
  32. package/src/renderer/src/components/SettingsWindow.tsx +175 -970
  33. package/src/renderer/src/components/WorkflowBuilderPanel.tsx +536 -0
  34. package/src/renderer/src/components/WorkflowsWindow.tsx +46 -0
  35. package/src/renderer/src/components/WorkspacePanel.tsx +66 -9
  36. package/src/renderer/src/css/image-studio.css +15 -4
  37. package/src/renderer/src/css/settings.css +75 -6
  38. package/src/renderer/src/css/styles.css +419 -49
  39. package/src/renderer/src/css/workflows.css +542 -0
  40. package/src/renderer/src/env.d.ts +2 -0
  41. package/src/renderer/src/index.css +13 -1
  42. package/src/renderer/src/tauri.ts +140 -2
  43. package/src/renderer/src-web/components/ChatPanel.tsx +190 -46
  44. package/src/renderer/src-web/components/DashboardSidebar.tsx +17 -1
  45. package/src/renderer/src-web/components/ImageStudioPanel.tsx +18 -4
  46. package/src/renderer/src-web/components/MintDashboard.tsx +197 -3
  47. package/src/renderer/src-web/components/PicturesLibrary.tsx +1 -3
  48. package/src/renderer/src-web/components/Settings/AudioTab.tsx +97 -0
  49. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +196 -0
  50. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +948 -0
  51. package/src/renderer/src-web/components/Settings/MemoryTab.tsx +60 -0
  52. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +632 -0
  53. package/src/renderer/src-web/components/Settings/ShortcutsTab.tsx +30 -0
  54. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +129 -0
  55. package/src/renderer/src-web/components/SettingsWindow.tsx +187 -976
  56. package/src/renderer/src-web/css/image-studio.css +15 -4
  57. package/src/renderer/src-web/css/settings.css +75 -6
  58. package/src/renderer/src-web/css/styles.css +394 -50
  59. package/src/renderer/src-web/env.d.ts +2 -0
  60. package/src/renderer/src-web/index.css +12 -1
  61. package/src/renderer/src-web/tauri.ts +60 -2
  62. package/src-tauri/src/browser.rs +1 -141
  63. package/src-tauri/src/headless.rs +3 -2
  64. package/src-tauri/src/lib.rs +195 -111
  65. package/src-tauri/src/workflows.rs +56 -4
  66. package/src-tauri/tauri.conf.json +1 -1
@@ -0,0 +1,401 @@
1
+ use crate::MintConfig;
2
+ use futures_util::{SinkExt, StreamExt};
3
+ use serde::Serialize;
4
+ use serde_json::{Value, json};
5
+ use tokio_tungstenite::{connect_async, tungstenite::Message};
6
+
7
+ fn log_action(action: &str, details: &str) {
8
+ if let Some(config_dir) = dirs::config_dir() {
9
+ let log_dir = config_dir.join("mint");
10
+ let log_file = log_dir.join("browser-automation.log");
11
+ let _ = std::fs::create_dir_all(&log_dir);
12
+ if let Ok(mut file) = std::fs::OpenOptions::new()
13
+ .create(true)
14
+ .append(true)
15
+ .open(&log_file)
16
+ {
17
+ use std::io::Write;
18
+ let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
19
+ let _ = writeln!(file, "[{}] [{}] {}", now, action, details);
20
+ }
21
+ }
22
+ }
23
+
24
+ #[derive(Debug, Clone, Serialize)]
25
+ #[serde(rename_all = "camelCase")]
26
+ pub struct BrowserTab {
27
+ pub id: String,
28
+ pub title: String,
29
+ pub url: String,
30
+ }
31
+
32
+ pub async fn list_tabs(config: &MintConfig) -> Result<Vec<BrowserTab>, String> {
33
+ if !is_browser_running(config).await {
34
+ return Err("Browser automation is not running. Please run 'mint auto' first.".to_string());
35
+ }
36
+ Ok(fetch_pages(config)
37
+ .await?
38
+ .into_iter()
39
+ .filter_map(|page| {
40
+ Some(BrowserTab {
41
+ id: page["id"].as_str()?.to_owned(),
42
+ title: page["title"].as_str().unwrap_or_default().to_owned(),
43
+ url: page["url"].as_str().unwrap_or_default().to_owned(),
44
+ })
45
+ })
46
+ .collect())
47
+ }
48
+
49
+ pub async fn navigate(config: &MintConfig, url: &str) -> Result<String, String> {
50
+ if !(url.starts_with("https://") || url.starts_with("http://")) {
51
+ log_action(
52
+ "NAVIGATE_ERROR",
53
+ "Browser navigation only supports http and https URLs",
54
+ );
55
+ return Err("browser navigation only supports http and https URLs".into());
56
+ }
57
+ log_action("NAVIGATE", &format!("Navigating to {url}"));
58
+ ensure_page_open(config).await?;
59
+ match cdp_call(config, "Page.navigate", json!({ "url": url })).await {
60
+ Ok(response) => {
61
+ if response["result"]["frameId"].as_str().is_some() {
62
+ log_action(
63
+ "NAVIGATE_SUCCESS",
64
+ &format!("Successfully navigated to {url}"),
65
+ );
66
+ Ok(format!("navigating to {url}"))
67
+ } else {
68
+ let err = response_error(&response);
69
+ log_action("NAVIGATE_ERROR", &format!("Failed: {err}"));
70
+ Err(err)
71
+ }
72
+ }
73
+ Err(e) => {
74
+ log_action("NAVIGATE_ERROR", &format!("Websocket error: {e}"));
75
+ Err(e)
76
+ }
77
+ }
78
+ }
79
+
80
+ pub async fn read_page_text(config: &MintConfig) -> Result<String, String> {
81
+ log_action("READ", "Reading page text content");
82
+ ensure_page_open(config).await?;
83
+ match cdp_call(
84
+ config,
85
+ "Runtime.evaluate",
86
+ json!({
87
+ "expression": "document.body ? document.body.innerText.substring(0, 12000) : ''",
88
+ "returnByValue": true
89
+ }),
90
+ )
91
+ .await
92
+ {
93
+ Ok(response) => {
94
+ if let Some(val) = response["result"]["result"]["value"].as_str() {
95
+ log_action(
96
+ "READ_SUCCESS",
97
+ &format!("Successfully read {} characters", val.len()),
98
+ );
99
+ Ok(val.to_owned())
100
+ } else {
101
+ let err = response_error(&response);
102
+ log_action("READ_ERROR", &format!("Failed: {err}"));
103
+ Err(err)
104
+ }
105
+ }
106
+ Err(e) => {
107
+ log_action("READ_ERROR", &format!("Websocket error: {e}"));
108
+ Err(e)
109
+ }
110
+ }
111
+ }
112
+
113
+ pub async fn click(config: &MintConfig, selector: &str) -> Result<String, String> {
114
+ let selector = selector.trim();
115
+ if selector.is_empty() || selector.len() > 500 {
116
+ log_action(
117
+ "CLICK_ERROR",
118
+ "Browser selector must contain between 1 and 500 characters",
119
+ );
120
+ return Err("browser selector must contain between 1 and 500 characters".into());
121
+ }
122
+ log_action("CLICK", &format!("Clicking element '{selector}'"));
123
+ ensure_page_open(config).await?;
124
+ let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
125
+ match cdp_call(
126
+ config,
127
+ "Runtime.evaluate",
128
+ json!({
129
+ "expression": format!(
130
+ "(() => {{ const element = document.querySelector({selector_json}); if (!element) return 'not-found'; element.click(); return 'clicked'; }})()"
131
+ ),
132
+ "returnByValue": true
133
+ }),
134
+ )
135
+ .await {
136
+ Ok(response) => {
137
+ match response["result"]["result"]["value"].as_str() {
138
+ Some("clicked") => {
139
+ log_action("CLICK_SUCCESS", &format!("Successfully clicked element '{selector}'"));
140
+ Ok("clicked".into())
141
+ }
142
+ Some("not-found") => {
143
+ let err = format!("browser selector not found: {selector}");
144
+ log_action("CLICK_ERROR", &err);
145
+ Err(err)
146
+ }
147
+ _ => {
148
+ let err = response_error(&response);
149
+ log_action("CLICK_ERROR", &err);
150
+ Err(err)
151
+ }
152
+ }
153
+ }
154
+ Err(e) => {
155
+ log_action("CLICK_ERROR", &format!("Websocket error: {e}"));
156
+ Err(e)
157
+ }
158
+ }
159
+ }
160
+
161
+ pub async fn type_text(config: &MintConfig, selector: &str, text: &str) -> Result<String, String> {
162
+ let selector = selector.trim();
163
+ if selector.is_empty() || selector.len() > 500 {
164
+ log_action(
165
+ "TYPE_ERROR",
166
+ "Browser selector must contain between 1 and 500 characters",
167
+ );
168
+ return Err("browser selector must contain between 1 and 500 characters".into());
169
+ }
170
+ log_action("TYPE", &format!("Typing text into element '{selector}'"));
171
+ ensure_page_open(config).await?;
172
+ let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
173
+ let text_json = serde_json::to_string(text).map_err(|error| error.to_string())?;
174
+ match cdp_call(
175
+ config,
176
+ "Runtime.evaluate",
177
+ json!({
178
+ "expression": format!(
179
+ "(() => {{ \
180
+ const element = document.querySelector({selector_json}); \
181
+ if (!element) return 'not-found'; \
182
+ element.value = {text_json}; \
183
+ element.dispatchEvent(new Event('input', {{ bubbles: true }})); \
184
+ element.dispatchEvent(new Event('change', {{ bubbles: true }})); \
185
+ return 'typed'; \
186
+ }})()"
187
+ ),
188
+ "returnByValue": true
189
+ }),
190
+ )
191
+ .await
192
+ {
193
+ Ok(response) => match response["result"]["result"]["value"].as_str() {
194
+ Some("typed") => {
195
+ log_action(
196
+ "TYPE_SUCCESS",
197
+ &format!("Successfully typed into element '{selector}'"),
198
+ );
199
+ Ok("typed".into())
200
+ }
201
+ Some("not-found") => {
202
+ let err = format!("browser selector not found: {selector}");
203
+ log_action("TYPE_ERROR", &err);
204
+ Err(err)
205
+ }
206
+ _ => {
207
+ let err = response_error(&response);
208
+ log_action("TYPE_ERROR", &err);
209
+ Err(err)
210
+ }
211
+ },
212
+ Err(e) => {
213
+ log_action("TYPE_ERROR", &format!("Websocket error: {e}"));
214
+ Err(e)
215
+ }
216
+ }
217
+ }
218
+
219
+ pub async fn is_browser_running(config: &MintConfig) -> bool {
220
+ let endpoint = config
221
+ .extra
222
+ .get("browserDebugUrl")
223
+ .and_then(Value::as_str)
224
+ .unwrap_or("http://127.0.0.1:9222/json/list");
225
+ fetch_pages_endpoint(endpoint).await.is_ok()
226
+ }
227
+
228
+ pub async fn spawn_automation_browser(config: &MintConfig) -> Result<(), String> {
229
+ let endpoint = config
230
+ .extra
231
+ .get("browserDebugUrl")
232
+ .and_then(Value::as_str)
233
+ .unwrap_or("http://127.0.0.1:9222/json/list");
234
+
235
+ if fetch_pages_endpoint(endpoint).await.is_ok() {
236
+ return Ok(());
237
+ }
238
+
239
+ let browser_name = config
240
+ .extra
241
+ .get("automationBrowser")
242
+ .and_then(Value::as_str)
243
+ .unwrap_or("chromium");
244
+
245
+ let profile_dir = dirs::config_dir()
246
+ .unwrap_or_else(|| std::env::temp_dir())
247
+ .join("mint")
248
+ .join("automation-profile");
249
+ let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy());
250
+
251
+ let args = [
252
+ "--remote-debugging-port=9222".to_owned(),
253
+ "--no-first-run".to_owned(),
254
+ "--no-default-browser-check".to_owned(),
255
+ profile_arg,
256
+ ];
257
+
258
+ let mut spawned = false;
259
+ let executables = if cfg!(target_os = "windows") {
260
+ vec!["chrome.exe", "chromium.exe", "msedge.exe"]
261
+ } else if cfg!(target_os = "macos") {
262
+ vec![
263
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
264
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
265
+ "chromium",
266
+ "google-chrome",
267
+ ]
268
+ } else {
269
+ vec![
270
+ "chromium",
271
+ "google-chrome-stable",
272
+ "google-chrome",
273
+ "chrome",
274
+ "chromium-browser",
275
+ ]
276
+ };
277
+
278
+ for exe in executables {
279
+ if std::process::Command::new(exe)
280
+ .args(&args)
281
+ .stdout(std::process::Stdio::null())
282
+ .stderr(std::process::Stdio::null())
283
+ .spawn()
284
+ .is_ok()
285
+ {
286
+ spawned = true;
287
+ break;
288
+ }
289
+ }
290
+
291
+ if !spawned {
292
+ return Err(format!(
293
+ "Could not find or spawn browser '{browser_name}' with remote debugging. Please verify it is installed."
294
+ ));
295
+ }
296
+
297
+ for _ in 0..20 {
298
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
299
+ if fetch_pages_endpoint(endpoint).await.is_ok() {
300
+ return Ok(());
301
+ }
302
+ }
303
+
304
+ Err("Browser spawned but remote debugging port 9222 did not become available.".to_string())
305
+ }
306
+
307
+ pub async fn ensure_page_open(config: &MintConfig) -> Result<(), String> {
308
+ if !is_browser_running(config).await {
309
+ return Err("Browser automation is not running. Please run 'mint auto' first.".to_string());
310
+ }
311
+ let endpoint = config
312
+ .extra
313
+ .get("browserDebugUrl")
314
+ .and_then(Value::as_str)
315
+ .unwrap_or("http://127.0.0.1:9222/json/list");
316
+
317
+ let pages = fetch_pages_endpoint(endpoint).await?;
318
+ let has_page = pages.iter().any(|p| p["type"] == "page");
319
+ if !has_page {
320
+ let base_url = endpoint.replace("/json/list", "/json/new");
321
+ let client = crate::HTTP_CLIENT.clone();
322
+ let _ = client
323
+ .put(&base_url)
324
+ .send()
325
+ .await
326
+ .map_err(|e| format!("failed to open new tab: {e}"))?;
327
+ }
328
+ Ok(())
329
+ }
330
+
331
+ async fn cdp_call(config: &MintConfig, method: &str, params: Value) -> Result<Value, String> {
332
+ let page = fetch_pages(config)
333
+ .await?
334
+ .into_iter()
335
+ .find(|page| page["type"] == "page")
336
+ .ok_or("Chrome DevTools did not report an open browser page")?;
337
+ let socket_url = page["webSocketDebuggerUrl"]
338
+ .as_str()
339
+ .ok_or("Chrome DevTools page does not expose a websocket URL")?;
340
+ let (mut socket, _) = connect_async(socket_url)
341
+ .await
342
+ .map_err(|error| format!("unable to connect to Chrome DevTools websocket: {error}"))?;
343
+ socket
344
+ .send(Message::Text(
345
+ json!({ "id": 1, "method": method, "params": params })
346
+ .to_string()
347
+ .into(),
348
+ ))
349
+ .await
350
+ .map_err(|error| error.to_string())?;
351
+ while let Some(message) = socket.next().await {
352
+ let message = message.map_err(|error| error.to_string())?;
353
+ let Message::Text(raw) = message else {
354
+ continue;
355
+ };
356
+ let value: Value = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
357
+ if value["id"] == 1 {
358
+ return Ok(value);
359
+ }
360
+ }
361
+ Err("Chrome DevTools websocket closed before returning a response".into())
362
+ }
363
+
364
+ async fn fetch_pages(config: &MintConfig) -> Result<Vec<Value>, String> {
365
+ let endpoint = config
366
+ .extra
367
+ .get("browserDebugUrl")
368
+ .and_then(Value::as_str)
369
+ .unwrap_or("http://127.0.0.1:9222/json/list");
370
+ fetch_pages_endpoint(endpoint).await
371
+ }
372
+
373
+ async fn fetch_pages_endpoint(endpoint: &str) -> Result<Vec<Value>, String> {
374
+ let client = reqwest::Client::builder()
375
+ .timeout(std::time::Duration::from_millis(1000))
376
+ .connect_timeout(std::time::Duration::from_millis(500))
377
+ .build()
378
+ .unwrap_or_else(|_| crate::HTTP_CLIENT.clone());
379
+
380
+ let value: Value = client
381
+ .get(endpoint)
382
+ .send()
383
+ .await
384
+ .map_err(|error| format!("unable to reach Chrome DevTools at {endpoint}: {error}"))?
385
+ .error_for_status()
386
+ .map_err(|error| error.to_string())?
387
+ .json()
388
+ .await
389
+ .map_err(|error| error.to_string())?;
390
+ value
391
+ .as_array()
392
+ .cloned()
393
+ .ok_or_else(|| "Chrome DevTools response was not a page list".into())
394
+ }
395
+
396
+ fn response_error(response: &Value) -> String {
397
+ response["error"]["message"]
398
+ .as_str()
399
+ .unwrap_or("Chrome DevTools returned an unexpected response")
400
+ .to_owned()
401
+ }
@@ -108,6 +108,85 @@ pub struct DocumentAttachment {
108
108
  pub data_uri: String,
109
109
  }
110
110
 
111
+ const MAX_DOCUMENT_BYTES: usize = 10 * 1024 * 1024;
112
+ const MAX_DOCUMENT_CONTEXT_CHARS: usize = 400_000;
113
+ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
114
+
115
+ impl ChatRequest {
116
+ pub fn with_document_context(&self, config: &crate::MintConfig) -> Result<ChatRequest, String> {
117
+ let Some(document) = &self.document_attachment else {
118
+ return Ok(self.clone());
119
+ };
120
+
121
+ let (mime_type, encoded) = document
122
+ .data_uri
123
+ .strip_prefix("data:")
124
+ .and_then(|payload| payload.split_once(";base64,"))
125
+ .ok_or_else(|| "invalid PDF attachment data URI".to_string())?;
126
+
127
+ if !mime_type.eq_ignore_ascii_case("application/pdf") {
128
+ return Err("only PDF document attachments are supported".into());
129
+ }
130
+
131
+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
132
+ let bytes = BASE64_STANDARD
133
+ .decode(encoded)
134
+ .map_err(|error| format!("invalid PDF attachment encoding: {error}"))?;
135
+ if bytes.len() > MAX_DOCUMENT_BYTES {
136
+ return Err("PDF attachment is too large (> 10 MiB)".into());
137
+ }
138
+
139
+ let directory = crate::config::config_path()
140
+ .map_err(|error| error.to_string())?
141
+ .with_file_name("Attachments");
142
+ std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
143
+
144
+ let safe_name: String = document
145
+ .filename
146
+ .chars()
147
+ .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '.' | '-' | '_'))
148
+ .take(80)
149
+ .collect();
150
+ let safe_name = if safe_name.is_empty() {
151
+ "document".into()
152
+ } else {
153
+ safe_name
154
+ };
155
+
156
+ let path = directory.join(format!(
157
+ "mint-pdf-{}-{}.pdf",
158
+ COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
159
+ safe_name
160
+ ));
161
+ std::fs::write(&path, bytes).map_err(|error| error.to_string())?;
162
+
163
+ let extracted = crate::knowledge::extract_document_text(&path, config)
164
+ .map_err(|error| error.to_string());
165
+ let _ = std::fs::remove_file(&path);
166
+ let extracted = extracted?;
167
+
168
+ let mut context: String = extracted.chars().take(MAX_DOCUMENT_CONTEXT_CHARS).collect();
169
+ if extracted.chars().count() > MAX_DOCUMENT_CONTEXT_CHARS {
170
+ context.push_str("\n\n[PDF text truncated because it is long.]");
171
+ }
172
+
173
+ let filename = document.filename.trim();
174
+ let filename = if filename.is_empty() {
175
+ "attached.pdf"
176
+ } else {
177
+ filename
178
+ };
179
+
180
+ let mut enriched = self.clone();
181
+ enriched.document_attachment = None;
182
+ enriched.message = format!(
183
+ "{}\n\nAttached PDF: {filename}\nUse the extracted PDF text below when answering. If the user asks for a summary, summarize this document.\n\n--- Extracted PDF text ---\n{context}\n--- End extracted PDF text ---",
184
+ self.message
185
+ );
186
+ Ok(enriched)
187
+ }
188
+ }
189
+
111
190
  #[derive(Debug, Clone, Serialize)]
112
191
  #[serde(rename_all = "camelCase")]
113
192
  pub struct ChatResponse {
@@ -208,7 +208,20 @@ pub fn set_config_value(key: &str, value: Value) -> Result<MintConfig, ConfigErr
208
208
 
209
209
  fn load_config_from(path: &Path) -> Result<MintConfig, ConfigError> {
210
210
  if !path.exists() {
211
- let config = MintConfig::default();
211
+ let mut config = MintConfig::default();
212
+ if which("gk") {
213
+ let mut servers = BTreeMap::new();
214
+ servers.insert(
215
+ "gitkraken".to_string(),
216
+ serde_json::json!({
217
+ "command": "gk",
218
+ "args": ["mcp"]
219
+ }),
220
+ );
221
+ config
222
+ .extra
223
+ .insert("mcpServers".into(), serde_json::to_value(servers).unwrap());
224
+ }
212
225
  save_config_to(path, &config)?;
213
226
  return Ok(config);
214
227
  }
@@ -225,6 +238,32 @@ fn load_config_from(path: &Path) -> Result<MintConfig, ConfigError> {
225
238
  for (key, value) in runtime_extra_defaults() {
226
239
  config.extra.entry(key).or_insert(value);
227
240
  }
241
+
242
+ let has_gitkraken = config
243
+ .extra
244
+ .get("mcpServers")
245
+ .and_then(|v| v.get("gitkraken"))
246
+ .is_some();
247
+ if !has_gitkraken && which("gk") {
248
+ let mut servers: BTreeMap<String, Value> = config
249
+ .extra
250
+ .get("mcpServers")
251
+ .cloned()
252
+ .and_then(|v| serde_json::from_value(v).ok())
253
+ .unwrap_or_default();
254
+ servers.insert(
255
+ "gitkraken".to_string(),
256
+ serde_json::json!({
257
+ "command": "gk",
258
+ "args": ["mcp"]
259
+ }),
260
+ );
261
+ config
262
+ .extra
263
+ .insert("mcpServers".into(), serde_json::to_value(servers).unwrap());
264
+ let _ = save_config_to(path, &config);
265
+ }
266
+
228
267
  Ok(config)
229
268
  }
230
269
 
@@ -317,6 +356,21 @@ fn runtime_extra_defaults() -> BTreeMap<String, Value> {
317
356
  .expect("runtime config defaults must be a JSON object")
318
357
  }
319
358
 
359
+ pub fn which(cmd: &str) -> bool {
360
+ #[cfg(windows)]
361
+ let check_cmd = "where";
362
+ #[cfg(not(windows))]
363
+ let check_cmd = "which";
364
+
365
+ std::process::Command::new(check_cmd)
366
+ .arg(cmd)
367
+ .stdout(std::process::Stdio::null())
368
+ .stderr(std::process::Stdio::null())
369
+ .status()
370
+ .map(|status| status.success())
371
+ .unwrap_or(false)
372
+ }
373
+
320
374
  #[cfg(test)]
321
375
  mod tests {
322
376
  use super::*;
@@ -384,4 +438,18 @@ mod tests {
384
438
  );
385
439
  assert_eq!(config.extra["allowedMcpTools"], serde_json::json!({}));
386
440
  }
441
+
442
+ #[test]
443
+ fn test_gitkraken_auto_detection() {
444
+ let path = std::env::temp_dir().join("mint-test-config.json");
445
+ let _ = std::fs::remove_file(&path);
446
+
447
+ let initial_config = MintConfig::default();
448
+ save_config_to(&path, &initial_config).unwrap();
449
+
450
+ let loaded = load_config_from(&path).unwrap();
451
+ assert!(loaded.theme.len() > 0);
452
+
453
+ let _ = std::fs::remove_file(&path);
454
+ }
387
455
  }
@@ -1,6 +1,7 @@
1
1
  #![recursion_limit = "256"]
2
2
 
3
3
  pub mod agent_loop;
4
+ pub mod browser;
4
5
  pub mod chat;
5
6
  pub mod code_tools;
6
7
  pub mod config;
@@ -29,6 +30,10 @@ pub use chat::{
29
30
  stream_chat_with_fallback,
30
31
  };
31
32
 
33
+ pub use browser::{
34
+ BrowserTab, click, is_browser_running, list_tabs, navigate, read_page_text,
35
+ spawn_automation_browser, type_text,
36
+ };
32
37
  pub use code_tools::{
33
38
  AppliedCodeEdit, CodeEdit, CodeEditPreview, CodeEditProposal, CodeFile, CodeInspectionError,
34
39
  CodePatchHunk, CodePlan, CodeSearchHit, RepositorySummary, apply_code_edits, build_code_patch,
@@ -78,7 +83,7 @@ pub use symbols::{CodeSymbol, SymbolError, SymbolIndex, build_symbol_index};
78
83
  pub use tasks::{Task, TaskError, TaskStore, tasks_path};
79
84
  pub use tts::{TtsUrl, google_tts_urls};
80
85
  pub use weather::{WeatherError, WeatherReport, weather};
81
- pub use workflows::{WorkflowError, load_workflows, workflows_path};
86
+ pub use workflows::{WorkflowError, load_workflows, save_workflows, workflows_path};
82
87
  pub mod api_server;
83
88
  pub use api_server::start_api_server;
84
89
  pub mod channels;