Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "opcode",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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=="],

Expand Down Expand Up @@ -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=="],

Expand Down Expand Up @@ -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=="],
Expand All @@ -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=="],
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
150 changes: 148 additions & 2 deletions src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -20,7 +23,7 @@ fn find_claude_binary(app_handle: &AppHandle) -> Result<String, String> {
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<i64>,
Expand All @@ -35,6 +38,10 @@ pub struct Agent {
pub hooks: Option<String>, // JSON string of hooks configuration
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>, // "database" or "filesystem"
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>, // Path if loaded from filesystem
}

/// Represents an agent execution run
Expand Down Expand Up @@ -92,6 +99,16 @@ pub struct AgentData {
pub hooks: Option<String>,
}

/// Frontmatter structure for agent markdown files
#[derive(Debug, Serialize, Deserialize)]
struct AgentFrontmatter {
name: String,
description: Option<String>,
tools: Option<String>,
model: Option<String>,
icon: Option<String>,
}

/// Database connection state
pub struct AgentDb(pub Mutex<Connection>);

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

/// Parse a markdown file with YAML frontmatter
fn parse_agent_markdown(file_path: &Path) -> Result<Agent, String> {
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<Agent> {
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<Agent>) {
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<Vec<Agent>, String> {
Expand All @@ -354,7 +484,7 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, 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)?),
Expand All @@ -371,12 +501,20 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, 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::<Result<Vec<_>, _>>()
.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)
}

Expand Down Expand Up @@ -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,
})
},
)
Expand Down Expand Up @@ -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,
})
},
)
Expand Down Expand Up @@ -554,6 +696,8 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result<Agent, String>
hooks: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
source: Some("database".to_string()),
file_path: None,
})
},
)
Expand Down Expand Up @@ -1768,6 +1912,8 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
hooks: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
source: Some("database".to_string()),
file_path: None,
})
},
)
Expand Down
77 changes: 45 additions & 32 deletions src/components/CCAgents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,14 +403,21 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
>
<Card className="h-full hover:shadow-lg transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center">
<div className="mb-4 p-4 rounded-full bg-primary/10 text-primary">
<div className="mb-4 p-4 rounded-full bg-primary/10 text-primary relative">
{renderIcon(agent.icon)}
{agent.source === "filesystem" && (
<span className="absolute -top-1 -right-1 bg-blue-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded">
FILE
</span>
)}
</div>
<h3 className="text-heading-4 mb-2">
{agent.name}
</h3>
<p className="text-caption text-muted-foreground">
Created: {new Date(agent.created_at).toLocaleDateString()}
{agent.source === "filesystem"
? "From .claude/agents"
: `Created: ${new Date(agent.created_at).toLocaleDateString()}`}
</p>
</CardContent>
<CardFooter className="p-4 pt-0 flex justify-center gap-1 flex-wrap">
Expand All @@ -424,36 +431,42 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
<Play className="h-3 w-3" />
Execute
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditAgent(agent)}
className="flex items-center gap-1"
title="Edit agent"
>
<Edit className="h-3 w-3" />
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleExportAgent(agent)}
className="flex items-center gap-1"
title="Export agent to .opcode.json"
>
<Upload className="h-3 w-3" />
Export
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteAgent(agent)}
className="flex items-center gap-1 text-destructive hover:text-destructive"
title="Delete agent"
>
<Trash2 className="h-3 w-3" />
Delete
</Button>
{agent.source !== "filesystem" && (
<Button
size="sm"
variant="ghost"
onClick={() => handleEditAgent(agent)}
className="flex items-center gap-1"
title="Edit agent"
>
<Edit className="h-3 w-3" />
Edit
</Button>
)}
{agent.source !== "filesystem" && (
<Button
size="sm"
variant="ghost"
onClick={() => handleExportAgent(agent)}
className="flex items-center gap-1"
title="Export agent to .opcode.json"
>
<Upload className="h-3 w-3" />
Export
</Button>
)}
{agent.source !== "filesystem" && (
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteAgent(agent)}
className="flex items-center gap-1 text-destructive hover:text-destructive"
title="Delete agent"
>
<Trash2 className="h-3 w-3" />
Delete
</Button>
)}
</CardFooter>
</Card>
</motion.div>
Expand Down
Loading