Add bxctl approvals, approve, refuse and grants check

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-20 23:25:05 -07:00
parent 469be2c0a1
commit 2d94067b52
12 changed files with 1780 additions and 79 deletions
+68 -78
View File
@@ -4,85 +4,75 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use bxctl::admin::{self, AdminError};
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
use proto::{ErrorCode, SessionId, TurnDone, TurnEvent};
const USAGE: &str =
"usage: bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]";
struct Options {
socket: PathBuf,
session: Option<SessionId>,
show_thinking: bool,
say: Option<String>,
json: bool,
}
use bxctl::cli::{self, ChatOptions, Command, USAGE};
use proto::{ErrorCode, SessionId, Timestamp, TurnDone, TurnEvent};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) != Some("chat") {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
match parse_chat(&args[1..]) {
Ok(opts) => run(&opts),
Err(()) => {
eprintln!("{USAGE}");
ExitCode::from(2)
}
}
}
fn parse_chat(args: &[String]) -> Result<Options, ()> {
let mut opts = Options {
socket: default_socket(),
session: None,
show_thinking: true,
say: None,
json: false,
};
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--socket" => {
i += 1;
match args.get(i) {
Some(p) => opts.socket = PathBuf::from(p),
None => return Err(()),
}
}
"--session" => {
i += 1;
match args.get(i) {
Some(s) => match SessionId::new(s) {
Ok(id) => opts.session = Some(id),
Err(_) => return Err(()),
},
None => return Err(()),
}
}
"--say" => {
i += 1;
match args.get(i) {
Some(t) => opts.say = Some(t.to_string()),
None => return Err(()),
}
}
"--no-thinking" => opts.show_thinking = false,
"--json" => opts.json = true,
_ => return Err(()),
}
i += 1;
}
Ok(opts)
}
// The base directory: $BOXMAKER_HOME if set, otherwise the system location the brief fixes.
fn default_socket() -> PathBuf {
let base = std::env::var_os("BOXMAKER_HOME")
// $BOXMAKER_HOME defaults to /var/lib/boxmaker; defaults for the sockets are read from it.
let home = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
base.join("run").join("loop").join("loop.sock")
// Parse before connecting, so a usage error exits 2 even without a broker.
let command = match cli::parse(&args, &home) {
Ok(command) => command,
Err(_) => {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
};
match command {
Command::Chat(opts) => run(&opts),
Command::Approvals { admin_socket } => exit(|| cmd_approvals(&admin_socket)),
Command::Approve {
admin_socket,
approval,
} => exit(|| cmd_approve(&admin_socket, approval)),
Command::Refuse {
admin_socket,
approval,
reason,
} => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())),
Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)),
Command::AuditVerify { home: _ } => {
eprintln!("bxctl: audit verify is not implemented yet");
ExitCode::from(1)
}
}
}
// Run a command, mapping its result to an exit code: success for true, 1 for false or an error.
fn exit<F: FnOnce() -> Result<bool, AdminError>>(f: F) -> ExitCode {
match f() {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Err(e) => {
eprintln!("bxctl: {e}");
ExitCode::from(1)
}
}
}
fn cmd_approvals(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approvals(admin, Timestamp::now(), &mut out)
}
fn cmd_approve(admin: &Path, id: u64) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approve(admin, id, &mut out)
}
fn cmd_refuse(admin: &Path, id: u64, reason: Option<&str>) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_refuse(admin, id, reason, &mut out)
}
fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_grants_check(admin, &mut out)
}
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
@@ -123,14 +113,14 @@ fn stream_turn(
}
}
fn run(opts: &Options) -> ExitCode {
fn run(opts: &ChatOptions) -> ExitCode {
match &opts.say {
Some(text) => run_say(opts, text),
None => run_interactive(opts),
}
}
fn run_say(opts: &Options, text: &str) -> ExitCode {
fn run_say(opts: &ChatOptions, text: &str) -> ExitCode {
let session = match &opts.session {
Some(s) => s.clone(),
None => new_session_id(),
@@ -152,7 +142,7 @@ fn run_say(opts: &Options, text: &str) -> ExitCode {
// Runs a --say turn, retrying once when the named session does not exist.
fn run_turn_twice(
opts: &Options,
opts: &ChatOptions,
session: &SessionId,
text: &str,
resume: bool,
@@ -189,7 +179,7 @@ fn run_turn_twice(
}
}
fn run_interactive(opts: &Options) -> ExitCode {
fn run_interactive(opts: &ChatOptions) -> ExitCode {
let mut printer = Printer::new(opts.show_thinking, opts.json);
let mut session = None;
let stdin = std::io::stdin();
@@ -255,7 +245,7 @@ fn run_interactive(opts: &Options) -> ExitCode {
}
// The plain answer goes to stdout; in --json mode the TurnDone goes to stderr after the events.
fn write_answer(opts: &Options, done: &TurnDone) -> std::io::Result<()> {
fn write_answer(opts: &ChatOptions, done: &TurnDone) -> std::io::Result<()> {
if opts.json {
let line = serde_json::to_string(done).map_err(std::io::Error::other)?;
let mut err = std::io::stderr().lock();