Skip to main content

balise_cli/
lib.rs

1//! The Balise CLI of one version, run through the `balise` launcher.
2//!
3//! Each command is declared once in [`COMMANDS`]: its options, and a function that returns
4//! data without printing anything. The terminal shows the data as compact text, `--json` as
5//! JSON; the MCP server will serve the same commands as tools.
6
7pub mod args;
8mod ask;
9mod commands;
10pub mod dev;
11mod project;
12pub mod style;
13mod template;
14mod tools;
15
16use std::io::{BufRead, IsTerminal, Write};
17use std::path::PathBuf;
18
19use balise_core::error_codes::{CLI0001, CLI0003, ErrorCode};
20use balise_core::json::{self, Value};
21use balise_core::text::closest;
22
23use args::{GLOBAL, Opt, Parsed};
24pub use tools::{Machine, System};
25
26/// One command of the CLI.
27pub struct Command {
28    pub name: &'static str,
29    pub summary: &'static str,
30    /// Positional arguments, as help shows them: `<name>`.
31    pub arguments: &'static str,
32    pub options: &'static [Opt],
33    /// Groups of options of which at most one may be given.
34    pub exclusive: &'static [&'static [&'static str]],
35    pub run: fn(&Parsed, &mut Context) -> Result<Report, Failure>,
36}
37
38/// Every command, in the order help lists them.
39pub const COMMANDS: &[&Command] = &[
40    &commands::new::COMMAND,
41    &commands::dev::COMMAND,
42    &commands::explain::COMMAND,
43    &commands::policy::COMMAND,
44];
45
46/// What a command works with: where it runs, whether it may ask questions, and the machine.
47pub struct Context<'a> {
48    pub cwd: PathBuf,
49    /// Whether a person can answer questions: a terminal, no agent, no `--no-input`.
50    pub interactive: bool,
51    pub input: &'a mut dyn BufRead,
52    /// Where questions and progress go: the terminal, never the result.
53    pub output: &'a mut dyn Write,
54    pub machine: &'a dyn Machine,
55}
56
57/// What a command returns: the text a person or an agent reads, and the same as data.
58pub struct Report {
59    pub text: String,
60    pub data: Value,
61}
62
63/// Why a command failed, with its code and the exit code: `2` for a wrong usage, `1`
64/// otherwise.
65#[derive(Debug)]
66pub struct Failure {
67    pub code: &'static ErrorCode,
68    pub message: String,
69    pub fix: String,
70    pub exit: u8,
71}
72
73impl Failure {
74    /// A wrong usage of the command.
75    pub fn usage(code: &'static ErrorCode, args: &[(&str, &str)], fix: &[(&str, &str)]) -> Failure {
76        Failure {
77            code,
78            message: code.format_message(args),
79            fix: code.format_fix(fix),
80            exit: 2,
81        }
82    }
83
84    /// A problem met while running.
85    pub fn problem(
86        code: &'static ErrorCode,
87        args: &[(&str, &str)],
88        fix: &[(&str, &str)],
89    ) -> Failure {
90        Failure {
91            exit: 1,
92            ..Failure::usage(code, args, fix)
93        }
94    }
95
96    /// As the terminal shows it.
97    pub fn report(&self) -> String {
98        format!(
99            "{}: {}\n  fix: {}\n  more: balise explain {}",
100            self.code.code(),
101            self.message,
102            self.fix,
103            self.code.code()
104        )
105    }
106
107    fn json(&self) -> Value {
108        Value::Object(vec![(
109            "error".into(),
110            Value::Object(vec![
111                ("code".into(), Value::String(self.code.code().into())),
112                ("message".into(), Value::String(self.message.clone())),
113                ("fix".into(), Value::String(self.fix.clone())),
114            ]),
115        )])
116    }
117}
118
119/// Whether a person is at the keyboard: standard input and output are a terminal, and the
120/// environment shows neither an agent nor CI.
121pub fn person_at_terminal() -> bool {
122    const AGENTS: &[&str] = &["CLAUDECODE", "GEMINI_CLI", "CI"];
123    std::io::stdin().is_terminal()
124        && std::io::stdout().is_terminal()
125        && AGENTS.iter().all(|name| std::env::var_os(name).is_none())
126}
127
128/// Runs the command `args` names. Returns what to print on standard output and the exit
129/// code.
130pub fn run(args: &[String], context: &mut Context) -> (String, u8) {
131    let json = args.iter().any(|arg| arg == "--json");
132    let result = dispatch(args, context);
133    match (result, json) {
134        (Ok(report), false) => (report.text, 0),
135        (Ok(report), true) => (to_json(&report.data), 0),
136        (Err(failure), false) => (failure.report(), failure.exit),
137        (Err(failure), true) => (to_json(&failure.json()), failure.exit),
138    }
139}
140
141fn dispatch(args: &[String], context: &mut Context) -> Result<Report, Failure> {
142    let (name, rest) = match args.split_first() {
143        None => return Ok(help(None)),
144        Some((name, rest)) => (name.as_str(), rest),
145    };
146    match name {
147        "help" | "--help" | "-h" => return Ok(help(rest.first().map(String::as_str))),
148        "--version" | "-V" => {
149            return Ok(text(format!("balise {}", env!("CARGO_PKG_VERSION"))));
150        }
151        _ => {}
152    }
153    let Some(command) = COMMANDS.iter().find(|command| command.name == name) else {
154        let names: Vec<&str> = COMMANDS.iter().map(|command| command.name).collect();
155        let suggestion = closest(name, &names)
156            .map(|name| format!("; did you mean `{name}`?"))
157            .unwrap_or_default();
158        return Err(Failure::usage(
159            CLI0001,
160            &[("command", name), ("suggestion", &suggestion)],
161            &[],
162        ));
163    };
164    let parsed = args::parse(command.name, rest, command.options, command.exclusive)?;
165    if parsed.has("--help") {
166        return Ok(help(Some(command.name)));
167    }
168    if parsed.has("--no-input") {
169        context.interactive = false;
170    }
171    (command.run)(&parsed, context)
172}
173
174/// A report that is only text.
175pub(crate) fn text(text: String) -> Report {
176    Report {
177        data: Value::String(text.clone()),
178        text,
179    }
180}
181
182/// `balise help`, or `balise help <command>`.
183fn help(command: Option<&str>) -> Report {
184    let mut out = String::new();
185    match command.and_then(|name| COMMANDS.iter().find(|c| c.name == name)) {
186        Some(command) => {
187            out.push_str(&format!(
188                "balise {} {}\n\n{}\n",
189                command.name, command.arguments, command.summary
190            ));
191            out.push_str("\nOptions:\n");
192            for opt in command.options.iter().chain(GLOBAL) {
193                let mut line = format!("  {:<26} {}", args::usage(opt), opt.help);
194                if !opt.values.is_empty() {
195                    line.push_str(&format!(" ({})", opt.values.join(", ")));
196                }
197                out.push_str(&line);
198                out.push('\n');
199            }
200        }
201        None => {
202            out.push_str(&format!(
203                "balise {}: the Balise command-line tool\n\nCommands:\n",
204                env!("CARGO_PKG_VERSION")
205            ));
206            for command in COMMANDS {
207                out.push_str(&format!("  {:<10} {}\n", command.name, command.summary));
208            }
209            out.push_str("\nRun `balise help <command>` for its options.\n");
210        }
211    }
212    text(out.trim_end().to_string())
213}
214
215/// A required argument is missing.
216pub(crate) fn missing(command: &str, missing: &str, example: &str) -> Failure {
217    Failure::usage(
218        CLI0003,
219        &[("command", command), ("missing", missing)],
220        &[("example", example)],
221    )
222}
223
224fn to_json(value: &Value) -> String {
225    let mut out = String::new();
226    json::write_value(&mut out, value);
227    out
228}