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:
2025-11-01 21:59:08 +01:00
parent 04a7085007
commit 5caf502009
8 changed files with 852 additions and 1 deletions

View 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"

View 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(())
}
}