Skip to content

Commit 7cf4b96

Browse files
author
Marlon Costa
committed
Merge PR winfunc#397: Filesystem Agent Loading
2 parents a735370 + f3ff582 commit 7cf4b96

File tree

5 files changed

+207
-36
lines changed

5 files changed

+207
-36
lines changed

bun.lock

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "GUI app and Toolkit for Claude Code"
55
authors = ["mufeedvh", "123vviekr"]
66
license = "AGPL-3.0"
77
edition = "2021"
8+
default-run = "opcode"
89

910
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
1011

src-tauri/src/commands/agents.rs

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ use reqwest;
66
use rusqlite::{params, Connection, Result as SqliteResult};
77
use serde::{Deserialize, Serialize};
88
use serde_json::Value as JsonValue;
9+
use serde_yaml;
10+
use std::fs;
911
use std::io::{BufRead, BufReader};
12+
use std::path::Path;
1013
use std::process::Stdio;
1114
use std::sync::Mutex;
1215
use tauri::{AppHandle, Emitter, Manager, State};
@@ -20,7 +23,7 @@ fn find_claude_binary(app_handle: &AppHandle) -> Result<String, String> {
2023
crate::claude_binary::find_claude_binary(app_handle)
2124
}
2225

23-
/// Represents a CC Agent stored in the database
26+
/// Represents a CC Agent stored in the database or loaded from filesystem
2427
#[derive(Debug, Serialize, Deserialize, Clone)]
2528
pub struct Agent {
2629
pub id: Option<i64>,
@@ -35,6 +38,10 @@ pub struct Agent {
3538
pub hooks: Option<String>, // JSON string of hooks configuration
3639
pub created_at: String,
3740
pub updated_at: String,
41+
#[serde(skip_serializing_if = "Option::is_none")]
42+
pub source: Option<String>, // "database" or "filesystem"
43+
#[serde(skip_serializing_if = "Option::is_none")]
44+
pub file_path: Option<String>, // Path if loaded from filesystem
3845
}
3946

4047
/// Represents an agent execution run
@@ -92,6 +99,16 @@ pub struct AgentData {
9299
pub hooks: Option<String>,
93100
}
94101

102+
/// Frontmatter structure for agent markdown files
103+
#[derive(Debug, Serialize, Deserialize)]
104+
struct AgentFrontmatter {
105+
name: String,
106+
description: Option<String>,
107+
tools: Option<String>,
108+
model: Option<String>,
109+
icon: Option<String>,
110+
}
111+
95112
/// Database connection state
96113
pub struct AgentDb(pub Mutex<Connection>);
97114

@@ -345,6 +362,119 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
345362
Ok(conn)
346363
}
347364

365+
/// Parse a markdown file with YAML frontmatter
366+
fn parse_agent_markdown(file_path: &Path) -> Result<Agent, String> {
367+
let content = fs::read_to_string(file_path)
368+
.map_err(|e| format!("Failed to read file {}: {}", file_path.display(), e))?;
369+
370+
// Split frontmatter and content
371+
let parts: Vec<&str> = content.splitn(3, "---").collect();
372+
if parts.len() < 3 {
373+
return Err(format!("Invalid markdown format in {}", file_path.display()));
374+
}
375+
376+
// Parse frontmatter
377+
let frontmatter: AgentFrontmatter = serde_yaml::from_str(parts[1])
378+
.map_err(|e| format!("Failed to parse frontmatter in {}: {}", file_path.display(), e))?;
379+
380+
// Extract system prompt from markdown content
381+
let system_prompt = parts[2].trim().to_string();
382+
383+
// Determine icon based on name or use default
384+
let icon = frontmatter.icon.unwrap_or_else(|| {
385+
// Map common agent names to icons
386+
match frontmatter.name.as_str() {
387+
name if name.contains("ai") => "bot",
388+
name if name.contains("api") => "globe",
389+
name if name.contains("cloud") => "cloud",
390+
name if name.contains("data") => "database",
391+
name if name.contains("test") || name.contains("qa") => "shield",
392+
name if name.contains("deploy") => "package",
393+
name if name.contains("architect") => "layout",
394+
name if name.contains("security") => "shield",
395+
name if name.contains("debug") => "bug",
396+
name if name.contains("doc") => "file-text",
397+
name if name.contains("review") => "eye",
398+
_ => "bot",
399+
}.to_string()
400+
});
401+
402+
let now = chrono::Local::now().to_rfc3339();
403+
404+
Ok(Agent {
405+
id: None, // File-based agents don't have database IDs
406+
name: frontmatter.name.clone(),
407+
icon,
408+
system_prompt,
409+
default_task: frontmatter.description,
410+
model: frontmatter.model.unwrap_or_else(|| "sonnet".to_string()),
411+
enable_file_read: true,
412+
enable_file_write: true,
413+
enable_network: false,
414+
hooks: None,
415+
created_at: now.clone(),
416+
updated_at: now,
417+
source: Some("filesystem".to_string()),
418+
file_path: Some(file_path.to_string_lossy().to_string()),
419+
})
420+
}
421+
422+
/// Load agents from the .claude/agents directory
423+
fn load_filesystem_agents() -> Vec<Agent> {
424+
let mut agents = Vec::new();
425+
426+
// Get the .claude/agents directory
427+
let home = match dirs::home_dir() {
428+
Some(h) => h,
429+
None => {
430+
warn!("Could not determine home directory");
431+
return agents;
432+
}
433+
};
434+
435+
let agents_dir = home.join(".claude").join("agents");
436+
if !agents_dir.exists() {
437+
debug!("No .claude/agents directory found");
438+
return agents;
439+
}
440+
441+
// Recursively walk through the agents directory
442+
fn scan_directory(dir: &Path, agents: &mut Vec<Agent>) {
443+
if let Ok(entries) = fs::read_dir(dir) {
444+
for entry in entries.flatten() {
445+
let path = entry.path();
446+
if path.is_dir() {
447+
// Skip hidden directories
448+
if let Some(name) = path.file_name() {
449+
if !name.to_string_lossy().starts_with('.') {
450+
scan_directory(&path, agents);
451+
}
452+
}
453+
} else if path.is_file() {
454+
// Check if it's a markdown file
455+
if let Some(ext) = path.extension() {
456+
if ext == "md" || ext == "markdown" {
457+
match parse_agent_markdown(&path) {
458+
Ok(agent) => {
459+
debug!("Loaded agent from file: {}", agent.name);
460+
agents.push(agent);
461+
}
462+
Err(e) => {
463+
warn!("Failed to parse agent file {}: {}", path.display(), e);
464+
}
465+
}
466+
}
467+
}
468+
}
469+
}
470+
}
471+
}
472+
473+
scan_directory(&agents_dir, &mut agents);
474+
info!("Loaded {} agents from filesystem", agents.len());
475+
agents
476+
}
477+
348478
/// List all agents
349479
#[tauri::command]
350480
pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, String> {
@@ -354,7 +484,7 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, String> {
354484
.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")
355485
.map_err(|e| e.to_string())?;
356486

357-
let agents = stmt
487+
let mut agents = stmt
358488
.query_map([], |row| {
359489
Ok(Agent {
360490
id: Some(row.get(0)?),
@@ -371,12 +501,20 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, String> {
371501
hooks: row.get(9)?,
372502
created_at: row.get(10)?,
373503
updated_at: row.get(11)?,
504+
source: Some("database".to_string()),
505+
file_path: None,
374506
})
375507
})
376508
.map_err(|e| e.to_string())?
377509
.collect::<Result<Vec<_>, _>>()
378510
.map_err(|e| e.to_string())?;
379511

512+
// Load agents from filesystem
513+
let filesystem_agents = load_filesystem_agents();
514+
515+
// Combine agents from both sources
516+
agents.extend(filesystem_agents);
517+
380518
Ok(agents)
381519
}
382520

@@ -427,6 +565,8 @@ pub async fn create_agent(
427565
hooks: row.get(9)?,
428566
created_at: row.get(10)?,
429567
updated_at: row.get(11)?,
568+
source: Some("database".to_string()),
569+
file_path: None,
430570
})
431571
},
432572
)
@@ -512,6 +652,8 @@ pub async fn update_agent(
512652
hooks: row.get(9)?,
513653
created_at: row.get(10)?,
514654
updated_at: row.get(11)?,
655+
source: Some("database".to_string()),
656+
file_path: None,
515657
})
516658
},
517659
)
@@ -554,6 +696,8 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result<Agent, String>
554696
hooks: row.get(9)?,
555697
created_at: row.get(10)?,
556698
updated_at: row.get(11)?,
699+
source: Some("database".to_string()),
700+
file_path: None,
557701
})
558702
},
559703
)
@@ -1768,6 +1912,8 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
17681912
hooks: row.get(9)?,
17691913
created_at: row.get(10)?,
17701914
updated_at: row.get(11)?,
1915+
source: Some("database".to_string()),
1916+
file_path: None,
17711917
})
17721918
},
17731919
)

src/components/CCAgents.tsx

Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -403,14 +403,21 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
403403
>
404404
<Card className="h-full hover:shadow-lg transition-shadow">
405405
<CardContent className="p-6 flex flex-col items-center text-center">
406-
<div className="mb-4 p-4 rounded-full bg-primary/10 text-primary">
406+
<div className="mb-4 p-4 rounded-full bg-primary/10 text-primary relative">
407407
{renderIcon(agent.icon)}
408+
{agent.source === "filesystem" && (
409+
<span className="absolute -top-1 -right-1 bg-blue-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded">
410+
FILE
411+
</span>
412+
)}
408413
</div>
409414
<h3 className="text-heading-4 mb-2">
410415
{agent.name}
411416
</h3>
412417
<p className="text-caption text-muted-foreground">
413-
Created: {new Date(agent.created_at).toLocaleDateString()}
418+
{agent.source === "filesystem"
419+
? "From .claude/agents"
420+
: `Created: ${new Date(agent.created_at).toLocaleDateString()}`}
414421
</p>
415422
</CardContent>
416423
<CardFooter className="p-4 pt-0 flex justify-center gap-1 flex-wrap">
@@ -424,36 +431,42 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
424431
<Play className="h-3 w-3" />
425432
Execute
426433
</Button>
427-
<Button
428-
size="sm"
429-
variant="ghost"
430-
onClick={() => handleEditAgent(agent)}
431-
className="flex items-center gap-1"
432-
title="Edit agent"
433-
>
434-
<Edit className="h-3 w-3" />
435-
Edit
436-
</Button>
437-
<Button
438-
size="sm"
439-
variant="ghost"
440-
onClick={() => handleExportAgent(agent)}
441-
className="flex items-center gap-1"
442-
title="Export agent to .opcode.json"
443-
>
444-
<Upload className="h-3 w-3" />
445-
Export
446-
</Button>
447-
<Button
448-
size="sm"
449-
variant="ghost"
450-
onClick={() => handleDeleteAgent(agent)}
451-
className="flex items-center gap-1 text-destructive hover:text-destructive"
452-
title="Delete agent"
453-
>
454-
<Trash2 className="h-3 w-3" />
455-
Delete
456-
</Button>
434+
{agent.source !== "filesystem" && (
435+
<Button
436+
size="sm"
437+
variant="ghost"
438+
onClick={() => handleEditAgent(agent)}
439+
className="flex items-center gap-1"
440+
title="Edit agent"
441+
>
442+
<Edit className="h-3 w-3" />
443+
Edit
444+
</Button>
445+
)}
446+
{agent.source !== "filesystem" && (
447+
<Button
448+
size="sm"
449+
variant="ghost"
450+
onClick={() => handleExportAgent(agent)}
451+
className="flex items-center gap-1"
452+
title="Export agent to .opcode.json"
453+
>
454+
<Upload className="h-3 w-3" />
455+
Export
456+
</Button>
457+
)}
458+
{agent.source !== "filesystem" && (
459+
<Button
460+
size="sm"
461+
variant="ghost"
462+
onClick={() => handleDeleteAgent(agent)}
463+
className="flex items-center gap-1 text-destructive hover:text-destructive"
464+
title="Delete agent"
465+
>
466+
<Trash2 className="h-3 w-3" />
467+
Delete
468+
</Button>
469+
)}
457470
</CardFooter>
458471
</Card>
459472
</motion.div>

0 commit comments

Comments
 (0)