mars-agents 0.8.0

Agent package manager for .agents/ directories
Documentation
use crate::build::bundle::{LaunchActions, LaunchBundle, RuntimeContext};
use crate::build::project::{agent_name, approval, effort, model, prompt_file, subprocess_actions};
use crate::error::MarsError;

const CLAUDE_PARENT_ALLOWED_TOOLS_FLAG: &str = "--meridian-parent-allowed-tools";
const CLAUDE_BUILTIN_AGENT_DENY_TOOLS: &[&str] = &[
    "Agent(Explore)",
    "Agent(Plan)",
    "Agent(General-purpose)",
    "Agent(general-purpose)",
];

pub fn project(
    bundle: &LaunchBundle,
    context: &RuntimeContext,
) -> Result<LaunchActions, MarsError> {
    let mut argv = vec![
        "claude".to_string(),
        "-p".to_string(),
        "--output-format".to_string(),
        "stream-json".to_string(),
        "--verbose".to_string(),
    ];

    // TODO(launch-actions-parity, launch-bundle-projection): interactive prompt via stdin, not positional arg
    if !context.interactive {
        argv.push("-".to_string());
    }

    if let Some(model) = model(bundle) {
        argv.extend(["--model".to_string(), model.to_string()]);
    }
    if let Some(effort) = effort(bundle) {
        argv.extend(["--effort".to_string(), claude_effort(effort).to_string()]);
    }
    if let Some(agent) = agent_name(bundle) {
        argv.extend(["--agent".to_string(), agent.to_string()]);
    }

    let (passthrough_tail, parent_allowed_tools) =
        split_internal_parent_allowed_tools(&context.extra_args);
    let (passthrough_tail, passthrough_allowed, passthrough_disallowed) =
        extract_claude_tool_flags(&passthrough_tail);

    argv.extend(permission_tail(bundle)?);

    // TODO(launch-actions-parity, launch-bundle-projection): no `--agents` native-agent payload emitted
    let mut allowed_tools = Vec::new();
    allowed_tools.extend(bundle.tools.allowed.iter().cloned());
    allowed_tools.extend(parent_allowed_tools);
    allowed_tools.extend(passthrough_allowed);

    let mut disallowed_tools = Vec::new();
    disallowed_tools.extend(bundle.tools.disallowed.iter().cloned());
    disallowed_tools.extend(passthrough_disallowed);
    disallowed_tools.extend(
        CLAUDE_BUILTIN_AGENT_DENY_TOOLS
            .iter()
            .map(|tool| (*tool).to_string()),
    );

    let mut allowed_tools = dedupe_nonempty(allowed_tools);
    allowed_tools.retain(|tool| !is_claude_agent_tool(tool));
    let disallowed_tools = dedupe_nonempty(disallowed_tools);
    if !disallowed_tools.is_empty() {
        allowed_tools.retain(|tool| !is_denied_tool(tool, &disallowed_tools));
    }

    if !allowed_tools.is_empty() {
        argv.extend(["--allowedTools".to_string(), allowed_tools.join(",")]);
    }
    if !disallowed_tools.is_empty() {
        argv.extend(["--disallowedTools".to_string(), disallowed_tools.join(",")]);
    }

    for tool in &bundle.tools.mcp {
        let normalized = tool.trim();
        if !normalized.is_empty() {
            argv.extend(["--mcp-config".to_string(), normalized.to_string()]);
        }
    }

    let mut files = Vec::new();
    let system_prompt = bundle.prompt_surface.system_instruction.trim();
    if !system_prompt.is_empty() {
        let file = prompt_file(context, bundle.prompt_surface.system_instruction.clone())?;
        argv.extend(["--append-system-prompt-file".to_string(), file.path.clone()]);
        files.push(file);
    }

    if let Some(session_id) = context
        .session_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        argv.extend(["--resume".to_string(), session_id.to_string()]);
        if context.fork {
            argv.push("--fork-session".to_string());
        }
    }

    for root in &context.workspace_roots {
        argv.extend(["--add-dir".to_string(), root.to_string()]);
    }

    argv.extend(passthrough_tail);

    // TODO(launch-actions-parity, launch-bundle-projection): interactive prompt via stdin, not positional arg
    subprocess_actions(context, argv, files, context.prompt.clone())
}

fn claude_effort(effort: &str) -> &str {
    match effort {
        "xhigh" => "max",
        other => other,
    }
}

fn permission_tail(bundle: &LaunchBundle) -> Result<Vec<String>, MarsError> {
    match approval(bundle) {
        "yolo" | "never" => Ok(vec!["--dangerously-skip-permissions".to_string()]),
        "auto" => Ok(vec![
            "--permission-mode".to_string(),
            "acceptEdits".to_string(),
        ]),
        "confirm" => Ok(vec!["--permission-mode".to_string(), "default".to_string()]),
        "default" => Ok(Vec::new()),
        other => Err(MarsError::InvalidRequest {
            message: format!("Claude projection does not support approval mode '{other}'."),
        }),
    }
}

fn split_internal_parent_allowed_tools(extra_args: &[String]) -> (Vec<String>, Vec<String>) {
    let mut passthrough_tail = Vec::new();
    let mut parent_allowed_tools = Vec::new();
    let mut index = 0;
    while index < extra_args.len() {
        let token = &extra_args[index];
        if token == CLAUDE_PARENT_ALLOWED_TOOLS_FLAG {
            if index + 1 < extra_args.len() {
                parent_allowed_tools.extend(split_csv_entries(&extra_args[index + 1]));
                index += 2;
                continue;
            }
            index += 1;
            continue;
        }
        if let Some(value) = token.strip_prefix(&format!("{CLAUDE_PARENT_ALLOWED_TOOLS_FLAG}=")) {
            parent_allowed_tools.extend(split_csv_entries(value));
            index += 1;
            continue;
        }
        passthrough_tail.push(token.clone());
        index += 1;
    }
    (passthrough_tail, dedupe_nonempty(parent_allowed_tools))
}

fn extract_claude_tool_flags(flags: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
    let mut projected = Vec::new();
    let mut allowed = Vec::new();
    let mut disallowed = Vec::new();
    let mut index = 0;
    while index < flags.len() {
        let token = &flags[index];
        if token == "--allowedTools" {
            if index + 1 < flags.len() {
                allowed.extend(split_csv_entries(&flags[index + 1]));
                index += 2;
                continue;
            }
            index += 1;
            continue;
        }
        if let Some(value) = token.strip_prefix("--allowedTools=") {
            allowed.extend(split_csv_entries(value));
            index += 1;
            continue;
        }
        if token == "--disallowedTools" {
            if index + 1 < flags.len() {
                disallowed.extend(split_csv_entries(&flags[index + 1]));
                index += 2;
                continue;
            }
            index += 1;
            continue;
        }
        if let Some(value) = token.strip_prefix("--disallowedTools=") {
            disallowed.extend(split_csv_entries(value));
            index += 1;
            continue;
        }
        projected.push(token.clone());
        index += 1;
    }
    (
        projected,
        dedupe_nonempty(allowed),
        dedupe_nonempty(disallowed),
    )
}

fn split_csv_entries(value: &str) -> Vec<String> {
    value
        .split(',')
        .map(str::trim)
        .filter(|entry| !entry.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn dedupe_nonempty(values: Vec<String>) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for value in values {
        let normalized = value.trim();
        if normalized.is_empty() || !seen.insert(normalized.to_string()) {
            continue;
        }
        out.push(normalized.to_string());
    }
    out
}

fn is_claude_agent_tool(tool: &str) -> bool {
    tool == "Agent" || tool.starts_with("Agent(")
}

fn is_denied_tool(tool: &str, denied_tools: &[String]) -> bool {
    denied_tools
        .iter()
        .any(|denied| denied == tool || (denied == "Agent" && is_claude_agent_tool(tool)))
}