roboticus-cli 0.11.4

CLI commands and migration engine for the Roboticus agent runtime
Documentation
fn collect_mechanic_json_local_findings(
    roboticus_dir: &Path,
    repair: bool,
    findings: &mut Vec<MechanicFinding>,
    actions: &mut RepairActionSummary,
) -> Result<(), Box<dyn std::error::Error>> {
    let roboticus_dir = roboticus_dir.to_path_buf();
    let dirs = [
        roboticus_dir.clone(),
        roboticus_dir.join("workspace"),
        roboticus_dir.join("skills"),
        roboticus_dir.join("plugins"),
        roboticus_dir.join("logs"),
    ];
    for dir in &dirs {
        if !dir.exists() {
            let mut f = finding(
                "missing-directory",
                "medium",
                0.99,
                format!("Missing directory: {}", dir.display()),
                "Required runtime directory is absent.",
                "Create required Roboticus directory tree.",
                vec![format!("mkdir -p \"{}\"", dir.display())],
                true,
                false,
            );
            if repair {
                std::fs::create_dir_all(dir)?;
                f.auto_repaired = true;
                actions.directories_created.push(dir.display().to_string());
            }
            findings.push(f);
        }
    }

    let config_path = std::path::Path::new("roboticus.toml");
    let alt_config = roboticus_dir.join("roboticus.toml");
    if !config_path.exists() && !alt_config.exists() {
        let mut f = finding(
            "missing-config",
            "high",
            0.98,
            "No Roboticus config file found",
            "Neither local ./roboticus.toml nor ~/.roboticus/roboticus.toml exists.",
            "Initialize or restore runtime configuration.",
            vec!["roboticus init".to_string()],
            true,
            false,
        );
        if repair {
            let default_config = format!(
                concat!(
                    "[agent]\n",
                    "name = \"Roboticus\"\n",
                    "id = \"roboticus-dev\"\n\n",
                    "[server]\n",
                    "port = 18789\n",
                    "bind = \"localhost\"\n\n",
                    "[database]\n",
                    "path = \"{}/state.db\"\n\n",
                    "[models]\n",
                    "primary = \"ollama/qwen3:8b\"\n",
                    "fallbacks = [\"openai/gpt-4o\"]\n",
                ),
                roboticus_dir.display()
            );
            std::fs::create_dir_all(&roboticus_dir)?;
            std::fs::write(&alt_config, default_config)?;
            f.auto_repaired = true;
            actions.config_created = true;
        }
        findings.push(f);
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        for file in [
            roboticus_dir.join("wallet.json"),
            roboticus_dir.join("state.db"),
        ] {
            if file.exists() {
                let meta = std::fs::metadata(&file)?;
                let mode = meta.permissions().mode() & 0o777;
                if mode & 0o077 != 0 {
                    let mut f = finding(
                        "loose-permissions",
                        "high",
                        0.97,
                        format!("Loose permissions on {}", file.display()),
                        format!("Current mode {:o} allows group/other access.", mode),
                        "Harden file permissions to owner-only (0600).",
                        vec![format!("chmod 600 \"{}\"", file.display())],
                        true,
                        false,
                    );
                    if repair {
                        let mut perms = meta.permissions();
                        perms.set_mode(0o600);
                        std::fs::set_permissions(&file, perms)?;
                        f.auto_repaired = true;
                        actions
                            .permissions_hardened
                            .push(file.display().to_string());
                    }
                    findings.push(f);
                }
            }
        }
    }

    let oauth_health = check_and_repair_oauth_storage(repair);
    if oauth_health.needs_attention() {
        let mut details = Vec::new();
        if oauth_health.legacy_plaintext_exists {
            details.push("legacy plaintext OAuth token file is present".to_string());
        }
        if !oauth_health.keystore_available {
            details.push("keystore is unavailable".to_string());
        }
        if oauth_health.malformed_keystore_entries > 0 {
            details.push(format!(
                "{} malformed keystore OAuth entr{}",
                oauth_health.malformed_keystore_entries,
                if oauth_health.malformed_keystore_entries == 1 {
                    "y"
                } else {
                    "ies"
                }
            ));
        }
        if oauth_health.legacy_parse_failed {
            details.push("legacy OAuth token file could not be parsed".to_string());
        }

        let mut finding = finding(
            "oauth-storage-drift",
            if !oauth_health.keystore_available {
                "high"
            } else {
                "medium"
            },
            0.97,
            "OAuth token storage needs migration/repair",
            details.join("; "),
            "Migrate OAuth tokens to encrypted keystore and remove legacy plaintext artifacts.",
            vec!["roboticus mechanic --repair".to_string()],
            true,
            false,
        );
        if repair && oauth_health.repaired {
            finding.auto_repaired = true;
        }
        findings.push(finding);
    }

    let skills_cleanup = cleanup_internalized_skill_artifacts(
        &roboticus_dir.join("state.db"),
        &roboticus_dir.join("skills"),
        repair,
    );
    if !skills_cleanup.stale_db_skills.is_empty()
        || !skills_cleanup.stale_files.is_empty()
        || !skills_cleanup.stale_dirs.is_empty()
    {
        let stale_db = if skills_cleanup.stale_db_skills.is_empty() {
            "none".to_string()
        } else {
            skills_cleanup.stale_db_skills.join(", ")
        };
        let stale_fs_items: Vec<String> = skills_cleanup
            .stale_files
            .iter()
            .chain(skills_cleanup.stale_dirs.iter())
            .map(|p| p.display().to_string())
            .collect();
        let stale_fs = if stale_fs_items.is_empty() {
            "none".to_string()
        } else {
            stale_fs_items.join(", ")
        };
        let mut f = finding(
            "internalized-skill-drift",
            "medium",
            0.98,
            "Internalized skills still exist as external artifacts",
            format!("stale_db=[{stale_db}]; stale_fs=[{stale_fs}]"),
            "Remove obsolete externalized skill entries/files for internalized skills.",
            vec!["roboticus mechanic --repair".to_string()],
            true,
            false,
        );
        if repair
            && (!skills_cleanup.removed_db_skills.is_empty()
                || !skills_cleanup.removed_paths.is_empty())
        {
            f.auto_repaired = true;
            actions.internalized_skills_cleaned.extend(
                skills_cleanup
                    .removed_db_skills
                    .iter()
                    .map(|s| format!("db:{s}"))
                    .chain(
                        skills_cleanup
                            .removed_paths
                            .iter()
                            .map(|p| format!("fs:{}", p.display())),
                    ),
            );
        }
        findings.push(f);
    }

    let capability_skill_parity = evaluate_capability_skill_parity(&roboticus_dir.join("state.db"));
    if !capability_skill_parity.missing_in_registry.is_empty() {
        findings.push(finding(
            "capability-skill-parity-registry-gap",
            "high",
            0.97,
            "Capability-to-skill parity gap in builtin skill registry",
            capability_skill_parity.missing_in_registry.join("; "),
            "Add missing builtin skills to registry/builtin-skills.json for declared runtime capabilities.",
            vec!["Update registry/builtin-skills.json and reload skills".to_string()],
            false,
            false,
        ));
    }
    if !capability_skill_parity.missing_in_db.is_empty() {
        findings.push(finding(
            "capability-skill-parity-db-gap",
            "medium",
            0.95,
            "Capability-to-skill parity gap in loaded skill database",
            capability_skill_parity.missing_in_db.join("; "),
            "Reload/reconcile skills so builtin capability skills are active in DB.",
            vec![
                "roboticus skills reload".to_string(),
                "roboticus mechanic --repair".to_string(),
            ],
            true,
            false,
        ));
    }

    // Memory hygiene — canned responses, memorised fallbacks, hallucinated output.
    let mem_hygiene = run_memory_hygiene(&roboticus_dir.join("state.db"), repair)?;
    if mem_hygiene.total_detected > 0 {
        let mut details_parts = Vec::new();
        if mem_hygiene.working_canned > 0 {
            details_parts.push(format!(
                "{} canned response(s) in working_memory",
                mem_hygiene.working_canned
            ));
        }
        if mem_hygiene.semantic_canned > 0 {
            details_parts.push(format!(
                "{} canned response(s) learned as semantic facts",
                mem_hygiene.semantic_canned
            ));
        }
        if mem_hygiene.episodic_hallucinated > 0 {
            details_parts.push(format!(
                "{} hallucinated subagent output(s) in episodic_memory",
                mem_hygiene.episodic_hallucinated
            ));
        }

        let mut f = finding(
            "memory-contamination",
            "high",
            0.95,
            format!(
                "Memory contamination: {} entries across {} tier(s)",
                mem_hygiene.total_detected,
                details_parts.len()
            ),
            details_parts.join("; "),
            "Purge canned/hallucinated entries from memory tiers and VACUUM.",
            vec!["roboticus mechanic --repair".to_string()],
            true,
            false,
        );
        if repair && mem_hygiene.total_purged > 0 {
            f.auto_repaired = true;
            actions.memory_entries_purged = mem_hygiene.total_purged;
        }
        findings.push(f);
    }

    // Model & channel triage — key resolution, API reachability, token validity.
    let config_path = std::path::Path::new("roboticus.toml");
    let alt_config = roboticus_dir.join("roboticus.toml");
    let triage_config_path = if config_path.exists() {
        Some(config_path.to_path_buf())
    } else if alt_config.exists() {
        Some(alt_config)
    } else {
        None
    };
    if let Some(ref cfg_path) = triage_config_path
        && let Ok(raw) = std::fs::read_to_string(cfg_path)
        && let Ok(cfg) = toml::from_str::<roboticus_core::RoboticusConfig>(&raw)
    {
        let triage = run_model_triage(&cfg, true);

        for m in &triage.models {
            if !m.key_status.is_healthy() {
                findings.push(finding(
                    "model-key-unhealthy",
                    m.key_status.severity(),
                    0.97,
                    format!(
                        "Model '{}' ({}) — {}",
                        m.model_id,
                        m.role,
                        m.key_status.summary()
                    ),
                    format!(
                        "The {} model '{}' (provider: {}) has an unhealthy key: {}",
                        m.role, m.model_id, m.provider, m.key_status.summary()
                    ),
                    m.key_status.remediation(),
                    vec![m.key_status.remediation()],
                    false,
                    true,
                ));
            }
            if m.reachable == Some(false) {
                findings.push(finding(
                    "model-unreachable",
                    "high",
                    0.90,
                    format!("Model '{}' ({}) — API unreachable", m.model_id, m.role),
                    m.probe_detail
                        .as_deref()
                        .unwrap_or("Provider endpoint did not respond.")
                        .to_string(),
                    "Verify provider URL and network connectivity.",
                    vec!["roboticus channels status".to_string()],
                    false,
                    false,
                ));
            }
        }

        for c in &triage.channels {
            if !c.enabled {
                continue;
            }
            if !c.key_status.is_healthy() {
                findings.push(finding(
                    "channel-key-unhealthy",
                    c.key_status.severity(),
                    0.97,
                    format!("{} channel — {}", c.channel, c.key_status.summary()),
                    format!(
                        "{} channel has an unhealthy token: {}",
                        c.channel,
                        c.key_status.summary()
                    ),
                    c.key_status.remediation(),
                    vec![c.key_status.remediation()],
                    false,
                    true,
                ));
            }
            if c.reachable == Some(false) {
                findings.push(finding(
                    "channel-unreachable",
                    "high",
                    0.90,
                    format!("{} channel — endpoint unreachable", c.channel),
                    c.probe_detail
                        .as_deref()
                        .unwrap_or("Channel endpoint did not respond.")
                        .to_string(),
                    "Verify channel configuration and network connectivity.",
                    vec!["roboticus channels status".to_string()],
                    false,
                    false,
                ));
            }
        }
    }

    Ok(())
}