feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features

Multi-LLM Provider Support:
- Add llm-core crate with LlmProvider trait abstraction
- Implement Anthropic Claude API client with streaming
- Implement OpenAI API client with streaming
- Add token counting with SimpleTokenCounter and ClaudeTokenCounter
- Add retry logic with exponential backoff and jitter

Borderless TUI Redesign:
- Rewrite theme system with terminal capability detection (Full/Unicode256/Basic)
- Add provider tabs component with keybind switching [1]/[2]/[3]
- Implement vim-modal input (Normal/Insert/Visual/Command modes)
- Redesign chat panel with timestamps and streaming indicators
- Add multi-provider status bar with cost tracking
- Add Nerd Font icons with graceful ASCII fallbacks
- Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark)

Advanced Agent Features:
- Add system prompt builder with configurable components
- Enhance subagent orchestration with parallel execution
- Add git integration module for safe command detection
- Add streaming tool results via channels
- Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell
- Add WebSearch with provider abstraction

Plugin System Enhancement:
- Add full agent definition parsing from YAML frontmatter
- Add skill system with progressive disclosure
- Wire plugin hooks into HookManager

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-02 17:24:14 +01:00
parent 09c8c9d83e
commit 10c8e2baae
67 changed files with 11444 additions and 626 deletions

View File

@@ -8,6 +8,7 @@ color-eyre = "0.6"
dirs = "5.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
walkdir = "2.5"
[dev-dependencies]

View File

@@ -44,6 +44,109 @@ pub struct McpServerConfig {
pub env: HashMap<String, String>,
}
/// Plugin hook configuration from hooks/hooks.json
#[derive(Debug, Clone, Deserialize)]
pub struct PluginHooksConfig {
pub description: Option<String>,
pub hooks: HashMap<String, Vec<HookMatcher>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HookMatcher {
pub matcher: Option<String>, // Regex pattern for tool names
pub hooks: Vec<HookDefinition>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HookDefinition {
#[serde(rename = "type")]
pub hook_type: String, // "command" or "prompt"
pub command: Option<String>,
pub prompt: Option<String>,
pub timeout: Option<u64>,
}
/// Parsed slash command from markdown file
#[derive(Debug, Clone)]
pub struct SlashCommand {
pub name: String,
pub description: Option<String>,
pub argument_hint: Option<String>,
pub allowed_tools: Option<Vec<String>>,
pub body: String, // Markdown content after frontmatter
pub source_path: PathBuf,
}
/// Parsed agent definition from markdown file
#[derive(Debug, Clone)]
pub struct AgentDefinition {
pub name: String,
pub description: String,
pub tools: Vec<String>, // Tool whitelist
pub model: Option<String>, // haiku, sonnet, opus
pub color: Option<String>,
pub system_prompt: String, // Markdown body
pub source_path: PathBuf,
}
/// Parsed skill definition
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub version: Option<String>,
pub content: String, // Core SKILL.md content
pub references: Vec<PathBuf>, // Reference files
pub examples: Vec<PathBuf>, // Example files
pub source_path: PathBuf,
}
/// YAML frontmatter for command files
#[derive(Deserialize)]
struct CommandFrontmatter {
description: Option<String>,
#[serde(rename = "argument-hint")]
argument_hint: Option<String>,
#[serde(rename = "allowed-tools")]
allowed_tools: Option<String>,
}
/// YAML frontmatter for agent files
#[derive(Deserialize)]
struct AgentFrontmatter {
name: String,
description: String,
#[serde(default)]
tools: Vec<String>,
model: Option<String>,
color: Option<String>,
}
/// YAML frontmatter for skill files
#[derive(Deserialize)]
struct SkillFrontmatter {
name: String,
description: String,
version: Option<String>,
}
/// Parse YAML frontmatter from markdown content
fn parse_frontmatter<T: serde::de::DeserializeOwned>(content: &str) -> Result<(T, String)> {
if !content.starts_with("---") {
return Err(eyre!("No frontmatter found"));
}
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Err(eyre!("Invalid frontmatter format"));
}
let frontmatter: T = serde_yaml::from_str(parts[1].trim())?;
let body = parts[2].trim().to_string();
Ok((frontmatter, body))
}
/// A loaded plugin with its manifest and base path
#[derive(Debug, Clone)]
pub struct Plugin {
@@ -64,7 +167,7 @@ impl Plugin {
/// 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))
self.base_path.join("skills").join(skill_name).join("SKILL.md")
}
/// Get the path to a hook script
@@ -73,6 +176,210 @@ impl Plugin {
self.base_path.join("hooks").join(path)
})
}
/// Parse a command file
pub fn parse_command(&self, name: &str) -> Result<SlashCommand> {
let path = self.command_path(name);
let content = fs::read_to_string(&path)?;
let (fm, body): (CommandFrontmatter, String) = parse_frontmatter(&content)?;
let allowed_tools = fm.allowed_tools.map(|s| {
s.split(',').map(|t| t.trim().to_string()).collect()
});
Ok(SlashCommand {
name: name.to_string(),
description: fm.description,
argument_hint: fm.argument_hint,
allowed_tools,
body,
source_path: path,
})
}
/// Parse an agent file
pub fn parse_agent(&self, name: &str) -> Result<AgentDefinition> {
let path = self.agent_path(name);
let content = fs::read_to_string(&path)?;
let (fm, body): (AgentFrontmatter, String) = parse_frontmatter(&content)?;
Ok(AgentDefinition {
name: fm.name,
description: fm.description,
tools: fm.tools,
model: fm.model,
color: fm.color,
system_prompt: body,
source_path: path,
})
}
/// Parse a skill file
pub fn parse_skill(&self, name: &str) -> Result<Skill> {
let path = self.skill_path(name);
let content = fs::read_to_string(&path)?;
let (fm, body): (SkillFrontmatter, String) = parse_frontmatter(&content)?;
// Discover reference and example files in the skill directory
let skill_dir = self.base_path.join("skills").join(name);
let references_dir = skill_dir.join("references");
let examples_dir = skill_dir.join("examples");
let references = if references_dir.exists() {
fs::read_dir(&references_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect()
} else {
Vec::new()
};
let examples = if examples_dir.exists() {
fs::read_dir(&examples_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect()
} else {
Vec::new()
};
Ok(Skill {
name: fm.name,
description: fm.description,
version: fm.version,
content: body,
references,
examples,
source_path: path,
})
}
/// Auto-discover commands in the commands/ directory
pub fn discover_commands(&self) -> Vec<String> {
let commands_dir = self.base_path.join("commands");
if !commands_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&commands_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
.filter_map(|e| {
e.path().file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.collect()
}
/// Auto-discover agents in the agents/ directory
pub fn discover_agents(&self) -> Vec<String> {
let agents_dir = self.base_path.join("agents");
if !agents_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&agents_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
.filter_map(|e| {
e.path().file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.collect()
}
/// Auto-discover skills in skills/*/SKILL.md
pub fn discover_skills(&self) -> Vec<String> {
let skills_dir = self.base_path.join("skills");
if !skills_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&skills_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter(|e| e.path().join("SKILL.md").exists())
.filter_map(|e| {
e.path().file_name()
.map(|s| s.to_string_lossy().to_string())
})
.collect()
}
/// Get all commands (manifest + discovered)
pub fn all_command_names(&self) -> Vec<String> {
let mut names: std::collections::HashSet<String> =
self.manifest.commands.iter().cloned().collect();
names.extend(self.discover_commands());
names.into_iter().collect()
}
/// Get all agent names (manifest + discovered)
pub fn all_agent_names(&self) -> Vec<String> {
let mut names: std::collections::HashSet<String> =
self.manifest.agents.iter().cloned().collect();
names.extend(self.discover_agents());
names.into_iter().collect()
}
/// Get all skill names (manifest + discovered)
pub fn all_skill_names(&self) -> Vec<String> {
let mut names: std::collections::HashSet<String> =
self.manifest.skills.iter().cloned().collect();
names.extend(self.discover_skills());
names.into_iter().collect()
}
/// Load hooks configuration from hooks/hooks.json
pub fn load_hooks_config(&self) -> Result<Option<PluginHooksConfig>> {
let hooks_path = self.base_path.join("hooks").join("hooks.json");
if !hooks_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&hooks_path)?;
let config: PluginHooksConfig = serde_json::from_str(&content)?;
Ok(Some(config))
}
/// Register hooks from this plugin's config into a hook manager
/// This requires the hooks crate to be available where this is called
pub fn register_hooks_with_manager(&self, config: &PluginHooksConfig) -> Vec<(String, String, Option<String>, Option<u64>)> {
let mut hooks_to_register = Vec::new();
for (event_name, matchers) in &config.hooks {
for matcher in matchers {
for hook_def in &matcher.hooks {
if let Some(command) = &hook_def.command {
// Substitute ${CLAUDE_PLUGIN_ROOT}
let resolved = command.replace(
"${CLAUDE_PLUGIN_ROOT}",
&self.base_path.to_string_lossy()
);
hooks_to_register.push((
event_name.clone(),
resolved,
matcher.matcher.clone(),
hook_def.timeout,
));
}
}
}
}
hooks_to_register
}
}
/// Plugin loader and registry
@@ -232,6 +539,45 @@ impl PluginManager {
servers
}
/// Get all parsed commands
pub fn load_all_commands(&self) -> Vec<SlashCommand> {
let mut commands = Vec::new();
for plugin in &self.plugins {
for cmd_name in &plugin.manifest.commands {
if let Ok(cmd) = plugin.parse_command(cmd_name) {
commands.push(cmd);
}
}
}
commands
}
/// Get all parsed agents
pub fn load_all_agents(&self) -> Vec<AgentDefinition> {
let mut agents = Vec::new();
for plugin in &self.plugins {
for agent_name in &plugin.manifest.agents {
if let Ok(agent) = plugin.parse_agent(agent_name) {
agents.push(agent);
}
}
}
agents
}
/// Get all parsed skills
pub fn load_all_skills(&self) -> Vec<Skill> {
let mut skills = Vec::new();
for plugin in &self.plugins {
for skill_name in &plugin.manifest.skills {
if let Ok(skill) = plugin.parse_skill(skill_name) {
skills.push(skill);
}
}
}
skills
}
}
impl Default for PluginManager {
@@ -274,12 +620,12 @@ mod tests {
fs::write(
dir.join("commands/test-cmd.md"),
"# Test Command\nThis is a test command.",
"---\ndescription: A test command\nargument-hint: <file>\nallowed-tools: read,write\n---\n\nThis is a test command body.",
)?;
fs::write(
dir.join("agents/test-agent.md"),
"# Test Agent\nThis is a test agent.",
"---\nname: test-agent\ndescription: A test agent\ntools:\n - read\n - write\nmodel: sonnet\ncolor: blue\n---\n\nYou are a helpful test agent.",
)?;
Ok(())
@@ -351,4 +697,77 @@ mod tests {
Ok(())
}
#[test]
fn test_parse_command() -> 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)?;
let cmd = plugin.parse_command("test-cmd")?;
assert_eq!(cmd.name, "test-cmd");
assert_eq!(cmd.description, Some("A test command".to_string()));
assert_eq!(cmd.argument_hint, Some("<file>".to_string()));
assert_eq!(cmd.allowed_tools, Some(vec!["read".to_string(), "write".to_string()]));
assert_eq!(cmd.body, "This is a test command body.");
Ok(())
}
#[test]
fn test_parse_agent() -> 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)?;
let agent = plugin.parse_agent("test-agent")?;
assert_eq!(agent.name, "test-agent");
assert_eq!(agent.description, "A test agent");
assert_eq!(agent.tools, vec!["read", "write"]);
assert_eq!(agent.model, Some("sonnet".to_string()));
assert_eq!(agent.color, Some("blue".to_string()));
assert_eq!(agent.system_prompt, "You are a helpful test agent.");
Ok(())
}
#[test]
fn test_load_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.load_all_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, "test-cmd");
assert_eq!(commands[0].description, Some("A test command".to_string()));
Ok(())
}
#[test]
fn test_load_all_agents() -> 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 agents = manager.load_all_agents();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "test-agent");
assert_eq!(agents[0].description, "A test agent");
Ok(())
}
}

View File

@@ -0,0 +1,175 @@
// End-to-end integration test for plugin hooks
use color_eyre::eyre::Result;
use plugins::PluginManager;
use std::fs;
use tempfile::TempDir;
fn create_test_plugin_with_hooks(plugin_dir: &std::path::Path) -> Result<()> {
fs::create_dir_all(plugin_dir)?;
// Create plugin manifest
let manifest = serde_json::json!({
"name": "test-hook-plugin",
"version": "1.0.0",
"description": "Test plugin with hooks",
"commands": [],
"agents": [],
"skills": [],
"hooks": {},
"mcp_servers": []
});
fs::write(
plugin_dir.join("plugin.json"),
serde_json::to_string_pretty(&manifest)?,
)?;
// Create hooks directory and hooks.json
let hooks_dir = plugin_dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let hooks_config = serde_json::json!({
"description": "Validate edit and write operations",
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validate.py",
"timeout": 5000
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo 'Bash hook' && exit 0"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "echo 'Post-tool hook' && exit 0"
}
]
}
]
}
});
fs::write(
hooks_dir.join("hooks.json"),
serde_json::to_string_pretty(&hooks_config)?,
)?;
Ok(())
}
#[test]
fn test_load_plugin_hooks_config() -> Result<()> {
let temp_dir = TempDir::new()?;
let plugin_dir = temp_dir.path().join("test-plugin");
create_test_plugin_with_hooks(&plugin_dir)?;
// Load all plugins
let mut plugin_manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
plugin_manager.load_all()?;
assert_eq!(plugin_manager.plugins().len(), 1);
let plugin = &plugin_manager.plugins()[0];
// Load hooks config
let hooks_config = plugin.load_hooks_config()?;
assert!(hooks_config.is_some());
let config = hooks_config.unwrap();
assert_eq!(config.description, Some("Validate edit and write operations".to_string()));
assert!(config.hooks.contains_key("PreToolUse"));
assert!(config.hooks.contains_key("PostToolUse"));
// Check PreToolUse hooks
let pre_tool_hooks = &config.hooks["PreToolUse"];
assert_eq!(pre_tool_hooks.len(), 2);
// First matcher: Edit|Write
assert_eq!(pre_tool_hooks[0].matcher, Some("Edit|Write".to_string()));
assert_eq!(pre_tool_hooks[0].hooks.len(), 1);
assert_eq!(pre_tool_hooks[0].hooks[0].hook_type, "command");
assert!(pre_tool_hooks[0].hooks[0].command.as_ref().unwrap().contains("validate.py"));
// Second matcher: Bash
assert_eq!(pre_tool_hooks[1].matcher, Some("Bash".to_string()));
assert_eq!(pre_tool_hooks[1].hooks.len(), 1);
Ok(())
}
#[test]
fn test_plugin_hooks_substitution() -> Result<()> {
let temp_dir = TempDir::new()?;
let plugin_dir = temp_dir.path().join("test-plugin");
create_test_plugin_with_hooks(&plugin_dir)?;
// Load all plugins
let mut plugin_manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
plugin_manager.load_all()?;
assert_eq!(plugin_manager.plugins().len(), 1);
let plugin = &plugin_manager.plugins()[0];
// Load hooks config and register
let hooks_config = plugin.load_hooks_config()?.unwrap();
let hooks_to_register = plugin.register_hooks_with_manager(&hooks_config);
// Check that ${CLAUDE_PLUGIN_ROOT} was substituted
let edit_write_hook = hooks_to_register.iter()
.find(|(event, _, pattern, _)| {
event == "PreToolUse" && pattern.as_ref().map(|p| p.contains("Edit")).unwrap_or(false)
})
.unwrap();
// The command should have the plugin path substituted
assert!(edit_write_hook.1.contains(&plugin_dir.to_string_lossy().to_string()));
assert!(edit_write_hook.1.contains("validate.py"));
assert!(!edit_write_hook.1.contains("${CLAUDE_PLUGIN_ROOT}"));
Ok(())
}
#[test]
fn test_multiple_plugins_with_hooks() -> Result<()> {
let temp_dir = TempDir::new()?;
// Create two plugins with hooks
let plugin1_dir = temp_dir.path().join("plugin1");
create_test_plugin_with_hooks(&plugin1_dir)?;
let plugin2_dir = temp_dir.path().join("plugin2");
create_test_plugin_with_hooks(&plugin2_dir)?;
// Load all plugins
let mut plugin_manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
plugin_manager.load_all()?;
assert_eq!(plugin_manager.plugins().len(), 2);
// Collect all hooks from all plugins
let mut total_hooks = 0;
for plugin in plugin_manager.plugins() {
if let Ok(Some(hooks_config)) = plugin.load_hooks_config() {
let hooks = plugin.register_hooks_with_manager(&hooks_config);
total_hooks += hooks.len();
}
}
// Each plugin has 3 hooks (2 PreToolUse + 1 PostToolUse)
assert_eq!(total_hooks, 6);
Ok(())
}