supercode-harness 0.4.8

The optional native Supercode agent and tool harness
Documentation
//! Claude Code project compatibility helpers.
//!
//! Claude stores named subagent definitions as Markdown files under
//! `<project>/.claude/agents/`. The body is the child's system prompt and a
//! small YAML-like frontmatter block carries its name, tool allowlist, and
//! model pin. These helpers import that durable project state without
//! starting a child or otherwise executing it.

use std::path::{Path, PathBuf};

use crate::subagents::NamedAgentDefinition;
use crate::{Config, Error, Result};

/// Claude-specific project state installed into a resumed agent config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaudeCompatibilitySnapshot {
    /// Exact global `~/.claude/CLAUDE.md` bytes, when present.
    pub global_instructions: Option<String>,
    /// Global instruction path that was checked.
    pub global_instructions_path: PathBuf,
    /// Imported project agent definitions and their exact source bytes.
    pub project_agents: Vec<ClaudeProjectAgent>,
}

/// One imported Claude project-agent file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaudeProjectAgent {
    /// Parsed definition usable by Supercode's named-subagent runtime.
    pub definition: NamedAgentDefinition,
    /// Optional human-facing description from the Claude frontmatter.
    pub description: Option<String>,
    /// Exact source path from which the definition was read.
    pub path: PathBuf,
    /// Exact Markdown source, retained for snapshot/export fidelity.
    pub raw_source: String,
    /// Original model value before alias resolution (for fidelity reporting).
    pub original_model: Option<String>,
    /// Original Claude tool names before compatibility mapping.
    pub original_tools: Option<Vec<String>>,
}

/// Discover and parse every `<cwd>/.claude/agents/*.md` definition.
///
/// Results are sorted by path. A malformed definition fails the whole import
/// instead of silently omitting a capability that the resumed session may
/// rely on. A missing agents directory is the ordinary empty result.
pub fn load_project_agents(cwd: &Path) -> Result<Vec<ClaudeProjectAgent>> {
    let dir = cwd.join(".claude/agents");
    let entries = match std::fs::read_dir(&dir) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(error) => return Err(error.into()),
    };
    let mut paths: Vec<PathBuf> = entries
        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
        .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
        .collect();
    paths.sort();
    paths
        .into_iter()
        .map(|path| {
            let source = std::fs::read_to_string(&path)?;
            parse_project_agent(&path, &source)
        })
        .collect()
}

/// Install Claude Code's non-executing compatibility state into `config`.
///
/// This imports Claude's global instructions and project named agents, then
/// enables the inert `Agent` compatibility surface. It does not spawn a child,
/// start a scheduler, or change sandbox/approval posture. The caller supplies
/// Claude's home (normally `$HOME/.claude`) explicitly so embedding/tests do
/// not depend on process-global environment mutation.
pub fn apply_resume_compatibility(
    config: &mut Config,
    claude_home: &Path,
) -> Result<ClaudeCompatibilitySnapshot> {
    // Restore Claude's scheduler-shaped tool surface over an inert manifest.
    // This only enables schemas/paused state mutation; Agent owns no timer
    // and the runtime manifest has no active execution posture.
    config.claude_runtime_tools_enabled = true;
    enable_claude_subagent_compatibility(config);
    let global_instructions_path = claude_home.join("CLAUDE.md");
    let global_instructions = match std::fs::read_to_string(&global_instructions_path) {
        Ok(source) => {
            if !source.trim().is_empty() {
                config
                    .system_prompt
                    .push_str("\n\n# Claude global CLAUDE.md\n");
                config.system_prompt.push_str(source.trim());
            }
            Some(source)
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
        Err(error) => return Err(error.into()),
    };

    let project_agents = load_project_agents(&config.cwd)?;
    for imported in &project_agents {
        config.subagents_definitions.insert(
            imported.definition.name.clone(),
            imported.definition.clone(),
        );
    }

    Ok(ClaudeCompatibilitySnapshot {
        global_instructions,
        global_instructions_path,
        project_agents,
    })
}

pub(crate) fn enable_claude_subagent_compatibility(config: &mut Config) {
    config.subagents_enabled = true;
    config.subagents_background = true;
    config.subagents_background_prompts = Some(crate::subagents::BackgroundPromptsPolicy::Parent);
    config.subagents_claude_agent_alias = true;
}

/// Parse one Claude project-agent Markdown definition.
pub fn parse_project_agent(path: &Path, source: &str) -> Result<ClaudeProjectAgent> {
    let normalized = source.replace("\r\n", "\n");
    let mut lines = normalized.lines();
    if lines.next() != Some("---") {
        return Err(agent_error(
            path,
            "missing opening `---` frontmatter delimiter",
        ));
    }

    let mut name = None;
    let mut description = None;
    let mut model = None;
    let mut tools = None;
    let mut body_start = None;
    let mut offset = 4usize; // opening `---\n`
    for line in lines {
        if line == "---" {
            body_start = Some(offset + line.len() + 1);
            break;
        }
        let Some((key, value)) = line.split_once(':') else {
            return Err(agent_error(
                path,
                format!("invalid frontmatter line `{line}`"),
            ));
        };
        let value = unquote(value.trim());
        match key.trim() {
            "name" => name = nonempty(value),
            "description" => description = nonempty(value),
            "model" => model = nonempty(value),
            "tools" => tools = Some(parse_tool_list(value)),
            _ => {}
        }
        offset += line.len() + 1;
    }
    let Some(body_start) = body_start else {
        return Err(agent_error(
            path,
            "missing closing `---` frontmatter delimiter",
        ));
    };
    let body = normalized[body_start..].trim().to_string();
    if body.is_empty() {
        return Err(agent_error(path, "agent system-prompt body is empty"));
    }
    let name = name
        .or_else(|| {
            path.file_stem()
                .and_then(|stem| stem.to_str())
                .map(str::to_string)
        })
        .filter(|name| !name.trim().is_empty())
        .ok_or_else(|| agent_error(path, "agent name is empty"))?;
    let mapped_tools = tools
        .as_ref()
        .map(|tools| tools.iter().map(|tool| map_claude_tool(tool)).collect());
    let mapped_model = model
        .as_deref()
        .map(|model| crate::model_catalog::resolve_alias(model, &[]));

    Ok(ClaudeProjectAgent {
        definition: NamedAgentDefinition {
            name,
            system_prompt: body,
            tools: mapped_tools,
            model: mapped_model,
        },
        description,
        path: path.to_path_buf(),
        raw_source: source.to_string(),
        original_model: model,
        original_tools: tools,
    })
}

fn parse_tool_list(value: &str) -> Vec<String> {
    let value = value
        .strip_prefix('[')
        .and_then(|value| value.strip_suffix(']'))
        .unwrap_or(value);
    value
        .split(',')
        .map(|tool| unquote(tool.trim()))
        .filter(|tool| !tool.is_empty())
        .map(str::to_string)
        .collect()
}

fn map_claude_tool(tool: &str) -> String {
    match tool {
        "Bash" => "bash",
        "Read" => "read_file",
        "Write" => "write_file",
        "Edit" => "edit_file",
        "Glob" => "glob",
        "Grep" => "search",
        "Agent" | "Task" => "spawn_subagent",
        other => other,
    }
    .to_string()
}

fn nonempty(value: &str) -> Option<String> {
    (!value.is_empty()).then(|| value.to_string())
}

fn unquote(value: &str) -> &str {
    value
        .strip_prefix('"')
        .and_then(|value| value.strip_suffix('"'))
        .or_else(|| {
            value
                .strip_prefix('\'')
                .and_then(|value| value.strip_suffix('\''))
        })
        .unwrap_or(value)
}

fn agent_error(path: &Path, message: impl std::fmt::Display) -> Error {
    Error::Other(format!(
        "invalid Claude project agent `{}`: {message}",
        path.display()
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_realistic_agent_and_maps_tools_and_model_without_losing_source() {
        let source = "---\nname: pilot-tick\ndescription: Sweep the fleet\ntools: Bash, Read, Write, Edit\nmodel: sonnet\n---\nYou are the pilot.\n\nFollow the drill exactly.\n";
        let parsed = parse_project_agent(Path::new(".claude/agents/pilot-tick.md"), source)
            .expect("definition parses");
        assert_eq!(parsed.definition.name, "pilot-tick");
        assert_eq!(parsed.description.as_deref(), Some("Sweep the fleet"));
        assert_eq!(parsed.original_model.as_deref(), Some("sonnet"));
        assert_eq!(
            parsed.definition.model.as_deref(),
            Some("anthropic/claude-sonnet-4-6")
        );
        assert_eq!(
            parsed.definition.tools.as_deref(),
            Some(
                ["bash", "read_file", "write_file", "edit_file"]
                    .map(str::to_string)
                    .as_slice()
            )
        );
        assert_eq!(
            parsed.definition.system_prompt,
            "You are the pilot.\n\nFollow the drill exactly."
        );
        assert_eq!(parsed.raw_source, source);
    }

    #[test]
    fn filename_supplies_name_and_bracketed_tools_are_supported() {
        let parsed = parse_project_agent(
            Path::new("reviewer.md"),
            "---\ntools: [Read, Grep, Agent]\n---\nReview carefully.\n",
        )
        .unwrap();
        assert_eq!(parsed.definition.name, "reviewer");
        assert_eq!(
            parsed.definition.tools.unwrap(),
            ["read_file", "search", "spawn_subagent"].map(str::to_string)
        );
    }

    #[test]
    fn malformed_definition_fails_loudly() {
        let error = parse_project_agent(Path::new("broken.md"), "No frontmatter")
            .expect_err("must reject malformed agent");
        assert!(error.to_string().contains("missing opening"));
    }

    #[test]
    fn compatibility_install_adds_global_and_named_agent_without_executing_it() {
        let nonce = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let root = std::env::temp_dir().join(format!(
            "supercode-claude-compat-{}-{nonce}",
            std::process::id()
        ));
        let project = root.join("project");
        let claude_home = root.join(".claude");
        std::fs::create_dir_all(project.join(".claude/agents")).unwrap();
        std::fs::create_dir_all(&claude_home).unwrap();
        std::fs::write(claude_home.join("CLAUDE.md"), "GLOBAL CLAUDE RULE").unwrap();
        std::fs::write(
            project.join(".claude/agents/pilot-tick.md"),
            "---\nname: pilot-tick\ntools: Bash, Read\nmodel: sonnet\n---\nPilot exactly.\n",
        )
        .unwrap();

        let mut config = Config::builder().cwd(&project).build();
        let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
        assert!(config.system_prompt.contains("GLOBAL CLAUDE RULE"));
        assert!(config.subagents_enabled);
        assert!(config.subagents_background);
        assert!(config.subagents_claude_agent_alias);
        assert!(config.claude_runtime_tools_enabled);
        assert_eq!(snapshot.project_agents.len(), 1);
        assert_eq!(
            config
                .subagents_definitions
                .get("pilot-tick")
                .and_then(|definition| definition.model.as_deref()),
            Some("anthropic/claude-sonnet-4-6")
        );
        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn compatibility_always_installs_claudes_builtin_general_purpose_agent() {
        let nonce = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let root = std::env::temp_dir().join(format!(
            "supercode-claude-built-in-{}-{nonce}",
            std::process::id()
        ));
        let project = root.join("project");
        let claude_home = root.join(".claude");
        std::fs::create_dir_all(&project).unwrap();
        std::fs::create_dir_all(&claude_home).unwrap();

        let mut config = Config::builder().cwd(&project).build();
        let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
        assert!(snapshot.project_agents.is_empty());
        assert!(config.subagents_enabled);
        assert!(config.subagents_background);
        assert_eq!(
            config.subagents_background_prompts,
            Some(crate::subagents::BackgroundPromptsPolicy::Parent)
        );
        assert!(config.subagents_claude_agent_alias);
        std::fs::remove_dir_all(root).ok();
    }
}