feat(M12): complete milestone with plugins, checkpointing, and rewind
Implements the remaining M12 features from AGENTS.md: **Plugin System (crates/platform/plugins)** - Plugin manifest schema with plugin.json support - Plugin loader for commands, agents, skills, hooks, and MCP servers - Discovers plugins from ~/.config/owlen/plugins and .owlen/plugins - Includes comprehensive tests (4 passing) **Session Checkpointing (crates/core/agent)** - Checkpoint struct capturing session state and file diffs - CheckpointManager with snapshot, diff, save, load, and rewind capabilities - File diff tracking with before/after content - Checkpoint persistence to .owlen/checkpoints/ - Includes comprehensive tests (6 passing) **REPL Commands (crates/app/cli)** - /checkpoint - Save current session with file diffs - /checkpoints - List all saved checkpoints - /rewind <id> - Restore session and files from checkpoint - Updated /help documentation M12 milestone now fully complete: ✅ /permissions, /status, /cost (previously implemented) ✅ Checkpointing and /rewind ✅ Plugin loader with manifest schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
14
crates/platform/plugins/Cargo.toml
Normal file
14
crates/platform/plugins/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "plugins"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
color-eyre = "0.6"
|
||||
dirs = "5.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
walkdir = "2.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13"
|
||||
354
crates/platform/plugins/src/lib.rs
Normal file
354
crates/platform/plugins/src/lib.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Plugin manifest schema (plugin.json)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
/// Plugin name
|
||||
pub name: String,
|
||||
/// Plugin version
|
||||
pub version: String,
|
||||
/// Plugin description
|
||||
pub description: Option<String>,
|
||||
/// Plugin author
|
||||
pub author: Option<String>,
|
||||
/// Commands provided by this plugin
|
||||
#[serde(default)]
|
||||
pub commands: Vec<String>,
|
||||
/// Agents provided by this plugin
|
||||
#[serde(default)]
|
||||
pub agents: Vec<String>,
|
||||
/// Skills provided by this plugin
|
||||
#[serde(default)]
|
||||
pub skills: Vec<String>,
|
||||
/// Hooks provided by this plugin
|
||||
#[serde(default)]
|
||||
pub hooks: HashMap<String, String>,
|
||||
/// MCP servers provided by this plugin
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<McpServerConfig>,
|
||||
}
|
||||
|
||||
/// MCP server configuration in plugin manifest
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// A loaded plugin with its manifest and base path
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Plugin {
|
||||
pub manifest: PluginManifest,
|
||||
pub base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Plugin {
|
||||
/// Get the path to a command file
|
||||
pub fn command_path(&self, command_name: &str) -> PathBuf {
|
||||
self.base_path.join("commands").join(format!("{}.md", command_name))
|
||||
}
|
||||
|
||||
/// Get the path to an agent file
|
||||
pub fn agent_path(&self, agent_name: &str) -> PathBuf {
|
||||
self.base_path.join("agents").join(format!("{}.md", agent_name))
|
||||
}
|
||||
|
||||
/// Get the path to a skill file
|
||||
pub fn skill_path(&self, skill_name: &str) -> PathBuf {
|
||||
self.base_path.join("skills").join(format!("{}.md", skill_name))
|
||||
}
|
||||
|
||||
/// Get the path to a hook script
|
||||
pub fn hook_path(&self, hook_name: &str) -> Option<PathBuf> {
|
||||
self.manifest.hooks.get(hook_name).map(|path| {
|
||||
self.base_path.join("hooks").join(path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin loader and registry
|
||||
pub struct PluginManager {
|
||||
plugins: Vec<Plugin>,
|
||||
plugin_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
/// Create a new plugin manager with default plugin directories
|
||||
pub fn new() -> Self {
|
||||
let mut plugin_dirs = Vec::new();
|
||||
|
||||
// User plugins: ~/.config/owlen/plugins
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
plugin_dirs.push(config_dir.join("owlen").join("plugins"));
|
||||
}
|
||||
|
||||
// Project plugins: .owlen/plugins
|
||||
plugin_dirs.push(PathBuf::from(".owlen/plugins"));
|
||||
|
||||
Self {
|
||||
plugins: Vec::new(),
|
||||
plugin_dirs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a plugin manager with custom plugin directories
|
||||
pub fn with_dirs(plugin_dirs: Vec<PathBuf>) -> Self {
|
||||
Self {
|
||||
plugins: Vec::new(),
|
||||
plugin_dirs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all plugins from configured directories
|
||||
pub fn load_all(&mut self) -> Result<()> {
|
||||
let plugin_dirs = self.plugin_dirs.clone();
|
||||
for dir in &plugin_dirs {
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.load_from_dir(dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load plugins from a specific directory
|
||||
fn load_from_dir(&mut self, dir: &Path) -> Result<()> {
|
||||
// Walk directory looking for plugin.json files
|
||||
for entry in WalkDir::new(dir)
|
||||
.max_depth(2) // Don't recurse too deep
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
if entry.file_name() == "plugin.json" {
|
||||
if let Some(plugin_dir) = entry.path().parent() {
|
||||
match self.load_plugin(plugin_dir) {
|
||||
Ok(plugin) => {
|
||||
self.plugins.push(plugin);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to load plugin from {:?}: {}", plugin_dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a single plugin from a directory
|
||||
fn load_plugin(&self, plugin_dir: &Path) -> Result<Plugin> {
|
||||
let manifest_path = plugin_dir.join("plugin.json");
|
||||
let content = fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| eyre!("Failed to read plugin manifest: {}", e))?;
|
||||
|
||||
let manifest: PluginManifest = serde_json::from_str(&content)
|
||||
.map_err(|e| eyre!("Failed to parse plugin manifest: {}", e))?;
|
||||
|
||||
Ok(Plugin {
|
||||
manifest,
|
||||
base_path: plugin_dir.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all loaded plugins
|
||||
pub fn plugins(&self) -> &[Plugin] {
|
||||
&self.plugins
|
||||
}
|
||||
|
||||
/// Find a plugin by name
|
||||
pub fn find_plugin(&self, name: &str) -> Option<&Plugin> {
|
||||
self.plugins.iter().find(|p| p.manifest.name == name)
|
||||
}
|
||||
|
||||
/// Get all available commands from all plugins
|
||||
pub fn all_commands(&self) -> HashMap<String, PathBuf> {
|
||||
let mut commands = HashMap::new();
|
||||
|
||||
for plugin in &self.plugins {
|
||||
for cmd_name in &plugin.manifest.commands {
|
||||
let path = plugin.command_path(cmd_name);
|
||||
if path.exists() {
|
||||
commands.insert(cmd_name.clone(), path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
/// Get all available agents from all plugins
|
||||
pub fn all_agents(&self) -> HashMap<String, PathBuf> {
|
||||
let mut agents = HashMap::new();
|
||||
|
||||
for plugin in &self.plugins {
|
||||
for agent_name in &plugin.manifest.agents {
|
||||
let path = plugin.agent_path(agent_name);
|
||||
if path.exists() {
|
||||
agents.insert(agent_name.clone(), path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agents
|
||||
}
|
||||
|
||||
/// Get all available skills from all plugins
|
||||
pub fn all_skills(&self) -> HashMap<String, PathBuf> {
|
||||
let mut skills = HashMap::new();
|
||||
|
||||
for plugin in &self.plugins {
|
||||
for skill_name in &plugin.manifest.skills {
|
||||
let path = plugin.skill_path(skill_name);
|
||||
if path.exists() {
|
||||
skills.insert(skill_name.clone(), path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skills
|
||||
}
|
||||
|
||||
/// Get all MCP servers from all plugins
|
||||
pub fn all_mcp_servers(&self) -> Vec<(String, &McpServerConfig)> {
|
||||
let mut servers = Vec::new();
|
||||
|
||||
for plugin in &self.plugins {
|
||||
for server in &plugin.manifest.mcp_servers {
|
||||
servers.push((plugin.manifest.name.clone(), server));
|
||||
}
|
||||
}
|
||||
|
||||
servers
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn create_test_plugin(dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dir)?;
|
||||
fs::create_dir_all(dir.join("commands"))?;
|
||||
fs::create_dir_all(dir.join("agents"))?;
|
||||
fs::create_dir_all(dir.join("hooks"))?;
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "test-plugin".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: Some("A test plugin".to_string()),
|
||||
author: Some("Test Author".to_string()),
|
||||
commands: vec!["test-cmd".to_string()],
|
||||
agents: vec!["test-agent".to_string()],
|
||||
skills: vec![],
|
||||
hooks: {
|
||||
let mut h = HashMap::new();
|
||||
h.insert("PreToolUse".to_string(), "pre_tool_use.sh".to_string());
|
||||
h
|
||||
},
|
||||
mcp_servers: vec![],
|
||||
};
|
||||
|
||||
fs::write(
|
||||
dir.join("plugin.json"),
|
||||
serde_json::to_string_pretty(&manifest)?,
|
||||
)?;
|
||||
|
||||
fs::write(
|
||||
dir.join("commands/test-cmd.md"),
|
||||
"# Test Command\nThis is a test command.",
|
||||
)?;
|
||||
|
||||
fs::write(
|
||||
dir.join("agents/test-agent.md"),
|
||||
"# Test Agent\nThis is a test agent.",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_plugin() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
let plugin = manager.load_plugin(&plugin_dir)?;
|
||||
|
||||
assert_eq!(plugin.manifest.name, "test-plugin");
|
||||
assert_eq!(plugin.manifest.version, "1.0.0");
|
||||
assert_eq!(plugin.manifest.commands, vec!["test-cmd"]);
|
||||
assert_eq!(plugin.manifest.agents, vec!["test-agent"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_all_plugins() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
manager.load_all()?;
|
||||
|
||||
assert_eq!(manager.plugins().len(), 1);
|
||||
assert_eq!(manager.plugins()[0].manifest.name, "test-plugin");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_plugin() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
manager.load_all()?;
|
||||
|
||||
let plugin = manager.find_plugin("test-plugin");
|
||||
assert!(plugin.is_some());
|
||||
assert_eq!(plugin.unwrap().manifest.name, "test-plugin");
|
||||
|
||||
let not_found = manager.find_plugin("nonexistent");
|
||||
assert!(not_found.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_commands() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
manager.load_all()?;
|
||||
|
||||
let commands = manager.all_commands();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert!(commands.contains_key("test-cmd"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user