Add bxctl approvals, approve, refuse and grants check
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
//! `bxctl` command-line parsing: the subcommands, the shared `USAGE` and `ChatOptions`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use proto::SessionId;
|
||||
|
||||
// Shown on stderr for any usage error. It names every subcommand and the flags they share, so a
|
||||
// single line tells the owner what they can type.
|
||||
pub const USAGE: &str = "\
|
||||
usage: bxctl <command> [options]
|
||||
|
||||
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
|
||||
Talk to loopd.
|
||||
|
||||
bxctl approvals [--admin-socket PATH]
|
||||
List the approvals waiting for a decision.
|
||||
|
||||
bxctl approve <id> [--admin-socket PATH]
|
||||
Approve approval <id>.
|
||||
|
||||
bxctl refuse <id> [--reason TEXT] [--admin-socket PATH]
|
||||
Refuse approval <id>, recording a reason.
|
||||
|
||||
bxctl grants check [--admin-socket PATH]
|
||||
Check that the grants load.
|
||||
|
||||
bxctl audit verify [--home DIR]
|
||||
Verify the audit log against the grants.";
|
||||
|
||||
// The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin
|
||||
// socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ChatOptions {
|
||||
pub socket: PathBuf,
|
||||
pub admin_socket: PathBuf,
|
||||
pub session: Option<SessionId>,
|
||||
pub show_thinking: bool,
|
||||
pub say: Option<String>,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
impl ChatOptions {
|
||||
pub fn default(home: &Path) -> Self {
|
||||
ChatOptions {
|
||||
socket: home.join("run").join("loop").join("loop.sock"),
|
||||
admin_socket: default_admin_socket(home),
|
||||
session: None,
|
||||
show_thinking: true,
|
||||
say: None,
|
||||
json: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why `parse` rejected the arguments. There is only one kind: the arguments were wrong.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct UsageError;
|
||||
|
||||
impl std::fmt::Display for UsageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "invalid arguments")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UsageError {}
|
||||
|
||||
// What the owner asked for. `Chat` carries its options; every admin subcommand carries the admin
|
||||
// socket to use (defaulted from `$BOXMAKER_HOME`) plus whatever only it needs.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Chat(ChatOptions),
|
||||
Approvals {
|
||||
admin_socket: PathBuf,
|
||||
},
|
||||
Approve {
|
||||
admin_socket: PathBuf,
|
||||
approval: u64,
|
||||
},
|
||||
Refuse {
|
||||
admin_socket: PathBuf,
|
||||
approval: u64,
|
||||
reason: Option<String>,
|
||||
},
|
||||
GrantsCheck {
|
||||
admin_socket: PathBuf,
|
||||
},
|
||||
AuditVerify {
|
||||
home: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
let (command, rest) = match args.split_first() {
|
||||
Some((c, r)) => (c.as_str(), r),
|
||||
None => return Err(UsageError),
|
||||
};
|
||||
match command {
|
||||
"chat" => parse_chat(rest, home),
|
||||
"approvals" => parse_approvals(rest, home),
|
||||
"approve" => parse_approve(rest, home),
|
||||
"refuse" => parse_refuse(rest, home),
|
||||
"grants" => parse_grants(rest, home),
|
||||
"audit" => parse_audit(rest, home),
|
||||
// A flag before the command, or any unknown command, is not valid.
|
||||
_ => Err(UsageError),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_admin_socket(home: &Path) -> PathBuf {
|
||||
home.join("run").join("owner-broker").join("admin.sock")
|
||||
}
|
||||
|
||||
fn parse_chat(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
let mut opts = ChatOptions::default(home);
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--socket" => opts.socket = PathBuf::from(value_at(args, i)?),
|
||||
"--admin-socket" => opts.admin_socket = PathBuf::from(value_at(args, i)?),
|
||||
"--session" => opts.session = Some(parse_session(&value_at(args, i)?)?),
|
||||
"--say" => opts.say = Some(value_at(args, i)?),
|
||||
"--no-thinking" => {
|
||||
opts.show_thinking = false;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
"--json" => {
|
||||
opts.json = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
_ => return Err(UsageError),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
Ok(Command::Chat(opts))
|
||||
}
|
||||
|
||||
fn parse_approvals(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
let mut admin_socket = default_admin_socket(home);
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--admin-socket" => admin_socket = PathBuf::from(value_at(args, i)?),
|
||||
// Anything else is a stray argument or an unknown flag.
|
||||
_ => return Err(UsageError),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
Ok(Command::Approvals { admin_socket })
|
||||
}
|
||||
|
||||
fn parse_approve(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
let mut admin_socket = default_admin_socket(home);
|
||||
let mut approval = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--admin-socket" => {
|
||||
admin_socket = PathBuf::from(value_at(args, i)?);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
other => {
|
||||
if approval.is_some() {
|
||||
return Err(UsageError);
|
||||
}
|
||||
approval = Some(parse_id(other)?);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
match approval {
|
||||
Some(approval) => Ok(Command::Approve {
|
||||
admin_socket,
|
||||
approval,
|
||||
}),
|
||||
None => Err(UsageError),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_refuse(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
let mut admin_socket = default_admin_socket(home);
|
||||
let mut approval = None;
|
||||
let mut reason = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--admin-socket" => {
|
||||
admin_socket = PathBuf::from(value_at(args, i)?);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
"--reason" => {
|
||||
if reason.is_some() {
|
||||
return Err(UsageError);
|
||||
}
|
||||
reason = Some(verify_reason(&value_at(args, i)?)?);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
other => {
|
||||
if approval.is_some() {
|
||||
return Err(UsageError);
|
||||
}
|
||||
approval = Some(parse_id(other)?);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
match approval {
|
||||
Some(approval) => Ok(Command::Refuse {
|
||||
admin_socket,
|
||||
approval,
|
||||
reason,
|
||||
}),
|
||||
None => Err(UsageError),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_grants(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
let rest = match args.first().map(String::as_str) {
|
||||
Some("check") => &args[1..],
|
||||
_ => return Err(UsageError),
|
||||
};
|
||||
let mut admin_socket = default_admin_socket(home);
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i].as_str() {
|
||||
"--admin-socket" => admin_socket = PathBuf::from(value_at(rest, i)?),
|
||||
_ => return Err(UsageError),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
Ok(Command::GrantsCheck { admin_socket })
|
||||
}
|
||||
|
||||
fn parse_audit(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
let rest = match args.first().map(String::as_str) {
|
||||
Some("verify") => &args[1..],
|
||||
_ => return Err(UsageError),
|
||||
};
|
||||
let mut home = home.to_path_buf();
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i].as_str() {
|
||||
"--home" => home = PathBuf::from(value_at(rest, i)?),
|
||||
_ => return Err(UsageError),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
Ok(Command::AuditVerify { home })
|
||||
}
|
||||
|
||||
// The argument after the flag at `i`, taken as the value even when it looks like a flag, so a value
|
||||
// is a value.
|
||||
fn value_at(args: &[String], i: usize) -> Result<String, UsageError> {
|
||||
args.get(i + 1).cloned().ok_or(UsageError)
|
||||
}
|
||||
|
||||
// An approval id is ASCII digits that fit in u64 and nothing else; `str::parse::<u64>` alone would
|
||||
// accept `+41`, so the digit check comes first.
|
||||
fn parse_id(value: &str) -> Result<u64, UsageError> {
|
||||
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(UsageError);
|
||||
}
|
||||
value.parse::<u64>().map_err(|_| UsageError)
|
||||
}
|
||||
|
||||
// A session id is a valid id, and one that is purely numeric must fit in u64 so it is never
|
||||
// mistaken for an approval id.
|
||||
fn parse_session(value: &str) -> Result<SessionId, UsageError> {
|
||||
match SessionId::new(value) {
|
||||
Ok(id) => {
|
||||
if value.bytes().all(|b| b.is_ascii_digit()) && value.parse::<u64>().is_err() {
|
||||
return Err(UsageError);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
Err(_) => Err(UsageError),
|
||||
}
|
||||
}
|
||||
|
||||
// A refuse reason must be present; its content is unvalidated on the client.
|
||||
fn verify_reason(reason: &str) -> Result<String, UsageError> {
|
||||
if reason.is_empty() {
|
||||
return Err(UsageError);
|
||||
}
|
||||
Ok(reason.to_string())
|
||||
}
|
||||
Reference in New Issue
Block a user