1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! Core plugin trait and metadata definitions
use crate::{
context::PluginContext,
error::Result,
menu::MenuItem,
types::{Action, ModalItem},
};
use async_trait::async_trait;
/// Main plugin trait that all plugins must implement
///
/// Plugins can hook into various events in the terminal lifecycle.
/// All methods have default implementations that do nothing.
#[async_trait]
pub trait Plugin: Send + Sync {
/// Get plugin metadata
fn metadata(&self) -> &PluginMetadata;
/// Get the menu items for this plugin's dock menu
///
/// This defines the menu that appears when the plugin is activated from the dock.
/// Return an empty Vec if the plugin has no menu.
///
/// # Example
///
/// ```rust,ignore
/// use scarab_plugin_api::menu::{MenuItem, MenuAction};
///
/// fn get_menu(&self) -> Vec<MenuItem> {
/// vec![
/// MenuItem::new("Chat", MenuAction::remote("open_chat"))
/// .with_icon("💬"),
/// MenuItem::new("Settings", MenuAction::remote("settings"))
/// .with_icon("⚙️"),
/// ]
/// }
/// ```
fn get_menu(&self) -> Vec<MenuItem> {
Vec::new()
}
/// Get list of commands provided by this plugin
fn get_commands(&self) -> Vec<ModalItem> {
Vec::new()
}
/// Called when the plugin is loaded
///
/// This is where plugins should initialize their state and resources.
async fn on_load(&mut self, _ctx: &mut PluginContext) -> Result<()> {
Ok(())
}
/// Called when the plugin is being unloaded
///
/// Plugins should clean up resources here.
async fn on_unload(&mut self) -> Result<()> {
Ok(())
}
/// Hook called before output is displayed to the terminal
///
/// Plugins can modify, block, or pass through the output.
async fn on_output(&mut self, _line: &str, _ctx: &PluginContext) -> Result<Action> {
Ok(Action::Continue)
}
/// Hook called after input is received from the user
///
/// Plugins can intercept and modify input before it reaches the PTY.
async fn on_input(&mut self, _input: &[u8], _ctx: &PluginContext) -> Result<Action> {
Ok(Action::Continue)
}
/// Hook called before a command is executed
async fn on_pre_command(&mut self, _command: &str, _ctx: &PluginContext) -> Result<Action> {
Ok(Action::Continue)
}
/// Hook called after a command completes
async fn on_post_command(
&mut self,
_command: &str,
_exit_code: i32,
_ctx: &PluginContext,
) -> Result<()> {
Ok(())
}
/// Hook called when terminal is resized
async fn on_resize(&mut self, _cols: u16, _rows: u16, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
/// Hook called when a client attaches to the session
async fn on_attach(&mut self, _client_id: u64, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
/// Hook called when a client detaches from the session
async fn on_detach(&mut self, _client_id: u64, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
/// Hook called when a remote command is selected/triggered by the client
///
/// This is called when a user selects a menu item with `MenuAction::Remote(id)`.
async fn on_remote_command(&mut self, _id: &str, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
}
/// Plugin metadata with personality
#[derive(Debug, Clone)]
pub struct PluginMetadata {
/// Plugin name (must be unique)
pub name: String,
/// Plugin version (semver)
pub version: String,
/// Short description
pub description: String,
/// Author name
pub author: String,
/// Homepage URL
pub homepage: Option<String>,
/// API version this plugin was built against
pub api_version: String,
/// Minimum Scarab version required
pub min_scarab_version: String,
/// Plugin emoji/icon for visual identification
pub emoji: Option<String>,
/// Plugin theme color (hex code)
pub color: Option<String>,
/// Plugin catchphrase or motto
pub catchphrase: Option<String>,
}
impl PluginMetadata {
/// Create new plugin metadata
pub fn new(
name: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
author: impl Into<String>,
) -> Self {
Self {
name: name.into(),
version: version.into(),
description: description.into(),
author: author.into(),
homepage: None,
api_version: crate::API_VERSION.to_string(),
min_scarab_version: "0.1.0".to_string(),
emoji: None,
color: None,
catchphrase: None,
}
}
/// Set homepage URL
pub fn with_homepage(mut self, homepage: impl Into<String>) -> Self {
self.homepage = Some(homepage.into());
self
}
/// Set API version
pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
self.api_version = version.into();
self
}
/// Set minimum Scarab version
pub fn with_min_scarab_version(mut self, version: impl Into<String>) -> Self {
self.min_scarab_version = version.into();
self
}
/// Set plugin emoji for visual flair
pub fn with_emoji(mut self, emoji: impl Into<String>) -> Self {
self.emoji = Some(emoji.into());
self
}
/// Set plugin theme color (hex code like "#FF5733")
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = Some(color.into());
self
}
/// Set plugin catchphrase or motto
pub fn with_catchphrase(mut self, catchphrase: impl Into<String>) -> Self {
self.catchphrase = Some(catchphrase.into());
self
}
/// Get display name with emoji if available
pub fn display_name(&self) -> String {
if let Some(emoji) = &self.emoji {
format!("{} {}", emoji, self.name)
} else {
self.name.clone()
}
}
/// Check if this plugin is compatible with the current API version
pub fn is_compatible(&self, current_api_version: &str) -> bool {
use semver::Version;
let Ok(plugin_version) = Version::parse(&self.api_version) else {
return false;
};
let Ok(current_version) = Version::parse(current_api_version) else {
return false;
};
// Compatible if major versions match and plugin minor <= current minor
plugin_version.major == current_version.major
&& plugin_version.minor <= current_version.minor
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_compatibility() {
let meta = PluginMetadata::new("test", "1.0.0", "Test plugin", "Test Author")
.with_api_version("0.1.0");
assert!(meta.is_compatible("0.1.0"));
assert!(meta.is_compatible("0.2.0"));
assert!(!meta.is_compatible("1.0.0"));
assert!(!meta.is_compatible("0.0.1"));
}
#[test]
fn test_display_name_with_emoji() {
let meta =
PluginMetadata::new("awesome-plugin", "1.0.0", "Cool plugin", "Dev").with_emoji("🚀");
assert_eq!(meta.display_name(), "🚀 awesome-plugin");
}
#[test]
fn test_display_name_without_emoji() {
let meta = PluginMetadata::new("plain-plugin", "1.0.0", "Plain plugin", "Dev");
assert_eq!(meta.display_name(), "plain-plugin");
}
}