oxi-cli 0.17.0

Terminal-based AI coding assistant — multi-provider, streaming-first, extensible
Documentation
//! Extension system for oxi
//!
//! Extensions allow custom tools, commands, and event hooks to be loaded dynamically at runtime.

pub mod context;
#[allow(missing_docs)]
pub mod ext_cli;
pub mod loading;
pub mod registry;
pub mod types;
#[allow(missing_docs)]
pub mod wasm;
pub mod wasm_hooks;
pub mod wasm_tool;

// Re-export types from submodules
pub use crate::extensions::context::{ExtensionContext, ExtensionContextBuilder};
pub use crate::extensions::loading::{
    discover_extensions, discover_extensions_in_dir, load_extension, load_extensions,
    validate_extension, ValidatedExtension, SHARED_LIB_EXTENSION,
};
pub use crate::extensions::registry::{ExtensionErrorHandle, ExtensionRegistry, ExtensionRunner};
pub use crate::extensions::types::{
    AfterProviderResponseEvent, BashEvent, BeforeProviderRequestEvent, Command, ContextEmitResult,
    ContextEvent, ExtensionError, ExtensionErrorListener, ExtensionErrorRecord, ExtensionManifest,
    ExtensionPermission, ExtensionState, InputEvent, InputEventResult, InputSource,
    ModelSelectEvent, ModelSelectSource, ProviderRequestEmitResult, SessionBeforeCompactEvent,
    SessionBeforeEmitResult, SessionBeforeForkEvent, SessionBeforeSwitchEvent,
    SessionBeforeTreeEvent, SessionCompactEvent, SessionShutdownEvent, SessionShutdownReason,
    SessionSwitchReason, SessionTreeEvent, ThinkingLevelSelectEvent, ToolCallEmitResult,
    ToolResultEmitResult,
};
pub use crate::extensions::wasm::{
    ExtensionInfo, WasmCommandDef, WasmExtensionManager, WasmToolDef,
};
pub use crate::extensions::wasm_tool::WasmTool;

// Re-export from oxi-agent
pub use oxi_agent::{AgentEvent, AgentTool, AgentToolResult};

// The Extension trait
/// Core trait that every oxi extension must implement.
pub trait Extension: Send + Sync {
    /// TODO: document.
    fn name(&self) -> &str;
    /// TODO: document.
    fn description(&self) -> &str;
    /// TODO: document.
    fn manifest(&self) -> ExtensionManifest {
        ExtensionManifest::new(self.name(), "0.0.0").with_description(self.description())
    }
    /// TODO: document.
    fn register_tools(&self) -> Vec<std::sync::Arc<dyn oxi_agent::AgentTool>> {
        vec![]
    }
    /// TODO: document.
    fn register_commands(&self) -> Vec<Command> {
        vec![]
    }
    /// TODO: document.
    fn on_load(&self, _ctx: &ExtensionContext) {}
    /// TODO: document.
    fn on_unload(&self) {}
    /// TODO: document.
    fn on_message_sent(&self, _msg: &str) {}
    /// TODO: document.
    fn on_message_received(&self, _msg: &str) {}
    /// TODO: document.
    fn on_tool_call(&self, _tool: &str, _params: &serde_json::Value) {}
    /// TODO: document.
    fn on_tool_result(&self, _tool: &str, _result: &oxi_agent::AgentToolResult) {}
    /// TODO: document.
    fn on_session_start(&self, _session_id: &str) {}
    /// TODO: document.
    fn on_session_end(&self, _session_id: &str) {}
    /// TODO: document.
    fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {}
    /// TODO: document.
    fn on_event(&self, _event: &oxi_agent::AgentEvent) {}
    /// TODO: document.
    fn on_before_tool_call(
        &self,
        _tool: &str,
        _args: &serde_json::Value,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn on_after_tool_call(
        &self,
        _tool: &str,
        _result: &oxi_agent::AgentToolResult,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn on_before_compaction(&self, _ctx: &crate::CompactionContext) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn on_after_compaction(&self, _summary: &str) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn on_error(&self, _error: &anyhow::Error) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn session_before_switch(
        &self,
        _event: &crate::extensions::types::SessionBeforeSwitchEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn session_before_fork(
        &self,
        _event: &crate::extensions::types::SessionBeforeForkEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn session_before_compact(
        &self,
        _event: &crate::extensions::types::SessionBeforeCompactEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn session_compact(
        &self,
        _event: &crate::extensions::types::SessionCompactEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn session_shutdown(&self, _event: &crate::extensions::types::SessionShutdownEvent) {}
    /// TODO: document.
    fn session_before_tree(
        &self,
        _event: &crate::extensions::types::SessionBeforeTreeEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn session_tree(&self, _event: &crate::extensions::types::SessionTreeEvent) {}
    /// TODO: document.
    fn context(
        &self,
        _event: &mut crate::extensions::types::ContextEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn before_provider_request(
        &self,
        _event: &mut crate::extensions::types::BeforeProviderRequestEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn after_provider_response(
        &self,
        _event: &crate::extensions::types::AfterProviderResponseEvent,
    ) -> Result<(), anyhow::Error> {
        Ok(())
    }
    /// TODO: document.
    fn model_select(&self, _event: &crate::extensions::types::ModelSelectEvent) {}
    /// TODO: document.
    fn thinking_level_select(&self, _event: &crate::extensions::types::ThinkingLevelSelectEvent) {}
    /// TODO: document.
    fn bash(&self, _event: &crate::extensions::types::BashEvent) {}
    /// TODO: document.
    fn input(
        &self,
        _event: &crate::extensions::types::InputEvent,
    ) -> crate::extensions::types::InputEventResult {
        crate::extensions::types::InputEventResult::Continue
    }
}

// Built-in "noop" extension
/// pub.
pub struct NoopExtension;
impl Extension for NoopExtension {
    fn name(&self) -> &str {
        "noop"
    }
    fn description(&self) -> &str {
        "Built-in no-op extension"
    }
}

// Test helpers
#[cfg(test)]
pub struct RecordingExtension {
    pub name: String,
    pub calls: std::sync::Mutex<Vec<String>>,
}
#[cfg(test)]
impl RecordingExtension {
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            calls: std::sync::Mutex::new(Vec::new()),
        }
    }
    pub fn push(&self, call: &str) {
        self.calls.lock().unwrap().push(call.to_string());
    }
    pub fn calls(&self) -> Vec<String> {
        self.calls.lock().unwrap().clone()
    }
}
#[cfg(test)]
impl Extension for RecordingExtension {
    fn name(&self) -> &str {
        &self.name
    }
    fn description(&self) -> &str {
        "recording test extension"
    }
    fn on_load(&self, _ctx: &ExtensionContext) {
        self.push("on_load");
    }
    fn on_unload(&self) {
        self.push("on_unload");
    }
    fn on_message_sent(&self, msg: &str) {
        self.push(&format!("on_message_sent({})", msg));
    }
    fn on_message_received(&self, msg: &str) {
        self.push(&format!("on_message_received({})", msg));
    }
    fn on_tool_call(&self, tool: &str, _params: &serde_json::Value) {
        self.push(&format!("on_tool_call({})", tool));
    }
    fn on_tool_result(&self, tool: &str, _result: &oxi_agent::AgentToolResult) {
        self.push(&format!("on_tool_result({})", tool));
    }
    fn on_session_start(&self, session_id: &str) {
        self.push(&format!("on_session_start({})", session_id));
    }
    fn on_session_end(&self, session_id: &str) {
        self.push(&format!("on_session_end({})", session_id));
    }
    fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {
        self.push("on_settings_changed");
    }
    fn on_event(&self, _event: &oxi_agent::AgentEvent) {
        self.push("on_event");
    }
}

// Tests
#[cfg(test)]
mod tests {
    use super::*;
    use oxi_store::settings::Settings;
    use std::sync::Arc;

    #[test]
    fn test_manifest_builder() {
        let manifest = ExtensionManifest::new("my-ext", "1.0.0")
            .with_description("A test extension")
            .with_author("test-author")
            .with_permission(ExtensionPermission::FileRead)
            .with_permission(ExtensionPermission::Bash)
            .with_config_schema(serde_json::json!({"type": "object", "properties": {"api_key": {"type": "string"}}}));

        assert_eq!(manifest.name, "my-ext");
        assert_eq!(manifest.version, "1.0.0");
        assert_eq!(manifest.description, "A test extension");
        assert_eq!(manifest.author, "test-author");
        assert!(manifest.has_permission(ExtensionPermission::FileRead));
        assert!(manifest.has_permission(ExtensionPermission::Bash));
        assert!(!manifest.has_permission(ExtensionPermission::Network));
    }

    #[test]
    fn test_permission_display() {
        assert_eq!(ExtensionPermission::FileRead.to_string(), "file_read");
        assert_eq!(ExtensionPermission::Bash.to_string(), "bash");
    }

    #[test]
    fn test_context_builder_minimal() {
        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
        assert_eq!(ctx.cwd, std::path::PathBuf::from("/tmp"));
        assert!(ctx.session_id.is_none());
        assert!(ctx.is_idle());
    }

    #[test]
    fn test_context_builder_full() {
        use parking_lot::RwLock;
        let settings = Arc::new(RwLock::new(Settings::default()));
        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/home"))
            .settings(settings)
            .config(serde_json::json!({"key": "value"}))
            .session_id("sess-123")
            .build();

        assert_eq!(ctx.cwd, std::path::PathBuf::from("/home"));
        assert_eq!(ctx.session_id, Some("sess-123".to_string()));
        assert_eq!(ctx.config_get("key"), Some(serde_json::json!("value")));
    }

    #[test]
    fn test_registry_register_and_collect() {
        let mut reg = ExtensionRegistry::new();
        reg.register(Arc::new(NoopExtension));
        assert_eq!(reg.len(), 1);
        assert!(!reg.is_empty());
    }

    #[test]
    fn test_registry_enable_disable() {
        let mut reg = ExtensionRegistry::new();
        let ext = Arc::new(RecordingExtension::new("rec"));
        reg.register(ext);
        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
        assert!(reg.is_enabled("rec"));
        reg.disable("rec").unwrap();
        assert!(!reg.is_enabled("rec"));
        reg.enable("rec", &ctx).unwrap();
        assert!(reg.is_enabled("rec"));
    }

    #[test]
    fn test_emit_load() {
        let mut reg = ExtensionRegistry::new();
        let ext = Arc::new(RecordingExtension::new("rec"));
        reg.register(ext.clone());
        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
        reg.emit_load(&ctx);
        assert_eq!(ext.calls(), vec!["on_load"]);
    }

    #[test]
    fn test_graceful_degradation_on_panic() {
        struct PanickingExtension;
        impl Extension for PanickingExtension {
            fn name(&self) -> &str {
                "panicker"
            }
            fn description(&self) -> &str {
                "Panics"
            }
            fn on_load(&self, _ctx: &ExtensionContext) {
                panic!("intentional panic in on_load");
            }
            fn on_message_sent(&self, _msg: &str) {
                panic!("intentional panic in on_message_sent");
            }
        }

        let mut reg = ExtensionRegistry::new();
        reg.register(Arc::new(PanickingExtension));
        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
        reg.emit_load(&ctx);
        reg.emit_message_sent("hello");
        let errors = reg.errors();
        assert_eq!(errors.len(), 2);
    }

    #[test]
    fn test_extension_state_display() {
        assert_eq!(ExtensionState::Pending.to_string(), "pending");
        assert_eq!(ExtensionState::Active.to_string(), "active");
    }

    #[test]
    fn test_tool_call_emit_result_default() {
        let result = ToolCallEmitResult::default();
        assert!(!result.blocked);
        assert!(result.errors.is_empty());
    }

    #[test]
    fn test_runner_new() {
        let runner = ExtensionRunner::new(std::path::PathBuf::from("/tmp"));
        assert!(runner.is_empty());
        assert_eq!(runner.len(), 0);
    }
}