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