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