@pheem49/mint 1.8.0 → 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.
- package/.github/workflows/release.yml +1 -1
- package/Cargo.lock +34 -3
- package/Cargo.toml +2 -2
- package/README.md +31 -0
- package/Release_Note.md +95 -0
- package/assets/Mint_CLI.png +0 -0
- package/crates/mint-cli/Cargo.toml +1 -0
- package/crates/mint-cli/src/agent.rs +142 -48
- package/crates/mint-cli/src/main.rs +231 -22
- package/crates/mint-core/src/api_server.rs +9 -0
- package/crates/mint-core/src/browser.rs +401 -0
- package/crates/mint-core/src/config.rs +69 -1
- package/crates/mint-core/src/lib.rs +5 -0
- package/crates/mint-core/src/orchestration.rs +137 -1
- package/crates/mint-core/src/skills.rs +113 -4
- package/crates/mint-core/src/workflows.rs +1 -22
- package/package.json +1 -1
- package/src/renderer/src/components/ChatPanel.tsx +170 -33
- package/src/renderer/src/components/ImageStudioPanel.tsx +11 -1
- package/src/renderer/src/components/MintDashboard.tsx +40 -2
- package/src/renderer/src/components/Settings/PluginsTab.tsx +221 -41
- package/src/renderer/src/components/SettingsWindow.tsx +1 -9
- package/src/renderer/src/components/WorkspacePanel.tsx +66 -9
- package/src/renderer/src/css/image-studio.css +6 -0
- package/src/renderer/src/css/styles.css +64 -11
- package/src/renderer/src/index.css +12 -1
- package/src/renderer/src/tauri.ts +45 -4
- package/src/renderer/src-web/components/ChatPanel.tsx +163 -32
- package/src/renderer/src-web/components/ImageStudioPanel.tsx +11 -1
- package/src/renderer/src-web/components/MintDashboard.tsx +8 -3
- package/src/renderer/src-web/components/Settings/PluginsTab.tsx +221 -41
- package/src/renderer/src-web/components/SettingsWindow.tsx +1 -9
- package/src/renderer/src-web/css/image-studio.css +6 -0
- package/src/renderer/src-web/css/styles.css +100 -12
- package/src/renderer/src-web/index.css +12 -1
- package/src/renderer/src-web/tauri.ts +24 -2
- package/src-tauri/src/browser.rs +1 -141
- package/src-tauri/src/headless.rs +3 -2
- package/src-tauri/src/lib.rs +117 -26
- package/src-tauri/src/workflows.rs +56 -4
- 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
|
+
}
|
|
@@ -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,
|
|
@@ -253,6 +253,16 @@ fn request_chat_id(request: &ChatRequest) -> &str {
|
|
|
253
253
|
|
|
254
254
|
const MAX_STEPS: usize = 32;
|
|
255
255
|
const MAX_OBSERVATION_BYTES: usize = 16_000;
|
|
256
|
+
fn is_port_9222_open() -> bool {
|
|
257
|
+
use std::net::TcpStream;
|
|
258
|
+
use std::time::Duration;
|
|
259
|
+
if let Ok(addr) = "127.0.0.1:9222".parse() {
|
|
260
|
+
TcpStream::connect_timeout(&addr, Duration::from_millis(50)).is_ok()
|
|
261
|
+
} else {
|
|
262
|
+
false
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
256
266
|
pub fn build_system_prompt(config: &MintConfig) -> String {
|
|
257
267
|
let mut allowed_actions = vec![
|
|
258
268
|
"list_files",
|
|
@@ -284,6 +294,14 @@ pub fn build_system_prompt(config: &MintConfig) -> String {
|
|
|
284
294
|
"apply_patch",
|
|
285
295
|
"write_file",
|
|
286
296
|
];
|
|
297
|
+
|
|
298
|
+
if is_port_9222_open() {
|
|
299
|
+
allowed_actions.push("browser_open");
|
|
300
|
+
allowed_actions.push("browser_click");
|
|
301
|
+
allowed_actions.push("browser_type");
|
|
302
|
+
allowed_actions.push("browser_read");
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
allowed_actions.retain(|action| !config.disabled_tools.contains(&action.to_string()));
|
|
288
306
|
allowed_actions.push("finish");
|
|
289
307
|
|
|
@@ -319,6 +337,19 @@ pub fn build_system_prompt(config: &MintConfig) -> String {
|
|
|
319
337
|
if allowed_actions.contains(&"web_search") {
|
|
320
338
|
input_formats.push("- web_search: {\"query\":\"search terms\",\"limit\":5}");
|
|
321
339
|
}
|
|
340
|
+
if allowed_actions.contains(&"browser_open") {
|
|
341
|
+
input_formats.push("- browser_open: {\"url\":\"https://example.com\"}");
|
|
342
|
+
}
|
|
343
|
+
if allowed_actions.contains(&"browser_click") {
|
|
344
|
+
input_formats.push("- browser_click: {\"selector\":\"button.submit-btn\"}");
|
|
345
|
+
}
|
|
346
|
+
if allowed_actions.contains(&"browser_type") {
|
|
347
|
+
input_formats
|
|
348
|
+
.push("- browser_type: {\"selector\":\"input.search-bar\", \"text\":\"search query\"}");
|
|
349
|
+
}
|
|
350
|
+
if allowed_actions.contains(&"browser_read") {
|
|
351
|
+
input_formats.push("- browser_read: {}");
|
|
352
|
+
}
|
|
322
353
|
if allowed_actions.contains(&"memory_recall") {
|
|
323
354
|
input_formats.push("- memory_recall: {\"query\":\"what did user say about X\"}");
|
|
324
355
|
}
|
|
@@ -420,6 +451,20 @@ pub fn build_system_prompt(config: &MintConfig) -> String {
|
|
|
420
451
|
if allowed_actions.contains(&"web_search") {
|
|
421
452
|
rules.push("7. Use web_search when the user asks to look something up online or needs current information.");
|
|
422
453
|
}
|
|
454
|
+
if allowed_actions.contains(&"browser_open") {
|
|
455
|
+
rules.push("7a. Use browser_open to navigate the virtual browser to a URL.");
|
|
456
|
+
}
|
|
457
|
+
if allowed_actions.contains(&"browser_click") {
|
|
458
|
+
rules.push("7b. Use browser_click to click elements on the page.");
|
|
459
|
+
}
|
|
460
|
+
if allowed_actions.contains(&"browser_type") {
|
|
461
|
+
rules.push("7c. Use browser_type to enter text/input into form fields or search boxes.");
|
|
462
|
+
}
|
|
463
|
+
if allowed_actions.contains(&"browser_read") {
|
|
464
|
+
rules.push(
|
|
465
|
+
"7d. Use browser_read to read the text content of the active page to analyze it.",
|
|
466
|
+
);
|
|
467
|
+
}
|
|
423
468
|
if allowed_actions.contains(&"memory_recall") {
|
|
424
469
|
rules.push("8. Use memory_recall to search past interactions before asking the user to repeat context.");
|
|
425
470
|
}
|
|
@@ -590,6 +635,12 @@ struct AgentInput {
|
|
|
590
635
|
title: String,
|
|
591
636
|
#[serde(default)]
|
|
592
637
|
status: String,
|
|
638
|
+
#[serde(default)]
|
|
639
|
+
url: String,
|
|
640
|
+
#[serde(default)]
|
|
641
|
+
selector: String,
|
|
642
|
+
#[serde(default)]
|
|
643
|
+
text: String,
|
|
593
644
|
}
|
|
594
645
|
|
|
595
646
|
#[derive(Debug, Deserialize, Serialize)]
|
|
@@ -632,7 +683,7 @@ where
|
|
|
632
683
|
))
|
|
633
684
|
})?;
|
|
634
685
|
let resolved_task = resolve_github_links(task, config).await;
|
|
635
|
-
let skills = crate::skills::learned_skills_context().unwrap_or_default();
|
|
686
|
+
let skills = crate::skills::learned_skills_context(Some(&root)).unwrap_or_default();
|
|
636
687
|
let mut observation = initial_observation(&resolved_task, &root, &skills);
|
|
637
688
|
let mut pending_image = image_data_uri;
|
|
638
689
|
|
|
@@ -1076,6 +1127,91 @@ where
|
|
|
1076
1127
|
)),
|
|
1077
1128
|
}
|
|
1078
1129
|
}
|
|
1130
|
+
"browser_open" => {
|
|
1131
|
+
let url = if !input.url.is_empty() {
|
|
1132
|
+
&input.url
|
|
1133
|
+
} else if !input.path.is_empty() {
|
|
1134
|
+
&input.path
|
|
1135
|
+
} else {
|
|
1136
|
+
return Err(OrchestrationError::Agent(
|
|
1137
|
+
"browser_open requires 'url'".into(),
|
|
1138
|
+
));
|
|
1139
|
+
};
|
|
1140
|
+
if crate::is_browser_running(config).await {
|
|
1141
|
+
let result = crate::browser::navigate(config, url)
|
|
1142
|
+
.await
|
|
1143
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1144
|
+
Ok(result)
|
|
1145
|
+
} else {
|
|
1146
|
+
let opened = if cfg!(target_os = "macos") {
|
|
1147
|
+
std::process::Command::new("open").arg(url).spawn().is_ok()
|
|
1148
|
+
} else if cfg!(target_os = "windows") {
|
|
1149
|
+
std::process::Command::new("cmd")
|
|
1150
|
+
.args(&["/C", "start", url])
|
|
1151
|
+
.spawn()
|
|
1152
|
+
.is_ok()
|
|
1153
|
+
} else {
|
|
1154
|
+
std::process::Command::new("xdg-open")
|
|
1155
|
+
.arg(url)
|
|
1156
|
+
.spawn()
|
|
1157
|
+
.is_ok()
|
|
1158
|
+
};
|
|
1159
|
+
if opened {
|
|
1160
|
+
Ok(format!(
|
|
1161
|
+
"Mint Auto is not active. Opened {url} in your default browser instead."
|
|
1162
|
+
))
|
|
1163
|
+
} else {
|
|
1164
|
+
Err(OrchestrationError::Agent(
|
|
1165
|
+
"Failed to open URL in default browser.".into(),
|
|
1166
|
+
))
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
"browser_click" => {
|
|
1171
|
+
let selector = if !input.selector.is_empty() {
|
|
1172
|
+
&input.selector
|
|
1173
|
+
} else if !input.path.is_empty() {
|
|
1174
|
+
&input.path
|
|
1175
|
+
} else {
|
|
1176
|
+
return Err(OrchestrationError::Agent(
|
|
1177
|
+
"browser_click requires 'selector'".into(),
|
|
1178
|
+
));
|
|
1179
|
+
};
|
|
1180
|
+
let result = crate::browser::click(config, selector)
|
|
1181
|
+
.await
|
|
1182
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1183
|
+
Ok(result)
|
|
1184
|
+
}
|
|
1185
|
+
"browser_type" => {
|
|
1186
|
+
let selector = if !input.selector.is_empty() {
|
|
1187
|
+
&input.selector
|
|
1188
|
+
} else if !input.path.is_empty() {
|
|
1189
|
+
&input.path
|
|
1190
|
+
} else {
|
|
1191
|
+
return Err(OrchestrationError::Agent(
|
|
1192
|
+
"browser_type requires 'selector'".into(),
|
|
1193
|
+
));
|
|
1194
|
+
};
|
|
1195
|
+
let text = if !input.text.is_empty() {
|
|
1196
|
+
&input.text
|
|
1197
|
+
} else if !input.query.is_empty() {
|
|
1198
|
+
&input.query
|
|
1199
|
+
} else {
|
|
1200
|
+
return Err(OrchestrationError::Agent(
|
|
1201
|
+
"browser_type requires 'text'".into(),
|
|
1202
|
+
));
|
|
1203
|
+
};
|
|
1204
|
+
let result = crate::browser::type_text(config, selector, text)
|
|
1205
|
+
.await
|
|
1206
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1207
|
+
Ok(result)
|
|
1208
|
+
}
|
|
1209
|
+
"browser_read" => {
|
|
1210
|
+
let result = crate::browser::read_page_text(config)
|
|
1211
|
+
.await
|
|
1212
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1213
|
+
Ok(result)
|
|
1214
|
+
}
|
|
1079
1215
|
"memory_recall" => {
|
|
1080
1216
|
let query = required(&input.query, "query")?;
|
|
1081
1217
|
let query_lower = query.to_ascii_lowercase();
|