Skip to main content

ai_agent/services/plugins/
plugin_cli_commands.rs

1// Source: ~/claudecode/openclaudecode/src/services/plugins/pluginCliCommands.ts
2#![allow(dead_code)]
3
4//! CLI command wrappers for plugin operations
5//!
6//! This module provides thin wrappers around the core plugin operations
7//! that handle CLI-specific concerns like console output and process exit.
8//!
9//! For the core operations (without CLI side effects), see plugin_operations.rs
10
11use std::collections::HashMap;
12use std::process;
13
14use super::plugin_operations::{
15    InstallableScope, PluginOperationResult, PluginUpdateResult, disable_all_plugins_op,
16    disable_plugin_op, enable_plugin_op, install_plugin_op, uninstall_plugin_op, update_plugin_op,
17};
18use crate::utils::plugins::loader::parse_plugin_identifier;
19
20pub use super::plugin_operations::{VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES};
21
22type PluginCliCommand = &'static str; // "install" | "uninstall" | "enable" | "disable" | "disable-all" | "update"
23
24/// Telemetry fields for plugin operations
25#[derive(Debug, Clone, Default)]
26struct PluginTelemetryFields {
27    plugin_name: Option<String>,
28    marketplace_name: Option<String>,
29    is_managed: bool,
30}
31
32/// Analytics event metadata
33#[derive(Debug, Clone, Default)]
34struct AnalyticsEvent {
35    event_name: String,
36    properties: HashMap<String, serde_json::Value>,
37}
38
39/// Classifies plugin command errors into categories for telemetry
40fn classify_plugin_command_error(error: &dyn std::error::Error) -> String {
41    let msg = error.to_string().to_lowercase();
42    if msg.contains("not found") {
43        "not_found".to_string()
44    } else if msg.contains("permission") || msg.contains("blocked") || msg.contains("policy") {
45        "permission_denied".to_string()
46    } else if msg.contains("network") || msg.contains("timeout") || msg.contains("connection") {
47        "network_error".to_string()
48    } else if msg.contains("parse") || msg.contains("invalid") || msg.contains("format") {
49        "parse_error".to_string()
50    } else {
51        "unknown".to_string()
52    }
53}
54
55/// Build plugin telemetry fields for analytics
56fn build_plugin_telemetry_fields(
57    name: Option<&str>,
58    marketplace: Option<&str>,
59    managed_plugin_names: &[String],
60) -> PluginTelemetryFields {
61    PluginTelemetryFields {
62        plugin_name: name.map(String::from),
63        marketplace_name: marketplace.map(String::from),
64        is_managed: name
65            .map(|n| managed_plugin_names.iter().any(|m| m == n))
66            .unwrap_or(false),
67    }
68}
69
70/// Get managed plugin names (stub - would read from managed plugins config)
71fn get_managed_plugin_names() -> Vec<String> {
72    Vec::new()
73}
74
75/// Log an analytics event
76fn log_event(event: AnalyticsEvent) {
77    log::debug!(
78        "Analytics event: {} {:?}",
79        event.event_name,
80        event.properties
81    );
82}
83
84/// Unicode figures for console output
85mod figures {
86    pub const TICK: &str = "\u{2713}"; // ✓
87    pub const CROSS: &str = "\u{2717}"; // ✗
88}
89
90/// Generic error handler for plugin CLI commands. Emits
91/// tengu_plugin_command_failed before exit so dashboards can compute a
92/// success rate against the corresponding success events.
93fn handle_plugin_command_error(
94    error: &dyn std::error::Error,
95    command: PluginCliCommand,
96    plugin: Option<&str>,
97) -> ! {
98    log::error!("Plugin command error: {}", error);
99
100    let operation = match plugin {
101        Some(p) => format!("{} plugin \"{}\"", command, p),
102        None if command == "disable-all" => "disable all plugins".to_string(),
103        None => format!("{} plugins", command),
104    };
105
106    eprintln!("{} Failed to {}: {}", figures::CROSS, operation, error);
107
108    let telemetry_fields = plugin.map(|p| {
109        let (name, marketplace) = parse_plugin_identifier(p);
110        let telemetry = build_plugin_telemetry_fields(
111            name.as_deref(),
112            marketplace.as_deref(),
113            &get_managed_plugin_names(),
114        );
115        (name, marketplace, telemetry)
116    });
117
118    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
119    properties.insert(
120        "command".to_string(),
121        serde_json::Value::String(command.to_string()),
122    );
123    properties.insert(
124        "error_category".to_string(),
125        serde_json::Value::String(classify_plugin_command_error(error)),
126    );
127
128    if let Some((name, marketplace, telemetry)) = telemetry_fields {
129        if let Some(n) = name {
130            properties.insert(
131                "_PROTO_plugin_name".to_string(),
132                serde_json::Value::String(n),
133            );
134        }
135        if let Some(m) = marketplace {
136            properties.insert(
137                "_PROTO_marketplace_name".to_string(),
138                serde_json::Value::String(m),
139            );
140        }
141        properties.insert(
142            "is_managed".to_string(),
143            serde_json::Value::Bool(telemetry.is_managed),
144        );
145    }
146
147    log_event(AnalyticsEvent {
148        event_name: "tengu_plugin_command_failed".to_string(),
149        properties,
150    });
151
152    process::exit(1);
153}
154
155/// CLI command: Install a plugin non-interactively
156///
157/// # Arguments
158/// * `plugin` - Plugin identifier (name or plugin@marketplace)
159/// * `scope` - Installation scope: user, project, or local (defaults to 'user')
160pub async fn install_plugin(
161    plugin: &str,
162    scope: InstallableScope,
163) -> Result<PluginOperationResult, Box<dyn std::error::Error>> {
164    println!("Installing plugin \"{}\"...", plugin);
165
166    let result = install_plugin_op(plugin, scope).await;
167
168    if !result.success {
169        return Err(result.message.clone().into());
170    }
171
172    println!("{} {}", figures::TICK, result.message);
173
174    // Log analytics
175    let (name, marketplace) =
176        parse_plugin_identifier(result.plugin_id.as_deref().unwrap_or(plugin));
177    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
178    if let Some(n) = &name {
179        properties.insert(
180            "_PROTO_plugin_name".to_string(),
181            serde_json::Value::String(n.clone()),
182        );
183    }
184    if let Some(m) = &marketplace {
185        properties.insert(
186            "_PROTO_marketplace_name".to_string(),
187            serde_json::Value::String(m.clone()),
188        );
189    }
190    properties.insert(
191        "scope".to_string(),
192        serde_json::Value::String(result.scope.clone().unwrap_or_else(|| scope.to_string())),
193    );
194    properties.insert(
195        "install_source".to_string(),
196        serde_json::Value::String("cli-explicit".to_string()),
197    );
198
199    let telemetry = build_plugin_telemetry_fields(
200        name.as_deref(),
201        marketplace.as_deref(),
202        &get_managed_plugin_names(),
203    );
204    properties.insert(
205        "is_managed".to_string(),
206        serde_json::Value::Bool(telemetry.is_managed),
207    );
208
209    log_event(AnalyticsEvent {
210        event_name: "tengu_plugin_installed_cli".to_string(),
211        properties,
212    });
213
214    Ok(result)
215}
216
217/// CLI command: Uninstall a plugin non-interactively
218///
219/// # Arguments
220/// * `plugin` - Plugin name or plugin@marketplace identifier
221/// * `scope` - Uninstall from scope: user, project, or local (defaults to 'user')
222/// * `keep_data` - Whether to keep the plugin's data directory
223pub async fn uninstall_plugin(
224    plugin: &str,
225    scope: InstallableScope,
226    keep_data: bool,
227) -> Result<PluginOperationResult, Box<dyn std::error::Error>> {
228    let result = uninstall_plugin_op(plugin, scope, !keep_data).await;
229
230    if !result.success {
231        return Err(result.message.clone().into());
232    }
233
234    println!("{} {}", figures::TICK, result.message);
235
236    // Log analytics
237    let (name, marketplace) =
238        parse_plugin_identifier(result.plugin_id.as_deref().unwrap_or(plugin));
239    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
240    if let Some(n) = &name {
241        properties.insert(
242            "_PROTO_plugin_name".to_string(),
243            serde_json::Value::String(n.clone()),
244        );
245    }
246    if let Some(m) = &marketplace {
247        properties.insert(
248            "_PROTO_marketplace_name".to_string(),
249            serde_json::Value::String(m.clone()),
250        );
251    }
252    properties.insert(
253        "scope".to_string(),
254        serde_json::Value::String(result.scope.clone().unwrap_or_else(|| scope.to_string())),
255    );
256
257    let telemetry = build_plugin_telemetry_fields(
258        name.as_deref(),
259        marketplace.as_deref(),
260        &get_managed_plugin_names(),
261    );
262    properties.insert(
263        "is_managed".to_string(),
264        serde_json::Value::Bool(telemetry.is_managed),
265    );
266
267    log_event(AnalyticsEvent {
268        event_name: "tengu_plugin_uninstalled_cli".to_string(),
269        properties,
270    });
271
272    Ok(result)
273}
274
275/// CLI command: Enable a plugin non-interactively
276///
277/// # Arguments
278/// * `plugin` - Plugin name or plugin@marketplace identifier
279/// * `scope` - Optional scope. If not provided, finds the most specific scope for the current project.
280pub async fn enable_plugin(
281    plugin: &str,
282    scope: Option<InstallableScope>,
283) -> Result<PluginOperationResult, Box<dyn std::error::Error>> {
284    let result = enable_plugin_op(plugin, scope).await;
285
286    if !result.success {
287        return Err(result.message.clone().into());
288    }
289
290    println!("{} {}", figures::TICK, result.message);
291
292    // Log analytics
293    let (name, marketplace) =
294        parse_plugin_identifier(result.plugin_id.as_deref().unwrap_or(plugin));
295    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
296    if let Some(n) = &name {
297        properties.insert(
298            "_PROTO_plugin_name".to_string(),
299            serde_json::Value::String(n.clone()),
300        );
301    }
302    if let Some(m) = &marketplace {
303        properties.insert(
304            "_PROTO_marketplace_name".to_string(),
305            serde_json::Value::String(m.clone()),
306        );
307    }
308    if let Some(ref s) = result.scope {
309        properties.insert("scope".to_string(), serde_json::Value::String(s.clone()));
310    }
311
312    let telemetry = build_plugin_telemetry_fields(
313        name.as_deref(),
314        marketplace.as_deref(),
315        &get_managed_plugin_names(),
316    );
317    properties.insert(
318        "is_managed".to_string(),
319        serde_json::Value::Bool(telemetry.is_managed),
320    );
321
322    log_event(AnalyticsEvent {
323        event_name: "tengu_plugin_enabled_cli".to_string(),
324        properties,
325    });
326
327    Ok(result)
328}
329
330/// CLI command: Disable a plugin non-interactively
331///
332/// # Arguments
333/// * `plugin` - Plugin name or plugin@marketplace identifier
334/// * `scope` - Optional scope. If not provided, finds the most specific scope for the current project.
335pub async fn disable_plugin(
336    plugin: &str,
337    scope: Option<InstallableScope>,
338) -> Result<PluginOperationResult, Box<dyn std::error::Error>> {
339    let result = disable_plugin_op(plugin, scope).await;
340
341    if !result.success {
342        return Err(result.message.clone().into());
343    }
344
345    println!("{} {}", figures::TICK, result.message);
346
347    // Log analytics
348    let (name, marketplace) =
349        parse_plugin_identifier(result.plugin_id.as_deref().unwrap_or(plugin));
350    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
351    if let Some(n) = &name {
352        properties.insert(
353            "_PROTO_plugin_name".to_string(),
354            serde_json::Value::String(n.clone()),
355        );
356    }
357    if let Some(m) = &marketplace {
358        properties.insert(
359            "_PROTO_marketplace_name".to_string(),
360            serde_json::Value::String(m.clone()),
361        );
362    }
363    if let Some(ref s) = result.scope {
364        properties.insert("scope".to_string(), serde_json::Value::String(s.clone()));
365    }
366
367    let telemetry = build_plugin_telemetry_fields(
368        name.as_deref(),
369        marketplace.as_deref(),
370        &get_managed_plugin_names(),
371    );
372    properties.insert(
373        "is_managed".to_string(),
374        serde_json::Value::Bool(telemetry.is_managed),
375    );
376
377    log_event(AnalyticsEvent {
378        event_name: "tengu_plugin_disabled_cli".to_string(),
379        properties,
380    });
381
382    Ok(result)
383}
384
385/// CLI command: Disable all enabled plugins non-interactively
386pub async fn disable_all_plugins() -> Result<PluginOperationResult, Box<dyn std::error::Error>> {
387    let result = disable_all_plugins_op().await;
388
389    if !result.success {
390        return Err(result.message.clone().into());
391    }
392
393    println!("{} {}", figures::TICK, result.message);
394
395    log_event(AnalyticsEvent {
396        event_name: "tengu_plugin_disabled_all_cli".to_string(),
397        properties: HashMap::new(),
398    });
399
400    Ok(result)
401}
402
403/// CLI command: Update a plugin non-interactively
404///
405/// # Arguments
406/// * `plugin` - Plugin name or plugin@marketplace identifier
407/// * `scope` - Scope to update
408pub async fn update_plugin_cli(
409    plugin: &str,
410    scope: &str,
411) -> Result<PluginUpdateResult, Box<dyn std::error::Error>> {
412    println!(
413        "Checking for updates for plugin \"{}\" at {} scope...",
414        plugin, scope
415    );
416
417    let result = update_plugin_op(plugin, scope).await;
418
419    if !result.success {
420        return Err(result.message.clone().into());
421    }
422
423    println!("{} {}", figures::TICK, result.message);
424
425    if !result.already_up_to_date.unwrap_or(false) {
426        let (name, marketplace) =
427            parse_plugin_identifier(result.plugin_id.as_deref().unwrap_or(plugin));
428        let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
429        if let Some(n) = &name {
430            properties.insert(
431                "_PROTO_plugin_name".to_string(),
432                serde_json::Value::String(n.clone()),
433            );
434        }
435        if let Some(m) = &marketplace {
436            properties.insert(
437                "_PROTO_marketplace_name".to_string(),
438                serde_json::Value::String(m.clone()),
439            );
440        }
441        properties.insert(
442            "old_version".to_string(),
443            serde_json::Value::String(
444                result
445                    .old_version
446                    .clone()
447                    .unwrap_or_else(|| "unknown".to_string()),
448            ),
449        );
450        properties.insert(
451            "new_version".to_string(),
452            serde_json::Value::String(
453                result
454                    .new_version
455                    .clone()
456                    .unwrap_or_else(|| "unknown".to_string()),
457            ),
458        );
459
460        let telemetry = build_plugin_telemetry_fields(
461            name.as_deref(),
462            marketplace.as_deref(),
463            &get_managed_plugin_names(),
464        );
465        properties.insert(
466            "is_managed".to_string(),
467            serde_json::Value::Bool(telemetry.is_managed),
468        );
469
470        log_event(AnalyticsEvent {
471            event_name: "tengu_plugin_updated_cli".to_string(),
472            properties,
473        });
474    }
475
476    Ok(result)
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn test_classify_plugin_command_error_not_found() {
485        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "plugin not found");
486        assert_eq!(classify_plugin_command_error(&err), "not_found");
487    }
488
489    #[test]
490    fn test_classify_plugin_command_error_permission() {
491        let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
492        assert_eq!(classify_plugin_command_error(&err), "permission_denied");
493    }
494
495    #[test]
496    fn test_classify_plugin_command_error_network() {
497        let err = std::io::Error::new(std::io::ErrorKind::TimedOut, "network timeout");
498        assert_eq!(classify_plugin_command_error(&err), "network_error");
499    }
500
501    #[test]
502    fn test_classify_plugin_command_error_unknown() {
503        let err = std::io::Error::new(std::io::ErrorKind::Other, "some other error");
504        assert_eq!(classify_plugin_command_error(&err), "unknown");
505    }
506
507    #[test]
508    fn test_build_plugin_telemetry_fields() {
509        let fields = build_plugin_telemetry_fields(
510            Some("test-plugin"),
511            Some("test-marketplace"),
512            &["managed-plugin".to_string()],
513        );
514        assert_eq!(fields.plugin_name, Some("test-plugin".to_string()));
515        assert_eq!(
516            fields.marketplace_name,
517            Some("test-marketplace".to_string())
518        );
519        assert!(!fields.is_managed);
520    }
521
522    #[test]
523    fn test_build_plugin_telemetry_fields_managed() {
524        let fields = build_plugin_telemetry_fields(
525            Some("managed-plugin"),
526            None,
527            &["managed-plugin".to_string()],
528        );
529        assert!(fields.is_managed);
530    }
531
532    #[test]
533    fn test_figures_constants() {
534        assert_eq!(figures::TICK, "\u{2713}");
535        assert_eq!(figures::CROSS, "\u{2717}");
536    }
537}