@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,727 @@
1
+ use std::{
2
+ fs,
3
+ path::{Component, Path, PathBuf},
4
+ process::Command,
5
+ time::{SystemTime, UNIX_EPOCH},
6
+ };
7
+
8
+ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
9
+ use serde::Serialize;
10
+ use serde_json::{Value, json};
11
+ use thiserror::Error;
12
+
13
+ use crate::MintConfig;
14
+
15
+ #[derive(Debug, Error)]
16
+ pub enum PluginError {
17
+ #[error("unknown native plugin: {0}")]
18
+ UnknownPlugin(String),
19
+ #[error("native plugin '{0}' is not allowed by policy")]
20
+ NotAllowed(String),
21
+ #[error("invalid instruction for {plugin}: {message}")]
22
+ InvalidInstruction {
23
+ plugin: &'static str,
24
+ message: String,
25
+ },
26
+ #[error("unable to run {program}: {source}")]
27
+ Execute {
28
+ program: &'static str,
29
+ source: std::io::Error,
30
+ },
31
+ #[error("{program} failed: {message}")]
32
+ Failed {
33
+ program: &'static str,
34
+ message: String,
35
+ },
36
+ #[error("unable to access note {path}: {source}")]
37
+ Note {
38
+ path: PathBuf,
39
+ source: std::io::Error,
40
+ },
41
+ #[error("plugin request failed: {message}")]
42
+ RequestFailed { message: String },
43
+ #[error("missing configuration value: {message}")]
44
+ MissingConfig { message: String },
45
+ }
46
+
47
+ #[derive(Debug, Clone, Serialize)]
48
+ pub struct NativePlugin {
49
+ pub name: &'static str,
50
+ pub description: &'static str,
51
+ }
52
+
53
+ pub fn native_plugins() -> Vec<NativePlugin> {
54
+ vec![
55
+ NativePlugin {
56
+ name: "dev_tools",
57
+ description: "Read git status, log, or branch information.",
58
+ },
59
+ NativePlugin {
60
+ name: "docker",
61
+ description: "List, start, stop, or restart local Docker containers.",
62
+ },
63
+ NativePlugin {
64
+ name: "obsidian",
65
+ description: "List, read, or append local Markdown notes.",
66
+ },
67
+ NativePlugin {
68
+ name: "spotify",
69
+ description: "Control Spotify through playerctl.",
70
+ },
71
+ NativePlugin {
72
+ name: "system_metrics",
73
+ description: "Read native RAM, CPU, and uptime metrics.",
74
+ },
75
+ NativePlugin {
76
+ name: "gmail",
77
+ description: "Search/read Gmail and create drafts safely.",
78
+ },
79
+ NativePlugin {
80
+ name: "google_calendar",
81
+ description: "List events and create calendar events via Google Calendar API.",
82
+ },
83
+ NativePlugin {
84
+ name: "notion",
85
+ description: "Create notes, read databases, and append blocks through Notion API.",
86
+ },
87
+ ]
88
+ }
89
+
90
+ pub async fn execute_native_plugin(
91
+ config: &MintConfig,
92
+ name: &str,
93
+ instruction: &str,
94
+ ) -> Result<String, PluginError> {
95
+ if !native_plugin_allowed(config, name) {
96
+ return Err(PluginError::NotAllowed(name.into()));
97
+ }
98
+ match name {
99
+ "dev_tools" => dev_tools(instruction),
100
+ "docker" => docker(instruction),
101
+ "obsidian" => obsidian(instruction),
102
+ "spotify" => spotify(instruction),
103
+ "system_metrics" => system_metrics(instruction),
104
+ "gmail" => gmail(config, instruction).await,
105
+ "google_calendar" => calendar(config, instruction).await,
106
+ "notion" => notion(config, instruction).await,
107
+ _ => Err(PluginError::UnknownPlugin(name.into())),
108
+ }
109
+ }
110
+
111
+ fn native_plugin_allowed(config: &MintConfig, name: &str) -> bool {
112
+ config
113
+ .extra
114
+ .get("allowedNativePlugins")
115
+ .and_then(|value| value.as_array())
116
+ .map(|values| {
117
+ values
118
+ .iter()
119
+ .filter_map(|value| value.as_str())
120
+ .any(|value| value == "*" || value == name)
121
+ })
122
+ .unwrap_or(false)
123
+ }
124
+
125
+ fn dev_tools(instruction: &str) -> Result<String, PluginError> {
126
+ let lower = instruction.to_lowercase();
127
+ let args = if lower.contains("status") {
128
+ vec!["status", "--short"]
129
+ } else if lower.contains("log") || lower.contains("commit") {
130
+ vec!["log", "-n", "5", "--oneline"]
131
+ } else if lower.contains("branch") {
132
+ vec!["branch"]
133
+ } else {
134
+ return invalid("dev_tools", "expected status, log, or branch");
135
+ };
136
+ run("git", &args)
137
+ }
138
+
139
+ fn docker(instruction: &str) -> Result<String, PluginError> {
140
+ let parts = instruction.split_whitespace().collect::<Vec<_>>();
141
+ match parts.as_slice() {
142
+ ["list"] => run("docker", &["ps", "--format", "{{.Names}} ({{.Status}})"]),
143
+ [action, container]
144
+ if matches!(*action, "start" | "stop" | "restart")
145
+ && container.chars().all(|character| {
146
+ character.is_ascii_alphanumeric() || "_.-".contains(character)
147
+ }) =>
148
+ {
149
+ run("docker", &[*action, *container])
150
+ }
151
+ _ => invalid(
152
+ "docker",
153
+ "expected list, start <container>, stop <container>, or restart <container>",
154
+ ),
155
+ }
156
+ }
157
+
158
+ fn obsidian(instruction: &str) -> Result<String, PluginError> {
159
+ let directory = notes_directory()?;
160
+ if instruction.trim() == "list" {
161
+ let mut notes = fs::read_dir(&directory)
162
+ .map_err(|source| PluginError::Note {
163
+ path: directory.clone(),
164
+ source,
165
+ })?
166
+ .flatten()
167
+ .map(|entry| entry.file_name().to_string_lossy().to_string())
168
+ .filter(|name| name.ends_with(".md"))
169
+ .collect::<Vec<_>>();
170
+ notes.sort();
171
+ return Ok(notes.join("\n"));
172
+ }
173
+ if let Some(name) = instruction.strip_prefix("read:") {
174
+ let path = note_path(&directory, name)?;
175
+ return fs::read_to_string(&path).map_err(|source| PluginError::Note { path, source });
176
+ }
177
+ if let Some(payload) = instruction.strip_prefix("write:") {
178
+ let Some((name, content)) = payload.split_once('|') else {
179
+ return invalid("obsidian", "expected write: filename | content");
180
+ };
181
+ let path = note_path(&directory, name)?;
182
+ let entry = format!("\n--- saved {} ---\n{}\n", timestamp(), content.trim());
183
+ fs::OpenOptions::new()
184
+ .create(true)
185
+ .append(true)
186
+ .open(&path)
187
+ .and_then(|mut file| std::io::Write::write_all(&mut file, entry.as_bytes()))
188
+ .map_err(|source| PluginError::Note { path, source })?;
189
+ return Ok("saved".into());
190
+ }
191
+ invalid(
192
+ "obsidian",
193
+ "expected list, read: filename, or write: filename | content",
194
+ )
195
+ }
196
+
197
+ fn spotify(instruction: &str) -> Result<String, PluginError> {
198
+ let parts = instruction.split_whitespace().collect::<Vec<_>>();
199
+ let mut args = vec!["-p", "spotify"];
200
+ match parts.as_slice() {
201
+ [action @ ("play" | "pause" | "stop" | "next" | "previous")] => args.push(action),
202
+ ["status"] | ["now_playing"] => args.extend([
203
+ "metadata",
204
+ "--format",
205
+ "{{status}} | {{artist}} - {{title}}",
206
+ ]),
207
+ ["volume", level] => {
208
+ let level = level
209
+ .parse::<u8>()
210
+ .ok()
211
+ .filter(|level| *level <= 100)
212
+ .ok_or_else(|| PluginError::InvalidInstruction {
213
+ plugin: "spotify",
214
+ message: "volume must be between 0 and 100".into(),
215
+ })?;
216
+ let level = format!("{:.2}", f32::from(level) / 100.0);
217
+ return run("playerctl", &["-p", "spotify", "volume", &level]);
218
+ }
219
+ ["shuffle", state] if matches!(*state, "on" | "off" | "toggle") => {
220
+ args.extend(["shuffle", state])
221
+ }
222
+ _ => {
223
+ return invalid(
224
+ "spotify",
225
+ "expected play, pause, stop, next, previous, status, volume <0-100>, or shuffle <on|off|toggle>",
226
+ );
227
+ }
228
+ }
229
+ run("playerctl", &args)
230
+ }
231
+
232
+ fn system_metrics(instruction: &str) -> Result<String, PluginError> {
233
+ let uptime = fs::read_to_string("/proc/uptime")
234
+ .ok()
235
+ .and_then(|raw| raw.split_whitespace().next()?.parse::<f64>().ok())
236
+ .unwrap_or_default();
237
+ let memory = fs::read_to_string("/proc/meminfo").unwrap_or_default();
238
+ let total = meminfo_kib(&memory, "MemTotal").unwrap_or_default();
239
+ let available = meminfo_kib(&memory, "MemAvailable").unwrap_or_default();
240
+ let used = total.saturating_sub(available);
241
+ let cpu_count = std::thread::available_parallelism()
242
+ .map(|count| count.get())
243
+ .unwrap_or(1);
244
+ match instruction.trim() {
245
+ "ram" => Ok(format!("RAM: {used} KiB used / {total} KiB total")),
246
+ "cpu" => Ok(format!("CPU: {cpu_count} logical cores")),
247
+ "uptime" => Ok(format!("uptime: {} minutes", (uptime / 60.0) as u64)),
248
+ "" | "all" => Ok(format!(
249
+ "RAM: {used} KiB used / {total} KiB total, CPU: {cpu_count} logical cores, uptime: {} minutes",
250
+ (uptime / 60.0) as u64
251
+ )),
252
+ _ => invalid("system_metrics", "expected all, ram, cpu, or uptime"),
253
+ }
254
+ }
255
+
256
+ fn notes_directory() -> Result<PathBuf, PluginError> {
257
+ let path = dirs::home_dir()
258
+ .unwrap_or_else(|| PathBuf::from("."))
259
+ .join("Documents")
260
+ .join("Mint_Notes");
261
+ fs::create_dir_all(&path).map_err(|source| PluginError::Note {
262
+ path: path.clone(),
263
+ source,
264
+ })?;
265
+ Ok(path)
266
+ }
267
+
268
+ fn note_path(directory: &Path, name: &str) -> Result<PathBuf, PluginError> {
269
+ let mut name = name.trim().to_owned();
270
+ if !name.ends_with(".md") {
271
+ name.push_str(".md");
272
+ }
273
+ let path = PathBuf::from(&name);
274
+ if path.components().count() != 1
275
+ || !matches!(path.components().next(), Some(Component::Normal(_)))
276
+ {
277
+ return invalid("obsidian", "note name must not contain a path");
278
+ }
279
+ Ok(directory.join(path))
280
+ }
281
+
282
+ fn run(program: &'static str, args: &[&str]) -> Result<String, PluginError> {
283
+ let output = Command::new(program)
284
+ .args(args)
285
+ .output()
286
+ .map_err(|source| PluginError::Execute { program, source })?;
287
+ if !output.status.success() {
288
+ return Err(PluginError::Failed {
289
+ program,
290
+ message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
291
+ });
292
+ }
293
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
294
+ }
295
+
296
+ fn invalid<T>(plugin: &'static str, message: impl Into<String>) -> Result<T, PluginError> {
297
+ Err(PluginError::InvalidInstruction {
298
+ plugin,
299
+ message: message.into(),
300
+ })
301
+ }
302
+
303
+ fn meminfo_kib(raw: &str, key: &str) -> Option<u64> {
304
+ raw.lines()
305
+ .find(|line| line.starts_with(key))?
306
+ .split_whitespace()
307
+ .nth(1)?
308
+ .parse()
309
+ .ok()
310
+ }
311
+
312
+ fn timestamp() -> u64 {
313
+ SystemTime::now()
314
+ .duration_since(UNIX_EPOCH)
315
+ .unwrap_or_default()
316
+ .as_secs()
317
+ }
318
+
319
+ async fn gmail(config: &MintConfig, instruction: &str) -> Result<String, PluginError> {
320
+ let token = google_access_token(
321
+ config_value(config, "gmailClientId")?,
322
+ config_value(config, "gmailClientSecret")?,
323
+ config_value(config, "gmailRefreshToken")?,
324
+ )
325
+ .await?;
326
+ let user = config_optional(config, "gmailUserId").unwrap_or("me");
327
+ let input = parse_instruction(instruction, "search");
328
+ match input["action"].as_str().unwrap_or("search") {
329
+ "read" => gmail_read(&token, user, &input).await,
330
+ "draft" => gmail_draft(&token, user, &input).await,
331
+ _ => gmail_search(&token, user, &input).await,
332
+ }
333
+ }
334
+
335
+ async fn gmail_search(token: &str, user: &str, input: &Value) -> Result<String, PluginError> {
336
+ let query = input["query"].as_str().unwrap_or("in:inbox");
337
+ let value: Value = crate::HTTP_CLIENT
338
+ .clone()
339
+ .get(format!(
340
+ "https://gmail.googleapis.com/gmail/v1/users/{user}/messages"
341
+ ))
342
+ .bearer_auth(token)
343
+ .query(&[("q", query), ("maxResults", "10")])
344
+ .send()
345
+ .await
346
+ .map_err(request_error)?
347
+ .error_for_status()
348
+ .map_err(request_error)?
349
+ .json()
350
+ .await
351
+ .map_err(request_error)?;
352
+ let messages = value["messages"].as_array().cloned().unwrap_or_default();
353
+ Ok(if messages.is_empty() {
354
+ "No Gmail messages found.".into()
355
+ } else {
356
+ format!(
357
+ "Gmail matched message IDs:\n{}",
358
+ messages
359
+ .iter()
360
+ .filter_map(|item| item["id"].as_str())
361
+ .map(|id| format!("- {id}"))
362
+ .collect::<Vec<_>>()
363
+ .join("\n")
364
+ )
365
+ })
366
+ }
367
+
368
+ async fn gmail_read(token: &str, user: &str, input: &Value) -> Result<String, PluginError> {
369
+ let id = input["id"]
370
+ .as_str()
371
+ .ok_or_else(|| PluginError::InvalidInstruction {
372
+ plugin: "gmail",
373
+ message: "missing Gmail message id".into(),
374
+ })?;
375
+ let value: Value = crate::HTTP_CLIENT
376
+ .clone()
377
+ .get(format!(
378
+ "https://gmail.googleapis.com/gmail/v1/users/{user}/messages/{id}"
379
+ ))
380
+ .bearer_auth(token)
381
+ .query(&[("format", "full")])
382
+ .send()
383
+ .await
384
+ .map_err(request_error)?
385
+ .error_for_status()
386
+ .map_err(request_error)?
387
+ .json()
388
+ .await
389
+ .map_err(request_error)?;
390
+ Ok(format!(
391
+ "Gmail message {id}:\n{}",
392
+ value["snippet"].as_str().unwrap_or("(No readable snippet)")
393
+ ))
394
+ }
395
+
396
+ async fn gmail_draft(token: &str, user: &str, input: &Value) -> Result<String, PluginError> {
397
+ let to = input["to"]
398
+ .as_str()
399
+ .ok_or_else(|| PluginError::InvalidInstruction {
400
+ plugin: "gmail",
401
+ message: "missing Gmail draft recipient".into(),
402
+ })?;
403
+ let subject = input["subject"].as_str().unwrap_or("(No subject)");
404
+ let body = input["body"].as_str().unwrap_or_default();
405
+ let raw = URL_SAFE_NO_PAD.encode(format!(
406
+ "To: {}\r\nSubject: {}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\n{}",
407
+ sanitize_header(to), sanitize_header(subject), body
408
+ ));
409
+ let value: Value = crate::HTTP_CLIENT
410
+ .clone()
411
+ .post(format!(
412
+ "https://gmail.googleapis.com/gmail/v1/users/{user}/drafts"
413
+ ))
414
+ .bearer_auth(token)
415
+ .json(&json!({ "message": { "raw": raw } }))
416
+ .send()
417
+ .await
418
+ .map_err(request_error)?
419
+ .error_for_status()
420
+ .map_err(request_error)?
421
+ .json()
422
+ .await
423
+ .map_err(request_error)?;
424
+ Ok(format!(
425
+ "Created Gmail draft {} for {}.",
426
+ value["id"].as_str().unwrap_or("(unknown id)"),
427
+ sanitize_header(to)
428
+ ))
429
+ }
430
+
431
+ async fn calendar(config: &MintConfig, instruction: &str) -> Result<String, PluginError> {
432
+ if instruction.trim().is_empty() || instruction.trim() == "open" {
433
+ return Ok("https://calendar.google.com/".into());
434
+ }
435
+ let token = google_access_token(
436
+ config_value(config, "googleCalendarClientId")?,
437
+ config_value(config, "googleCalendarClientSecret")?,
438
+ config_value(config, "googleCalendarRefreshToken")?,
439
+ )
440
+ .await?;
441
+ let id = config_optional(config, "googleCalendarId").unwrap_or("primary");
442
+ let input = parse_instruction(instruction, "list");
443
+ if input["action"].as_str() == Some("create") {
444
+ return calendar_create(&token, id, &input).await;
445
+ }
446
+ let value: Value = crate::HTTP_CLIENT
447
+ .clone()
448
+ .get(format!(
449
+ "https://www.googleapis.com/calendar/v3/calendars/{id}/events"
450
+ ))
451
+ .bearer_auth(token)
452
+ .query(&[
453
+ ("singleEvents", "true"),
454
+ ("orderBy", "startTime"),
455
+ ("maxResults", "10"),
456
+ ])
457
+ .send()
458
+ .await
459
+ .map_err(request_error)?
460
+ .error_for_status()
461
+ .map_err(request_error)?
462
+ .json()
463
+ .await
464
+ .map_err(request_error)?;
465
+ Ok(format!(
466
+ "Google Calendar events:\n{}",
467
+ value["items"]
468
+ .as_array()
469
+ .into_iter()
470
+ .flatten()
471
+ .map(|event| format!("- {}", event["summary"].as_str().unwrap_or("(Untitled)")))
472
+ .collect::<Vec<_>>()
473
+ .join("\n")
474
+ ))
475
+ }
476
+
477
+ async fn calendar_create(token: &str, id: &str, input: &Value) -> Result<String, PluginError> {
478
+ let summary = input["summary"]
479
+ .as_str()
480
+ .ok_or_else(|| PluginError::InvalidInstruction {
481
+ plugin: "google_calendar",
482
+ message: "missing Calendar event summary".into(),
483
+ })?;
484
+ let start = input["start"]
485
+ .as_str()
486
+ .ok_or_else(|| PluginError::InvalidInstruction {
487
+ plugin: "google_calendar",
488
+ message: "missing Calendar event start".into(),
489
+ })?;
490
+ let end = input["end"].as_str().unwrap_or(start);
491
+ let value: Value = crate::HTTP_CLIENT
492
+ .clone()
493
+ .post(format!(
494
+ "https://www.googleapis.com/calendar/v3/calendars/{id}/events"
495
+ ))
496
+ .bearer_auth(token)
497
+ .json(&json!({
498
+ "summary": summary, "description": input["description"], "location": input["location"],
499
+ "start": { "dateTime": start }, "end": { "dateTime": end }
500
+ }))
501
+ .send()
502
+ .await
503
+ .map_err(request_error)?
504
+ .error_for_status()
505
+ .map_err(request_error)?
506
+ .json()
507
+ .await
508
+ .map_err(request_error)?;
509
+ Ok(format!(
510
+ "Created Calendar event \"{summary}\".{}",
511
+ value["htmlLink"]
512
+ .as_str()
513
+ .map(|link| format!("\n{link}"))
514
+ .unwrap_or_default()
515
+ ))
516
+ }
517
+
518
+ async fn notion(config: &MintConfig, instruction: &str) -> Result<String, PluginError> {
519
+ let key = config_value(config, "notionApiKey")?;
520
+ let input = parse_instruction(instruction, "create_page");
521
+ match input["action"].as_str().unwrap_or("create_page") {
522
+ "query_database" => notion_query(key, config_value(config, "notionDatabaseId")?).await,
523
+ "append_block" => notion_append(key, &input).await,
524
+ _ => notion_create(key, config, instruction, &input).await,
525
+ }
526
+ }
527
+
528
+ async fn notion_create(
529
+ key: &str,
530
+ config: &MintConfig,
531
+ instruction: &str,
532
+ input: &Value,
533
+ ) -> Result<String, PluginError> {
534
+ let database = config_value(config, "notionDatabaseId")?;
535
+ let property = config_optional(config, "notionTitleProperty").unwrap_or("Name");
536
+ let title = input["title"]
537
+ .as_str()
538
+ .or_else(|| instruction.lines().next())
539
+ .unwrap_or("Mint Note")
540
+ .trim();
541
+ let content = input["content"].as_str().unwrap_or(instruction);
542
+ let value: Value = crate::HTTP_CLIENT
543
+ .clone()
544
+ .post("https://api.notion.com/v1/pages")
545
+ .bearer_auth(key)
546
+ .header("Notion-Version", "2022-06-28")
547
+ .json(&json!({
548
+ "parent": { "database_id": database },
549
+ "properties": { (property): { "title": [{ "text": { "content": title } }] } },
550
+ "children": [{ "object": "block", "type": "paragraph", "paragraph": {
551
+ "rich_text": [{ "type": "text", "text": { "content": content } }]
552
+ }}]
553
+ }))
554
+ .send()
555
+ .await
556
+ .map_err(request_error)?
557
+ .error_for_status()
558
+ .map_err(request_error)?
559
+ .json()
560
+ .await
561
+ .map_err(request_error)?;
562
+ Ok(format!(
563
+ "Created Notion page \"{title}\".{}",
564
+ value["url"]
565
+ .as_str()
566
+ .map(|url| format!("\n{url}"))
567
+ .unwrap_or_default()
568
+ ))
569
+ }
570
+
571
+ async fn notion_query(key: &str, database: &str) -> Result<String, PluginError> {
572
+ let value: Value = crate::HTTP_CLIENT
573
+ .clone()
574
+ .post(format!(
575
+ "https://api.notion.com/v1/databases/{database}/query"
576
+ ))
577
+ .bearer_auth(key)
578
+ .header("Notion-Version", "2022-06-28")
579
+ .json(&json!({ "page_size": 10 }))
580
+ .send()
581
+ .await
582
+ .map_err(request_error)?
583
+ .error_for_status()
584
+ .map_err(request_error)?
585
+ .json()
586
+ .await
587
+ .map_err(request_error)?;
588
+ Ok(format!(
589
+ "Notion matched page URLs:\n{}",
590
+ value["results"]
591
+ .as_array()
592
+ .into_iter()
593
+ .flatten()
594
+ .filter_map(|page| page["url"].as_str())
595
+ .map(|url| format!("- {url}"))
596
+ .collect::<Vec<_>>()
597
+ .join("\n")
598
+ ))
599
+ }
600
+
601
+ async fn notion_append(key: &str, input: &Value) -> Result<String, PluginError> {
602
+ let page = input["pageId"]
603
+ .as_str()
604
+ .ok_or_else(|| PluginError::InvalidInstruction {
605
+ plugin: "notion",
606
+ message: "missing Notion pageId".into(),
607
+ })?;
608
+ let content = input["content"]
609
+ .as_str()
610
+ .ok_or_else(|| PluginError::InvalidInstruction {
611
+ plugin: "notion",
612
+ message: "missing Notion append content".into(),
613
+ })?;
614
+ crate::HTTP_CLIENT
615
+ .clone()
616
+ .patch(format!("https://api.notion.com/v1/blocks/{page}/children"))
617
+ .bearer_auth(key)
618
+ .header("Notion-Version", "2022-06-28")
619
+ .json(
620
+ &json!({ "children": [{ "object": "block", "type": "paragraph", "paragraph": {
621
+ "rich_text": [{ "type": "text", "text": { "content": content } }]
622
+ }}] }),
623
+ )
624
+ .send()
625
+ .await
626
+ .map_err(request_error)?
627
+ .error_for_status()
628
+ .map_err(request_error)?;
629
+ Ok("Appended block to Notion page.".into())
630
+ }
631
+
632
+ async fn google_access_token(
633
+ client_id: &str,
634
+ secret: &str,
635
+ refresh: &str,
636
+ ) -> Result<String, PluginError> {
637
+ let value: Value = crate::HTTP_CLIENT
638
+ .clone()
639
+ .post("https://oauth2.googleapis.com/token")
640
+ .form(&[
641
+ ("client_id", client_id),
642
+ ("client_secret", secret),
643
+ ("refresh_token", refresh),
644
+ ("grant_type", "refresh_token"),
645
+ ])
646
+ .send()
647
+ .await
648
+ .map_err(request_error)?
649
+ .error_for_status()
650
+ .map_err(request_error)?
651
+ .json()
652
+ .await
653
+ .map_err(request_error)?;
654
+ value["access_token"]
655
+ .as_str()
656
+ .map(str::to_owned)
657
+ .ok_or_else(|| PluginError::RequestFailed {
658
+ message: "OAuth token response did not include access_token".into(),
659
+ })
660
+ }
661
+
662
+ fn parse_instruction(instruction: &str, action: &str) -> Value {
663
+ serde_json::from_str(instruction).unwrap_or_else(|_| json!({
664
+ "action": action, "query": instruction.strip_prefix("search ").unwrap_or(instruction).trim(),
665
+ "title": instruction.lines().next().unwrap_or("Mint Note"), "content": instruction
666
+ }))
667
+ }
668
+
669
+ fn config_value<'a>(config: &'a MintConfig, key: &str) -> Result<&'a str, PluginError> {
670
+ config_optional(config, key).ok_or_else(|| PluginError::MissingConfig {
671
+ message: format!("missing config value '{key}'"),
672
+ })
673
+ }
674
+
675
+ fn config_optional<'a>(config: &'a MintConfig, key: &str) -> Option<&'a str> {
676
+ config
677
+ .extra
678
+ .get(key)
679
+ .and_then(Value::as_str)
680
+ .filter(|value| !value.trim().is_empty())
681
+ }
682
+
683
+ fn sanitize_header(value: &str) -> String {
684
+ value.replace(['\r', '\n'], " ").trim().to_owned()
685
+ }
686
+
687
+ fn request_error(error: reqwest::Error) -> PluginError {
688
+ PluginError::RequestFailed {
689
+ message: format!("plugin request failed: {error}"),
690
+ }
691
+ }
692
+
693
+ #[cfg(test)]
694
+ mod tests {
695
+ use super::*;
696
+
697
+ #[test]
698
+ fn blocks_obsidian_path_traversal() {
699
+ let error = note_path(Path::new("/tmp"), "../secret").unwrap_err();
700
+ assert!(matches!(error, PluginError::InvalidInstruction { .. }));
701
+ }
702
+
703
+ #[tokio::test]
704
+ async fn rejects_unknown_plugins() {
705
+ let config = MintConfig::default();
706
+ assert!(matches!(
707
+ execute_native_plugin(&config, "missing", "").await,
708
+ Err(PluginError::NotAllowed(_))
709
+ ));
710
+ }
711
+
712
+ #[tokio::test]
713
+ async fn rejects_native_plugin_not_in_allowlist() {
714
+ let config = MintConfig::default();
715
+ assert!(matches!(
716
+ execute_native_plugin(&config, "docker", "list").await,
717
+ Err(PluginError::NotAllowed(name)) if name == "docker"
718
+ ));
719
+ }
720
+
721
+ #[tokio::test]
722
+ async fn allows_default_read_only_native_plugin() {
723
+ let config = MintConfig::default();
724
+ let result = execute_native_plugin(&config, "dev_tools", "status").await;
725
+ assert!(result.is_ok());
726
+ }
727
+ }