patchloom 0.37.6

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
pub mod agent_packaging;
pub mod agent_rules;
pub mod append;
pub mod apply_fragment;
#[cfg(feature = "ast")]
pub mod ast;
pub mod batch;
pub mod create;
pub mod delete;
pub mod doc;
pub mod explain;
pub mod init;
pub mod list_files;
#[cfg(feature = "mcp")]
pub mod mcp;
pub mod md;
pub mod output;
pub mod patch;
pub mod prepend;
pub mod read;
pub mod rename;
pub mod replace;
pub mod schema;
pub mod search;
pub mod status;
pub mod tidy;
pub mod tx;
pub mod undo;
pub mod write_dispatch;
pub mod write_mode;
pub mod write_targets;

use crate::cli::Cli;
use clap::Subcommand;

#[derive(Debug, Subcommand)]
pub enum Command {
    // -- File Operations (display_order 10-19) --
    /// Append content to an existing file.
    #[command(display_order = 10)]
    Append(append::AppendArgs),
    /// Create a new file with specified content.
    #[command(display_order = 11)]
    Create(create::CreateArgs),
    /// Delete a file.
    #[command(display_order = 12)]
    Delete(delete::DeleteArgs),
    /// Prepend content to the beginning of an existing file.
    #[command(display_order = 11)]
    Prepend(prepend::PrependArgs),
    /// Read file contents with optional line range.
    #[command(display_order = 13)]
    Read(read::ReadArgs),
    /// Rename (move) a file.
    #[command(display_order = 14)]
    Rename(rename::RenameArgs),
    /// List files under the workspace with the same ignore/exclude/glob rules as search.
    #[command(display_order = 15, name = "list-files")]
    ListFiles(list_files::ListFilesArgs),

    // -- Text Operations (display_order 20-29) --
    /// Fast literal or regex search across text files.
    #[command(display_order = 20)]
    Search(search::SearchArgs),
    /// Mechanical string replacement across text files with diff preview.
    #[command(display_order = 21)]
    Replace(replace::ReplaceArgs),
    /// Constrained freeform fragment apply (Morph-style markers; anchors required).
    #[command(display_order = 22, name = "apply-fragment")]
    ApplyFragment(apply_fragment::ApplyFragmentArgs),
    /// Preview or apply unified diffs safely.
    #[command(display_order = 23)]
    Patch(patch::PatchArgs),
    /// Text-file newline, line ending, and whitespace normalization.
    #[command(display_order = 23)]
    Tidy(tidy::TidyArgs),
    /// Execute multiple operations from a simple line-oriented format.
    #[command(display_order = 24)]
    Batch(batch::BatchArgs),

    // -- Structured Data (display_order 30-39) --
    /// Parser-backed JSON, YAML, and TOML operations.
    #[command(display_order = 30)]
    Doc(doc::DocArgs),
    /// Markdown section-aware operations.
    #[command(display_order = 31)]
    Md(md::MdArgs),

    // -- AST Operations (display_order 40-49) --
    /// AST-aware operations: list, read, rename, validate, search, refs, deps, map, replace, impact, diff.
    #[cfg(feature = "ast")]
    #[command(display_order = 40)]
    Ast(ast::AstArgs),

    // -- Automation (display_order 50-69) --
    /// Execute a multi-operation plan atomically.
    #[command(display_order = 50)]
    Tx(crate::tx::TxArgs),
    /// Explain a tx plan in plain English.
    #[command(display_order = 51)]
    Explain(explain::ExplainArgs),
    /// Restore files from a backup created by --apply.
    #[command(display_order = 52)]
    Undo(undo::UndoArgs),
    /// Show which files have uncommitted changes.
    #[command(display_order = 53)]
    Status(status::StatusArgs),
    /// Export operation schemas, tier-filtered listings, or system prompt fragments.
    #[command(display_order = 54)]
    Schema(schema::SchemaArgs),
    /// Print agent rules for using patchloom (AGENTS.md content for end users).
    #[command(display_order = 55)]
    AgentRules(agent_rules::AgentRulesArgs),
    /// Set up patchloom in the current project.
    #[command(display_order = 56)]
    Init(init::InitArgs),
    /// Start an MCP (Model Context Protocol) server (stdio by default, Streamable HTTP with --http).
    #[cfg(feature = "mcp")]
    #[command(
        display_order = 57,
        after_help = "\
ENVIRONMENT:
  PATCHLOOM_MCP_SURFACE=core|full
          Tool inventory at handshake (env only; no CLI flag). Unset or `full`
          keeps the full inventory (product default). `core` registers the
          12-tool pack for small agents. See README.md (MCP / coding agents)."
    )]
    McpServer {
        /// Log every tool call to a JSONL file (tool name, duration, status).
        /// Also settable via PATCHLOOM_MCP_LOG env var; the flag takes precedence.
        #[arg(long)]
        log: Option<String>,

        /// Use Streamable HTTP transport instead of stdio.
        #[cfg(feature = "mcp-http")]
        #[arg(long)]
        http: bool,

        /// Bind address (requires --http).
        #[cfg(feature = "mcp-http")]
        #[arg(long, default_value = "127.0.0.1", requires = "http")]
        host: String,

        /// Bind port (requires --http).
        #[cfg(feature = "mcp-http")]
        #[arg(long, default_value_t = 8080, requires = "http")]
        port: u16,

        /// TLS certificate PEM file; enables HTTPS (requires --http and --tls-key).
        #[cfg(feature = "mcp-http")]
        #[arg(long, requires_all = ["http", "tls_key"])]
        tls_cert: Option<std::path::PathBuf>,

        /// TLS private key PEM file (requires --http and --tls-cert).
        #[cfg(feature = "mcp-http")]
        #[arg(long, requires_all = ["http", "tls_cert"])]
        tls_key: Option<std::path::PathBuf>,

        /// Permit Streamable HTTP on a non-loopback bind. HTTP has no authentication.
        #[cfg(feature = "mcp-http")]
        #[arg(long, requires = "http")]
        allow_unauthenticated: bool,

        /// Additional Host header name to accept (requires --http). Repeatable.
        #[cfg(feature = "mcp-http")]
        #[arg(long, requires = "http", action = clap::ArgAction::Append, value_name = "NAME")]
        allowed_host: Vec<String>,
    },
    /// Generate shell completions for bash, zsh, fish, or elvish.
    #[command(display_order = 58)]
    Completions {
        /// Shell to generate completions for.
        #[arg(value_enum)]
        shell: clap_complete::Shell,
    },
}

/// Generate completions to a buffer, then write stdout. Broken pipe
/// (`completions zsh | head`) is success, not a clap_complete panic.
fn emit_completions(shell: clap_complete::Shell) -> anyhow::Result<u8> {
    let mut cmd = <Cli as clap::CommandFactory>::command();
    let mut buf = Vec::new();
    clap_complete::generate(shell, &mut cmd, "patchloom", &mut buf);
    crate::json_emit::write_stdout_ignore_epipe(&buf, false)?;
    Ok(crate::exit::SUCCESS)
}

/// Load and apply project config from `.patchloom.toml`.
fn load_project_config(global: &mut crate::cli::global::GlobalFlags) -> anyhow::Result<()> {
    let Ok(cwd) = global.resolve_cwd() else {
        return Ok(());
    };
    if let Some((config, _)) = crate::config::find_and_load_strict(&cwd)? {
        crate::config::apply_config(global, &config);
    }
    Ok(())
}

pub fn dispatch(cli: Cli) -> anyhow::Result<u8> {
    let mut global = cli.global;

    // Load config early for read-only commands (no merge_write).
    // Write commands call load_project_config after merge_write.
    match cli.command {
        #[cfg(feature = "mcp")]
        Command::McpServer {
            log,
            #[cfg(feature = "mcp-http")]
            http,
            #[cfg(feature = "mcp-http")]
            host,
            #[cfg(feature = "mcp-http")]
            port,
            #[cfg(feature = "mcp-http")]
            tls_cert,
            #[cfg(feature = "mcp-http")]
            tls_key,
            #[cfg(feature = "mcp-http")]
            allow_unauthenticated,
            #[cfg(feature = "mcp-http")]
            allowed_host,
        } => {
            load_project_config(&mut global)?;
            #[cfg(feature = "mcp-http")]
            if http {
                return mcp::run_mcp_http_server(
                    &global,
                    log,
                    mcp::McpHttpListen {
                        host: &host,
                        port,
                        tls_cert: tls_cert.as_deref(),
                        tls_key: tls_key.as_deref(),
                        allow_unauthenticated,
                        allowed_hosts: &allowed_host,
                    },
                );
            }
            mcp::run_mcp_server(&global, log)
        }
        Command::Schema(args) => schema::run(args, &global),
        Command::AgentRules(args) => agent_rules::run(args, &global),
        Command::Init(args) => init::run(args, &global),
        Command::Completions { shell } => {
            if global.json || global.jsonl {
                global.emit_error_json_kind(
                    Some("invalid_input"),
                    "completions does not support --json/--jsonl; omit those flags to print a shell script",
                )?;
                return Ok(crate::exit::FAILURE);
            }
            emit_completions(shell)
        }
        Command::Read(args) => {
            load_project_config(&mut global)?;
            read::run(args, &global)
        }
        Command::Explain(args) => {
            load_project_config(&mut global)?;
            explain::run(args, &global)
        }
        Command::Undo(args) => {
            load_project_config(&mut global)?;
            undo::run(args, &global)
        }
        Command::Search(args) => {
            load_project_config(&mut global)?;
            search::run(args, &global)
        }
        Command::Status(args) => {
            load_project_config(&mut global)?;
            status::run(args, &global)
        }
        Command::ListFiles(args) => {
            load_project_config(&mut global)?;
            list_files::run(args, &global)
        }
        Command::Append(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            append::run(args, &global)
        }
        Command::Prepend(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            prepend::run(args, &global)
        }
        Command::Create(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            create::run(args, &global)
        }
        Command::Delete(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            delete::run(args, &global)
        }
        Command::Rename(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            rename::run(args, &global)
        }
        Command::Replace(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            replace::run(args, &global)
        }
        Command::ApplyFragment(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            apply_fragment::run(args, &global)
        }
        Command::Patch(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            patch::run(args, &global)
        }
        Command::Md(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            md::run(args, &global)
        }
        Command::Doc(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            doc::run(args, &global)
        }
        Command::Tidy(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            tidy::run(args, &global)
        }
        Command::Tx(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            tx::run(args, &global)
        }
        Command::Batch(args) => {
            global.merge_write(&args.write);
            load_project_config(&mut global)?;
            batch::run(args, &global)
        }
        #[cfg(feature = "ast")]
        Command::Ast(args) => {
            // ast write subcommands merge WriteFlags into the global apply/check/diff matrix
            match args.command {
                ast::AstCommand::Rename(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Replace(ref a) => global.merge_write(&a.write),
                ast::AstCommand::ReplaceSymbol(ref a) => global.merge_write(&a.write),
                ast::AstCommand::DeleteSymbol(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Insert(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Wrap(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Imports(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Reorder(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Group(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Move(ref a) => global.merge_write(&a.write),
                ast::AstCommand::ExtractToFile(ref a) => global.merge_write(&a.write),
                ast::AstCommand::Split(ref a) => global.merge_write(&a.write),
                ast::AstCommand::RewriteSignature(ref a) => global.merge_write(&a.write),
                _ => {}
            }
            load_project_config(&mut global)?;
            ast::run(args, &global)
        }
    }
}