Skip to main content

roboticus_cli/cli/
apps.rs

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// ── Manifest types ───────────────────────────────────────────────────────
9
10#[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
73// ── Commands ─────────────────────────────────────────────────────────────
74
75/// `roboticus apps list` — show installed apps.
76pub 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    // Filter to app-installed profiles (source = "local" or "registry").
86    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        // Try to read manifest for richer metadata.
109        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
139/// `roboticus apps install <source>` — install an app from a local path or registry.
140pub 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    // Detect source type.
145    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    // ── 1. Read and parse manifest ───────────────────────────────────────
159    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    // ── 2. Register profile ──────────────────────────────────────────────
182    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    // ── 3. Create profile directory structure ────────────────────────────
206    let profile_dir = registry.ensure_profile_dir(app_id)?;
207
208    // Also create themes dir if the app ships themes.
209    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    // ── 4. Copy files ────────────────────────────────────────────────────
215    let mut skills_count = 0u32;
216    let mut themes_count = 0u32;
217
218    // FIRMWARE.toml → workspace/
219    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    // skills/*.md → skills/
227    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    // themes/*.json → themes/
243    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    // manifest.toml → profile root (for reference).
258    std::fs::copy(&manifest_path, profile_dir.join("manifest.toml"))?;
259
260    // ── 5. Generate profile config ───────────────────────────────────────
261    // Strategy: copy the user's default roboticus.toml as a base (so all required
262    // sections like [server], [providers], [cache], etc. are present), then overlay
263    // the app-specific fields on top.
264    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    // Overlay app-specific agent fields
279    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    // Overlay database path
292    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    // Overlay skills dir
303    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    // Overlay recommended model if specified
314    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    // Deep-merge config-overrides.toml into the config table.
326    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    // Serialize the merged config.
334    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    // ── 6. Register subagents ────────────────────────────────────────────
345    let mut subagent_names: Vec<String> = Vec::new();
346    let subagents_src = source_dir.join("subagents");
347
348    if subagents_src.is_dir() {
349        // Create the database and run migrations.
350        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    // ── 7. Print success ─────────────────────────────────────────────────
393    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
426/// `roboticus apps uninstall <name>` — remove an installed app.
427pub 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    // Verify it is an app-installed profile.
444    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        // Show what will be deleted and require confirmation.
467        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        // Require the user to type the app name to confirm.
481        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        // Safe-delete: only if under ~/.roboticus/profiles/
493        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
517// ── Helpers ──────────────────────────────────────────────────────────────
518
519fn 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
528/// Walk a directory tree and return (file_count, total_bytes).
529fn 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
562/// Recursively merge `overlay` into `base`. For table values, recurse; for all
563/// other values, the overlay wins.
564fn 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}