slave 0.13.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! `slave login`: a session at a project's MCP backend, for commands that act
//! as a user (e.g. `make:app`, which links the app to one of their teams).
//!
//! Signing in uses the backend's email code tools; the session token is kept
//! per backend URL in `~/.config/slave/sessions.json`. Calls go through
//! `curl`, so the CLI needs no HTTP client of its own.

use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};

use crate::apps::{self, Env};

/// The project's backend: `http://<APP_HOST>:<APP_PORT>/mcp` from `.env`.
pub fn project_url(root: &Path) -> Result<String> {
    let env = Env::load(root)?;
    let host = env.get("APP_HOST").unwrap_or_else(|| "127.0.0.1".into());
    let port = env
        .get("APP_PORT")
        .and_then(|port| port.parse().ok())
        .unwrap_or(3000);
    Ok(format!("http://{}/mcp", apps::connect_address(&host, port)))
}

/// `slave login [--url]`.
pub fn login(url: &str) -> Result<()> {
    let email = prompt("Email: ")?;
    call(url, None, "auth_email_start", json!({ "email": email }))?;
    let code = prompt(
        "We sent you a 6-digit code (printed in the backend's log until SMTP_URL is set). Code: ",
    )?;
    let session = call(
        url,
        None,
        "auth_email_verify",
        json!({ "email": email, "code": code }),
    )?;
    let token = session["token"]
        .as_str()
        .ok_or_else(|| anyhow!("the backend returned no session token"))?;
    let mut sessions = load()?;
    sessions.insert(url.to_owned(), token.to_owned());
    save(&sessions)?;
    println!("Signed in to {url} as {email}.");
    Ok(())
}

/// `slave logout [--url]`.
pub fn logout(url: &str) -> Result<()> {
    let mut sessions = load()?;
    if let Some(token) = sessions.remove(url) {
        let _ = call(url, Some(&token), "auth_logout", json!({}));
        save(&sessions)?;
    }
    println!("Signed out of {url}.");
    Ok(())
}

/// The saved session token for `url`, or how to get one.
pub fn token(url: &str) -> Result<String> {
    load()?.remove(url).ok_or_else(|| {
        anyhow!("sign in first: `slave login` (with the backend running, `slave serve`)")
    })
}

/// Calls MCP tool `tool` at `url` and returns its structured result.
pub fn call(url: &str, token: Option<&str>, tool: &str, arguments: Value) -> Result<Value> {
    let body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": { "name": tool, "arguments": arguments },
    });
    let mut curl = Command::new("curl");
    curl.args(["-sS", "--max-time", "30", "-X", "POST", url])
        .args(["-H", "content-type: application/json"])
        .args(["-H", "accept: application/json, text/event-stream"])
        .args(["--data-binary", &body.to_string()]);
    if let Some(token) = token {
        curl.args(["-H", &format!("authorization: Bearer {token}")]);
    }
    let output = curl
        .output()
        .context("failed to run curl (is it installed?)")?;
    if !output.status.success() {
        bail!(
            "cannot reach {url}: {} (is `slave serve` running?)",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    let response: Value = serde_json::from_slice(&output.stdout)
        .with_context(|| format!("unexpected answer from {url}"))?;
    let result = &response["result"];
    if result["isError"].as_bool() == Some(true) {
        let content = &result["structuredContent"];
        if content["code"] == "unauthenticated" {
            bail!("your session ended; sign in again with `slave login`");
        }
        bail!("{}", content["error"].as_str().unwrap_or("the tool failed"));
    }
    if result.is_null() {
        bail!(
            "{}",
            response["error"]["message"]
                .as_str()
                .unwrap_or("unexpected answer")
        );
    }
    Ok(result["structuredContent"].clone())
}

/// Reads a line from the terminal.
pub fn prompt(question: &str) -> Result<String> {
    print!("{question}");
    std::io::stdout().flush()?;
    let mut line = String::new();
    std::io::stdin().read_line(&mut line)?;
    Ok(line.trim().to_owned())
}

fn path() -> Result<PathBuf> {
    let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?;
    Ok(PathBuf::from(home).join(".config/slave/sessions.json"))
}

fn load() -> Result<BTreeMap<String, String>> {
    match std::fs::read(path()?) {
        Ok(bytes) => Ok(serde_json::from_slice(&bytes).unwrap_or_default()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
        Err(err) => Err(err.into()),
    }
}

fn save(sessions: &BTreeMap<String, String>) -> Result<()> {
    let path = path()?;
    std::fs::create_dir_all(path.parent().expect("a parent directory"))?;
    std::fs::write(&path, serde_json::to_vec_pretty(sessions)?)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
    }
    Ok(())
}