use super::*;
use std::path::Path;
use roboticus_core::{ProfileEntry, ProfileRegistry, home_dir};
use roboticus_db::agents::{SubAgentRow, upsert_sub_agent};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct AppManifest {
package: PackageInfo,
profile: ProfileInfo,
requirements: Option<Requirements>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PackageInfo {
name: String,
version: String,
description: String,
author: Option<String>,
#[serde(default)]
min_roboticus_version: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct ProfileInfo {
agent_name: String,
agent_id: String,
default_theme: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Requirements {
min_model_params: Option<String>,
recommended_model: Option<String>,
embedding_model: Option<String>,
delegation_enabled: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct SubagentManifest {
subagent: SubagentDef,
observer: Option<ObserverDef>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct SubagentDef {
name: String,
display_name: String,
role: String,
model: String,
description: String,
#[serde(default)]
skills: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct ObserverDef {
enabled: bool,
trigger: String,
instruction: String,
}
pub fn cmd_apps_list() -> Result<(), Box<dyn std::error::Error>> {
let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
let (OK, ACTION, WARN, DETAIL, ERR) = icons();
let registry = ProfileRegistry::load()?;
let profiles = registry.list();
heading("Installed Apps");
let apps: Vec<_> = profiles
.iter()
.filter(|(_, e)| matches!(e.source.as_deref(), Some("local") | Some("registry")))
.collect();
if apps.is_empty() {
empty_state("No apps installed. Run `roboticus apps install <path>` to add one.");
return Ok(());
}
let widths = [22, 22, 10, 10, 8];
table_header(&["ID", "Name", "Version", "Source", "Active"], &widths);
for (id, entry) in &apps {
let active_str = if entry.active {
format!("{GREEN}{OK}{RESET}")
} else {
String::new()
};
let version = entry.version.as_deref().unwrap_or("-");
let source = entry.source.as_deref().unwrap_or("?");
let profile_dir = home_dir().join(".roboticus").join(&entry.path);
let manifest_path = profile_dir.join("manifest.toml");
let display_name = if let Ok(contents) = std::fs::read_to_string(&manifest_path) {
if let Ok(manifest) = toml::from_str::<AppManifest>(&contents) {
manifest.package.description.clone()
} else {
entry.name.clone()
}
} else {
entry.name.clone()
};
table_row(
&[
format!("{ACCENT}{id}{RESET}"),
display_name,
version.to_string(),
source.to_string(),
active_str,
],
&widths,
);
}
eprintln!();
eprintln!(" {DIM}{} app(s) installed{RESET}", apps.len());
eprintln!();
Ok(())
}
pub fn cmd_apps_install(source: &str) -> Result<(), Box<dyn std::error::Error>> {
let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
let (OK, ACTION, WARN, DETAIL, ERR) = icons();
if source.starts_with("https://") || source.starts_with("http://") {
eprintln!(
" {WARN} GitHub registry install is not yet supported. Use a local directory path."
);
eprintln!(" {DIM}Example: roboticus apps install /path/to/app{RESET}");
return Ok(());
}
let source_dir = Path::new(source);
if !source_dir.is_dir() {
return Err(format!("source path is not a directory: {source}").into());
}
let manifest_path = source_dir.join("manifest.toml");
if !manifest_path.exists() {
return Err(format!(
"no manifest.toml found in {}. Is this a Roboticus app?",
source_dir.display()
)
.into());
}
let manifest_contents = std::fs::read_to_string(&manifest_path)?;
let manifest: AppManifest = toml::from_str(&manifest_contents)
.map_err(|e| format!("failed to parse manifest.toml: {e}"))?;
let app_id = &manifest.package.name;
let agent_name = &manifest.profile.agent_name;
let agent_id = &manifest.profile.agent_id;
eprintln!(
" {ACTION} Installing {BOLD}{}{RESET} v{}...",
manifest.package.description, manifest.package.version
);
let mut registry = ProfileRegistry::load()?;
if registry.profiles.contains_key(app_id) {
return Err(format!(
"app '{app_id}' is already installed. Uninstall first with `roboticus apps uninstall {app_id}`"
)
.into());
}
let rel_path = format!("profiles/{app_id}");
let entry = ProfileEntry {
name: agent_name.clone(),
description: Some(manifest.package.description.clone()),
path: rel_path.clone(),
active: false,
installed_at: Some(chrono_now()),
version: Some(manifest.package.version.clone()),
source: Some("local".to_string()),
};
registry.profiles.insert(app_id.clone(), entry);
registry.save()?;
let profile_dir = registry.ensure_profile_dir(app_id)?;
let themes_src = source_dir.join("themes");
if themes_src.is_dir() {
std::fs::create_dir_all(profile_dir.join("themes"))?;
}
let mut skills_count = 0u32;
let mut themes_count = 0u32;
let firmware_src = source_dir.join("FIRMWARE.toml");
if firmware_src.exists() {
let workspace_dir = profile_dir.join("workspace");
std::fs::create_dir_all(&workspace_dir)?;
std::fs::copy(&firmware_src, workspace_dir.join("FIRMWARE.toml"))?;
}
let skills_src = source_dir.join("skills");
if skills_src.is_dir() {
let skills_dst = profile_dir.join("skills");
std::fs::create_dir_all(&skills_dst)?;
for entry in std::fs::read_dir(&skills_src)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
let filename = path.file_name().unwrap();
std::fs::copy(&path, skills_dst.join(filename))?;
skills_count += 1;
}
}
}
if themes_src.is_dir() {
let themes_dst = profile_dir.join("themes");
std::fs::create_dir_all(&themes_dst)?;
for entry in std::fs::read_dir(&themes_src)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
let filename = path.file_name().unwrap();
std::fs::copy(&path, themes_dst.join(filename))?;
themes_count += 1;
}
}
}
std::fs::copy(&manifest_path, profile_dir.join("manifest.toml"))?;
let db_path = profile_dir.join(format!("{agent_id}.db"));
let workspace_path = profile_dir.join("workspace");
let skills_dir = profile_dir.join("skills");
let default_config_path = roboticus_core::home_dir()
.join(".roboticus")
.join("roboticus.toml");
let mut config: toml::Table = if default_config_path.exists() {
let raw = std::fs::read_to_string(&default_config_path)?;
raw.parse().unwrap_or_default()
} else {
toml::Table::new()
};
let agent_tbl = config
.entry("agent")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.unwrap();
agent_tbl.insert("name".into(), toml::Value::String(agent_name.clone()));
agent_tbl.insert("id".into(), toml::Value::String(agent_id.clone()));
agent_tbl.insert(
"workspace".into(),
toml::Value::String(workspace_path.display().to_string()),
);
let db_tbl = config
.entry("database")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.unwrap();
db_tbl.insert(
"path".into(),
toml::Value::String(db_path.display().to_string()),
);
let skills_tbl = config
.entry("skills")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.unwrap();
skills_tbl.insert(
"skills_dir".into(),
toml::Value::String(skills_dir.display().to_string()),
);
if let Some(ref reqs) = manifest.requirements
&& let Some(ref model) = reqs.recommended_model
{
let models_tbl = config
.entry("models")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.unwrap();
models_tbl.insert("primary".into(), toml::Value::String(model.clone()));
}
let overrides_path = source_dir.join("config-overrides.toml");
if overrides_path.exists() {
let raw = std::fs::read_to_string(&overrides_path)?;
let overrides: toml::Table = raw.parse().unwrap_or_default();
deep_merge_toml(&mut config, &overrides);
}
let config_toml = format!(
"# Auto-generated by `roboticus apps install` — do not edit manually.\n\
# App: {} v{}\n\n{}",
manifest.package.name,
manifest.package.version,
toml::to_string_pretty(&config).unwrap_or_default(),
);
std::fs::write(profile_dir.join("roboticus.toml"), &config_toml)?;
let mut subagent_names: Vec<String> = Vec::new();
let subagents_src = source_dir.join("subagents");
if subagents_src.is_dir() {
let db = roboticus_db::Database::new(db_path.to_str().unwrap_or(""))
.map_err(|e| format!("failed to create database: {e}"))?;
for entry in std::fs::read_dir(&subagents_src)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let contents = std::fs::read_to_string(&path)?;
let sa_manifest: SubagentManifest = toml::from_str(&contents).map_err(|e| {
format!("failed to parse subagent manifest {}: {e}", path.display())
})?;
let skills_json = if sa_manifest.subagent.skills.is_empty() {
None
} else {
Some(serde_json::to_string(&sa_manifest.subagent.skills)?)
};
let effective_role = if sa_manifest.observer.as_ref().is_some_and(|o| o.enabled) {
"observer".to_string()
} else {
sa_manifest.subagent.role
};
let effective_description = if let Some(ref obs) = sa_manifest.observer {
if obs.enabled {
Some(format!(
"[observer: {}] {}",
obs.instruction, sa_manifest.subagent.description
))
} else {
Some(sa_manifest.subagent.description)
}
} else {
Some(sa_manifest.subagent.description)
};
let row = SubAgentRow {
id: uuid::Uuid::new_v4().to_string(),
name: sa_manifest.subagent.name.clone(),
display_name: Some(sa_manifest.subagent.display_name.clone()),
model: sa_manifest.subagent.model,
fallback_models_json: None,
role: effective_role,
description: effective_description,
skills_json,
enabled: true,
session_count: 0,
last_used_at: None,
};
upsert_sub_agent(&db, &row)
.map_err(|e| format!("failed to register subagent '{}': {e}", row.name))?;
subagent_names.push(sa_manifest.subagent.display_name);
}
}
eprintln!();
eprintln!(
" {GREEN}{OK}{RESET} Installed: {BOLD}{}{RESET} v{}",
manifest.package.description, manifest.package.version
);
eprintln!();
eprintln!(" Profile: {ACCENT}{app_id}{RESET}");
eprintln!(" Agent: {agent_name}");
eprintln!(" Skills: {skills_count}");
if !subagent_names.is_empty() {
eprintln!(
" Subagents: {} ({})",
subagent_names.len(),
subagent_names.join(", ")
);
}
if themes_count > 0 {
eprintln!(" Themes: {themes_count}");
}
eprintln!();
eprintln!(" Launch:");
eprintln!(" {MONO}roboticus serve --profile {app_id}{RESET}");
eprintln!();
eprintln!(" Or switch your active profile:");
eprintln!(" {MONO}roboticus profile switch {app_id}{RESET}");
eprintln!();
Ok(())
}
pub fn cmd_apps_uninstall(name: &str, delete_data: bool) -> Result<(), Box<dyn std::error::Error>> {
let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
let (OK, ACTION, WARN, DETAIL, ERR) = icons();
if name == "default" {
return Err("cannot uninstall the built-in 'default' profile".into());
}
let mut registry = ProfileRegistry::load()?;
let entry = registry
.profiles
.get(name)
.cloned()
.ok_or_else(|| format!("app '{name}' is not installed"))?;
match entry.source.as_deref() {
Some("local") | Some("registry") => {}
_ => {
return Err(format!(
"'{name}' is a manually-created profile, not an app. \
Use `roboticus profile delete {name}` instead."
)
.into());
}
}
if entry.active {
return Err(format!(
"app '{name}' is the active profile. \
Switch to a different profile first: `roboticus profile switch default`"
)
.into());
}
let profile_dir = registry.resolve_config_dir(name)?;
if delete_data {
let (file_count, total_bytes) = dir_stats(&profile_dir);
let size_display = if total_bytes > 1_048_576 {
format!("{:.1} MB", total_bytes as f64 / 1_048_576.0)
} else if total_bytes > 1024 {
format!("{:.0} KB", total_bytes as f64 / 1024.0)
} else {
format!("{total_bytes} bytes")
};
eprintln!(" {WARN} This will permanently delete {file_count} files ({size_display}) at:");
eprintln!(" {}", profile_dir.display());
eprintln!();
let prompt = format!(" Type '{name}' to confirm deletion: ");
eprint!("{prompt}");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim();
if input != name {
eprintln!(" {DIM}Aborted.{RESET}");
return Ok(());
}
let safe_root = home_dir().join(".roboticus").join("profiles");
if profile_dir.starts_with(&safe_root) && profile_dir.exists() {
std::fs::remove_dir_all(&profile_dir)?;
}
}
registry.profiles.remove(name);
registry.save()?;
if delete_data {
eprintln!(
" {GREEN}{OK}{RESET} Uninstalled app {ACCENT}{name}{RESET} and deleted all data"
);
} else {
eprintln!(
" {GREEN}{OK}{RESET} Uninstalled app {ACCENT}{name}{RESET} (data kept at {})",
profile_dir.display()
);
}
eprintln!();
Ok(())
}
fn chrono_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}")
}
fn dir_stats(path: &Path) -> (u64, u64) {
let mut files = 0u64;
let mut bytes = 0u64;
if let Ok(entries) = walkdir(path) {
for (f, b) in entries {
files += f;
bytes += b;
}
}
(files, bytes)
}
fn walkdir(path: &Path) -> Result<Vec<(u64, u64)>, std::io::Error> {
let mut results = Vec::new();
if !path.is_dir() {
if let Ok(meta) = std::fs::metadata(path) {
return Ok(vec![(1, meta.len())]);
}
return Ok(vec![]);
}
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_dir() {
results.extend(walkdir(&entry.path())?);
} else {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
results.push((1, size));
}
}
Ok(results)
}
fn deep_merge_toml(base: &mut toml::Table, overlay: &toml::Table) {
for (key, val) in overlay {
match (base.get_mut(key), val) {
(Some(toml::Value::Table(base_tbl)), toml::Value::Table(overlay_tbl)) => {
deep_merge_toml(base_tbl, overlay_tbl);
}
_ => {
base.insert(key.clone(), val.clone());
}
}
}
}