1use super::*;
2use std::path::Path;
3
4use roboticus_core::{ProfileEntry, ProfileRegistry, home_dir};
5use roboticus_db::agents::{SubAgentRow, upsert_sub_agent};
6use serde::Deserialize;
7
8#[derive(Debug, Deserialize)]
11#[allow(dead_code)]
12struct AppManifest {
13 package: PackageInfo,
14 profile: ProfileInfo,
15 requirements: Option<Requirements>,
16}
17
18#[derive(Debug, Deserialize)]
19#[allow(dead_code)]
20struct PackageInfo {
21 name: String,
22 version: String,
23 description: String,
24 author: Option<String>,
25 #[serde(default)]
26 min_roboticus_version: Option<String>,
27}
28
29#[derive(Debug, Deserialize)]
30#[allow(dead_code)]
31struct ProfileInfo {
32 agent_name: String,
33 agent_id: String,
34 default_theme: Option<String>,
35}
36
37#[derive(Debug, Deserialize)]
38#[allow(dead_code)]
39struct Requirements {
40 min_model_params: Option<String>,
41 recommended_model: Option<String>,
42 embedding_model: Option<String>,
43 delegation_enabled: Option<bool>,
44}
45
46#[derive(Debug, Deserialize)]
47#[allow(dead_code)]
48struct SubagentManifest {
49 subagent: SubagentDef,
50 observer: Option<ObserverDef>,
51}
52
53#[derive(Debug, Deserialize)]
54#[allow(dead_code)]
55struct SubagentDef {
56 name: String,
57 display_name: String,
58 role: String,
59 model: String,
60 description: String,
61 #[serde(default)]
62 skills: Vec<String>,
63}
64
65#[derive(Debug, Deserialize)]
66#[allow(dead_code)]
67struct ObserverDef {
68 enabled: bool,
69 trigger: String,
70 instruction: String,
71}
72
73pub fn cmd_apps_list() -> Result<(), Box<dyn std::error::Error>> {
77 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
78 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
79
80 let registry = ProfileRegistry::load()?;
81 let profiles = registry.list();
82
83 heading("Installed Apps");
84
85 let apps: Vec<_> = profiles
87 .iter()
88 .filter(|(_, e)| matches!(e.source.as_deref(), Some("local") | Some("registry")))
89 .collect();
90
91 if apps.is_empty() {
92 empty_state("No apps installed. Run `roboticus apps install <path>` to add one.");
93 return Ok(());
94 }
95
96 let widths = [22, 22, 10, 10, 8];
97 table_header(&["ID", "Name", "Version", "Source", "Active"], &widths);
98
99 for (id, entry) in &apps {
100 let active_str = if entry.active {
101 format!("{GREEN}{OK}{RESET}")
102 } else {
103 String::new()
104 };
105 let version = entry.version.as_deref().unwrap_or("-");
106 let source = entry.source.as_deref().unwrap_or("?");
107
108 let profile_dir = home_dir().join(".roboticus").join(&entry.path);
110 let manifest_path = profile_dir.join("manifest.toml");
111 let display_name = if let Ok(contents) = std::fs::read_to_string(&manifest_path) {
112 if let Ok(manifest) = toml::from_str::<AppManifest>(&contents) {
113 manifest.package.description.clone()
114 } else {
115 entry.name.clone()
116 }
117 } else {
118 entry.name.clone()
119 };
120
121 table_row(
122 &[
123 format!("{ACCENT}{id}{RESET}"),
124 display_name,
125 version.to_string(),
126 source.to_string(),
127 active_str,
128 ],
129 &widths,
130 );
131 }
132
133 eprintln!();
134 eprintln!(" {DIM}{} app(s) installed{RESET}", apps.len());
135 eprintln!();
136 Ok(())
137}
138
139pub fn cmd_apps_install(source: &str) -> Result<(), Box<dyn std::error::Error>> {
141 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
142 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
143
144 if source.starts_with("https://") || source.starts_with("http://") {
146 eprintln!(
147 " {WARN} GitHub registry install is not yet supported. Use a local directory path."
148 );
149 eprintln!(" {DIM}Example: roboticus apps install /path/to/app{RESET}");
150 return Ok(());
151 }
152
153 let source_dir = Path::new(source);
154 if !source_dir.is_dir() {
155 return Err(format!("source path is not a directory: {source}").into());
156 }
157
158 let manifest_path = source_dir.join("manifest.toml");
160 if !manifest_path.exists() {
161 return Err(format!(
162 "no manifest.toml found in {}. Is this a Roboticus app?",
163 source_dir.display()
164 )
165 .into());
166 }
167
168 let manifest_contents = std::fs::read_to_string(&manifest_path)?;
169 let manifest: AppManifest = toml::from_str(&manifest_contents)
170 .map_err(|e| format!("failed to parse manifest.toml: {e}"))?;
171
172 let app_id = &manifest.package.name;
173 let agent_name = &manifest.profile.agent_name;
174 let agent_id = &manifest.profile.agent_id;
175
176 eprintln!(
177 " {ACTION} Installing {BOLD}{}{RESET} v{}...",
178 manifest.package.description, manifest.package.version
179 );
180
181 let mut registry = ProfileRegistry::load()?;
183
184 if registry.profiles.contains_key(app_id) {
185 return Err(format!(
186 "app '{app_id}' is already installed. Uninstall first with `roboticus apps uninstall {app_id}`"
187 )
188 .into());
189 }
190
191 let rel_path = format!("profiles/{app_id}");
192 let entry = ProfileEntry {
193 name: agent_name.clone(),
194 description: Some(manifest.package.description.clone()),
195 path: rel_path.clone(),
196 active: false,
197 installed_at: Some(chrono_now()),
198 version: Some(manifest.package.version.clone()),
199 source: Some("local".to_string()),
200 };
201
202 registry.profiles.insert(app_id.clone(), entry);
203 registry.save()?;
204
205 let profile_dir = registry.ensure_profile_dir(app_id)?;
207
208 let themes_src = source_dir.join("themes");
210 if themes_src.is_dir() {
211 std::fs::create_dir_all(profile_dir.join("themes"))?;
212 }
213
214 let mut skills_count = 0u32;
216 let mut themes_count = 0u32;
217
218 let firmware_src = source_dir.join("FIRMWARE.toml");
220 if firmware_src.exists() {
221 let workspace_dir = profile_dir.join("workspace");
222 std::fs::create_dir_all(&workspace_dir)?;
223 std::fs::copy(&firmware_src, workspace_dir.join("FIRMWARE.toml"))?;
224 }
225
226 let skills_src = source_dir.join("skills");
228 if skills_src.is_dir() {
229 let skills_dst = profile_dir.join("skills");
230 std::fs::create_dir_all(&skills_dst)?;
231 for entry in std::fs::read_dir(&skills_src)? {
232 let entry = entry?;
233 let path = entry.path();
234 if path.extension().and_then(|e| e.to_str()) == Some("md") {
235 let filename = path.file_name().unwrap();
236 std::fs::copy(&path, skills_dst.join(filename))?;
237 skills_count += 1;
238 }
239 }
240 }
241
242 if themes_src.is_dir() {
244 let themes_dst = profile_dir.join("themes");
245 std::fs::create_dir_all(&themes_dst)?;
246 for entry in std::fs::read_dir(&themes_src)? {
247 let entry = entry?;
248 let path = entry.path();
249 if path.extension().and_then(|e| e.to_str()) == Some("json") {
250 let filename = path.file_name().unwrap();
251 std::fs::copy(&path, themes_dst.join(filename))?;
252 themes_count += 1;
253 }
254 }
255 }
256
257 std::fs::copy(&manifest_path, profile_dir.join("manifest.toml"))?;
259
260 let db_path = profile_dir.join(format!("{agent_id}.db"));
265 let workspace_path = profile_dir.join("workspace");
266 let skills_dir = profile_dir.join("skills");
267
268 let default_config_path = roboticus_core::home_dir()
269 .join(".roboticus")
270 .join("roboticus.toml");
271 let mut config: toml::Table = if default_config_path.exists() {
272 let raw = std::fs::read_to_string(&default_config_path)?;
273 raw.parse().unwrap_or_default()
274 } else {
275 toml::Table::new()
276 };
277
278 let agent_tbl = config
280 .entry("agent")
281 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
282 .as_table_mut()
283 .unwrap();
284 agent_tbl.insert("name".into(), toml::Value::String(agent_name.clone()));
285 agent_tbl.insert("id".into(), toml::Value::String(agent_id.clone()));
286 agent_tbl.insert(
287 "workspace".into(),
288 toml::Value::String(workspace_path.display().to_string()),
289 );
290
291 let db_tbl = config
293 .entry("database")
294 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
295 .as_table_mut()
296 .unwrap();
297 db_tbl.insert(
298 "path".into(),
299 toml::Value::String(db_path.display().to_string()),
300 );
301
302 let skills_tbl = config
304 .entry("skills")
305 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
306 .as_table_mut()
307 .unwrap();
308 skills_tbl.insert(
309 "skills_dir".into(),
310 toml::Value::String(skills_dir.display().to_string()),
311 );
312
313 if let Some(ref reqs) = manifest.requirements
315 && let Some(ref model) = reqs.recommended_model
316 {
317 let models_tbl = config
318 .entry("models")
319 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
320 .as_table_mut()
321 .unwrap();
322 models_tbl.insert("primary".into(), toml::Value::String(model.clone()));
323 }
324
325 let overrides_path = source_dir.join("config-overrides.toml");
327 if overrides_path.exists() {
328 let raw = std::fs::read_to_string(&overrides_path)?;
329 let overrides: toml::Table = raw.parse().unwrap_or_default();
330 deep_merge_toml(&mut config, &overrides);
331 }
332
333 let config_toml = format!(
335 "# Auto-generated by `roboticus apps install` — do not edit manually.\n\
336 # App: {} v{}\n\n{}",
337 manifest.package.name,
338 manifest.package.version,
339 toml::to_string_pretty(&config).unwrap_or_default(),
340 );
341
342 std::fs::write(profile_dir.join("roboticus.toml"), &config_toml)?;
343
344 let mut subagent_names: Vec<String> = Vec::new();
346 let subagents_src = source_dir.join("subagents");
347
348 if subagents_src.is_dir() {
349 let db = roboticus_db::Database::new(db_path.to_str().unwrap_or(""))
351 .map_err(|e| format!("failed to create database: {e}"))?;
352
353 for entry in std::fs::read_dir(&subagents_src)? {
354 let entry = entry?;
355 let path = entry.path();
356 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
357 continue;
358 }
359
360 let contents = std::fs::read_to_string(&path)?;
361 let sa_manifest: SubagentManifest = toml::from_str(&contents).map_err(|e| {
362 format!("failed to parse subagent manifest {}: {e}", path.display())
363 })?;
364
365 let skills_json = if sa_manifest.subagent.skills.is_empty() {
366 None
367 } else {
368 Some(serde_json::to_string(&sa_manifest.subagent.skills)?)
369 };
370
371 let row = SubAgentRow {
372 id: uuid::Uuid::new_v4().to_string(),
373 name: sa_manifest.subagent.name.clone(),
374 display_name: Some(sa_manifest.subagent.display_name.clone()),
375 model: sa_manifest.subagent.model,
376 fallback_models_json: None,
377 role: sa_manifest.subagent.role,
378 description: Some(sa_manifest.subagent.description),
379 skills_json,
380 enabled: true,
381 session_count: 0,
382 last_used_at: None,
383 };
384
385 upsert_sub_agent(&db, &row)
386 .map_err(|e| format!("failed to register subagent '{}': {e}", row.name))?;
387
388 subagent_names.push(sa_manifest.subagent.display_name);
389 }
390 }
391
392 eprintln!();
394 eprintln!(
395 " {GREEN}{OK}{RESET} Installed: {BOLD}{}{RESET} v{}",
396 manifest.package.description, manifest.package.version
397 );
398 eprintln!();
399 eprintln!(" Profile: {ACCENT}{app_id}{RESET}");
400 eprintln!(" Agent: {agent_name}");
401 eprintln!(" Skills: {skills_count}");
402
403 if !subagent_names.is_empty() {
404 eprintln!(
405 " Subagents: {} ({})",
406 subagent_names.len(),
407 subagent_names.join(", ")
408 );
409 }
410
411 if themes_count > 0 {
412 eprintln!(" Themes: {themes_count}");
413 }
414
415 eprintln!();
416 eprintln!(" Launch:");
417 eprintln!(" {MONO}roboticus serve --profile {app_id}{RESET}");
418 eprintln!();
419 eprintln!(" Or switch your active profile:");
420 eprintln!(" {MONO}roboticus profile switch {app_id}{RESET}");
421 eprintln!();
422
423 Ok(())
424}
425
426pub fn cmd_apps_uninstall(name: &str, delete_data: bool) -> Result<(), Box<dyn std::error::Error>> {
428 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
429 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
430
431 if name == "default" {
432 return Err("cannot uninstall the built-in 'default' profile".into());
433 }
434
435 let mut registry = ProfileRegistry::load()?;
436
437 let entry = registry
438 .profiles
439 .get(name)
440 .cloned()
441 .ok_or_else(|| format!("app '{name}' is not installed"))?;
442
443 match entry.source.as_deref() {
445 Some("local") | Some("registry") => {}
446 _ => {
447 return Err(format!(
448 "'{name}' is a manually-created profile, not an app. \
449 Use `roboticus profile delete {name}` instead."
450 )
451 .into());
452 }
453 }
454
455 if entry.active {
456 return Err(format!(
457 "app '{name}' is the active profile. \
458 Switch to a different profile first: `roboticus profile switch default`"
459 )
460 .into());
461 }
462
463 let profile_dir = registry.resolve_config_dir(name)?;
464
465 if delete_data {
466 let (file_count, total_bytes) = dir_stats(&profile_dir);
468 let size_display = if total_bytes > 1_048_576 {
469 format!("{:.1} MB", total_bytes as f64 / 1_048_576.0)
470 } else if total_bytes > 1024 {
471 format!("{:.0} KB", total_bytes as f64 / 1024.0)
472 } else {
473 format!("{total_bytes} bytes")
474 };
475
476 eprintln!(" {WARN} This will permanently delete {file_count} files ({size_display}) at:");
477 eprintln!(" {}", profile_dir.display());
478 eprintln!();
479
480 let prompt = format!(" Type '{name}' to confirm deletion: ");
482 eprint!("{prompt}");
483 let mut input = String::new();
484 std::io::stdin().read_line(&mut input)?;
485 let input = input.trim();
486
487 if input != name {
488 eprintln!(" {DIM}Aborted.{RESET}");
489 return Ok(());
490 }
491
492 let safe_root = home_dir().join(".roboticus").join("profiles");
494 if profile_dir.starts_with(&safe_root) && profile_dir.exists() {
495 std::fs::remove_dir_all(&profile_dir)?;
496 }
497 }
498
499 registry.profiles.remove(name);
500 registry.save()?;
501
502 if delete_data {
503 eprintln!(
504 " {GREEN}{OK}{RESET} Uninstalled app {ACCENT}{name}{RESET} and deleted all data"
505 );
506 } else {
507 eprintln!(
508 " {GREEN}{OK}{RESET} Uninstalled app {ACCENT}{name}{RESET} (data kept at {})",
509 profile_dir.display()
510 );
511 }
512 eprintln!();
513
514 Ok(())
515}
516
517fn chrono_now() -> String {
520 use std::time::{SystemTime, UNIX_EPOCH};
521 let secs = SystemTime::now()
522 .duration_since(UNIX_EPOCH)
523 .map(|d| d.as_secs())
524 .unwrap_or(0);
525 format!("{secs}")
526}
527
528fn dir_stats(path: &Path) -> (u64, u64) {
530 let mut files = 0u64;
531 let mut bytes = 0u64;
532 if let Ok(entries) = walkdir(path) {
533 for (f, b) in entries {
534 files += f;
535 bytes += b;
536 }
537 }
538 (files, bytes)
539}
540
541fn walkdir(path: &Path) -> Result<Vec<(u64, u64)>, std::io::Error> {
542 let mut results = Vec::new();
543 if !path.is_dir() {
544 if let Ok(meta) = std::fs::metadata(path) {
545 return Ok(vec![(1, meta.len())]);
546 }
547 return Ok(vec![]);
548 }
549 for entry in std::fs::read_dir(path)? {
550 let entry = entry?;
551 let ft = entry.file_type()?;
552 if ft.is_dir() {
553 results.extend(walkdir(&entry.path())?);
554 } else {
555 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
556 results.push((1, size));
557 }
558 }
559 Ok(results)
560}
561
562fn deep_merge_toml(base: &mut toml::Table, overlay: &toml::Table) {
565 for (key, val) in overlay {
566 match (base.get_mut(key), val) {
567 (Some(toml::Value::Table(base_tbl)), toml::Value::Table(overlay_tbl)) => {
568 deep_merge_toml(base_tbl, overlay_tbl);
569 }
570 _ => {
571 base.insert(key.clone(), val.clone());
572 }
573 }
574 }
575}