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