agentis-ctx 0.3.4

Fast CLI tool that generates AI-ready context from your codebase, with built-in code intelligence
Documentation
//! `ctx harness` -- Claude Code and Codex integration packaging CLI.
//!
//! Thin wrapper around the [`ctx::harness`] library: `init` scaffolds
//! generated files (local hooks or a full plugin), `compat` is the version
//! guard baked into generated hook scripts, and `doctor` reports
//! integration health.
//!
//! Exit codes: 0 = success/healthy, 1 = doctor found problems,
//! 2 = operational error, 3 = compat version mismatch (reserved for
//! `harness compat`).

use std::path::Path;

use ctx::error::{CtxError, Result};
use ctx::exit::Outcome;
use ctx::harness::{
    self,
    doctor::{is_healthy, run_doctor_checks, Severity},
    FileAction, Mode,
};

use crate::cli::{HarnessCommand, HarnessMode, HarnessTarget};

/// Run `ctx harness <SUBCOMMAND>` in the current directory.
pub fn run_harness(cmd: HarnessCommand, json: bool) -> Result<Outcome> {
    match cmd {
        HarnessCommand::Init {
            target,
            mode,
            force,
        } => {
            let root = std::env::current_dir()?;
            let target = match target {
                HarnessTarget::Claude => harness::Target::Claude,
                HarnessTarget::Codex => harness::Target::Codex,
            };
            let mode = match mode {
                HarnessMode::Local => Mode::Local,
                HarnessMode::Plugin => Mode::Plugin,
            };
            run_init(&root, target, mode, force, json)
        }
        HarnessCommand::Compat { require } => run_compat(&require),
        HarnessCommand::Doctor => {
            let root = std::env::current_dir()?;
            run_doctor(&root, json)
        }
    }
}

// ============================================================================
// init
// ============================================================================

fn run_init(
    root: &Path,
    target: harness::Target,
    mode: Mode,
    force: bool,
    json_mode: bool,
) -> Result<Outcome> {
    let plan = match (target, mode) {
        (harness::Target::Claude, Mode::Local) => harness::plan_local(root),
        (harness::Target::Claude, Mode::Plugin) => harness::plan_plugin(root),
        (harness::Target::Codex, Mode::Local) => harness::plan_codex_local(root),
        (harness::Target::Codex, Mode::Plugin) => harness::plan_codex_plugin(root),
    };
    let actions = harness::write_plan(root, &plan, force)?;

    // Human-facing status always goes to stderr so stdout stays reserved
    // for model-bound / machine-bound content (snippet, guidance, JSON).
    for (rel, action) in &actions {
        match action {
            FileAction::Created => eprintln!("  created      {rel}"),
            FileAction::Regenerated => eprintln!("  regenerated  {rel}"),
            FileAction::Overwritten => eprintln!("  overwrote    {rel} (--force)"),
            FileAction::SkippedModified => eprintln!(
                "  warning: skipped {rel}: modified since generation (use --force to overwrite)"
            ),
            FileAction::SkippedForeign => eprintln!(
                "  warning: skipped {rel}: exists but was not generated by ctx (use --force to overwrite)"
            ),
            FileAction::SkippedPolicy => eprintln!(
                "  note: skipped {rel}: never overwritten by ctx (it encodes your policy), not even with --force"
            ),
        }
    }

    let snippet = harness::render_settings_snippet();
    let guidance = match target {
        harness::Target::Claude => harness::render_claude_md_block(root),
        harness::Target::Codex => harness::render_agents_md_block(root),
    };

    // In local mode, merge the ctx hooks + permissions into
    // .claude/settings.json ourselves (additive + idempotent). Plugin mode
    // is unaffected.
    let settings_action = if mode == Mode::Local && target == harness::Target::Claude {
        Some(harness::wire_local_settings(root)?)
    } else {
        None
    };

    if json_mode {
        let files: Vec<serde_json::Value> = actions
            .iter()
            .map(|(rel, action)| serde_json::json!({ "path": rel, "action": action.as_str() }))
            .collect();
        let mut data = serde_json::json!({
            "mode": match mode { Mode::Local => "local", Mode::Plugin => "plugin" },
            "target": match target { harness::Target::Claude => "claude", harness::Target::Codex => "codex" },
            "force": force,
            "files": files,
        });
        if mode == Mode::Local {
            if target == harness::Target::Claude {
                data["settings_snippet"] = serde_json::Value::String(snippet);
                data["claude_md_block"] = serde_json::Value::String(guidance);
            } else {
                data["agents_md_block"] = serde_json::Value::String(guidance);
            }
            if let Some(action) = &settings_action {
                data["settings_action"] =
                    serde_json::Value::String(settings_action_str(action).to_string());
            }
        }
        ctx::json::emit("harness.init", data)?;
        return Ok(Outcome::Clean);
    }

    match (target, mode) {
        (harness::Target::Claude, Mode::Local) => {
            eprintln!();
            match settings_action.expect("local mode always wires settings") {
                harness::SettingsWireAction::Created => {
                    eprintln!("wired ctx hooks into .claude/settings.json");
                }
                harness::SettingsWireAction::Merged => {
                    eprintln!("merged ctx hooks into existing .claude/settings.json");
                }
                harness::SettingsWireAction::AlreadyWired => {
                    eprintln!(".claude/settings.json already wired for ctx");
                }
                harness::SettingsWireAction::SkippedInvalid => {
                    eprintln!(
                        "warning: .claude/settings.json is not valid JSON; not modified. \
                         Merge this snippet manually:"
                    );
                    eprintln!();
                    println!("{snippet}");
                }
            }
            eprintln!();
            eprintln!("Add this block to your CLAUDE.md so the model knows about ctx:");
            eprintln!();
            println!("{guidance}");
            eprintln!("Then verify the integration with 'ctx harness doctor'.");
        }
        (harness::Target::Claude, Mode::Plugin) => {
            eprintln!();
            eprintln!("Plugin scaffold ready. Install it locally with:");
            eprintln!("  claude");
            eprintln!("  /plugin marketplace add ./");
            eprintln!("  /plugin install ctx@ctx-local");
            eprintln!("See the generated README.md for the full walkthrough.");
            if !cfg!(feature = "mcp") {
                eprintln!();
                eprintln!(
                    "note: .mcp.json was not generated because this ctx build lacks the mcp \
                     feature. Install one with 'cargo install agentis-ctx --features mcp' and \
                     rerun 'ctx harness init --mode plugin' to wire the MCP server."
                );
            }
        }
        (harness::Target::Codex, Mode::Local) => {
            eprintln!();
            eprintln!("Add this block to your AGENTS.md so Codex knows about ctx:");
            eprintln!();
            println!("{guidance}");
            eprintln!("Review and trust the generated hooks with '/hooks', then run 'ctx harness doctor'.");
        }
        (harness::Target::Codex, Mode::Plugin) => {
            eprintln!();
            eprintln!("Codex plugin scaffold ready. Add its marketplace with:");
            eprintln!("  codex plugin marketplace add ./");
            eprintln!("Install the ctx plugin, then review and trust its hooks with '/hooks'.");
            if !cfg!(feature = "mcp") {
                eprintln!("note: .mcp.json was not generated because this ctx build lacks the mcp feature.");
            }
        }
    }

    Ok(Outcome::Clean)
}

/// Stable identifier for a settings-wire action, used in `--json` output.
fn settings_action_str(action: &harness::SettingsWireAction) -> &'static str {
    match action {
        harness::SettingsWireAction::Created => "created",
        harness::SettingsWireAction::Merged => "merged",
        harness::SettingsWireAction::AlreadyWired => "already_wired",
        harness::SettingsWireAction::SkippedInvalid => "skipped_invalid",
    }
}

// ============================================================================
// compat
// ============================================================================

fn run_compat(require: &str) -> Result<Outcome> {
    let current = semver::Version::parse(env!("CARGO_PKG_VERSION"))
        .map_err(|e| CtxError::Other(format!("cannot parse own version: {e}")))?;
    if ctx::harness::compat::satisfies(&current, require)? {
        Ok(Outcome::Clean)
    } else {
        // Exactly one stderr line; exit code 3 is reserved for this.
        eprintln!(
            "ctx v{current} does not satisfy the required version '{require}'; update ctx and rerun 'ctx harness init'"
        );
        Ok(Outcome::CompatMismatch)
    }
}

// ============================================================================
// doctor
// ============================================================================

fn run_doctor(root: &Path, json_mode: bool) -> Result<Outcome> {
    let findings = run_doctor_checks(root);
    let healthy = is_healthy(&findings);
    let errors = findings
        .iter()
        .filter(|f| f.severity == Severity::Error)
        .count();
    let warnings = findings
        .iter()
        .filter(|f| f.severity == Severity::Warning)
        .count();
    let info = findings.len() - errors - warnings;

    if json_mode {
        ctx::json::emit(
            "harness.doctor",
            serde_json::json!({
                "healthy": healthy,
                "binary_version": env!("CARGO_PKG_VERSION"),
                "mcp_compiled": cfg!(feature = "mcp"),
                "templates_version": env!("CARGO_PKG_VERSION"),
                "summary": { "errors": errors, "warnings": warnings, "info": info },
                "checks": findings,
            }),
        )?;
    } else {
        for finding in &findings {
            let tag = match finding.severity {
                Severity::Error => "error",
                Severity::Warning => "warning",
                Severity::Info => "info",
            };
            println!("[{tag}] {}: {}", finding.code, finding.message);
            if let Some(hint) = &finding.hint {
                println!("        hint: {hint}");
            }
        }
        println!();
        if healthy {
            println!("Harness healthy ({info} info).");
        } else {
            println!(
                "Problems found: {errors} error{}, {warnings} warning{}.",
                if errors == 1 { "" } else { "s" },
                if warnings == 1 { "" } else { "s" }
            );
        }
    }

    Ok(if healthy {
        Outcome::Clean
    } else {
        Outcome::Findings
    })
}