1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
#[macro_use]
extern crate log;
extern crate confy;
use clap::{Parser, Subcommand};
use colored::Colorize;
use inflector::Inflector;
use crate::batbelt::metadata::BatMetadata;
use crate::batbelt::path::BatFile;
use crate::commands::sonar_commands::SonarCommand;
use crate::commands::{BatCommandEnumerator, CommandResult};
use crate::batbelt::BatEnumerator;
use commands::CommandError;
use error_stack::fmt::{Charset, ColorMode};
use error_stack::{IntoReport, Result};
use error_stack::{Report, ResultExt};
use crate::commands::project_commands::ProjectCommands;
use regex::Regex;
pub mod batbelt;
pub mod commands;
pub mod config;
pub mod guide;
// pub type BatDerive = #[derive(Debug, PartialEq, Copy, strum_macros::Display, strum_macros::EnumIter)];
#[derive(Parser, Debug, Clone)]
#[command(
author,
version,
about = "Blockchain Auditor Toolkit (BAT) CLI",
after_help = "AI assistant setup: run `bat-cli refresh-ai-guide` — it installs the assistant \
skill and prints how to point an AI (Claude, Codex, Gemini) at bat-cli's guide, \
so you can just say \"use the bat-cli skill\"."
)]
struct Cli {
#[clap(flatten)]
verbose: clap_verbosity_flag::Verbosity,
#[command(subcommand)]
command: BatCommands,
}
#[derive(strum_macros::Display, Subcommand, Debug, PartialEq, Clone, strum_macros::EnumIter)]
enum BatCommands {
// Init is the default command (run with no subcommand).
/// Set up a bat project here: detect the framework, create the Miro board
/// and scan the source code
Init {
/// Run without any prompt (for scripts / AI): use the folder name for the
/// project and, when logged in to Miro, create a board named after it.
#[arg(long)]
yes: bool,
/// Attach this existing Miro board (URL) instead of creating one. Implies
/// a non-interactive board step.
#[arg(long = "board-url", value_name = "URL")]
board_url: Option<String>,
},
/// Authorize bat-cli against Miro once, for every project on this machine
Login {
/// Register the Miro app credentials before authorizing
#[arg(long)]
setup: bool,
/// Show who the stored token belongs to, and its scopes
#[arg(long)]
status: bool,
/// Re-authorize even if a valid token is already stored
#[arg(long)]
force: bool,
},
/// Revoke the stored Miro token and forget it
Logout,
/// Update bat-cli to the latest published version
Update {
/// Only report whether a newer version exists
#[arg(long)]
check: bool,
/// Reinstall even when already up to date
#[arg(long)]
force: bool,
},
/// Show or edit the machine-wide preferences (~/.config/bat-cli/config.toml)
Config {
/// Re-answer the preferences instead of only printing them
#[arg(long)]
edit: bool,
},
/// Rescan the source code after it changed, rebuilding the metadata that
/// deploy reads
Sonar,
/// Regenerate the machine-global AI guide and reinstall the assistant skills.
///
/// Install the AI-assistant skill + guide and print how to point an AI at bat-cli
/// (run this once right after installing). Needs no project.
#[command(name = "refresh-ai-guide")]
RefreshAiGuide,
/// Deploy an entry point's screenshots to a Miro board
Deploy {
/// Entry point to deploy, as `name` or `Contract.name`. Omit to pick
/// one from a list.
#[arg(long)]
entry_point: Option<String>,
/// Deploy every entry point at once. Not recommended: a real project
/// puts thousands of objects on the board, and review happens one entry
/// point at a time.
#[arg(long)]
all: bool,
/// Stop expanding the call graph past this depth. Unset follows it to
/// the end
#[arg(long)]
max_depth: Option<usize>,
/// Cap the screenshots per frame. Unset draws the whole tree
#[arg(long)]
max_nodes: Option<usize>,
/// Print the computed layout without sending anything to Miro
#[arg(long)]
dry_run: bool,
/// Include contracts coming from lib/
#[arg(long)]
include_external: bool,
/// Write a local PNG preview of the composed frame to this path
#[arg(long)]
preview: Option<String>,
/// Connector thickness in dp (1-24)
#[arg(long, default_value_t = 8)]
stroke_width: u32,
/// Answer the "already on the board — deploy again?" prompt with yes, so a
/// redeploy runs non-interactively (for scripts / AI). Builds a second frame.
#[arg(long)]
yes: bool,
/// Draw the frame even if some interface calls in the tree are unresolved,
/// instead of stopping to list them. Downstream nodes behind those calls are
/// simply omitted.
#[arg(long)]
allow_unresolved: bool,
},
/// Record an interface→contract resolution so `deploy` can follow a runtime-bound
/// interface call to its concrete implementation. `deploy` stops and lists what to
/// resolve; add them here, then deploy again. Stored in the metadata.
Resolve {
/// Interface type to resolve, e.g. `IBorrowerOperations`.
interface: Option<String>,
/// The concrete in-scope contract it points to, e.g. `BorrowerOperations`.
contract: Option<String>,
/// List the current resolutions instead of adding one.
#[arg(long)]
list: bool,
/// Remove the resolution for <interface>.
#[arg(long)]
remove: bool,
},
}
impl BatEnumerator for BatCommands {}
impl Default for BatCommands {
fn default() -> Self {
BatCommands::Init {
yes: false,
board_url: None,
}
}
}
impl BatCommands {
pub async fn execute(&self) -> Result<(), CommandError> {
self.validate_command()?;
match self {
BatCommands::Init { yes, board_url } => {
ProjectCommands::Init
.init_bat_project(*yes, board_url.clone())
.await
}
BatCommands::Login {
setup,
status,
force,
} => {
use crate::batbelt::miro::auth;
if *status {
auth::status().await.change_context(CommandError)
} else {
auth::login(*setup, *force).await.change_context(CommandError)
}
}
BatCommands::Logout => crate::batbelt::miro::auth::logout()
.await
.change_context(CommandError),
BatCommands::Update { check, force } => {
crate::commands::update_commands::run(*check, *force).await
}
BatCommands::Config { edit } => {
ProjectCommands::show_global_config(*edit).change_context(CommandError)
}
BatCommands::Sonar => SonarCommand::Run.execute_command(),
// The refresh already ran in `main::run`; nothing left to do.
BatCommands::RefreshAiGuide => {
// refresh_ai_surface() already ran in main(); just show where the
// AI integration lives and how to point an assistant at it.
crate::guide::print_ai_setup_hint();
Ok(())
}
BatCommands::Deploy {
entry_point,
all,
max_depth,
max_nodes,
dry_run,
include_external,
preview,
stroke_width,
yes,
allow_unresolved,
} => {
crate::batbelt::evm::miro::auto_deploy::run(
crate::batbelt::evm::miro::auto_deploy::AutoDeployOptions {
entry_point: entry_point.clone(),
all: *all,
max_depth: *max_depth,
max_nodes: *max_nodes,
dry_run: *dry_run,
include_external: *include_external,
preview: preview.clone(),
stroke_width: *stroke_width,
assume_yes: *yes,
allow_unresolved: *allow_unresolved,
},
)
.await
.change_context(CommandError)
}
BatCommands::Resolve {
interface,
contract,
list,
remove,
} => run_resolve(interface.clone(), contract.clone(), *list, *remove),
}
}
/// Every command that reads project data needs the metadata cache to exist.
///
/// There is no branch check any more: bat-cli does not create commits or
/// manage git, so it has no business dictating which branch you are on.
fn validate_command(&self) -> CommandResult<()> {
let check_metadata = match self {
BatCommands::Init { .. }
| BatCommands::Login { .. }
| BatCommands::Logout
| BatCommands::Config { .. }
| BatCommands::RefreshAiGuide
| BatCommands::Update { .. } => return Ok(()),
BatCommands::Sonar => false,
BatCommands::Deploy { .. } | BatCommands::Resolve { .. } => true,
};
if check_metadata {
let bat_config = crate::config::BatConfig::get_config().change_context(CommandError)?;
if bat_config.project_type == crate::config::ProjectType::Foundry {
crate::batbelt::evm::metadata::bat_metadata::EvmBatMetadata::read_metadata()
.change_context(CommandError)?;
} else {
BatMetadata::read_metadata()
.change_context(CommandError)?
.check_metadata_is_initialized()
.change_context(CommandError)?;
}
}
Ok(())
}
pub fn get_pretty_command(&self) -> CommandResult<String> {
let multi_line_command_regex = Regex::new(r#"[\w]+(\([\w\s,]+\))+"#)
.into_report()
.change_context(CommandError)?;
let command_string = format!("{self:#?}");
if multi_line_command_regex.is_match(&command_string) {
let mut command_string_lines = command_string.lines();
let command_name = command_string_lines.next().unwrap().to_kebab_case();
let command_option = command_string_lines.next().unwrap().trim().to_kebab_case();
return Ok(format!("{} {}", command_name, command_option));
}
Ok(self.to_string().to_kebab_case())
}
}
pub struct Suggestion(String);
impl Suggestion {
pub fn set_report() {
Report::set_charset(Charset::Utf8);
Report::set_color_mode(ColorMode::Color);
Report::install_debug_hook::<Self>(|Self(value), context| {
context.push_body(format!("{}: {value}", "suggestion".yellow()))
});
}
}
async fn run() -> CommandResult<()> {
let cli: Cli = Cli::parse();
Suggestion::set_report();
// Logs go to stderr, controlled by -v/-q or RUST_LOG, rather than to a file
// inside the project that nobody read.
env_logger::Builder::new()
.filter_level(cli.verbose.log_level_filter())
.parse_default_env()
.format_timestamp(None)
.init();
// The AI guide describes the binary, so every command is a chance to make sure the one
// on disk is the one just installed — including `config` and `update`, which have no
// project at all. Best-effort by construction: it never fails the command.
crate::guide::refresh_ai_surface();
cli.command.execute().await
}
/// `bat-cli resolve` — read/update the metadata's interface→contract resolutions.
fn run_resolve(
interface: Option<String>,
contract: Option<String>,
list: bool,
remove: bool,
) -> CommandResult<()> {
use crate::batbelt::evm::metadata::bat_metadata::EvmBatMetadata;
let mut md = EvmBatMetadata::read_metadata().change_context(CommandError)?;
// No interface given (or --list) → show what is resolved and what still needs it.
if list || interface.is_none() {
if md.resolutions.is_empty() {
println!("no resolutions yet.");
} else {
println!("resolutions:");
let mut items: Vec<_> = md.resolutions.iter().collect();
items.sort();
for (i, c) in items {
println!(" {i} → {c}");
}
}
return Ok(());
}
let interface = interface.expect("checked above");
if remove {
md.resolutions.remove(&interface);
md.save_metadata().change_context(CommandError)?;
println!("removed resolution for {interface}");
return Ok(());
}
let Some(contract) = contract else {
return Err(error_stack::Report::new(CommandError).attach_printable(
"usage: bat-cli resolve <INTERFACE> <CONTRACT> (or --list / --remove <INTERFACE>)",
));
};
md.resolutions.insert(interface.clone(), contract.clone());
md.save_metadata().change_context(CommandError)?;
println!("✓ resolved {interface} → {contract}");
Ok(())
}
#[tokio::main]
async fn main() -> CommandResult<()> {
let cli: Cli = Cli::parse();
match run().await {
Ok(_) => {
println!(
"{} {} script successfully executed!",
"bat-cli".green(),
cli.command.get_pretty_command()?.green()
);
Ok(())
}
Err(error) => {
eprintln!(
"{} {} script finished with error",
"bat-cli".red(),
cli.command.get_pretty_command()?.red()
);
Err(error)
}
}
}