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// Fields populated by `toml::from_str` deserialization (manifest.toml parsing).
11// Individually they appear unused, but the structs are read as a unit.
12#[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// Fields populated by `toml::from_str` deserialization (subagent TOML parsing).
49#[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
76// ── Commands ─────────────────────────────────────────────────────────────
77
78/// `roboticus apps list` — show installed apps.
79pub 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    // Filter to app-installed profiles (source = "local" or "registry").
89    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        // Try to read manifest for richer metadata.
112        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
142/// `roboticus apps install <source>` — install an app from a local path or registry.
143pub 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    // Detect source type.
148    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    // ── 1. Read and parse manifest ───────────────────────────────────────
162    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    // ── 2. Register profile ──────────────────────────────────────────────
185    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    // ── 3. Create profile directory structure ────────────────────────────
209    let profile_dir = registry.ensure_profile_dir(app_id)?;
210
211    // Also create themes dir if the app ships themes.
212    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    // ── 4. Copy files ────────────────────────────────────────────────────
218    let mut skills_count = 0u32;
219    let mut themes_count = 0u32;
220
221    // FIRMWARE.toml → workspace/
222    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    // skills/*.md → skills/
230    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    // themes/*.json → themes/
246    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    // manifest.toml → profile root (for reference).
261    std::fs::copy(&manifest_path, profile_dir.join("manifest.toml"))?;
262
263    // ── 5. Generate profile config ───────────────────────────────────────
264    // Strategy: copy the user's default roboticus.toml as a base (so all required
265    // sections like [server], [providers], [cache], etc. are present), then overlay
266    // the app-specific fields on top.
267    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    // Overlay app-specific agent fields
282    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    // Overlay database path
295    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    // Overlay skills dir
306    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    // Overlay recommended model if specified
317    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    // Deep-merge config-overrides.toml into the config table.
329    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    // Serialize the merged config.
337    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    // ── 6. Register subagents ────────────────────────────────────────────
348    let mut subagent_names: Vec<String> = Vec::new();
349    let subagents_src = source_dir.join("subagents");
350
351    if subagents_src.is_dir() {
352        // Create the database and run migrations.
353        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            // If the manifest declares an observer, set role to "observer"
375            // so the post-turn dispatch system recognizes it. The observer
376            // instruction is stored in the description field as a prefix.
377            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    // ── 7. Print success ─────────────────────────────────────────────────
418    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
451/// `roboticus apps uninstall <name>` — remove an installed app.
452pub 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    // Verify it is an app-installed profile.
469    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        // Show what will be deleted and require confirmation.
492        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        // Require the user to type the app name to confirm.
506        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        // Safe-delete: only if under ~/.roboticus/profiles/
518        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
542// ── Helpers ──────────────────────────────────────────────────────────────
543
544fn 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
553/// Walk a directory tree and return (file_count, total_bytes).
554fn 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
587/// Recursively merge `overlay` into `base`. For table values, recurse; for all
588/// other values, the overlay wins.
589fn 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}