offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline init`: add offline.config.json, AGENTS.md, and .mcp.json to an existing app.
//! An existing `.mcp.json` is never modified.

use std::path::PathBuf;

use anyhow::{bail, Result};
use clap::Args;
use offline_generator::project_wiring::{self, McpRegistration};
use offline_registry::Registry;
use serde_json::json;

use crate::config::CliConfig;
use crate::output;

#[derive(Args)]
pub struct InitArgs {
    /// Project directory (defaults to current directory)
    #[arg(long, default_value = ".")]
    pub dir: PathBuf,
    /// Offline application ID (defaults to the value from `offline login` / `offline setup`)
    #[arg(long)]
    pub app_id: Option<String>,
    /// Output JSON
    #[arg(long)]
    pub json: bool,
}

pub fn run(args: InitArgs) -> Result<()> {
    let dir = &args.dir;
    let pkg_path = dir.join("package.json");
    if !pkg_path.exists() {
        bail!(
            "no package.json in {} — `offline init` wires existing JS/TS projects; \
use `offline create` for new ones",
            dir.display()
        );
    }
    let app_id = args
        .app_id
        .or_else(|| CliConfig::load().ok().and_then(|c| c.effective_app_id()))
        .unwrap_or_default();
    let pkg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pkg_path)?)?;
    let deps = collect_deps(&pkg);
    let platform = detect_platform(&deps);
    let project_name = pkg
        .get("name")
        .and_then(|n| n.as_str())
        .unwrap_or("offline-app")
        .to_string();

    let registry = Registry::load_default()?;
    let installed: Vec<String> = registry
        .packages()
        .filter(|p| deps.iter().any(|d| d == &p.name))
        .map(|p| p.name.clone())
        .collect();

    let mut written = Vec::new();

    let config_path = dir.join("offline.config.json");
    if config_path.exists() {
        output::warn("offline.config.json already exists — leaving it untouched");
    } else {
        let config = json!({
            "$schema": "https://dev.offlineprotocol.com/schemas/offline.config.json",
            "projectName": project_name,
            "appId": app_id,
            "platform": platform,
            "packages": installed,
        });
        std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
        written.push("offline.config.json");
    }

    let mcp_path = dir.join(".mcp.json");
    let registration = if mcp_path.exists() {
        if project_wiring::registers_offline_mcp(&std::fs::read_to_string(&mcp_path)?) {
            McpRegistration::AlreadyRegistered
        } else {
            output::warn(&format!(
                ".mcp.json already exists and does not register the Offline Protocol MCP server; \
left it unchanged. Add `{}` to your agent (see `offline mcp install`) or use the Offline Protocol plugin.",
                offline_registry::mcp_serve_command_line()
            ));
            McpRegistration::NotRegistered
        }
    } else {
        std::fs::write(
            &mcp_path,
            serde_json::to_string_pretty(&project_wiring::mcp_json())? + "\n",
        )?;
        written.push(".mcp.json");
        McpRegistration::Written
    };

    let agents_path = dir.join("AGENTS.md");
    let skills: Vec<String> = registry
        .packages()
        .filter(|p| installed.contains(&p.name))
        .flat_map(|p| p.skills.clone())
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect();
    let section = project_wiring::agents_section(&installed, &skills, registration);
    if agents_path.exists() {
        let existing = std::fs::read_to_string(&agents_path)?;
        if project_wiring::agents_has_section(&existing) {
            output::warn("AGENTS.md already has an Offline Protocol section; skipping");
        } else {
            std::fs::write(&agents_path, format!("{existing}\n{section}"))?;
            written.push("AGENTS.md (appended)");
        }
    } else {
        std::fs::write(&agents_path, format!("# Agent guide\n{section}"))?;
        written.push("AGENTS.md");
    }

    if args.json {
        return output::json(&json!({
            "dir": dir,
            "platform": platform,
            "detectedPackages": installed,
            "filesWritten": written,
        }));
    }
    output::success(&format!(
        "Initialized Offline Protocol wiring in {} ({})",
        dir.display(),
        platform
    ));
    for w in &written {
        output::item(w, "");
    }
    if installed.is_empty() {
        output::note(
            "no @offline-protocol packages detected yet — add one with `offline add <package>`",
        );
    }
    Ok(())
}

fn collect_deps(pkg: &serde_json::Value) -> Vec<String> {
    let mut out = Vec::new();
    for key in ["dependencies", "devDependencies", "peerDependencies"] {
        if let Some(map) = pkg.get(key).and_then(|d| d.as_object()) {
            out.extend(map.keys().cloned());
        }
    }
    out
}

fn detect_platform(deps: &[String]) -> &'static str {
    if deps.iter().any(|d| d == "react-native") {
        "react-native"
    } else if deps.iter().any(|d| d == "next") {
        "nextjs"
    } else if deps.iter().any(|d| d == "react") {
        "web"
    } else {
        "unknown"
    }
}