murk-cli 0.10.2

Encrypted secrets manager for developers — one file, age encryption, git-friendly
Documentation
//! Dev-only tool: render generated documentation artifacts from the clap model
//! (and a small doc-only registry) so the documented surface cannot drift from
//! the binary.
//!
//! Generates:
//!   - docs/cli-reference.md   — the command reference, from the clap model.
//!   - docs/env-reference.md   — the environment-variable reference.
//!
//! Regenerate:  `cargo run --features doc-gen --bin gen-docs`
//! Check (CI):  `cargo run --features doc-gen --bin gen-docs -- --check`
//!
//! Gated behind the `doc-gen` feature; the shipped `murk` binary never links it.

use std::path::PathBuf;
use std::process::ExitCode;

use clap::{CommandFactory, ValueEnum};
use clap_complete::{Generator, Shell};
use murk_cli::cli::Cli;

fn manifest_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

/// The clap command with the version stripped, so generated artifacts stay
/// stable across release bumps: the version lives in Cargo.toml (guarded by
/// check-versions.cjs). Coupling generated docs to it would churn every bump.
fn base_command() -> clap::Command {
    Cli::command().version(None::<&'static str>)
}

/// Prepended so readers know a file is generated and CI enforces it.
const BANNER: &str = "<!-- Generated by \
`cargo run --features doc-gen --bin gen-docs` from the clap model and doc-only \
registry. Do not edit by hand; CI checks it. -->\n\n";

/// The CLI command reference, rendered from the clap model.
fn cli_reference() -> (PathBuf, String) {
    let cmd = base_command();
    let options = clap_markdown::MarkdownOptions::new()
        .title("murk command reference".to_string())
        .show_footer(false)
        .show_table_of_contents(true);
    let body = clap_markdown::help_markdown_command_custom(&cmd, &options);
    (
        manifest_dir().join("docs/cli-reference.md"),
        format!("{BANNER}{body}"),
    )
}

/// One documented environment variable.
struct EnvVar {
    name: &'static str,
    summary: &'static str,
}

/// Doc-only registry of the environment variables murk honors.
///
/// clap only annotates `MURK_VAULT` (via `#[arg(env = ...)]`); every other
/// variable is read ad hoc in `src/env.rs`, `src/hardening.rs`, and
/// `src/pins.rs`, so a complete reference needs this list. Names mirror the
/// `ENV_*` constants in `src/env.rs` and the reads in those modules — keep it in
/// sync when env handling changes (CI `--check` fails until
/// `docs/env-reference.md` is regenerated). Summaries avoid literal `|` so they
/// stay valid inside the Markdown table.
const ENV_GROUPS: &[(&str, &[EnvVar])] = &[
    (
        "Identity and vault selection",
        &[
            EnvVar {
                name: "MURK_KEY",
                summary: "Your raw age private key (`AGE-SECRET-KEY-1…`), inline. Rejected for hardware-plugin identity strings (`AGE-PLUGIN-…`) — use `MURK_KEY_FILE` for those.",
            },
            EnvVar {
                name: "MURK_KEY_FILE",
                summary: "Path to a private-key file: a raw age key, an SSH PEM key, or an age plugin identity file. `murk init` writes this reference into `.env`.",
            },
            EnvVar {
                name: "MURK_VAULT",
                summary: "Vault filename, defaulting to `.murk`. Equivalent to passing `--vault` on every command (the one variable in the clap model).",
            },
        ],
    ),
    (
        "Safety and agent context",
        &[
            EnvVar {
                name: "MURK_STRICT",
                summary: "Truthy fails closed rather than let a secret touch disk, and disables the automatic key lookup under `~/.config/murk/keys`.",
            },
            EnvVar {
                name: "MURK_AGENT",
                summary: "Marks the process as running for an AI agent. Forces strict mode unconditionally; `murk agent exec` sets it on its child.",
            },
            EnvVar {
                name: "MURK_SELF_SCOPE",
                summary: "Truthy holds your own key to the vault's agent allow-tag policy, as if you were an agent. Implied inside an agent context.",
            },
            EnvVar {
                name: "MURK_NO_SIGNER_PIN",
                summary: "Opts out of signer-registry pinning (TOFU) on load — an escape hatch for a deliberate signer-key change, not for everyday use.",
            },
        ],
    ),
    (
        "Also honored",
        &[
            EnvVar {
                name: "CI",
                summary: "Truthy prints a one-line nudge toward the scoped-agent path when a pipeline decrypts with a personal key. Advisory only.",
            },
            EnvVar {
                name: "EDITOR, VISUAL",
                summary: "Editor launched by `murk edit`, checking `EDITOR` then `VISUAL`, falling back to `vi`.",
            },
            EnvVar {
                name: "XDG_RUNTIME_DIR",
                summary: "Preferred (typically tmpfs) scratch location for `murk edit`'s temporary file, over `/tmp`.",
            },
            EnvVar {
                name: "HOME (USERPROFILE on Windows)",
                summary: "Base directory for murk state: `~/.config/murk/keys`, `agent-keys`, and `signer-pins`.",
            },
        ],
    ),
];

/// The environment-variable reference, rendered from the doc-only registry.
fn env_reference() -> (PathBuf, String) {
    let mut body = String::from(
        "# murk environment variable reference\n\n\
         The environment variables murk reads, and the CLI flags they mirror \
         where one exists. This is the terse reference; the narrative version \
         with resolution order and interactions lives in the Environment \
         variables concept page.\n",
    );
    for (group, vars) in ENV_GROUPS {
        body.push_str(&format!(
            "\n## {group}\n\n| Variable | Description |\n| --- | --- |\n"
        ));
        for v in *vars {
            body.push_str(&format!("| `{}` | {} |\n", v.name, v.summary));
        }
    }
    (
        manifest_dir().join("docs/env-reference.md"),
        format!("{BANNER}{body}"),
    )
}

/// Shell completion scripts for every shell clap_complete supports.
fn completions() -> Vec<(PathBuf, String)> {
    let mut out = Vec::new();
    for shell in Shell::value_variants() {
        let mut cmd = base_command();
        let mut buf = Vec::new();
        clap_complete::generate(*shell, &mut cmd, "murk", &mut buf);
        let file = manifest_dir()
            .join("docs/completions")
            .join(shell.file_name("murk"));
        out.push((file, String::from_utf8(buf).expect("utf8 completion")));
    }
    out
}

/// A man page (roff) for the root command and every visible subcommand.
fn man_pages() -> Vec<(PathBuf, String)> {
    let mut out = Vec::new();
    collect_man(&base_command(), &["murk"], &mut out);
    out
}

fn collect_man(cmd: &clap::Command, path: &[&str], out: &mut Vec<(PathBuf, String)>) {
    // `bin_name` (a runtime String) drives the SYNOPSIS, so it shows the real
    // spaced invocation ("murk agent plan"); the filename uses the git-man
    // dashed convention ("murk-agent-plan.1"). Both come from the command path.
    let render_cmd = cmd.clone().bin_name(path.join(" "));
    let mut buf = Vec::new();
    clap_mangen::Man::new(render_cmd)
        .render(&mut buf)
        .expect("render man page");
    out.push((
        manifest_dir()
            .join("docs/man")
            .join(format!("{}.1", path.join("-"))),
        String::from_utf8(buf).expect("utf8 man page"),
    ));
    for sub in cmd.get_subcommands() {
        // Skip hidden subcommands (e.g. deprecated aliases): not part of the
        // documented surface.
        if sub.is_hide_set() {
            continue;
        }
        let mut child = path.to_vec();
        child.push(sub.get_name());
        collect_man(sub, &child, out);
    }
}

/// Directories whose entire file set is generated. On regen they are wiped and
/// rebuilt; on --check an unexpected file in them counts as stale, so a renamed
/// or removed command can't leave an orphan artifact behind.
const MANAGED_DIRS: &[&str] = &["docs/completions", "docs/man"];

/// Every generated artifact: (path, expected content).
fn artifacts() -> Vec<(PathBuf, String)> {
    let mut a = vec![cli_reference(), env_reference()];
    a.extend(completions());
    a.extend(man_pages());
    a
}

fn main() -> ExitCode {
    let check = std::env::args().any(|a| a == "--check");
    let all = artifacts();
    let expected: std::collections::HashSet<PathBuf> = all.iter().map(|(p, _)| p.clone()).collect();
    let mut stale = Vec::new();

    // Wipe managed dirs so a removed/renamed artifact doesn't linger on regen.
    if !check {
        for d in MANAGED_DIRS {
            let dir = manifest_dir().join(d);
            if dir.exists() {
                std::fs::remove_dir_all(&dir)
                    .unwrap_or_else(|e| panic!("clear {}: {e}", dir.display()));
            }
        }
    }

    for (path, generated) in &all {
        if check {
            let current = std::fs::read_to_string(path).unwrap_or_default();
            if current != *generated {
                stale.push(path.clone());
            }
        } else {
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent).expect("create docs directory");
            }
            std::fs::write(path, generated)
                .unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
            eprintln!("wrote {}", path.display());
        }
    }

    if check {
        // Flag orphans: files in a managed dir that no artifact accounts for.
        for d in MANAGED_DIRS {
            if let Ok(entries) = std::fs::read_dir(manifest_dir().join(d)) {
                for entry in entries.flatten() {
                    let p = entry.path();
                    if p.is_file() && !expected.contains(&p) {
                        stale.push(p);
                    }
                }
            }
        }
        if stale.is_empty() {
            eprintln!("generated docs are up to date.");
            return ExitCode::SUCCESS;
        }
        stale.sort();
        stale.dedup();
        for p in &stale {
            eprintln!("out of date: {}", p.display());
        }
        eprintln!("Regenerate with: cargo run --features doc-gen --bin gen-docs");
        return ExitCode::FAILURE;
    }

    ExitCode::SUCCESS
}