use std::path::PathBuf;
use std::process::{Command as ProcessCommand, ExitCode};
use clap::{Parser, Subcommand};
use musts_core::bootstrap::StateSession;
use musts_core::evidence::{submit, EvidenceSubmissionResult};
use musts_core::extension::runtime::RuntimeOptions;
use musts_core::lint;
use musts_core::manifest::discover as discover_manifests;
use musts_core::report::{render_json, render_text, ValidateReport};
use musts_core::run::RunOutcome;
use musts_core::stats;
use musts_core::validate::{self, ValidateOptions};
use musts_core::workspace;
use musts_core::Error;
#[derive(Debug, Parser)]
#[command(name = "musts", version, about = "Agent-first validation loop", long_about = None)]
struct Cli {
#[arg(long, global = true)]
workspace: Option<PathBuf>,
#[arg(long, global = true)]
log: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Validate {
#[arg(long)]
json: bool,
},
Run {
task_id: String,
},
Evidence {
task_id: String,
#[arg(long)]
text: Option<String>,
#[arg(long = "asset", value_name = "PATH")]
assets: Vec<PathBuf>,
},
Lint {
#[arg(long)]
json: bool,
},
Stats {
#[arg(long)]
json: bool,
},
Skill {
#[command(subcommand)]
command: SkillCommand,
},
}
#[derive(Debug, Subcommand)]
enum SkillCommand {
Install {
#[arg(long)]
agent: Option<String>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
init_logging(cli.log.as_deref());
match run(&cli) {
Ok(code) => code,
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::from(70)
}
}
}
fn run(cli: &Cli) -> anyhow::Result<ExitCode> {
match &cli.command {
Command::Validate { json } => validate_command(cli.workspace.as_deref(), *json),
Command::Run { task_id } => run_command(cli.workspace.as_deref(), task_id),
Command::Evidence {
task_id,
text,
assets,
} => evidence_command(cli.workspace.as_deref(), task_id, text.as_deref(), assets),
Command::Lint { json } => lint_command(cli.workspace.as_deref(), *json),
Command::Stats { json } => stats_command(cli.workspace.as_deref(), *json),
Command::Skill { command } => match command {
SkillCommand::Install { agent } => skill_install(agent.as_deref()),
},
}
}
fn validate_command(
explicit_workspace: Option<&std::path::Path>,
json: bool,
) -> anyhow::Result<ExitCode> {
let cwd = std::env::current_dir()?;
let root = match workspace::resolve(explicit_workspace, &cwd) {
Ok(root) => root,
Err(err) => return Ok(report_error(err)),
};
let manifests = match discover_manifests(&root) {
Ok(m) => m,
Err(err) => return Ok(report_error(err)),
};
if manifests.is_empty() {
let report = ValidateReport {
workspace_root: root.display().to_string(),
tasks: vec![],
ignored_checks: vec![],
notes: vec![],
warnings: vec![],
repeated_task_ids: vec![],
};
if json {
println!("{}", serde_json::to_string_pretty(&render_json(&report))?);
} else {
println!("Musts validation clean. No MUSTS.yml files found.");
}
return Ok(ExitCode::from(0));
}
let mut session = match StateSession::acquire(&root) {
Ok(s) => s,
Err(err) => return Ok(report_error(err)),
};
let options = ValidateOptions {
workspace_root: root.clone(),
runtime_options: RuntimeOptions::from_env(root.clone()),
};
let report = match validate::run(&mut session, &options) {
Ok(r) => r,
Err(err) => return Ok(report_error(err)),
};
if json {
println!("{}", serde_json::to_string_pretty(&render_json(&report))?);
} else {
print!("{}", render_text(&report));
}
if report.is_clean() {
Ok(ExitCode::from(0))
} else {
Ok(ExitCode::from(1))
}
}
fn lint_command(
explicit_workspace: Option<&std::path::Path>,
json: bool,
) -> anyhow::Result<ExitCode> {
let cwd = std::env::current_dir()?;
let root = match workspace::resolve(explicit_workspace, &cwd) {
Ok(r) => r,
Err(err) => return Ok(report_error(err)),
};
let report = match lint::run(&root) {
Ok(r) => r,
Err(err) => return Ok(report_error(err)),
};
if json {
println!("{}", lint::render_json(&report));
} else {
print!("{}", lint::render_text(&report));
}
Ok(ExitCode::from(u8::from(report.has_errors())))
}
fn stats_command(
explicit_workspace: Option<&std::path::Path>,
json: bool,
) -> anyhow::Result<ExitCode> {
let cwd = std::env::current_dir()?;
let root = match workspace::resolve(explicit_workspace, &cwd) {
Ok(r) => r,
Err(err) => return Ok(report_error(err)),
};
let declared = match stats::declared_checks(&root) {
Ok(d) => d,
Err(err) => return Ok(report_error(err)),
};
let report = match stats::collect(&root, &root.join(".musts"), &declared) {
Ok(r) => r,
Err(err) => return Ok(report_error(err)),
};
if json {
println!("{}", stats::render_json(&report));
} else {
print!("{}", stats::render_text(&report));
}
Ok(ExitCode::from(0))
}
fn evidence_command(
explicit_workspace: Option<&std::path::Path>,
task_id: &str,
text: Option<&str>,
assets: &[PathBuf],
) -> anyhow::Result<ExitCode> {
let cwd = std::env::current_dir()?;
let root = match workspace::resolve(explicit_workspace, &cwd) {
Ok(r) => r,
Err(err) => return Ok(report_error(err)),
};
let mut session = match StateSession::acquire(&root) {
Ok(s) => s,
Err(err) => return Ok(report_error(err)),
};
let runtime_options = RuntimeOptions::from_env(root.clone());
let asset_refs: Vec<&std::path::Path> = assets.iter().map(|p| p.as_path()).collect();
let inputs = musts_core::evidence::submit::SubmissionInputs {
task_id,
text,
asset_paths: &asset_refs,
};
match submit(&mut session, &root, &runtime_options, &inputs) {
Ok(result) => {
print_evidence_result(&result);
Ok(ExitCode::from(0))
}
Err(err) => Ok(report_error(err)),
}
}
fn run_command(
explicit_workspace: Option<&std::path::Path>,
task_id: &str,
) -> anyhow::Result<ExitCode> {
let cwd = std::env::current_dir()?;
let root = match workspace::resolve(explicit_workspace, &cwd) {
Ok(r) => r,
Err(err) => return Ok(report_error(err)),
};
let mut session = match StateSession::acquire(&root) {
Ok(s) => s,
Err(err) => return Ok(report_error(err)),
};
let runtime_options = RuntimeOptions::from_env(root.clone());
match musts_core::run::execute(&mut session, &root, &runtime_options, task_id) {
Ok(RunOutcome::NotRunnable { reason }) => {
eprintln!("error: {reason}");
Ok(ExitCode::from(2))
}
Ok(RunOutcome::Failed {
command,
code,
output,
log_path,
}) => {
print!("{output}");
if !output.ends_with('\n') {
println!();
}
let code_str = code
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());
eprintln!(
"`{command}` failed (exit {code_str}). Full log: {}. Fix it and re-run `musts run {task_id}`.",
log_path.display()
);
Ok(ExitCode::from(1))
}
Ok(RunOutcome::Recorded { command, result }) => {
println!("`{command}` exited 0.");
print_evidence_result(&result);
Ok(ExitCode::from(0))
}
Err(err) => Ok(report_error(err)),
}
}
fn print_evidence_result(result: &EvidenceSubmissionResult) {
println!(
"Evidence accepted for `{}` (submission {}).",
result.task_id, result.submission_id
);
if !result.satisfied.is_empty() {
println!("Satisfied:");
for s in &result.satisfied {
println!(" - {s}");
}
}
if let Some(summary) = &result.summary {
println!("Summary: {summary}");
}
println!("\nRun `musts validate` again to confirm the report is now clean.");
}
fn skill_install(agent: Option<&str>) -> anyhow::Result<ExitCode> {
let mut args: Vec<String> = vec![
"--yes".to_string(),
"skills".to_string(),
"add".to_string(),
"bitomule/musts".to_string(),
"--skill".to_string(),
"musts".to_string(),
"--global".to_string(),
"--yes".to_string(),
];
if let Some(agent) = agent {
args.push("--agent".to_string());
args.push(agent.to_string());
}
eprintln!("musts: running `npx {}`", args.join(" "));
let status = match ProcessCommand::new("npx").args(&args).status() {
Ok(status) => status,
Err(err) => {
eprintln!(
"error: failed to spawn `npx`: {err}\n\
hint: install Node.js so `npx` is on PATH, then rerun `musts skill install`."
);
return Ok(ExitCode::from(1));
}
};
if status.success() {
eprintln!("musts: skill installed globally.");
return Ok(ExitCode::from(0));
}
let code = status.code().unwrap_or(1) as u8;
eprintln!(
"error: `npx skills add` exited with code {code}.\n\
hint: rerun `npx {}` to retry.",
args.join(" ")
);
Ok(ExitCode::from(code))
}
fn report_error(err: Error) -> ExitCode {
let exit = err.exit_code();
eprintln!("error: {err}");
ExitCode::from(exit as u8)
}
fn init_logging(filter: Option<&str>) {
let env = filter.unwrap_or("warn");
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(env))
.with_writer(std::io::stderr)
.try_init();
}