collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
pub(super) fn parse_mcp_flags(tokens: &[String]) -> (Vec<String>, Vec<(String, String)>) {
    let mut positional = Vec::new();
    let mut flags: Vec<(String, String)> = Vec::new();
    let mut i = 0;
    while i < tokens.len() {
        let tok = &tokens[i];
        if let Some(rest) = tok.strip_prefix("--") {
            if let Some((key, val)) = rest.split_once('=') {
                flags.push((key.to_string(), val.to_string()));
            } else if i + 1 < tokens.len()
                && !tokens[i + 1].starts_with("--")
                && !tokens[i + 1].starts_with('-')
            {
                flags.push((rest.to_string(), tokens[i + 1].clone()));
                i += 1;
            } else {
                // boolean flag
                flags.push((rest.to_string(), "true".to_string()));
            }
        } else if tok == "-g" {
            flags.push(("global".to_string(), "true".to_string()));
        } else if tok.starts_with('-') && tok.len() == 2 {
            // Single-char flags with values: -n name, -d desc, -e K=V, -a arg, -h K=V
            let key = match &tok[1..] {
                "n" => "name",
                "d" => "description",
                "e" => "env",
                "a" => "args",
                "h" => "headers",
                _ => {
                    positional.push(tok.clone());
                    i += 1;
                    continue;
                }
            };
            if i + 1 < tokens.len() {
                flags.push((key.to_string(), tokens[i + 1].clone()));
                i += 1;
            }
        } else {
            positional.push(tok.clone());
        }
        i += 1;
    }
    (positional, flags)
}

/// Resolve mcp.json path: `--global` → `~/.collet/mcp.json`, else `.collet/mcp.json`.
pub(super) fn mcp_json_path(working_dir: &str, global: bool) -> std::path::PathBuf {
    if global {
        crate::config::collet_home(None).join("mcp.json")
    } else {
        std::path::Path::new(working_dir)
            .join(".collet")
            .join("mcp.json")
    }
}

/// Read mcp.json → serde_json::Value (returns empty mcpServers object if file missing).
/// Returns Err if the file exists but contains invalid JSON (to prevent data loss).
pub(super) fn read_mcp_json(path: &std::path::Path) -> Result<serde_json::Value, String> {
    if let Ok(raw) = std::fs::read_to_string(path) {
        serde_json::from_str(&raw).map_err(|e| {
            format!(
                "Failed to parse `{}`: {e}\nRefusing to overwrite — fix the JSON manually.",
                path.display()
            )
        })
    } else {
        Ok(serde_json::json!({ "version": 1, "mcpServers": {} }))
    }
}

/// Write mcp.json atomically (pretty-printed).
pub(super) fn write_mcp_json(
    path: &std::path::Path,
    value: &serde_json::Value,
) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    let pretty = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
    std::fs::write(path, pretty).map_err(|e| e.to_string())
}

pub(super) fn entry_from_source_inner(source: &str) -> serde_json::Value {
    if let Some(pkg) = source.strip_prefix("npm:") {
        let versioned = if source_has_explicit_version(pkg) {
            pkg.to_string()
        } else {
            format!("{pkg}@latest")
        };
        serde_json::json!({
            "source": source,
            "command": "npx",
            "args": ["-y", versioned],
            "env": {},
            "enabled": true
        })
    } else if let Some(bin) = source.strip_prefix("local:") {
        serde_json::json!({
            "source": source,
            "command": bin,
            "args": [],
            "env": {},
            "enabled": true
        })
    } else if let Some(url) = source.strip_prefix("http:") {
        serde_json::json!({
            "source": source,
            "url": url,
            "headers": {},
            "enabled": true
        })
    } else if let Some(repo) = source.strip_prefix("github:") {
        serde_json::json!({
            "source": source,
            "command": "npx",
            "args": ["-y", format!("github:{repo}")],
            "env": {},
            "enabled": true
        })
    } else {
        // bare → npm
        let versioned = if source_has_explicit_version(source) {
            source.to_string()
        } else {
            format!("{source}@latest")
        };
        serde_json::json!({
            "source": format!("npm:{source}"),
            "command": "npx",
            "args": ["-y", versioned],
            "env": {},
            "enabled": true
        })
    }
}

/// Check if an npm package string already has a version (`pkg@1.0` or `@scope/pkg@1.0`).
pub(super) fn source_has_explicit_version(pkg: &str) -> bool {
    if let Some(stripped) = pkg.strip_prefix('@') {
        // @scope/pkg@ver → split off scope prefix, then check remainder
        stripped.contains('@')
    } else {
        pkg.contains('@')
    }
}

/// `/mcp add <name> <source> [-g|--global] [-a arg]... [-e K=V]... [-h K=V]... [-d desc]`
pub(super) fn mcp_add(args: &[String], working_dir: &str) -> String {
    let (positional, flags) = parse_mcp_flags(args);

    let (name, source) = match (positional.first(), positional.get(1)) {
        (Some(n), Some(s)) => (n.clone(), s.clone()),
        _ => {
            return "Usage: /mcp add <name> <source> [-g] [-a arg] [-e K=V] [-h K=V] [-d desc]\n\n\
                    Sources:\n  npm:@playwright/mcp       npm package (npx -y)\n  local:alcove              local binary\n  https://example.com/mcp   HTTP endpoint (auto-detected)\n  http://example.com/mcp    HTTP endpoint (auto-detected)\n  github:owner/repo@tag     GitHub repo\n  bare-name                 treated as npm package\n\n\
                    Flags:\n  -g, --global        Save to ~/.collet/mcp.json (default: project)\n  -a, --args <val>    Extra argument (repeatable)\n  -e, --env K=V       Environment variable (repeatable)\n  -h, --headers K=V   HTTP header (repeatable)\n  -d, --description   Description text\n\n\
                    Examples:\n  /mcp add playwright npm:@playwright/mcp -g\n  /mcp add alcove local:alcove -g -e DOC_ROOT=/path\n  /mcp add context7 https://mcp.context7.com/mcp -h Authorization=\"Bearer $KEY\"\n  /mcp rm context7 -g".to_string();
        }
    };

    // Auto-detect HTTP source from URL protocol
    let source = if source.starts_with("https://") || source.starts_with("http://") {
        format!("http:{source}")
    } else {
        source
    };

    let global = flags.iter().any(|(k, _)| k == "global");
    let path = mcp_json_path(working_dir, global);

    let mut entry = entry_from_source_inner(&source);

    // Apply --description
    if let Some((_, desc)) = flags.iter().find(|(k, _)| k == "description") {
        entry["description"] = serde_json::Value::String(desc.clone());
    }

    // Apply --args / -a values (repeatable, appended to existing args array)
    let extra_args: Vec<&str> = flags
        .iter()
        .filter(|(k, _)| k.as_str() == "args")
        .map(|(_, v)| v.as_str())
        .collect();
    if !extra_args.is_empty() {
        let arr = entry.get_mut("args").and_then(|v| v.as_array_mut());
        if let Some(arr) = arr {
            for a in &extra_args {
                arr.push(serde_json::Value::String(a.to_string()));
            }
        } else {
            entry["args"] = serde_json::json!(extra_args);
        }
    }

    // Apply --env KEY=VALUE pairs (can appear multiple times as --env K=V)
    for (_k, v) in flags.iter().filter(|(k, _)| k.as_str() == "env") {
        if let Some((ek, ev)) = v.split_once('=') {
            entry["env"][ek] = serde_json::Value::String(ev.to_string());
        }
    }

    // Apply --header / --headers KEY=VALUE pairs
    for (_k, v) in flags
        .iter()
        .filter(|(k, _)| k.as_str() == "header" || k.as_str() == "headers")
    {
        if let Some((hk, hv)) = v.split_once('=') {
            if entry.get("headers").is_none() {
                entry["headers"] = serde_json::json!({});
            }
            entry["headers"][hk] = serde_json::Value::String(hv.to_string());
        }
    }

    let mut doc = match read_mcp_json(&path) {
        Ok(v) => v,
        Err(e) => return e,
    };
    if doc.get("mcpServers").is_none() {
        doc["mcpServers"] = serde_json::json!({});
    }

    let existed = doc["mcpServers"].get(&name).is_some();
    doc["mcpServers"][&name] = entry;

    match write_mcp_json(&path, &doc) {
        Ok(()) => {
            let verb = if existed { "Updated" } else { "Added" };
            let scope = if global { "global" } else { "project" };
            format!(
                "{verb} `{name}` to {scope} MCP config (`{}`).",
                path.display()
            )
        }
        Err(e) => format!("Failed to write `{}`: {e}", path.display()),
    }
}

/// `/mcp remove <name> [-g|--global]`
pub(super) fn mcp_remove(args: &[String], working_dir: &str) -> String {
    let (positional, flags) = parse_mcp_flags(args);

    let name = match positional.first() {
        Some(n) => n.clone(),
        None => return "Usage: `/mcp remove|rm <name> [-g|--global]`".to_string(),
    };

    let global = flags.iter().any(|(k, _)| k == "global");
    let path = mcp_json_path(working_dir, global);
    let mut doc = match read_mcp_json(&path) {
        Ok(v) => v,
        Err(e) => return e,
    };

    if doc["mcpServers"].get(&name).is_none() {
        return format!("`{name}` not found in `{}`.", path.display());
    }

    doc["mcpServers"].as_object_mut().unwrap().remove(&name);

    match write_mcp_json(&path, &doc) {
        Ok(()) => format!("Removed `{name}` from `{}`.", path.display()),
        Err(e) => format!("Failed to write `{}`: {e}", path.display()),
    }
}

/// `/mcp enable <name> [--global]` / `/mcp disable <name> [--global]`
pub(super) fn mcp_set_enabled(args: &[String], working_dir: &str, enabled: bool) -> String {
    let (positional, flags) = parse_mcp_flags(args);

    let name = match positional.first() {
        Some(n) => n.clone(),
        None => {
            let sub = if enabled { "enable" } else { "disable" };
            return format!("Usage: `/mcp {sub} <name> [--global]`");
        }
    };

    let global = flags.iter().any(|(k, _)| k == "global");
    let path = mcp_json_path(working_dir, global);
    let mut doc = match read_mcp_json(&path) {
        Ok(v) => v,
        Err(e) => return e,
    };

    if doc["mcpServers"].get(&name).is_none() {
        return format!("`{name}` not found in `{}`.", path.display());
    }

    doc["mcpServers"][&name]["enabled"] = serde_json::Value::Bool(enabled);

    match write_mcp_json(&path, &doc) {
        Ok(()) => {
            let state = if enabled { "enabled" } else { "disabled" };
            format!("`{name}` {state} in `{}`.", path.display())
        }
        Err(e) => format!("Failed to write `{}`: {e}", path.display()),
    }
}

// ---------------------------------------------------------------------------

/// CLI entry point for `collet mcp <sub> [args...]`.
/// `args` is a token slice preserving shell quoting (e.g. `["add", "npm:pkg", "--global"]`).
pub(crate) fn handle_mcp_command(args: &[String], working_dir: &str) -> String {
    let sub = args.first().map(|s| s.as_str()).unwrap_or("");
    let rest = if args.len() > 1 {
        &args[1..]
    } else {
        &[] as &[String]
    };
    match sub {
        "add" => mcp_add(rest, working_dir),
        "remove" | "rm" => mcp_remove(rest, working_dir),
        "enable" => mcp_set_enabled(rest, working_dir, true),
        "disable" => mcp_set_enabled(rest, working_dir, false),
        _ => mcp_text(working_dir),
    }
}

pub(super) fn mcp_text(working_dir: &str) -> String {
    // Look for MCP config in project and user dirs
    let project_cfg = std::path::Path::new(working_dir)
        .join(".collet")
        .join("mcp.json");
    let user_cfg = Some(crate::config::collet_home(None).join("mcp.json"));

    let mut lines = vec!["## MCP Servers\n".to_string()];

    let mut found = false;
    for path in [Some(project_cfg), user_cfg].into_iter().flatten() {
        if path.exists() {
            found = true;
            let label = if path.starts_with(working_dir) {
                "project"
            } else {
                "user"
            };
            lines.push(format!("**Config** (`{label}`): `{}`\n", path.display()));
            match std::fs::read_to_string(&path) {
                Ok(contents) => lines.push(format!("```json\n{}\n```", contents.trim())),
                Err(e) => lines.push(format!("_(error reading file: {e})_")),
            }
        }
    }

    if !found {
        lines.push("No MCP configuration found.".to_string());
        lines.push(
            "\nCreate `.collet/mcp.json` in your project or `~/.collet/mcp.json` globally to configure MCP servers.".to_string(),
        );
    }

    lines.join("\n")
}