init: push source

This commit is contained in:
Mufeed VH
2025-06-19 19:24:01 +05:30
commit 8e76d016d4
136 changed files with 38177 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,786 @@
use tauri::AppHandle;
use tauri::Manager;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use log::{info, error, warn};
use dirs;
/// Helper function to create a std::process::Command with proper environment variables
/// This ensures commands like Claude can find Node.js and other dependencies
fn create_command_with_env(program: &str) -> Command {
let mut cmd = Command::new(program);
// Inherit essential environment variables from parent process
// This is crucial for commands like Claude that need to find Node.js
for (key, value) in std::env::vars() {
// Pass through PATH and other essential environment variables
if key == "PATH" || key == "HOME" || key == "USER"
|| key == "SHELL" || key == "LANG" || key == "LC_ALL" || key.starts_with("LC_")
|| key == "NODE_PATH" || key == "NVM_DIR" || key == "NVM_BIN"
|| key == "HOMEBREW_PREFIX" || key == "HOMEBREW_CELLAR" {
log::debug!("Inheriting env var: {}={}", key, value);
cmd.env(&key, &value);
}
}
cmd
}
/// Finds the full path to the claude binary
/// This is necessary because macOS apps have a limited PATH environment
fn find_claude_binary(app_handle: &AppHandle) -> Result<String> {
log::info!("Searching for claude binary...");
// First check if we have a stored path in the database
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
let db_path = app_data_dir.join("agents.db");
if db_path.exists() {
if let Ok(conn) = rusqlite::Connection::open(&db_path) {
if let Ok(stored_path) = conn.query_row(
"SELECT value FROM app_settings WHERE key = 'claude_binary_path'",
[],
|row| row.get::<_, String>(0),
) {
log::info!("Found stored claude path in database: {}", stored_path);
let path_buf = std::path::PathBuf::from(&stored_path);
if path_buf.exists() && path_buf.is_file() {
return Ok(stored_path);
} else {
log::warn!("Stored claude path no longer exists: {}", stored_path);
}
}
}
}
}
// Common installation paths for claude
let mut paths_to_check: Vec<String> = vec![
"/usr/local/bin/claude".to_string(),
"/opt/homebrew/bin/claude".to_string(),
"/usr/bin/claude".to_string(),
"/bin/claude".to_string(),
];
// Also check user-specific paths
if let Ok(home) = std::env::var("HOME") {
paths_to_check.extend(vec![
format!("{}/.claude/local/claude", home),
format!("{}/.local/bin/claude", home),
format!("{}/.npm-global/bin/claude", home),
format!("{}/.yarn/bin/claude", home),
format!("{}/.bun/bin/claude", home),
format!("{}/bin/claude", home),
// Check common node_modules locations
format!("{}/node_modules/.bin/claude", home),
format!("{}/.config/yarn/global/node_modules/.bin/claude", home),
]);
}
// Check each path
for path in paths_to_check {
let path_buf = std::path::PathBuf::from(&path);
if path_buf.exists() && path_buf.is_file() {
log::info!("Found claude at: {}", path);
return Ok(path);
}
}
// Fallback: try using 'which' command
log::info!("Trying 'which claude' to find binary...");
if let Ok(output) = std::process::Command::new("which")
.arg("claude")
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
log::info!("'which' found claude at: {}", path);
return Ok(path);
}
}
}
// Additional fallback: check if claude is in the current PATH
// This might work in dev mode
if let Ok(output) = std::process::Command::new("claude")
.arg("--version")
.output()
{
if output.status.success() {
log::info!("claude is available in PATH (dev mode?)");
return Ok("claude".to_string());
}
}
log::error!("Could not find claude binary in any common location");
Err(anyhow::anyhow!("Claude Code not found. Please ensure it's installed and in one of these locations: /usr/local/bin, /opt/homebrew/bin, ~/.claude/local, ~/.local/bin, or in your PATH"))
}
/// Represents an MCP server configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPServer {
/// Server name/identifier
pub name: String,
/// Transport type: "stdio" or "sse"
pub transport: String,
/// Command to execute (for stdio)
pub command: Option<String>,
/// Command arguments (for stdio)
pub args: Vec<String>,
/// Environment variables
pub env: HashMap<String, String>,
/// URL endpoint (for SSE)
pub url: Option<String>,
/// Configuration scope: "local", "project", or "user"
pub scope: String,
/// Whether the server is currently active
pub is_active: bool,
/// Server status
pub status: ServerStatus,
}
/// Server status information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerStatus {
/// Whether the server is running
pub running: bool,
/// Last error message if any
pub error: Option<String>,
/// Last checked timestamp
pub last_checked: Option<u64>,
}
/// MCP configuration for project scope (.mcp.json)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPProjectConfig {
#[serde(rename = "mcpServers")]
pub mcp_servers: HashMap<String, MCPServerConfig>,
}
/// Individual server configuration in .mcp.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPServerConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
/// Result of adding a server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddServerResult {
pub success: bool,
pub message: String,
pub server_name: Option<String>,
}
/// Import result for multiple servers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub imported_count: u32,
pub failed_count: u32,
pub servers: Vec<ImportServerResult>,
}
/// Result for individual server import
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportServerResult {
pub name: String,
pub success: bool,
pub error: Option<String>,
}
/// Executes a claude mcp command
fn execute_claude_mcp_command(app_handle: &AppHandle, args: Vec<&str>) -> Result<String> {
info!("Executing claude mcp command with args: {:?}", args);
let claude_path = find_claude_binary(app_handle)?;
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("mcp");
for arg in args {
cmd.arg(arg);
}
let output = cmd.output()
.context("Failed to execute claude command")?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(anyhow::anyhow!("Command failed: {}", stderr))
}
}
/// Adds a new MCP server
#[tauri::command]
pub async fn mcp_add(
app: AppHandle,
name: String,
transport: String,
command: Option<String>,
args: Vec<String>,
env: HashMap<String, String>,
url: Option<String>,
scope: String,
) -> Result<AddServerResult, String> {
info!("Adding MCP server: {} with transport: {}", name, transport);
// Prepare owned strings for environment variables
let env_args: Vec<String> = env.iter()
.map(|(key, value)| format!("{}={}", key, value))
.collect();
let mut cmd_args = vec!["add"];
// Add scope flag
cmd_args.push("-s");
cmd_args.push(&scope);
// Add transport flag for SSE
if transport == "sse" {
cmd_args.push("--transport");
cmd_args.push("sse");
}
// Add environment variables
for (i, _) in env.iter().enumerate() {
cmd_args.push("-e");
cmd_args.push(&env_args[i]);
}
// Add name
cmd_args.push(&name);
// Add command/URL based on transport
if transport == "stdio" {
if let Some(cmd) = &command {
// Add "--" separator before command to prevent argument parsing issues
if !args.is_empty() || cmd.contains('-') {
cmd_args.push("--");
}
cmd_args.push(cmd);
// Add arguments
for arg in &args {
cmd_args.push(arg);
}
} else {
return Ok(AddServerResult {
success: false,
message: "Command is required for stdio transport".to_string(),
server_name: None,
});
}
} else if transport == "sse" {
if let Some(url_str) = &url {
cmd_args.push(url_str);
} else {
return Ok(AddServerResult {
success: false,
message: "URL is required for SSE transport".to_string(),
server_name: None,
});
}
}
match execute_claude_mcp_command(&app, cmd_args) {
Ok(output) => {
info!("Successfully added MCP server: {}", name);
Ok(AddServerResult {
success: true,
message: output.trim().to_string(),
server_name: Some(name),
})
}
Err(e) => {
error!("Failed to add MCP server: {}", e);
Ok(AddServerResult {
success: false,
message: e.to_string(),
server_name: None,
})
}
}
}
/// Lists all configured MCP servers
#[tauri::command]
pub async fn mcp_list(app: AppHandle) -> Result<Vec<MCPServer>, String> {
info!("Listing MCP servers");
match execute_claude_mcp_command(&app, vec!["list"]) {
Ok(output) => {
info!("Raw output from 'claude mcp list': {:?}", output);
let trimmed = output.trim();
info!("Trimmed output: {:?}", trimmed);
// Check if no servers are configured
if trimmed.contains("No MCP servers configured") || trimmed.is_empty() {
info!("No servers found - empty or 'No MCP servers' message");
return Ok(vec![]);
}
// Parse the text output, handling multi-line commands
let mut servers = Vec::new();
let lines: Vec<&str> = trimmed.lines().collect();
info!("Total lines in output: {}", lines.len());
for (idx, line) in lines.iter().enumerate() {
info!("Line {}: {:?}", idx, line);
}
let mut i = 0;
while i < lines.len() {
let line = lines[i];
info!("Processing line {}: {:?}", i, line);
// Check if this line starts a new server entry
if let Some(colon_pos) = line.find(':') {
info!("Found colon at position {} in line: {:?}", colon_pos, line);
// Make sure this is a server name line (not part of a path)
// Server names typically don't contain '/' or '\'
let potential_name = line[..colon_pos].trim();
info!("Potential server name: {:?}", potential_name);
if !potential_name.contains('/') && !potential_name.contains('\\') {
info!("Valid server name detected: {:?}", potential_name);
let name = potential_name.to_string();
let mut command_parts = vec![line[colon_pos + 1..].trim().to_string()];
info!("Initial command part: {:?}", command_parts[0]);
// Check if command continues on next lines
i += 1;
while i < lines.len() {
let next_line = lines[i];
info!("Checking next line {} for continuation: {:?}", i, next_line);
// If the next line starts with a server name pattern, break
if next_line.contains(':') {
let potential_next_name = next_line.split(':').next().unwrap_or("").trim();
info!("Found colon in next line, potential name: {:?}", potential_next_name);
if !potential_next_name.is_empty() &&
!potential_next_name.contains('/') &&
!potential_next_name.contains('\\') {
info!("Next line is a new server, breaking");
break;
}
}
// Otherwise, this line is a continuation of the command
info!("Line {} is a continuation", i);
command_parts.push(next_line.trim().to_string());
i += 1;
}
// Join all command parts
let full_command = command_parts.join(" ");
info!("Full command for server '{}': {:?}", name, full_command);
// For now, we'll create a basic server entry
servers.push(MCPServer {
name: name.clone(),
transport: "stdio".to_string(), // Default assumption
command: Some(full_command),
args: vec![],
env: HashMap::new(),
url: None,
scope: "local".to_string(), // Default assumption
is_active: false,
status: ServerStatus {
running: false,
error: None,
last_checked: None,
},
});
info!("Added server: {:?}", name);
continue;
} else {
info!("Skipping line - name contains path separators");
}
} else {
info!("No colon found in line {}", i);
}
i += 1;
}
info!("Found {} MCP servers total", servers.len());
for (idx, server) in servers.iter().enumerate() {
info!("Server {}: name='{}', command={:?}", idx, server.name, server.command);
}
Ok(servers)
}
Err(e) => {
error!("Failed to list MCP servers: {}", e);
Err(e.to_string())
}
}
}
/// Gets details for a specific MCP server
#[tauri::command]
pub async fn mcp_get(app: AppHandle, name: String) -> Result<MCPServer, String> {
info!("Getting MCP server details for: {}", name);
match execute_claude_mcp_command(&app, vec!["get", &name]) {
Ok(output) => {
// Parse the structured text output
let mut scope = "local".to_string();
let mut transport = "stdio".to_string();
let mut command = None;
let mut args = vec![];
let env = HashMap::new();
let mut url = None;
for line in output.lines() {
let line = line.trim();
if line.starts_with("Scope:") {
let scope_part = line.replace("Scope:", "").trim().to_string();
if scope_part.to_lowercase().contains("local") {
scope = "local".to_string();
} else if scope_part.to_lowercase().contains("project") {
scope = "project".to_string();
} else if scope_part.to_lowercase().contains("user") || scope_part.to_lowercase().contains("global") {
scope = "user".to_string();
}
} else if line.starts_with("Type:") {
transport = line.replace("Type:", "").trim().to_string();
} else if line.starts_with("Command:") {
command = Some(line.replace("Command:", "").trim().to_string());
} else if line.starts_with("Args:") {
let args_str = line.replace("Args:", "").trim().to_string();
if !args_str.is_empty() {
args = args_str.split_whitespace().map(|s| s.to_string()).collect();
}
} else if line.starts_with("URL:") {
url = Some(line.replace("URL:", "").trim().to_string());
} else if line.starts_with("Environment:") {
// TODO: Parse environment variables if they're listed
// For now, we'll leave it empty
}
}
Ok(MCPServer {
name,
transport,
command,
args,
env,
url,
scope,
is_active: false,
status: ServerStatus {
running: false,
error: None,
last_checked: None,
},
})
}
Err(e) => {
error!("Failed to get MCP server: {}", e);
Err(e.to_string())
}
}
}
/// Removes an MCP server
#[tauri::command]
pub async fn mcp_remove(app: AppHandle, name: String) -> Result<String, String> {
info!("Removing MCP server: {}", name);
match execute_claude_mcp_command(&app, vec!["remove", &name]) {
Ok(output) => {
info!("Successfully removed MCP server: {}", name);
Ok(output.trim().to_string())
}
Err(e) => {
error!("Failed to remove MCP server: {}", e);
Err(e.to_string())
}
}
}
/// Adds an MCP server from JSON configuration
#[tauri::command]
pub async fn mcp_add_json(app: AppHandle, name: String, json_config: String, scope: String) -> Result<AddServerResult, String> {
info!("Adding MCP server from JSON: {} with scope: {}", name, scope);
// Build command args
let mut cmd_args = vec!["add-json", &name, &json_config];
// Add scope flag
let scope_flag = "-s";
cmd_args.push(scope_flag);
cmd_args.push(&scope);
match execute_claude_mcp_command(&app, cmd_args) {
Ok(output) => {
info!("Successfully added MCP server from JSON: {}", name);
Ok(AddServerResult {
success: true,
message: output.trim().to_string(),
server_name: Some(name),
})
}
Err(e) => {
error!("Failed to add MCP server from JSON: {}", e);
Ok(AddServerResult {
success: false,
message: e.to_string(),
server_name: None,
})
}
}
}
/// Imports MCP servers from Claude Desktop
#[tauri::command]
pub async fn mcp_add_from_claude_desktop(app: AppHandle, scope: String) -> Result<ImportResult, String> {
info!("Importing MCP servers from Claude Desktop with scope: {}", scope);
// Get Claude Desktop config path based on platform
let config_path = if cfg!(target_os = "macos") {
dirs::home_dir()
.ok_or_else(|| "Could not find home directory".to_string())?
.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json")
} else if cfg!(target_os = "linux") {
// For WSL/Linux, check common locations
dirs::config_dir()
.ok_or_else(|| "Could not find config directory".to_string())?
.join("Claude")
.join("claude_desktop_config.json")
} else {
return Err("Import from Claude Desktop is only supported on macOS and Linux/WSL".to_string());
};
// Check if config file exists
if !config_path.exists() {
return Err("Claude Desktop configuration not found. Make sure Claude Desktop is installed.".to_string());
}
// Read and parse the config file
let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read Claude Desktop config: {}", e))?;
let config: serde_json::Value = serde_json::from_str(&config_content)
.map_err(|e| format!("Failed to parse Claude Desktop config: {}", e))?;
// Extract MCP servers
let mcp_servers = config.get("mcpServers")
.and_then(|v| v.as_object())
.ok_or_else(|| "No MCP servers found in Claude Desktop config".to_string())?;
let mut imported_count = 0;
let mut failed_count = 0;
let mut server_results = Vec::new();
// Import each server using add-json
for (name, server_config) in mcp_servers {
info!("Importing server: {}", name);
// Convert Claude Desktop format to add-json format
let mut json_config = serde_json::Map::new();
// All Claude Desktop servers are stdio type
json_config.insert("type".to_string(), serde_json::Value::String("stdio".to_string()));
// Add command
if let Some(command) = server_config.get("command").and_then(|v| v.as_str()) {
json_config.insert("command".to_string(), serde_json::Value::String(command.to_string()));
} else {
failed_count += 1;
server_results.push(ImportServerResult {
name: name.clone(),
success: false,
error: Some("Missing command field".to_string()),
});
continue;
}
// Add args if present
if let Some(args) = server_config.get("args").and_then(|v| v.as_array()) {
json_config.insert("args".to_string(), args.clone().into());
} else {
json_config.insert("args".to_string(), serde_json::Value::Array(vec![]));
}
// Add env if present
if let Some(env) = server_config.get("env").and_then(|v| v.as_object()) {
json_config.insert("env".to_string(), env.clone().into());
} else {
json_config.insert("env".to_string(), serde_json::Value::Object(serde_json::Map::new()));
}
// Convert to JSON string
let json_str = serde_json::to_string(&json_config)
.map_err(|e| format!("Failed to serialize config for {}: {}", name, e))?;
// Call add-json command
match mcp_add_json(app.clone(), name.clone(), json_str, scope.clone()).await {
Ok(result) => {
if result.success {
imported_count += 1;
server_results.push(ImportServerResult {
name: name.clone(),
success: true,
error: None,
});
info!("Successfully imported server: {}", name);
} else {
failed_count += 1;
let error_msg = result.message.clone();
server_results.push(ImportServerResult {
name: name.clone(),
success: false,
error: Some(result.message),
});
error!("Failed to import server {}: {}", name, error_msg);
}
}
Err(e) => {
failed_count += 1;
let error_msg = e.clone();
server_results.push(ImportServerResult {
name: name.clone(),
success: false,
error: Some(e),
});
error!("Error importing server {}: {}", name, error_msg);
}
}
}
info!("Import complete: {} imported, {} failed", imported_count, failed_count);
Ok(ImportResult {
imported_count,
failed_count,
servers: server_results,
})
}
/// Starts Claude Code as an MCP server
#[tauri::command]
pub async fn mcp_serve(app: AppHandle) -> Result<String, String> {
info!("Starting Claude Code as MCP server");
// Start the server in a separate process
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
error!("Failed to find claude binary: {}", e);
return Err(e.to_string());
}
};
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("mcp").arg("serve");
match cmd.spawn() {
Ok(_) => {
info!("Successfully started Claude Code MCP server");
Ok("Claude Code MCP server started".to_string())
}
Err(e) => {
error!("Failed to start MCP server: {}", e);
Err(e.to_string())
}
}
}
/// Tests connection to an MCP server
#[tauri::command]
pub async fn mcp_test_connection(app: AppHandle, name: String) -> Result<String, String> {
info!("Testing connection to MCP server: {}", name);
// For now, we'll use the get command to test if the server exists
match execute_claude_mcp_command(&app, vec!["get", &name]) {
Ok(_) => Ok(format!("Connection to {} successful", name)),
Err(e) => Err(e.to_string()),
}
}
/// Resets project-scoped server approval choices
#[tauri::command]
pub async fn mcp_reset_project_choices(app: AppHandle) -> Result<String, String> {
info!("Resetting MCP project choices");
match execute_claude_mcp_command(&app, vec!["reset-project-choices"]) {
Ok(output) => {
info!("Successfully reset MCP project choices");
Ok(output.trim().to_string())
}
Err(e) => {
error!("Failed to reset project choices: {}", e);
Err(e.to_string())
}
}
}
/// Gets the status of MCP servers
#[tauri::command]
pub async fn mcp_get_server_status() -> Result<HashMap<String, ServerStatus>, String> {
info!("Getting MCP server status");
// TODO: Implement actual status checking
// For now, return empty status
Ok(HashMap::new())
}
/// Reads .mcp.json from the current project
#[tauri::command]
pub async fn mcp_read_project_config(project_path: String) -> Result<MCPProjectConfig, String> {
info!("Reading .mcp.json from project: {}", project_path);
let mcp_json_path = PathBuf::from(&project_path).join(".mcp.json");
if !mcp_json_path.exists() {
return Ok(MCPProjectConfig {
mcp_servers: HashMap::new(),
});
}
match fs::read_to_string(&mcp_json_path) {
Ok(content) => {
match serde_json::from_str::<MCPProjectConfig>(&content) {
Ok(config) => Ok(config),
Err(e) => {
error!("Failed to parse .mcp.json: {}", e);
Err(format!("Failed to parse .mcp.json: {}", e))
}
}
}
Err(e) => {
error!("Failed to read .mcp.json: {}", e);
Err(format!("Failed to read .mcp.json: {}", e))
}
}
}
/// Saves .mcp.json to the current project
#[tauri::command]
pub async fn mcp_save_project_config(
project_path: String,
config: MCPProjectConfig,
) -> Result<String, String> {
info!("Saving .mcp.json to project: {}", project_path);
let mcp_json_path = PathBuf::from(&project_path).join(".mcp.json");
let json_content = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&mcp_json_path, json_content)
.map_err(|e| format!("Failed to write .mcp.json: {}", e))?;
Ok("Project MCP configuration saved".to_string())
}

View File

@@ -0,0 +1,5 @@
pub mod claude;
pub mod agents;
pub mod sandbox;
pub mod usage;
pub mod mcp;

View File

@@ -0,0 +1,919 @@
use crate::{
commands::agents::AgentDb,
sandbox::{
platform::PlatformCapabilities,
profile::{SandboxProfile, SandboxRule},
},
};
use rusqlite::params;
use serde::{Deserialize, Serialize};
use tauri::State;
/// Represents a sandbox violation event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxViolation {
pub id: Option<i64>,
pub profile_id: Option<i64>,
pub agent_id: Option<i64>,
pub agent_run_id: Option<i64>,
pub operation_type: String,
pub pattern_value: Option<String>,
pub process_name: Option<String>,
pub pid: Option<i32>,
pub denied_at: String,
}
/// Represents sandbox profile export data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxProfileExport {
pub version: u32,
pub exported_at: String,
pub platform: String,
pub profiles: Vec<SandboxProfileWithRules>,
}
/// Represents a profile with its rules for export
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxProfileWithRules {
pub profile: SandboxProfile,
pub rules: Vec<SandboxRule>,
}
/// Import result for a profile
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub profile_name: String,
pub imported: bool,
pub reason: Option<String>,
pub new_name: Option<String>,
}
/// List all sandbox profiles
#[tauri::command]
pub async fn list_sandbox_profiles(db: State<'_, AgentDb>) -> Result<Vec<SandboxProfile>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles ORDER BY name")
.map_err(|e| e.to_string())?;
let profiles = stmt
.query_map([], |row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(profiles)
}
/// Create a new sandbox profile
#[tauri::command]
pub async fn create_sandbox_profile(
db: State<'_, AgentDb>,
name: String,
description: Option<String>,
) -> Result<SandboxProfile, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO sandbox_profiles (name, description) VALUES (?1, ?2)",
params![name, description],
)
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
// Fetch the created profile
let profile = conn
.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(profile)
}
/// Update a sandbox profile
#[tauri::command]
pub async fn update_sandbox_profile(
db: State<'_, AgentDb>,
id: i64,
name: String,
description: Option<String>,
is_active: bool,
is_default: bool,
) -> Result<SandboxProfile, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// If setting as default, unset other defaults
if is_default {
conn.execute(
"UPDATE sandbox_profiles SET is_default = 0 WHERE id != ?1",
params![id],
)
.map_err(|e| e.to_string())?;
}
conn.execute(
"UPDATE sandbox_profiles SET name = ?1, description = ?2, is_active = ?3, is_default = ?4 WHERE id = ?5",
params![name, description, is_active, is_default, id],
)
.map_err(|e| e.to_string())?;
// Fetch the updated profile
let profile = conn
.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(profile)
}
/// Delete a sandbox profile
#[tauri::command]
pub async fn delete_sandbox_profile(db: State<'_, AgentDb>, id: i64) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Check if it's the default profile
let is_default: bool = conn
.query_row(
"SELECT is_default FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if is_default {
return Err("Cannot delete the default profile".to_string());
}
conn.execute("DELETE FROM sandbox_profiles WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// Get a single sandbox profile by ID
#[tauri::command]
pub async fn get_sandbox_profile(db: State<'_, AgentDb>, id: i64) -> Result<SandboxProfile, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let profile = conn
.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(profile)
}
/// List rules for a sandbox profile
#[tauri::command]
pub async fn list_sandbox_rules(
db: State<'_, AgentDb>,
profile_id: i64,
) -> Result<Vec<SandboxRule>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE profile_id = ?1 ORDER BY operation_type, pattern_value")
.map_err(|e| e.to_string())?;
let rules = stmt
.query_map(params![profile_id], |row| {
Ok(SandboxRule {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
operation_type: row.get(2)?,
pattern_type: row.get(3)?,
pattern_value: row.get(4)?,
enabled: row.get(5)?,
platform_support: row.get(6)?,
created_at: row.get(7)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(rules)
}
/// Create a new sandbox rule
#[tauri::command]
pub async fn create_sandbox_rule(
db: State<'_, AgentDb>,
profile_id: i64,
operation_type: String,
pattern_type: String,
pattern_value: String,
enabled: bool,
platform_support: Option<String>,
) -> Result<SandboxRule, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Validate rule doesn't conflict
// TODO: Add more validation logic here
conn.execute(
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support],
)
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
// Fetch the created rule
let rule = conn
.query_row(
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE id = ?1",
params![id],
|row| {
Ok(SandboxRule {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
operation_type: row.get(2)?,
pattern_type: row.get(3)?,
pattern_value: row.get(4)?,
enabled: row.get(5)?,
platform_support: row.get(6)?,
created_at: row.get(7)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(rule)
}
/// Update a sandbox rule
#[tauri::command]
pub async fn update_sandbox_rule(
db: State<'_, AgentDb>,
id: i64,
operation_type: String,
pattern_type: String,
pattern_value: String,
enabled: bool,
platform_support: Option<String>,
) -> Result<SandboxRule, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE sandbox_rules SET operation_type = ?1, pattern_type = ?2, pattern_value = ?3, enabled = ?4, platform_support = ?5 WHERE id = ?6",
params![operation_type, pattern_type, pattern_value, enabled, platform_support, id],
)
.map_err(|e| e.to_string())?;
// Fetch the updated rule
let rule = conn
.query_row(
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE id = ?1",
params![id],
|row| {
Ok(SandboxRule {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
operation_type: row.get(2)?,
pattern_type: row.get(3)?,
pattern_value: row.get(4)?,
enabled: row.get(5)?,
platform_support: row.get(6)?,
created_at: row.get(7)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(rule)
}
/// Delete a sandbox rule
#[tauri::command]
pub async fn delete_sandbox_rule(db: State<'_, AgentDb>, id: i64) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM sandbox_rules WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// Get platform capabilities for sandbox configuration
#[tauri::command]
pub async fn get_platform_capabilities() -> Result<PlatformCapabilities, String> {
Ok(crate::sandbox::platform::get_platform_capabilities())
}
/// Test a sandbox profile by creating a simple test process
#[tauri::command]
pub async fn test_sandbox_profile(
db: State<'_, AgentDb>,
profile_id: i64,
) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Load the profile and rules
let profile = crate::sandbox::profile::load_profile(&conn, profile_id)
.map_err(|e| format!("Failed to load profile: {}", e))?;
if !profile.is_active {
return Ok(format!(
"Profile '{}' is currently inactive. Activate it to use with agents.",
profile.name
));
}
let rules = crate::sandbox::profile::load_profile_rules(&conn, profile_id)
.map_err(|e| format!("Failed to load profile rules: {}", e))?;
if rules.is_empty() {
return Ok(format!(
"Profile '{}' has no rules configured. Add rules to define sandbox permissions.",
profile.name
));
}
// Try to build the gaol profile
let test_path = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/tmp"));
let builder = crate::sandbox::profile::ProfileBuilder::new(test_path.clone())
.map_err(|e| format!("Failed to create profile builder: {}", e))?;
let build_result = builder.build_profile_with_serialization(rules.clone())
.map_err(|e| format!("Failed to build sandbox profile: {}", e))?;
// Check platform support
let platform_caps = crate::sandbox::platform::get_platform_capabilities();
if !platform_caps.sandboxing_supported {
return Ok(format!(
"Profile '{}' validated successfully. {} rules loaded.\n\nNote: Sandboxing is not supported on {} platform. The profile configuration is valid but sandbox enforcement will not be active.",
profile.name,
rules.len(),
platform_caps.os
));
}
// Try to execute a simple command in the sandbox
let executor = crate::sandbox::executor::SandboxExecutor::new_with_serialization(
build_result.profile,
test_path.clone(),
build_result.serialized
);
// Use a simple echo command for testing
let test_command = if cfg!(windows) {
"cmd"
} else {
"echo"
};
let test_args = if cfg!(windows) {
vec!["/C", "echo", "sandbox test successful"]
} else {
vec!["sandbox test successful"]
};
match executor.execute_sandboxed_spawn(test_command, &test_args, &test_path) {
Ok(mut child) => {
// Wait for the process to complete with a timeout
match child.wait() {
Ok(status) => {
if status.success() {
Ok(format!(
"✅ Profile '{}' tested successfully!\n\n\
{} rules loaded and validated\n\
• Sandbox activation: Success\n\
• Test process execution: Success\n\
• Platform: {} (fully supported)",
profile.name,
rules.len(),
platform_caps.os
))
} else {
Ok(format!(
"⚠️ Profile '{}' validated with warnings.\n\n\
{} rules loaded and validated\n\
• Sandbox activation: Success\n\
• Test process exit code: {}\n\
• Platform: {}",
profile.name,
rules.len(),
status.code().unwrap_or(-1),
platform_caps.os
))
}
}
Err(e) => {
Ok(format!(
"⚠️ Profile '{}' validated with warnings.\n\n\
{} rules loaded and validated\n\
• Sandbox activation: Partial\n\
• Test process: Could not get exit status ({})\n\
• Platform: {}",
profile.name,
rules.len(),
e,
platform_caps.os
))
}
}
}
Err(e) => {
// Check if it's a permission error or platform limitation
let error_str = e.to_string();
if error_str.contains("permission") || error_str.contains("denied") {
Ok(format!(
"⚠️ Profile '{}' validated with limitations.\n\n\
{} rules loaded and validated\n\
• Sandbox configuration: Valid\n\
• Sandbox enforcement: Limited by system permissions\n\
• Platform: {}\n\n\
Note: The sandbox profile is correctly configured but may require elevated privileges or system configuration to fully enforce on this platform.",
profile.name,
rules.len(),
platform_caps.os
))
} else {
Ok(format!(
"⚠️ Profile '{}' validated with limitations.\n\n\
{} rules loaded and validated\n\
• Sandbox configuration: Valid\n\
• Test execution: Failed ({})\n\
• Platform: {}\n\n\
The sandbox profile is correctly configured. The test execution failed due to platform-specific limitations, but the profile can still be used.",
profile.name,
rules.len(),
e,
platform_caps.os
))
}
}
}
}
/// List sandbox violations with optional filtering
#[tauri::command]
pub async fn list_sandbox_violations(
db: State<'_, AgentDb>,
profile_id: Option<i64>,
agent_id: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<SandboxViolation>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Build dynamic query
let mut query = String::from(
"SELECT id, profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid, denied_at
FROM sandbox_violations WHERE 1=1"
);
let mut param_idx = 1;
if profile_id.is_some() {
query.push_str(&format!(" AND profile_id = ?{}", param_idx));
param_idx += 1;
}
if agent_id.is_some() {
query.push_str(&format!(" AND agent_id = ?{}", param_idx));
param_idx += 1;
}
query.push_str(" ORDER BY denied_at DESC");
if limit.is_some() {
query.push_str(&format!(" LIMIT ?{}", param_idx));
}
// Execute query based on parameters
let violations: Vec<SandboxViolation> = if let Some(pid) = profile_id {
if let Some(aid) = agent_id {
if let Some(lim) = limit {
// All three parameters
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![pid, aid, lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
} else {
// profile_id and agent_id only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![pid, aid], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
}
} else if let Some(lim) = limit {
// profile_id and limit only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![pid, lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
} else {
// profile_id only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![pid], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
}
} else if let Some(aid) = agent_id {
if let Some(lim) = limit {
// agent_id and limit only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![aid, lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
} else {
// agent_id only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![aid], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
}
} else if let Some(lim) = limit {
// limit only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
} else {
// No parameters
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
};
Ok(violations)
}
/// Log a sandbox violation
#[tauri::command]
pub async fn log_sandbox_violation(
db: State<'_, AgentDb>,
profile_id: Option<i64>,
agent_id: Option<i64>,
agent_run_id: Option<i64>,
operation_type: String,
pattern_value: Option<String>,
process_name: Option<String>,
pid: Option<i32>,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO sandbox_violations (profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Clear old sandbox violations
#[tauri::command]
pub async fn clear_sandbox_violations(
db: State<'_, AgentDb>,
older_than_days: Option<i64>,
) -> Result<i64, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let query = if let Some(days) = older_than_days {
format!(
"DELETE FROM sandbox_violations WHERE denied_at < datetime('now', '-{} days')",
days
)
} else {
"DELETE FROM sandbox_violations".to_string()
};
let deleted = conn.execute(&query, [])
.map_err(|e| e.to_string())?;
Ok(deleted as i64)
}
/// Get sandbox violation statistics
#[tauri::command]
pub async fn get_sandbox_violation_stats(
db: State<'_, AgentDb>,
) -> Result<serde_json::Value, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Get total violations
let total: i64 = conn
.query_row("SELECT COUNT(*) FROM sandbox_violations", [], |row| row.get(0))
.map_err(|e| e.to_string())?;
// Get violations by operation type
let mut stmt = conn
.prepare(
"SELECT operation_type, COUNT(*) as count
FROM sandbox_violations
GROUP BY operation_type
ORDER BY count DESC"
)
.map_err(|e| e.to_string())?;
let by_operation: Vec<(String, i64)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
// Get recent violations count (last 24 hours)
let recent: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sandbox_violations WHERE denied_at > datetime('now', '-1 day')",
[],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"total": total,
"recent_24h": recent,
"by_operation": by_operation.into_iter().map(|(op, count)| {
serde_json::json!({
"operation": op,
"count": count
})
}).collect::<Vec<_>>()
}))
}
/// Export a single sandbox profile with its rules
#[tauri::command]
pub async fn export_sandbox_profile(
db: State<'_, AgentDb>,
profile_id: i64,
) -> Result<SandboxProfileExport, String> {
// Get the profile
let profile = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
crate::sandbox::profile::load_profile(&conn, profile_id).map_err(|e| e.to_string())?
};
// Get the rules
let rules = list_sandbox_rules(db.clone(), profile_id).await?;
Ok(SandboxProfileExport {
version: 1,
exported_at: chrono::Utc::now().to_rfc3339(),
platform: std::env::consts::OS.to_string(),
profiles: vec![SandboxProfileWithRules { profile, rules }],
})
}
/// Export all sandbox profiles
#[tauri::command]
pub async fn export_all_sandbox_profiles(
db: State<'_, AgentDb>,
) -> Result<SandboxProfileExport, String> {
let profiles = list_sandbox_profiles(db.clone()).await?;
let mut profile_exports = Vec::new();
for profile in profiles {
if let Some(id) = profile.id {
let rules = list_sandbox_rules(db.clone(), id).await?;
profile_exports.push(SandboxProfileWithRules {
profile,
rules,
});
}
}
Ok(SandboxProfileExport {
version: 1,
exported_at: chrono::Utc::now().to_rfc3339(),
platform: std::env::consts::OS.to_string(),
profiles: profile_exports,
})
}
/// Import sandbox profiles from export data
#[tauri::command]
pub async fn import_sandbox_profiles(
db: State<'_, AgentDb>,
export_data: SandboxProfileExport,
) -> Result<Vec<ImportResult>, String> {
let mut results = Vec::new();
// Validate version
if export_data.version != 1 {
return Err(format!("Unsupported export version: {}", export_data.version));
}
for profile_export in export_data.profiles {
let mut profile = profile_export.profile;
let original_name = profile.name.clone();
// Check for name conflicts
let existing: Result<i64, _> = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.query_row(
"SELECT id FROM sandbox_profiles WHERE name = ?1",
params![&profile.name],
|row| row.get(0),
)
};
let (imported, new_name) = match existing {
Ok(_) => {
// Name conflict - append timestamp
let new_name = format!("{} (imported {})", profile.name, chrono::Utc::now().format("%Y-%m-%d %H:%M"));
profile.name = new_name.clone();
(true, Some(new_name))
}
Err(_) => (true, None),
};
if imported {
// Reset profile fields for new insert
profile.id = None;
profile.is_default = false; // Never import as default
// Create the profile
let created_profile = create_sandbox_profile(
db.clone(),
profile.name.clone(),
profile.description,
).await?;
if let Some(new_id) = created_profile.id {
// Import rules
for rule in profile_export.rules {
if rule.enabled {
// Create the rule with the new profile ID
let _ = create_sandbox_rule(
db.clone(),
new_id,
rule.operation_type,
rule.pattern_type,
rule.pattern_value,
rule.enabled,
rule.platform_support,
).await;
}
}
// Update profile status if needed
if profile.is_active {
let _ = update_sandbox_profile(
db.clone(),
new_id,
created_profile.name,
created_profile.description,
profile.is_active,
false, // Never set as default on import
).await;
}
}
results.push(ImportResult {
profile_name: original_name,
imported: true,
reason: new_name.as_ref().map(|_| "Name conflict resolved".to_string()),
new_name,
});
}
}
Ok(results)
}

View File

@@ -0,0 +1,648 @@
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use chrono::{DateTime, Local, NaiveDate};
use serde::{Deserialize, Serialize};
use serde_json;
use tauri::command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UsageEntry {
timestamp: String,
model: String,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
cost: f64,
session_id: String,
project_path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UsageStats {
total_cost: f64,
total_tokens: u64,
total_input_tokens: u64,
total_output_tokens: u64,
total_cache_creation_tokens: u64,
total_cache_read_tokens: u64,
total_sessions: u64,
by_model: Vec<ModelUsage>,
by_date: Vec<DailyUsage>,
by_project: Vec<ProjectUsage>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ModelUsage {
model: String,
total_cost: f64,
total_tokens: u64,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
session_count: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DailyUsage {
date: String,
total_cost: f64,
total_tokens: u64,
models_used: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectUsage {
project_path: String,
project_name: String,
total_cost: f64,
total_tokens: u64,
session_count: u64,
last_used: String,
}
// Claude 4 pricing constants (per million tokens)
const OPUS_4_INPUT_PRICE: f64 = 15.0;
const OPUS_4_OUTPUT_PRICE: f64 = 75.0;
const OPUS_4_CACHE_WRITE_PRICE: f64 = 18.75;
const OPUS_4_CACHE_READ_PRICE: f64 = 1.50;
const SONNET_4_INPUT_PRICE: f64 = 3.0;
const SONNET_4_OUTPUT_PRICE: f64 = 15.0;
const SONNET_4_CACHE_WRITE_PRICE: f64 = 3.75;
const SONNET_4_CACHE_READ_PRICE: f64 = 0.30;
#[derive(Debug, Deserialize)]
struct JsonlEntry {
timestamp: String,
message: Option<MessageData>,
#[serde(rename = "sessionId")]
session_id: Option<String>,
#[serde(rename = "requestId")]
request_id: Option<String>,
#[serde(rename = "costUSD")]
cost_usd: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct MessageData {
id: Option<String>,
model: Option<String>,
usage: Option<UsageData>,
}
#[derive(Debug, Deserialize)]
struct UsageData {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
cache_read_input_tokens: Option<u64>,
}
fn calculate_cost(model: &str, usage: &UsageData) -> f64 {
let input_tokens = usage.input_tokens.unwrap_or(0) as f64;
let output_tokens = usage.output_tokens.unwrap_or(0) as f64;
let cache_creation_tokens = usage.cache_creation_input_tokens.unwrap_or(0) as f64;
let cache_read_tokens = usage.cache_read_input_tokens.unwrap_or(0) as f64;
// Calculate cost based on model
let (input_price, output_price, cache_write_price, cache_read_price) =
if model.contains("opus-4") || model.contains("claude-opus-4") {
(OPUS_4_INPUT_PRICE, OPUS_4_OUTPUT_PRICE, OPUS_4_CACHE_WRITE_PRICE, OPUS_4_CACHE_READ_PRICE)
} else if model.contains("sonnet-4") || model.contains("claude-sonnet-4") {
(SONNET_4_INPUT_PRICE, SONNET_4_OUTPUT_PRICE, SONNET_4_CACHE_WRITE_PRICE, SONNET_4_CACHE_READ_PRICE)
} else {
// Return 0 for unknown models to avoid incorrect cost estimations.
(0.0, 0.0, 0.0, 0.0)
};
// Calculate cost (prices are per million tokens)
let cost = (input_tokens * input_price / 1_000_000.0)
+ (output_tokens * output_price / 1_000_000.0)
+ (cache_creation_tokens * cache_write_price / 1_000_000.0)
+ (cache_read_tokens * cache_read_price / 1_000_000.0);
cost
}
fn parse_jsonl_file(
path: &PathBuf,
encoded_project_name: &str,
processed_hashes: &mut HashSet<String>,
) -> Vec<UsageEntry> {
let mut entries = Vec::new();
let mut actual_project_path: Option<String> = None;
if let Ok(content) = fs::read_to_string(path) {
// Extract session ID from the file path
let session_id = path.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line) {
// Extract the actual project path from cwd if we haven't already
if actual_project_path.is_none() {
if let Some(cwd) = json_value.get("cwd").and_then(|v| v.as_str()) {
actual_project_path = Some(cwd.to_string());
}
}
// Try to parse as JsonlEntry for usage data
if let Ok(entry) = serde_json::from_value::<JsonlEntry>(json_value) {
if let Some(message) = &entry.message {
// Deduplication based on message ID and request ID
if let (Some(msg_id), Some(req_id)) = (&message.id, &entry.request_id) {
let unique_hash = format!("{}:{}", msg_id, req_id);
if processed_hashes.contains(&unique_hash) {
continue; // Skip duplicate entry
}
processed_hashes.insert(unique_hash);
}
if let Some(usage) = &message.usage {
// Skip entries without meaningful token usage
if usage.input_tokens.unwrap_or(0) == 0 &&
usage.output_tokens.unwrap_or(0) == 0 &&
usage.cache_creation_input_tokens.unwrap_or(0) == 0 &&
usage.cache_read_input_tokens.unwrap_or(0) == 0 {
continue;
}
let cost = entry.cost_usd.unwrap_or_else(|| {
if let Some(model_str) = &message.model {
calculate_cost(model_str, usage)
} else {
0.0
}
});
// Use actual project path if found, otherwise use encoded name
let project_path = actual_project_path.clone()
.unwrap_or_else(|| encoded_project_name.to_string());
entries.push(UsageEntry {
timestamp: entry.timestamp,
model: message.model.clone().unwrap_or_else(|| "unknown".to_string()),
input_tokens: usage.input_tokens.unwrap_or(0),
output_tokens: usage.output_tokens.unwrap_or(0),
cache_creation_tokens: usage.cache_creation_input_tokens.unwrap_or(0),
cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
cost,
session_id: entry.session_id.unwrap_or_else(|| session_id.clone()),
project_path,
});
}
}
}
}
}
}
entries
}
fn get_earliest_timestamp(path: &PathBuf) -> Option<String> {
if let Ok(content) = fs::read_to_string(path) {
let mut earliest_timestamp: Option<String> = None;
for line in content.lines() {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(timestamp_str) = json_value.get("timestamp").and_then(|v| v.as_str()) {
if let Some(current_earliest) = &earliest_timestamp {
if timestamp_str < current_earliest.as_str() {
earliest_timestamp = Some(timestamp_str.to_string());
}
} else {
earliest_timestamp = Some(timestamp_str.to_string());
}
}
}
}
return earliest_timestamp;
}
None
}
fn get_all_usage_entries(claude_path: &PathBuf) -> Vec<UsageEntry> {
let mut all_entries = Vec::new();
let mut processed_hashes = HashSet::new();
let projects_dir = claude_path.join("projects");
let mut files_to_process: Vec<(PathBuf, String)> = Vec::new();
if let Ok(projects) = fs::read_dir(&projects_dir) {
for project in projects.flatten() {
if project.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let project_name = project.file_name().to_string_lossy().to_string();
let project_path = project.path();
walkdir::WalkDir::new(&project_path)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("jsonl"))
.for_each(|entry| {
files_to_process.push((entry.path().to_path_buf(), project_name.clone()));
});
}
}
}
// Sort files by their earliest timestamp to ensure chronological processing
// and deterministic deduplication.
files_to_process.sort_by_cached_key(|(path, _)| get_earliest_timestamp(path));
for (path, project_name) in files_to_process {
let entries = parse_jsonl_file(&path, &project_name, &mut processed_hashes);
all_entries.extend(entries);
}
// Sort by timestamp
all_entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
all_entries
}
#[command]
pub fn get_usage_stats(days: Option<u32>) -> Result<UsageStats, String> {
let claude_path = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join(".claude");
let all_entries = get_all_usage_entries(&claude_path);
if all_entries.is_empty() {
return Ok(UsageStats {
total_cost: 0.0,
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
total_sessions: 0,
by_model: vec![],
by_date: vec![],
by_project: vec![],
});
}
// Filter by days if specified
let filtered_entries = if let Some(days) = days {
let cutoff = Local::now().naive_local().date() - chrono::Duration::days(days as i64);
all_entries.into_iter()
.filter(|e| {
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
dt.naive_local().date() >= cutoff
} else {
false
}
})
.collect()
} else {
all_entries
};
// Calculate aggregated stats
let mut total_cost = 0.0;
let mut total_input_tokens = 0u64;
let mut total_output_tokens = 0u64;
let mut total_cache_creation_tokens = 0u64;
let mut total_cache_read_tokens = 0u64;
let mut model_stats: HashMap<String, ModelUsage> = HashMap::new();
let mut daily_stats: HashMap<String, DailyUsage> = HashMap::new();
let mut project_stats: HashMap<String, ProjectUsage> = HashMap::new();
for entry in &filtered_entries {
// Update totals
total_cost += entry.cost;
total_input_tokens += entry.input_tokens;
total_output_tokens += entry.output_tokens;
total_cache_creation_tokens += entry.cache_creation_tokens;
total_cache_read_tokens += entry.cache_read_tokens;
// Update model stats
let model_stat = model_stats.entry(entry.model.clone()).or_insert(ModelUsage {
model: entry.model.clone(),
total_cost: 0.0,
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
session_count: 0,
});
model_stat.total_cost += entry.cost;
model_stat.input_tokens += entry.input_tokens;
model_stat.output_tokens += entry.output_tokens;
model_stat.cache_creation_tokens += entry.cache_creation_tokens;
model_stat.cache_read_tokens += entry.cache_read_tokens;
model_stat.total_tokens = model_stat.input_tokens + model_stat.output_tokens;
model_stat.session_count += 1;
// Update daily stats
let date = entry.timestamp.split('T').next().unwrap_or(&entry.timestamp).to_string();
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
date,
total_cost: 0.0,
total_tokens: 0,
models_used: vec![],
});
daily_stat.total_cost += entry.cost;
daily_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
if !daily_stat.models_used.contains(&entry.model) {
daily_stat.models_used.push(entry.model.clone());
}
// Update project stats
let project_stat = project_stats.entry(entry.project_path.clone()).or_insert(ProjectUsage {
project_path: entry.project_path.clone(),
project_name: entry.project_path.split('/').last()
.unwrap_or(&entry.project_path)
.to_string(),
total_cost: 0.0,
total_tokens: 0,
session_count: 0,
last_used: entry.timestamp.clone(),
});
project_stat.total_cost += entry.cost;
project_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
project_stat.session_count += 1;
if entry.timestamp > project_stat.last_used {
project_stat.last_used = entry.timestamp.clone();
}
}
let total_tokens = total_input_tokens + total_output_tokens + total_cache_creation_tokens + total_cache_read_tokens;
let total_sessions = filtered_entries.len() as u64;
// Convert hashmaps to sorted vectors
let mut by_model: Vec<ModelUsage> = model_stats.into_values().collect();
by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
let mut by_date: Vec<DailyUsage> = daily_stats.into_values().collect();
by_date.sort_by(|a, b| b.date.cmp(&a.date));
let mut by_project: Vec<ProjectUsage> = project_stats.into_values().collect();
by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
Ok(UsageStats {
total_cost,
total_tokens,
total_input_tokens,
total_output_tokens,
total_cache_creation_tokens,
total_cache_read_tokens,
total_sessions,
by_model,
by_date,
by_project,
})
}
#[command]
pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result<UsageStats, String> {
let claude_path = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join(".claude");
let all_entries = get_all_usage_entries(&claude_path);
// Parse dates
let start = NaiveDate::parse_from_str(&start_date, "%Y-%m-%d")
.or_else(|_| {
// Try parsing ISO datetime format
DateTime::parse_from_rfc3339(&start_date)
.map(|dt| dt.naive_local().date())
.map_err(|e| format!("Invalid start date: {}", e))
})?;
let end = NaiveDate::parse_from_str(&end_date, "%Y-%m-%d")
.or_else(|_| {
// Try parsing ISO datetime format
DateTime::parse_from_rfc3339(&end_date)
.map(|dt| dt.naive_local().date())
.map_err(|e| format!("Invalid end date: {}", e))
})?;
// Filter entries by date range
let filtered_entries: Vec<_> = all_entries.into_iter()
.filter(|e| {
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
let date = dt.naive_local().date();
date >= start && date <= end
} else {
false
}
})
.collect();
if filtered_entries.is_empty() {
return Ok(UsageStats {
total_cost: 0.0,
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
total_sessions: 0,
by_model: vec![],
by_date: vec![],
by_project: vec![],
});
}
// Calculate aggregated stats (same logic as get_usage_stats)
let mut total_cost = 0.0;
let mut total_input_tokens = 0u64;
let mut total_output_tokens = 0u64;
let mut total_cache_creation_tokens = 0u64;
let mut total_cache_read_tokens = 0u64;
let mut model_stats: HashMap<String, ModelUsage> = HashMap::new();
let mut daily_stats: HashMap<String, DailyUsage> = HashMap::new();
let mut project_stats: HashMap<String, ProjectUsage> = HashMap::new();
for entry in &filtered_entries {
// Update totals
total_cost += entry.cost;
total_input_tokens += entry.input_tokens;
total_output_tokens += entry.output_tokens;
total_cache_creation_tokens += entry.cache_creation_tokens;
total_cache_read_tokens += entry.cache_read_tokens;
// Update model stats
let model_stat = model_stats.entry(entry.model.clone()).or_insert(ModelUsage {
model: entry.model.clone(),
total_cost: 0.0,
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
session_count: 0,
});
model_stat.total_cost += entry.cost;
model_stat.input_tokens += entry.input_tokens;
model_stat.output_tokens += entry.output_tokens;
model_stat.cache_creation_tokens += entry.cache_creation_tokens;
model_stat.cache_read_tokens += entry.cache_read_tokens;
model_stat.total_tokens = model_stat.input_tokens + model_stat.output_tokens;
model_stat.session_count += 1;
// Update daily stats
let date = entry.timestamp.split('T').next().unwrap_or(&entry.timestamp).to_string();
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
date,
total_cost: 0.0,
total_tokens: 0,
models_used: vec![],
});
daily_stat.total_cost += entry.cost;
daily_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
if !daily_stat.models_used.contains(&entry.model) {
daily_stat.models_used.push(entry.model.clone());
}
// Update project stats
let project_stat = project_stats.entry(entry.project_path.clone()).or_insert(ProjectUsage {
project_path: entry.project_path.clone(),
project_name: entry.project_path.split('/').last()
.unwrap_or(&entry.project_path)
.to_string(),
total_cost: 0.0,
total_tokens: 0,
session_count: 0,
last_used: entry.timestamp.clone(),
});
project_stat.total_cost += entry.cost;
project_stat.total_tokens += entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens + entry.cache_read_tokens;
project_stat.session_count += 1;
if entry.timestamp > project_stat.last_used {
project_stat.last_used = entry.timestamp.clone();
}
}
let total_tokens = total_input_tokens + total_output_tokens + total_cache_creation_tokens + total_cache_read_tokens;
let total_sessions = filtered_entries.len() as u64;
// Convert hashmaps to sorted vectors
let mut by_model: Vec<ModelUsage> = model_stats.into_values().collect();
by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
let mut by_date: Vec<DailyUsage> = daily_stats.into_values().collect();
by_date.sort_by(|a, b| b.date.cmp(&a.date));
let mut by_project: Vec<ProjectUsage> = project_stats.into_values().collect();
by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap());
Ok(UsageStats {
total_cost,
total_tokens,
total_input_tokens,
total_output_tokens,
total_cache_creation_tokens,
total_cache_read_tokens,
total_sessions,
by_model,
by_date,
by_project,
})
}
#[command]
pub fn get_usage_details(project_path: Option<String>, date: Option<String>) -> Result<Vec<UsageEntry>, String> {
let claude_path = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join(".claude");
let mut all_entries = get_all_usage_entries(&claude_path);
// Filter by project if specified
if let Some(project) = project_path {
all_entries.retain(|e| e.project_path == project);
}
// Filter by date if specified
if let Some(date) = date {
all_entries.retain(|e| e.timestamp.starts_with(&date));
}
Ok(all_entries)
}
#[command]
pub fn get_session_stats(
since: Option<String>,
until: Option<String>,
order: Option<String>,
) -> Result<Vec<ProjectUsage>, String> {
let claude_path = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join(".claude");
let all_entries = get_all_usage_entries(&claude_path);
let since_date = since.and_then(|s| NaiveDate::parse_from_str(&s, "%Y%m%d").ok());
let until_date = until.and_then(|s| NaiveDate::parse_from_str(&s, "%Y%m%d").ok());
let filtered_entries: Vec<_> = all_entries
.into_iter()
.filter(|e| {
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
let date = dt.date_naive();
let is_after_since = since_date.map_or(true, |s| date >= s);
let is_before_until = until_date.map_or(true, |u| date <= u);
is_after_since && is_before_until
} else {
false
}
})
.collect();
let mut session_stats: HashMap<String, ProjectUsage> = HashMap::new();
for entry in &filtered_entries {
let session_key = format!("{}/{}", entry.project_path, entry.session_id);
let project_stat = session_stats.entry(session_key).or_insert_with(|| ProjectUsage {
project_path: entry.project_path.clone(),
project_name: entry.session_id.clone(), // Using session_id as project_name for session view
total_cost: 0.0,
total_tokens: 0,
session_count: 0, // In this context, this will count entries per session
last_used: " ".to_string(),
});
project_stat.total_cost += entry.cost;
project_stat.total_tokens += entry.input_tokens
+ entry.output_tokens
+ entry.cache_creation_tokens
+ entry.cache_read_tokens;
project_stat.session_count += 1;
if entry.timestamp > project_stat.last_used {
project_stat.last_used = entry.timestamp.clone();
}
}
let mut by_session: Vec<ProjectUsage> = session_stats.into_values().collect();
// Sort by last_used date
if let Some(order_str) = order {
if order_str == "asc" {
by_session.sort_by(|a, b| a.last_used.cmp(&b.last_used));
} else {
by_session.sort_by(|a, b| b.last_used.cmp(&a.last_used));
}
} else {
// Default to descending
by_session.sort_by(|a, b| b.last_used.cmp(&a.last_used));
}
Ok(by_session)
}