From f3ff582e22ee786b12b9d3acf98d6aa9b0a9cc86 Mon Sep 17 00:00:00 2001 From: Michael Hunter Date: Thu, 13 Nov 2025 18:34:13 -0700 Subject: [PATCH] feat(agents): add support for loading agents from filesystem markdown files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ability to load agents from ~/.claude/agents/*.md files - Parse YAML frontmatter for agent metadata (name, description, tools, model, icon) - Display filesystem agents alongside database agents in UI - Prevent editing/deletion of filesystem-loaded agents - Add visual indicator for filesystem vs database agents - Update bun.lock dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bun.lock | 13 ++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/agents.rs | 150 ++++++++++++++++++++++++++++++- src/components/CCAgents.tsx | 77 +++++++++------- src/lib/api.ts | 2 + 5 files changed, 207 insertions(+), 36 deletions(-) diff --git a/bun.lock b/bun.lock index 0152c93a4..f9c77b60c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opcode", @@ -58,6 +59,10 @@ "typescript": "~5.6.2", "vite": "^6.0.3", }, + "optionalDependencies": { + "@esbuild/linux-x64": "^0.25.6", + "@rollup/rollup-linux-x64-gnu": "^4.45.1", + }, }, }, "trustedDependencies": [ @@ -139,7 +144,7 @@ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], @@ -357,7 +362,7 @@ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.43.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.2", "", { "os": "linux", "cpu": "x64" }, "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw=="], "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ=="], @@ -1061,6 +1066,8 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -1087,6 +1094,8 @@ "rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], + "rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "@uiw/react-markdown-preview/rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 91288d4f6..56e43d43c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,6 +5,7 @@ description = "GUI app and Toolkit for Claude Code" authors = ["mufeedvh", "123vviekr"] license = "AGPL-3.0" edition = "2021" +default-run = "opcode" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 36513a7a4..3aa0a1bd9 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -6,7 +6,10 @@ use reqwest; use rusqlite::{params, Connection, Result as SqliteResult}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; +use serde_yaml; +use std::fs; use std::io::{BufRead, BufReader}; +use std::path::Path; use std::process::Stdio; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State}; @@ -20,7 +23,7 @@ fn find_claude_binary(app_handle: &AppHandle) -> Result { crate::claude_binary::find_claude_binary(app_handle) } -/// Represents a CC Agent stored in the database +/// Represents a CC Agent stored in the database or loaded from filesystem #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Agent { pub id: Option, @@ -35,6 +38,10 @@ pub struct Agent { pub hooks: Option, // JSON string of hooks configuration pub created_at: String, pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, // "database" or "filesystem" + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, // Path if loaded from filesystem } /// Represents an agent execution run @@ -92,6 +99,16 @@ pub struct AgentData { pub hooks: Option, } +/// Frontmatter structure for agent markdown files +#[derive(Debug, Serialize, Deserialize)] +struct AgentFrontmatter { + name: String, + description: Option, + tools: Option, + model: Option, + icon: Option, +} + /// Database connection state pub struct AgentDb(pub Mutex); @@ -345,6 +362,119 @@ pub fn init_database(app: &AppHandle) -> SqliteResult { Ok(conn) } +/// Parse a markdown file with YAML frontmatter +fn parse_agent_markdown(file_path: &Path) -> Result { + let content = fs::read_to_string(file_path) + .map_err(|e| format!("Failed to read file {}: {}", file_path.display(), e))?; + + // Split frontmatter and content + let parts: Vec<&str> = content.splitn(3, "---").collect(); + if parts.len() < 3 { + return Err(format!("Invalid markdown format in {}", file_path.display())); + } + + // Parse frontmatter + let frontmatter: AgentFrontmatter = serde_yaml::from_str(parts[1]) + .map_err(|e| format!("Failed to parse frontmatter in {}: {}", file_path.display(), e))?; + + // Extract system prompt from markdown content + let system_prompt = parts[2].trim().to_string(); + + // Determine icon based on name or use default + let icon = frontmatter.icon.unwrap_or_else(|| { + // Map common agent names to icons + match frontmatter.name.as_str() { + name if name.contains("ai") => "bot", + name if name.contains("api") => "globe", + name if name.contains("cloud") => "cloud", + name if name.contains("data") => "database", + name if name.contains("test") || name.contains("qa") => "shield", + name if name.contains("deploy") => "package", + name if name.contains("architect") => "layout", + name if name.contains("security") => "shield", + name if name.contains("debug") => "bug", + name if name.contains("doc") => "file-text", + name if name.contains("review") => "eye", + _ => "bot", + }.to_string() + }); + + let now = chrono::Local::now().to_rfc3339(); + + Ok(Agent { + id: None, // File-based agents don't have database IDs + name: frontmatter.name.clone(), + icon, + system_prompt, + default_task: frontmatter.description, + model: frontmatter.model.unwrap_or_else(|| "sonnet".to_string()), + enable_file_read: true, + enable_file_write: true, + enable_network: false, + hooks: None, + created_at: now.clone(), + updated_at: now, + source: Some("filesystem".to_string()), + file_path: Some(file_path.to_string_lossy().to_string()), + }) +} + +/// Load agents from the .claude/agents directory +fn load_filesystem_agents() -> Vec { + let mut agents = Vec::new(); + + // Get the .claude/agents directory + let home = match dirs::home_dir() { + Some(h) => h, + None => { + warn!("Could not determine home directory"); + return agents; + } + }; + + let agents_dir = home.join(".claude").join("agents"); + if !agents_dir.exists() { + debug!("No .claude/agents directory found"); + return agents; + } + + // Recursively walk through the agents directory + fn scan_directory(dir: &Path, agents: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // Skip hidden directories + if let Some(name) = path.file_name() { + if !name.to_string_lossy().starts_with('.') { + scan_directory(&path, agents); + } + } + } else if path.is_file() { + // Check if it's a markdown file + if let Some(ext) = path.extension() { + if ext == "md" || ext == "markdown" { + match parse_agent_markdown(&path) { + Ok(agent) => { + debug!("Loaded agent from file: {}", agent.name); + agents.push(agent); + } + Err(e) => { + warn!("Failed to parse agent file {}: {}", path.display(), e); + } + } + } + } + } + } + } + } + + scan_directory(&agents_dir, &mut agents); + info!("Loaded {} agents from filesystem", agents.len()); + agents +} + /// List all agents #[tauri::command] pub async fn list_agents(db: State<'_, AgentDb>) -> Result, String> { @@ -354,7 +484,7 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result, String> { .prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents ORDER BY created_at DESC") .map_err(|e| e.to_string())?; - let agents = stmt + let mut agents = stmt .query_map([], |row| { Ok(Agent { id: Some(row.get(0)?), @@ -371,12 +501,20 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result, String> { hooks: row.get(9)?, created_at: row.get(10)?, updated_at: row.get(11)?, + source: Some("database".to_string()), + file_path: None, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; + // Load agents from filesystem + let filesystem_agents = load_filesystem_agents(); + + // Combine agents from both sources + agents.extend(filesystem_agents); + Ok(agents) } @@ -427,6 +565,8 @@ pub async fn create_agent( hooks: row.get(9)?, created_at: row.get(10)?, updated_at: row.get(11)?, + source: Some("database".to_string()), + file_path: None, }) }, ) @@ -512,6 +652,8 @@ pub async fn update_agent( hooks: row.get(9)?, created_at: row.get(10)?, updated_at: row.get(11)?, + source: Some("database".to_string()), + file_path: None, }) }, ) @@ -554,6 +696,8 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result hooks: row.get(9)?, created_at: row.get(10)?, updated_at: row.get(11)?, + source: Some("database".to_string()), + file_path: None, }) }, ) @@ -1768,6 +1912,8 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result = ({ onBack, className }) => { > -
+
{renderIcon(agent.icon)} + {agent.source === "filesystem" && ( + + FILE + + )}

{agent.name}

- Created: {new Date(agent.created_at).toLocaleDateString()} + {agent.source === "filesystem" + ? "From .claude/agents" + : `Created: ${new Date(agent.created_at).toLocaleDateString()}`}

@@ -424,36 +431,42 @@ export const CCAgents: React.FC = ({ onBack, className }) => { Execute - - - + {agent.source !== "filesystem" && ( + + )} + {agent.source !== "filesystem" && ( + + )} + {agent.source !== "filesystem" && ( + + )} diff --git a/src/lib/api.ts b/src/lib/api.ts index eb76da821..c6889eb34 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -122,6 +122,8 @@ export interface Agent { hooks?: string; // JSON string of HooksConfiguration created_at: string; updated_at: string; + source?: string; // "database" or "filesystem" + file_path?: string; // Path if loaded from filesystem } export interface AgentExport {