pub(crate) mod grammar;
pub(crate) mod instance;
use std::fmt::Write as _;
use std::sync::OnceLock;
use crate::db::ipc::{CallAttempt, CallOutcome, CallRequest};
use crate::util::lock::InstanceLockState;
pub const TOP_LEVEL_USAGE: &str = "\
mahbot — autonomous agentic engineering system with a GUI dashboard daemon
Usage:
mahbot Launch the GUI dashboard daemon
mahbot paused Launch the daemon with every registered workspace
paused (no ticket is claimed or advanced)
mahbot audio <operation> [flags]
Speech on the command line (macOS): a recording into
text, text into a spoken WAV file
mahbot chrome <args> Browser automation CLI over the shared chrome core
mahbot debug Read-only SQL query tool against the live stores
mahbot bench-openrouter <args>
Standalone OpenRouter provider benchmark
mahbot config <operation> [flags]
Set the RUNNING product up (Telegram bot, users, tool
grants, web search, custom roles)
mahbot workspace <operation> [flags]
Work with the RUNNING product's workspaces and tickets
mahbot send [flags] Hand one message to an agent and finish: one-way — the
answer arrives in your assistant's conversation
mahbot document <operation> [flags]
Read a document with the RUNNING product's reader, or
produce one with its document tool
mahbot image gen [flags]
Produce a picture with the RUNNING product's picture tool
mahbot video <gen|edit> [flags]
Produce or edit a clip with the RUNNING product's video
tools
mahbot rasterize [flags]
Turn a file into an ordinary picture with the RUNNING
product's rasterizer
Options:
-h, --help Print this help and exit
-V, --version Print the version and exit";
pub(crate) const CHANNEL: &str = "cli";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ServiceState {
Starting,
Ready,
Unavailable,
}
static SERVICE: OnceLock<tokio::sync::watch::Sender<ServiceState>> = OnceLock::new();
fn service_sender() -> &'static tokio::sync::watch::Sender<ServiceState> {
SERVICE.get_or_init(|| tokio::sync::watch::channel(ServiceState::Starting).0)
}
pub fn mark_ready() {
service_sender().send_replace(ServiceState::Ready);
}
pub fn mark_unavailable() {
service_sender().send_replace(ServiceState::Unavailable);
}
pub(crate) enum ServiceGate {
Ready,
Unavailable,
NotAccepted,
StillStarting,
}
const SERVICE_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
pub(crate) async fn wait_for_service() -> ServiceGate {
let mut rx = service_sender().subscribe();
let shutdown = crate::shutdown::shutdown_token();
let bound = tokio::time::sleep(SERVICE_READY_TIMEOUT);
tokio::pin!(bound);
loop {
if shutdown.is_cancelled() {
return ServiceGate::NotAccepted;
}
match *rx.borrow_and_update() {
ServiceState::Ready => return ServiceGate::Ready,
ServiceState::Unavailable => return ServiceGate::Unavailable,
ServiceState::Starting => {}
}
tokio::select! {
changed = rx.changed() => {
if changed.is_err() {
return ServiceGate::NotAccepted;
}
}
() = shutdown.cancelled() => {
return ServiceGate::NotAccepted;
}
() = &mut bound => {
return ServiceGate::StillStarting;
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Exit {
Ok = 0,
Failed = 1,
Environment = 2,
Usage = 3,
}
impl Exit {
pub(crate) const fn code(self) -> i32 {
self as i32
}
}
pub(crate) struct Family {
pub(crate) word: &'static str,
pub(crate) tools: &'static [&'static str],
pub(crate) protocol: &'static str,
pub(crate) purpose: &'static str,
pub(crate) surface: Surface,
}
pub(crate) enum Surface {
Operations(&'static [Operation]),
Standalone(&'static [Flag]),
}
pub(crate) struct Operation {
pub(crate) tool: &'static str,
pub(crate) action: Option<&'static str>,
pub(crate) purpose: &'static str,
pub(crate) flags: &'static [Flag],
}
fn operation_word(family: &Family, operation: &Operation) -> String {
match operation.action {
Some(action) => action.replace('_', "-"),
None => operation
.tool
.strip_prefix(family.word)
.unwrap_or(operation.tool)
.trim_start_matches('_')
.replace('_', "-"),
}
}
#[must_use]
fn produces_files(family: &Family) -> bool {
match family.surface {
Surface::Operations(operations) => operations
.iter()
.flat_map(|operation| operation.flags)
.any(|flag| matches!(flag.kind, FlagKind::Destination)),
Surface::Standalone(flags) => flags
.iter()
.any(|flag| matches!(flag.kind, FlagKind::Destination)),
}
}
#[must_use]
fn family_syntax(family: &Family) -> String {
match family.surface {
Surface::Standalone(_) => format!("mahbot {} [flags]", family.word),
Surface::Operations(operations) => {
let words: Vec<String> = operations
.iter()
.map(|operation| operation_word(family, operation))
.collect();
match (
operations
.iter()
.all(|operation| operation.action.is_none()),
words.as_slice(),
) {
(true, [word]) => format!("mahbot {} {word} [flags]", family.word),
(true, words) => format!("mahbot {} <{}> [flags]", family.word, words.join("|")),
(false, _) => format!("mahbot {} <operation> [flags]", family.word),
}
}
}
}
pub(crate) struct Flag {
pub(crate) arg: &'static str,
pub(crate) kind: FlagKind,
pub(crate) secret: bool,
pub(crate) purpose: &'static str,
}
pub(crate) enum FlagKind {
Value,
Path,
Number,
List,
PathList,
Structured(&'static [&'static str]),
Present,
Destination,
}
impl FlagKind {
#[must_use]
const fn has_file_form(&self) -> bool {
matches!(
self,
Self::Value | Self::List | Self::PathList | Self::Structured(_)
)
}
}
pub(crate) const DOCUMENT_READ: &str = "read";
pub(crate) const FAMILIES: &[Family] = &[
Family {
word: "config",
tools: &["mahbot_config"],
protocol: crate::db::ipc::CALL_STORE_BASE,
purpose: "Set the running product up — its Telegram bot, its users and \
their tool grants, web search, custom roles",
surface: Surface::Operations(&[
Operation {
tool: "mahbot_config",
action: Some("setup_telegram_bot"),
purpose: "Save the Telegram bot token from BotFather",
flags: &[Flag {
arg: "token",
kind: FlagKind::Value,
secret: true,
purpose: "The bot token BotFather gave you",
}],
},
Operation {
tool: "mahbot_config",
action: Some("bind_telegram"),
purpose: "Bind your own Telegram account, so the bot's messages reach you",
flags: &[Flag {
arg: "handle",
kind: FlagKind::Value,
secret: false,
purpose: "Your Telegram username or your numeric id — write './@name' for the \
'@' spelling, which otherwise names a file",
}],
},
Operation {
tool: "mahbot_config",
action: Some("add_user"),
purpose: "Create a guest account and bind its Telegram account",
flags: &[
Flag {
arg: "name",
kind: FlagKind::Value,
secret: false,
purpose: "The new user's display name, which is also their personal \
workspace folder",
},
Flag {
arg: "telegram",
kind: FlagKind::Value,
secret: false,
purpose: "Their Telegram username or their numeric id — write './@name' \
for the '@' spelling, which otherwise names a file",
},
],
},
Operation {
tool: "mahbot_config",
action: Some("setup_web_search"),
purpose: "Choose the web-search provider and store its API key",
flags: &[
Flag {
arg: "provider",
kind: FlagKind::Value,
secret: false,
purpose: "'firecrawl' or 'exa'",
},
Flag {
arg: "key",
kind: FlagKind::Value,
secret: true,
purpose: "The API key for the chosen provider",
},
],
},
Operation {
tool: "mahbot_config",
action: Some("grant_tool"),
purpose: "Give a user one of your custom tools",
flags: &[USER_FLAG, CUSTOM_TOOL_FLAG],
},
Operation {
tool: "mahbot_config",
action: Some("revoke_tool"),
purpose: "Take one of your custom tools back from a user",
flags: &[USER_FLAG, CUSTOM_TOOL_FLAG],
},
Operation {
tool: "mahbot_config",
action: Some("list_grants"),
purpose: "List the custom tools granted to a user, or to every user",
flags: &[Flag {
arg: "user",
kind: FlagKind::Value,
secret: false,
purpose: "The user's name; omit it to list every user's grants",
}],
},
Operation {
tool: "mahbot_config",
action: Some("create_custom_role"),
purpose: "Create a custom role from a prompt",
flags: &[
Flag {
arg: "name",
kind: FlagKind::Value,
secret: false,
purpose: "The role's name — ASCII letters, digits, '_' and '-' only, and \
not a built-in agent namespace",
},
Flag {
arg: "prompt",
kind: FlagKind::Value,
secret: false,
purpose: "The whole definition of the role: everything it is told besides \
the folder it works in",
},
Flag {
arg: "tools",
kind: FlagKind::List,
secret: false,
purpose: "A tool name, optionally `name:kind1,kind2` for one with kinds; \
repeat once per tool (omit for none)",
},
Flag {
arg: "behavior",
kind: FlagKind::Value,
secret: false,
purpose: "'continuous' (one shared branch of the role) or 'one_off' (runs \
once and reports back)",
},
],
},
Operation {
tool: "mahbot_config",
action: Some("update_custom_role"),
purpose: "Change a custom role's prompt, tools or behaviour",
flags: &[
Flag {
arg: "name",
kind: FlagKind::Value,
secret: false,
purpose: "The custom role to change",
},
Flag {
arg: "prompt",
kind: FlagKind::Value,
secret: false,
purpose: "The role's new definition (omit to leave it as it is)",
},
Flag {
arg: "tools",
kind: FlagKind::List,
secret: false,
purpose: "The role's new tool subset; repeat once per tool (omit to leave \
it as it is)",
},
Flag {
arg: "behavior",
kind: FlagKind::Value,
secret: false,
purpose: "The role's new behaviour: 'continuous' or 'one_off' (omit to \
leave it as it is)",
},
],
},
Operation {
tool: "mahbot_config",
action: Some("list_custom_roles"),
purpose: "List the custom roles",
flags: &[],
},
Operation {
tool: "mahbot_config",
action: Some("delete_custom_role"),
purpose: "Delete a custom role",
flags: &[Flag {
arg: "name",
kind: FlagKind::Value,
secret: false,
purpose: "The custom role to delete",
}],
},
]),
},
Family {
word: "workspace",
tools: &["workspace"],
protocol: crate::db::ipc::CALL_STORE_BASE,
purpose: "Work with the running product's workspaces, their tickets and their board",
surface: Surface::Operations(&[
Operation {
tool: "workspace",
action: Some("list"),
purpose: "List the registered workspaces",
flags: &[Flag {
arg: "workspace",
kind: FlagKind::Value,
secret: false,
purpose: "One registered workspace; omit it to list every one",
}],
},
Operation {
tool: "workspace",
action: Some("add"),
purpose: "Register a software project directory as a workspace",
flags: &[
Flag {
arg: "name",
kind: FlagKind::Value,
secret: false,
purpose: "The new workspace's short unique name, used in ticket ids and \
the GUI",
},
Flag {
arg: "path",
kind: FlagKind::Value,
secret: false,
purpose: "Absolute path to the project directory to register",
},
],
},
Operation {
tool: "workspace",
action: Some("pause"),
purpose: "Freeze one registered workspace, or every one when none is named",
flags: &[Flag {
arg: "workspace",
kind: FlagKind::Value,
secret: false,
purpose: "The registered workspace to freeze; omit it to freeze EVERY \
registered workspace (the fleet-wide pause — a workspace that is \
not ready is skipped and left untouched, which makes that result \
a partial one)",
}],
},
Operation {
tool: "workspace",
action: Some("resume"),
purpose: "Resume one registered workspace, or every one when none is named",
flags: &[Flag {
arg: "workspace",
kind: FlagKind::Value,
secret: false,
purpose: "The registered workspace to resume; omit it to resume EVERY \
registered workspace (the fleet-wide resume — a workspace that is \
not ready is skipped and left untouched, which makes that result \
a partial one)",
}],
},
Operation {
tool: "workspace",
action: Some("board"),
purpose: "List the tickets of one workspace, or of every one, by phase",
flags: &[
Flag {
arg: "workspace",
kind: FlagKind::Value,
secret: false,
purpose: "One registered workspace; omit it to cover every one",
},
Flag {
arg: "phase",
kind: FlagKind::Value,
secret: false,
purpose: "Narrow the listing to one ticket phase (e.g. 'queued', \
'in_development', 'done'); omit it for every non-archived ticket",
},
],
},
Operation {
tool: "workspace",
action: Some("ticket"),
purpose: "Show one ticket",
flags: &[
Flag {
arg: "ticket_id",
kind: FlagKind::Value,
secret: false,
purpose: "The ticket's full id — '<workspace>-<number>', which already \
carries its workspace (a bare number is refused)",
},
Flag {
arg: "full",
kind: FlagKind::Present,
secret: false,
purpose: "The ticket whole and unclipped — omitted, the tool's own \
default is the clipped form",
},
],
},
Operation {
tool: "workspace",
action: Some("comment"),
purpose: "Leave a clarification note on a ticket",
flags: &[
Flag {
arg: "ticket_id",
kind: FlagKind::Value,
secret: false,
purpose: "The ticket's full id — '<workspace>-<number>'",
},
Flag {
arg: "content",
kind: FlagKind::Value,
secret: false,
purpose: "The clarification note to leave on the ticket",
},
],
},
]),
},
Family {
word: "send",
tools: &["send"],
protocol: crate::db::ipc::CALL_STORE,
purpose: "Hand one message to an agent and finish: one-way — no reply is waited for or \
printed, and the answer arrives in your assistant's conversation",
surface: Surface::Standalone(&[
Flag {
arg: "message",
kind: FlagKind::Value,
secret: false,
purpose: "The work to hand over; make it self-contained — the addressee cannot \
see your conversation (required)",
},
Flag {
arg: "addressee",
kind: FlagKind::Value,
secret: false,
purpose: crate::tools::send::ADDRESSEE_FLAG_HELP,
},
Flag {
arg: "place",
kind: FlagKind::Value,
secret: false,
purpose: "The folder the addressee works in: for 'manager', the project to \
message, by its registered name or its own folder; for 'role', a \
registered project, a folder path, or 'personal:<user>'; a worker or \
a worker branch works in a directory outside the registered projects \
(one inside is refused), and in your own workspace when it names none",
},
Flag {
arg: "branch",
kind: FlagKind::Value,
secret: false,
purpose: "The branch to continue or create — a worker branch's, or a custom \
role's own named branch (ASCII letters, digits, '_' and '-', at most \
32 chars)",
},
Flag {
arg: "name",
kind: FlagKind::Value,
secret: false,
purpose: "The custom role to start (it must exist)",
},
]),
},
Family {
word: "document",
tools: &["document"],
protocol: crate::db::ipc::CALL_STORE,
purpose: "Read a document with the product's own reader, or produce one with its document \
tool — write, fill a template, edit an Office file, and every PDF operation",
surface: Surface::Operations(&[
Operation {
tool: "document",
action: Some(DOCUMENT_READ),
purpose: "Read a document with the running product's own reader: its full text and \
every image it produces, in a folder of their own",
flags: &[DOCUMENT_PATH_FLAG, DOCUMENT_FILE_NAME_FLAG, OUTPUT_DIR_FLAG],
},
Operation {
tool: "document",
action: Some("create"),
purpose: "Write a new file: docx/pptx/pdf blocks, or xlsx sheets",
flags: &[
DOCUMENT_FORMAT_FLAG,
DOCUMENT_CONTENT_FLAG,
DOCUMENT_SHEETS_FLAG,
DOCUMENT_SLIDE_SIZE_FLAG,
DOCUMENT_HEADER_FLAG,
DOCUMENT_FOOTER_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("fill_template"),
purpose: "Fill a copy of your own .docx/.pptx/.xlsx sample's placeholders",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_VALUES_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("docx_edit"),
purpose: "Edit a .docx/.docm",
flags: &[
DOCUMENT_PATH_FLAG,
IMAGE_EDITS_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("xlsx_edit"),
purpose: "Edit an .xlsx/.xlsm",
flags: &[
DOCUMENT_PATH_FLAG,
IMAGE_EDITS_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pptx_edit"),
purpose: "Edit a .pptx/.pptm",
flags: &[
DOCUMENT_PATH_FLAG,
IMAGE_EDITS_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_merge"),
purpose: "Merge PDFs into one new file",
flags: &[
DOCUMENT_PATHS_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_split"),
purpose: "Split a PDF, one produced file per page entry",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_PAGES_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_rotate"),
purpose: "Add a rotation to a PDF's pages",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_DEGREES_FLAG,
DOCUMENT_PAGES_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_crop"),
purpose: "Crop a PDF page's visible area",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_AREA_FLAG,
DOCUMENT_PAGES_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_resize"),
purpose: "Resize a PDF page, scaling its content to fit",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_WIDTH_FLAG,
DOCUMENT_HEIGHT_FLAG,
DOCUMENT_PAGES_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_text"),
purpose: "Draw text on a PDF's pages, as page content rather than an annotation",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_TEXT_FLAG,
DOCUMENT_PAGES_FLAG,
DOCUMENT_X_FLAG,
DOCUMENT_Y_FLAG,
DOCUMENT_SIZE_FLAG,
DOCUMENT_COLOR_FLAG,
DOCUMENT_STAMP_FLAG,
DOCUMENT_OPACITY_FLAG,
DOCUMENT_ROTATE_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_image"),
purpose: "Place a PNG or JPEG on a PDF's pages, as page content rather than an \
annotation",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_IMAGE_FLAG,
DOCUMENT_PAGES_FLAG,
DOCUMENT_X_FLAG,
DOCUMENT_Y_FLAG,
DOCUMENT_WIDTH_FLAG,
DOCUMENT_HEIGHT_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "document",
action: Some("pdf_form_fill"),
purpose: "Fill a PDF's form fields into a copy",
flags: &[
DOCUMENT_PATH_FLAG,
DOCUMENT_VALUES_FLAG,
DOCUMENT_FLATTEN_FLAG,
DOCUMENT_METADATA_FLAG,
DOCUMENT_FILE_NAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
]),
},
Family {
word: "image",
tools: &["image_gen"],
protocol: crate::db::ipc::CALL_STORE,
purpose: "Produce a picture with the running product's own picture tool",
surface: Surface::Operations(&[Operation {
tool: "image_gen",
action: None,
purpose: "Produce a picture from a description, or from reference pictures",
flags: &[
IMAGE_PROMPT_FLAG,
IMAGE_IMAGES_FLAG,
IMAGE_ASPECT_RATIO_FLAG,
IMAGE_SIZE_FLAG,
OUTPUT_DIR_FLAG,
],
}]),
},
Family {
word: "video",
tools: &["video_gen", "video_edit"],
protocol: crate::db::ipc::CALL_STORE,
purpose: "Produce or edit a clip with the running product's own video tools",
surface: Surface::Operations(&[
Operation {
tool: "video_gen",
action: None,
purpose: "Produce a clip from a description, or from a reference picture",
flags: &[
VIDEO_PROMPT_FLAG,
VIDEO_IMAGES_FLAG,
VIDEO_DURATION_FLAG,
VIDEO_RESOLUTION_FLAG,
VIDEO_ASPECT_RATIO_FLAG,
VIDEO_SIZE_FLAG,
VIDEO_GENERATE_AUDIO_FLAG,
VIDEO_SEED_FLAG,
OUTPUT_DIR_FLAG,
],
},
Operation {
tool: "video_edit",
action: None,
purpose: "Edit a clip: restyle it from a description, or give its first and \
last frames",
flags: &[
EDIT_VIDEO_URL_FLAG,
EDIT_INSTRUCTION_FLAG,
EDIT_DURATION_FLAG,
EDIT_IMAGES_FLAG,
EDIT_FIRST_FRAME_FLAG,
EDIT_LAST_FRAME_FLAG,
OUTPUT_DIR_FLAG,
],
},
]),
},
Family {
word: "rasterize",
tools: &["rasterize"],
protocol: crate::db::ipc::CALL_STORE,
purpose: "Turn a file into an ordinary picture with the running product's own rasterizer",
surface: Surface::Standalone(&[RASTER_PATH_FLAG, RASTER_PAGE_FLAG, OUTPUT_DIR_FLAG]),
},
];
const RASTER_PATH_FLAG: Flag = Flag {
arg: "path",
kind: FlagKind::Path,
secret: false,
purpose: "The file to render — an SVG, one page of a PDF, or an HTML file inside your \
personal workspace — or an http(s) address; resolved where this command runs, and \
read with the owner's own authority",
};
const RASTER_PAGE_FLAG: Flag = Flag {
arg: "page",
kind: FlagKind::Number,
secret: false,
purpose: "The 1-based page to render, for a PDF of several pages",
};
const OUTPUT_DIR_FLAG: Flag = Flag {
arg: "output_dir",
kind: FlagKind::Destination,
secret: false,
purpose: "The directory the operation writes into — the produced file(s), or the one folder \
they are all in; it must already exist, and it is the directory this command is run \
in when it is left out",
};
const DOCUMENT_FILE_NAME_FLAG: Flag = Flag {
arg: "file_name",
kind: FlagKind::Value,
secret: false,
purpose: "The base name of what is produced, without an extension where one is appended; a \
free name is used, so nothing already there is overwritten",
};
const DOCUMENT_FORMAT_FLAG: Flag = Flag {
arg: "format",
kind: FlagKind::Value,
secret: false,
purpose: "The format to write: docx, pptx, xlsx or pdf",
};
const DOCUMENT_SLIDE_SIZE_FLAG: Flag = Flag {
arg: "size",
kind: FlagKind::Value,
secret: false,
purpose: "A pptx slide size: widescreen, standard or portrait; unnamed is the tool's own default deck",
};
const DOCUMENT_CONTENT_FLAG: Flag = Flag {
arg: "content",
kind: FlagKind::Structured(&["image"]),
secret: false,
purpose: "The blocks to lay down, as a JSON array — one object per block, with the \
'text'/'table'/'image'/'...' kind it names beside its own fields",
};
const DOCUMENT_HEADER_FLAG: Flag = Flag {
arg: "header",
kind: FlagKind::Value,
secret: false,
purpose: "A created .docx's running header, one paragraph per line; {page} is the page \
number and {pages} the document's page count",
};
const DOCUMENT_FOOTER_FLAG: Flag = Flag {
arg: "footer",
kind: FlagKind::Value,
secret: false,
purpose: "A created .docx's running footer, one paragraph per line; {page} is the page \
number and {pages} the document's page count",
};
const DOCUMENT_SHEETS_FLAG: Flag = Flag {
arg: "sheets",
kind: FlagKind::Structured(&[]),
secret: false,
purpose: "The worksheets to write, as a JSON array — one object per sheet with its 'name', \
its 'rows' and its own fields",
};
const DOCUMENT_METADATA_FLAG: Flag = Flag {
arg: "metadata",
kind: FlagKind::Structured(&[]),
secret: false,
purpose: "A PDF's own description, as a JSON object keyed {{pdf_metadata_fields}}; a named \
field is set (a date is {{pdf_date_format}}), an empty string clears one, a field \
you do not name is left as the file has it, and an existing modified date advances \
to the write",
};
const DOCUMENT_PATH_FLAG: Flag = Flag {
arg: "path",
kind: FlagKind::Path,
secret: false,
purpose: "The input file to work on",
};
const DOCUMENT_PATHS_FLAG: Flag = Flag {
arg: "paths",
kind: FlagKind::PathList,
secret: false,
purpose: "One input PDF, in the order it must be merged; repeat once per file, or name a \
file holding them as a JSON array with '@<file>'",
};
const DOCUMENT_PAGES_FLAG: Flag = Flag {
arg: "pages",
kind: FlagKind::List,
secret: false,
purpose: "One page entry: \"all\", a page, a range, or a comma list like \"1-3,5\" (\"all\" \
stands alone); repeat once per entry, or name a file holding them as a JSON array \
with '@<file>'; left out, every page",
};
const IMAGE_EDITS_FLAG: Flag = Flag {
arg: "edits",
kind: FlagKind::Structured(&["image"]),
secret: false,
purpose: EDITS_PURPOSE,
};
const EDITS_PURPOSE: &str = "The edits to apply, as a JSON array — one object per edit, naming the \
operation it is ('format_text', 'set_cell', 'add_slide', …) beside \
its own fields";
const DOCUMENT_VALUES_FLAG: Flag = Flag {
arg: "values",
kind: FlagKind::Structured(&[]),
secret: false,
purpose: "The values to write, as a JSON object keyed by placeholder name (or form field \
name)",
};
const DOCUMENT_DEGREES_FLAG: Flag = Flag {
arg: "degrees",
kind: FlagKind::Number,
secret: false,
purpose: "The rotation to add, in degrees (a multiple of the tool's own step)",
};
const DOCUMENT_AREA_FLAG: Flag = Flag {
arg: "area",
kind: FlagKind::Structured(&[]),
secret: false,
purpose: "The area to keep, as a JSON object of x, y, width and height — fractions of the \
page's visible area, from the displayed top-left",
};
const DOCUMENT_TEXT_FLAG: Flag = Flag {
arg: "text",
kind: FlagKind::Value,
secret: false,
purpose: "The text to draw",
};
const DOCUMENT_IMAGE_FLAG: Flag = Flag {
arg: "image",
kind: FlagKind::Path,
secret: false,
purpose: "The PNG or JPEG to place",
};
const DOCUMENT_X_FLAG: Flag = Flag {
arg: "x",
kind: FlagKind::Number,
secret: false,
purpose: "Across the page, in points",
};
const DOCUMENT_Y_FLAG: Flag = Flag {
arg: "y",
kind: FlagKind::Number,
secret: false,
purpose: "Down the page, in points",
};
const DOCUMENT_SIZE_FLAG: Flag = Flag {
arg: "size",
kind: FlagKind::Number,
secret: false,
purpose: "The font size, in points",
};
const DOCUMENT_COLOR_FLAG: Flag = Flag {
arg: "color",
kind: FlagKind::Value,
secret: false,
purpose: "The text colour, as hex digits",
};
const DOCUMENT_STAMP_FLAG: Flag = Flag {
arg: "stamp",
kind: FlagKind::Present,
secret: false,
purpose: "Draw a box around the text",
};
const DOCUMENT_OPACITY_FLAG: Flag = Flag {
arg: "opacity",
kind: FlagKind::Number,
secret: false,
purpose: "Opacity from just above zero to one; unnamed is fully opaque",
};
const DOCUMENT_ROTATE_FLAG: Flag = Flag {
arg: "rotate",
kind: FlagKind::Number,
secret: false,
purpose: "The text's own rotation, in degrees",
};
const DOCUMENT_WIDTH_FLAG: Flag = Flag {
arg: "width",
kind: FlagKind::Number,
secret: false,
purpose: "The width, in points: a picture's placed width, or the page width a resize makes",
};
const DOCUMENT_HEIGHT_FLAG: Flag = Flag {
arg: "height",
kind: FlagKind::Number,
secret: false,
purpose: "The height, in points: a picture's placed height, or the page height a resize makes",
};
const DOCUMENT_FLATTEN_FLAG: Flag = Flag {
arg: "flatten",
kind: FlagKind::Present,
secret: false,
purpose: "Bake the fields into the page content and remove them",
};
const IMAGE_PROMPT_FLAG: Flag = Flag {
arg: "prompt",
kind: FlagKind::Value,
secret: false,
purpose: "The text description of the picture to produce (required)",
};
const IMAGE_IMAGES_FLAG: Flag = Flag {
arg: "images",
kind: FlagKind::PathList,
secret: false,
purpose: "One reference picture, by path; repeat once per picture, or name a file holding \
them as a JSON array with '@<file>' — given any, the request is \
picture-to-picture rather than text-to-picture",
};
const IMAGE_ASPECT_RATIO_FLAG: Flag = Flag {
arg: "aspect_ratio",
kind: FlagKind::Value,
secret: false,
purpose: "The aspect ratio, like '16:9', '1:1' or '4:3'",
};
const IMAGE_SIZE_FLAG: Flag = Flag {
arg: "size",
kind: FlagKind::Value,
secret: false,
purpose: "The picture's size — a tier like '1K'/'2K' or explicit pixels like '1024x1024'; \
a model that cannot express the value is not sent the request",
};
const VIDEO_PROMPT_FLAG: Flag = Flag {
arg: "prompt",
kind: FlagKind::Value,
secret: false,
purpose: "The text description of the clip to produce (required)",
};
const VIDEO_IMAGES_FLAG: Flag = Flag {
arg: "images",
kind: FlagKind::PathList,
secret: false,
purpose: "The path of ONE reference picture to start the clip from — at most one is \
accepted, and given it the request is picture-to-clip rather than \
text-to-clip",
};
const VIDEO_DURATION_FLAG: Flag = Flag {
arg: "duration",
kind: FlagKind::Number,
secret: false,
purpose: "The clip's length in seconds — the values the account's own model takes are the \
ones it takes",
};
const VIDEO_RESOLUTION_FLAG: Flag = Flag {
arg: "resolution",
kind: FlagKind::Value,
secret: false,
purpose: "The resolution to ask the model for, as that model states it",
};
const VIDEO_ASPECT_RATIO_FLAG: Flag = Flag {
arg: "aspect_ratio",
kind: FlagKind::Value,
secret: false,
purpose: "The aspect ratio to ask the model for, as that model states it",
};
const VIDEO_SIZE_FLAG: Flag = Flag {
arg: "size",
kind: FlagKind::Value,
secret: false,
purpose: "The exact size, as WxH (like '1280x720')",
};
const VIDEO_GENERATE_AUDIO_FLAG: Flag = Flag {
arg: "generate_audio",
kind: FlagKind::Present,
secret: false,
purpose: "Ask for an audio track, where the model offers one — omitted, the model's own \
default applies and `=false` asks for a silent clip",
};
const VIDEO_SEED_FLAG: Flag = Flag {
arg: "seed",
kind: FlagKind::Number,
secret: false,
purpose: "The seed for a repeatable generation",
};
const EDIT_VIDEO_URL_FLAG: Flag = Flag {
arg: "video_url",
kind: FlagKind::Path,
secret: false,
purpose: "The clip to edit: a local path or a public https URL — required for a restyle, \
and mutually exclusive with the first and last frames",
};
const EDIT_INSTRUCTION_FLAG: Flag = Flag {
arg: "instruction",
kind: FlagKind::Value,
secret: false,
purpose: "The edit to apply, described in words (required)",
};
const EDIT_DURATION_FLAG: Flag = Flag {
arg: "duration",
kind: FlagKind::Number,
secret: false,
purpose: "The produced clip's length in seconds — the values the account's own model takes \
are the ones it takes",
};
const EDIT_IMAGES_FLAG: Flag = Flag {
arg: "images",
kind: FlagKind::PathList,
secret: false,
purpose: "One reference picture guiding the restyle, by local path or public https URL; \
repeat once per picture, or name a file holding them as a JSON array with \
'@<file>' — needs a clip, and excludes the first and last frames",
};
const EDIT_FIRST_FRAME_FLAG: Flag = Flag {
arg: "first_frame",
kind: FlagKind::Path,
secret: false,
purpose: "The picture to use as the exact first frame, by local path or public https URL — \
the frame-anchor form, which excludes a clip and reference pictures",
};
const EDIT_LAST_FRAME_FLAG: Flag = Flag {
arg: "last_frame",
kind: FlagKind::Path,
secret: false,
purpose: "The picture to use as the exact last frame, by local path or public https URL — \
the frame-anchor form, which excludes a clip and reference pictures",
};
const USER_FLAG: Flag = Flag {
arg: "user",
kind: FlagKind::Value,
secret: false,
purpose: "The user's name",
};
const CUSTOM_TOOL_FLAG: Flag = Flag {
arg: "tool",
kind: FlagKind::Value,
secret: false,
purpose: "The custom tool's name — a script in the 'shared' folder of your personal workspace",
};
pub struct Cli(&'static Family);
#[must_use]
fn family(word: &str) -> Option<Cli> {
FAMILIES.iter().find(|family| family.word == word).map(Cli)
}
pub enum EntryPoint {
Daemon,
Paused,
Help,
Version,
Debug,
GrepEngine,
EnvDump,
BenchOpenRouter,
Audio,
Chrome,
Family(Cli),
}
#[must_use]
pub fn entry_point(word: Option<&str>) -> Option<EntryPoint> {
Some(match word {
None => EntryPoint::Daemon,
Some("paused") => EntryPoint::Paused,
Some("-h" | "--help") => EntryPoint::Help,
Some("-V" | "--version") => EntryPoint::Version,
Some("debug") => EntryPoint::Debug,
Some("__grep-engine") => EntryPoint::GrepEngine,
Some("__env-dump") => EntryPoint::EnvDump,
Some("bench-openrouter") => EntryPoint::BenchOpenRouter,
Some("audio") => EntryPoint::Audio,
Some("chrome") => EntryPoint::Chrome,
Some(word) => EntryPoint::Family(family(word)?),
})
}
pub(crate) fn fronts_tool(tool: &str) -> bool {
family_tools().any(|fronted| fronted == tool)
}
fn family_tools() -> impl Iterator<Item = &'static str> {
FAMILIES
.iter()
.flat_map(|family| family.tools.iter().copied())
}
pub(crate) fn family_words() -> impl Iterator<Item = &'static str> {
FAMILIES.iter().map(|family| family.word)
}
pub(crate) fn producing_families() -> String {
FAMILIES
.iter()
.filter(|family| produces_files(family))
.map(|family| format!("`{}`", family.word))
.collect::<Vec<_>>()
.join("/")
}
enum Tail {
Help,
Call(Parsed),
}
struct Parsed {
args: serde_json::Value,
secrets: Vec<String>,
output_dir: Option<String>,
}
fn flag_spelling(arg: &str) -> String {
arg.replace('_', "-")
}
fn parse_flags(
action: Option<&'static str>,
operation_word: &str,
flags: &'static [Flag],
tokens: &[String],
) -> Result<Tail, String> {
let mut args = serde_json::Map::new();
if let Some(action) = action {
args.insert(
"action".to_string(),
serde_json::Value::String(action.to_string()),
);
}
let mut secrets = Vec::new();
let mut output_dir: Option<String> = None;
let mut given: Vec<&'static str> = Vec::new();
let mut lists: Vec<(&'static str, Vec<serde_json::Value>)> = Vec::new();
let spellings: Vec<String> = flags.iter().map(|flag| flag_spelling(flag.arg)).collect();
let mut rest = tokens.iter();
while let Some(token) = rest.next() {
let (name, inline) = match grammar::read_token(token)? {
grammar::Token::Help => return Ok(Tail::Help),
grammar::Token::Flag { name, inline } => (name, inline),
};
let Some(index) = spellings.iter().position(|spelling| spelling == name) else {
return Err(grammar::unknown_flag(name, operation_word));
};
let flag = &flags[index];
if !matches!(flag.kind, FlagKind::List | FlagKind::PathList) {
if given.contains(&flag.arg) {
return Err(grammar::repeated_flag(name));
}
given.push(flag.arg);
}
match flag.kind {
FlagKind::Present => {
let value = match inline {
Some(text) => text.parse::<bool>().map_err(|_| bool_switch_misuse(name))?,
None => true,
};
args.insert(flag.arg.to_string(), serde_json::Value::Bool(value));
}
FlagKind::Value
| FlagKind::Path
| FlagKind::Number
| FlagKind::List
| FlagKind::PathList
| FlagKind::Structured(_)
| FlagKind::Destination => {
let value = match inline {
Some(value) => value.to_string(),
None => rest
.next()
.cloned()
.ok_or_else(|| grammar::flag_value_missing(name))?,
};
take_value(flag, &value, &mut args, &mut lists, &mut output_dir)?;
if flag.secret {
secrets.extend(held_texts(flag, &args, &lists));
}
}
}
}
for (arg, entries) in lists {
args.insert(arg.to_string(), serde_json::Value::Array(entries));
}
Ok(Tail::Call(Parsed {
args: serde_json::Value::Object(args),
secrets,
output_dir,
}))
}
fn take_value(
flag: &Flag,
value: &str,
args: &mut serde_json::Map<String, serde_json::Value>,
lists: &mut Vec<(&'static str, Vec<serde_json::Value>)>,
output_dir: &mut Option<String>,
) -> Result<(), String> {
let arg = flag.arg.to_string();
let escaped = value.strip_prefix("./@").map(|rest| format!("@{rest}"));
let value: &str = escaped.as_deref().unwrap_or(value);
let file: Option<&str> = if escaped.is_none() && flag.kind.has_file_form() {
value.strip_prefix('@')
} else {
None
};
match flag.kind {
FlagKind::Destination => *output_dir = Some(value.to_string()),
FlagKind::Value => {
let held = match file {
Some(file) => text_from_file(flag, file)?,
None => value.to_string(),
};
args.insert(arg, serde_json::Value::String(held));
}
FlagKind::Path => {
args.insert(arg, serde_json::Value::String(caller_path(flag, value)?));
}
FlagKind::Number => {
args.insert(arg, number_or_text(value));
}
FlagKind::Structured(fields) => {
let mut structured = match file {
Some(file) => json_from_file(flag, file)?,
None => serde_json::from_str(value)
.unwrap_or_else(|_| serde_json::Value::String(value.to_string())),
};
resolve_embedded_paths(flag, fields, &mut structured)?;
args.insert(arg, structured);
}
FlagKind::List | FlagKind::PathList => {
let named = matches!(flag.kind, FlagKind::PathList);
let entries = if let Some(index) = lists.iter().position(|(held, _)| *held == flag.arg)
{
&mut lists[index].1
} else {
lists.push((flag.arg, Vec::new()));
let index = lists.len() - 1;
&mut lists[index].1
};
let mut entry = |value: &str| -> Result<(), String> {
let held = if named {
caller_path(flag, value)?
} else {
value.to_string()
};
entries.push(serde_json::Value::String(held));
Ok(())
};
match file {
Some(file) => {
for held in list_entries_from_file(flag, file)? {
entry(held.as_str().expect("a list entry is a string"))?;
}
}
None if value.is_empty() => entries.clear(),
None => entry(value)?,
}
}
FlagKind::Present => unreachable!("a switch carries no value"),
}
Ok(())
}
fn held_texts(
flag: &Flag,
args: &serde_json::Map<String, serde_json::Value>,
lists: &[(&'static str, Vec<serde_json::Value>)],
) -> Vec<String> {
if let Some((_, entries)) = lists.iter().find(|(held, _)| *held == flag.arg) {
return entries
.iter()
.filter_map(|entry| entry.as_str().map(str::to_string))
.collect();
}
args.get(flag.arg)
.and_then(|value| value.as_str().map(str::to_string))
.into_iter()
.collect()
}
fn number_or_text(value: &str) -> serde_json::Value {
serde_json::from_str::<serde_json::Value>(value)
.ok()
.filter(serde_json::Value::is_number)
.unwrap_or_else(|| serde_json::Value::String(value.to_string()))
}
fn bool_switch_misuse(name: &str) -> String {
format!("flag '--{name}' is a switch: write it bare for true, or '--{name}=false' for false")
}
fn caller_path(flag: &Flag, value: &str) -> Result<String, String> {
if value.is_empty() || crate::util::is_http_url(value) {
return Ok(value.to_string());
}
let path = {
let expanded = crate::util::expand_tilde(value);
if expanded.is_absolute() {
expanded.to_string_lossy().into_owned()
} else {
std::env::current_dir().map_or_else(
|_| value.to_string(),
|here| here.join(expanded).to_string_lossy().into_owned(),
)
}
};
let spelling = flag_spelling(flag.arg);
let unreadable = |spelling: &str| {
format!("flag '--{spelling}' names a reference file that is not there or cannot be read")
};
let Ok(meta) = std::fs::metadata(&path) else {
return Err(unreadable(&spelling));
};
if !meta.is_file() {
return Err(format!(
"flag '--{spelling}' names something that is not a regular file; a reference file must \
be one"
));
}
if std::fs::File::open(&path).is_err() {
return Err(unreadable(&spelling));
}
Ok(path)
}
fn resolve_embedded_paths(
flag: &Flag,
fields: &'static [&'static str],
value: &mut serde_json::Value,
) -> Result<(), String> {
match value {
serde_json::Value::Object(members) => {
for (key, member) in members.iter_mut() {
if fields.contains(&key.as_str()) {
resolve_embedded_path(flag, member)?;
} else {
resolve_embedded_paths(flag, fields, member)?;
}
}
}
serde_json::Value::Array(items) => {
for item in items {
resolve_embedded_paths(flag, fields, item)?;
}
}
_ => {}
}
Ok(())
}
fn resolve_embedded_path(flag: &Flag, member: &mut serde_json::Value) -> Result<(), String> {
match member {
serde_json::Value::String(text) => *text = caller_path(flag, text)?,
serde_json::Value::Array(items) => {
for item in items {
if let serde_json::Value::String(text) = item {
*text = caller_path(flag, text)?;
}
}
}
_ => {}
}
Ok(())
}
const MAX_VALUE_FILE_BYTES: u64 = 8 * 1024 * 1024;
fn value_file_text(flag: &Flag, file: &str, what: &str) -> Result<String, String> {
let spelling = flag_spelling(flag.arg);
let unusable = |why: &str| {
format!(
"flag '--{spelling}' names a file that {why}; '@<file>' must name a file holding {what}"
)
};
let path = crate::util::expand_tilde(file);
let Ok(meta) = std::fs::metadata(&path) else {
return Err(unusable("could not be read"));
};
if !meta.is_file() {
return Err(unusable("is not a regular file"));
}
if meta.len() > MAX_VALUE_FILE_BYTES {
return Err(unusable(&format!(
"is over {} MB",
MAX_VALUE_FILE_BYTES / (1024 * 1024)
)));
}
std::fs::read_to_string(&path).map_err(|_| unusable("could not be read"))
}
fn text_from_file(flag: &Flag, file: &str) -> Result<String, String> {
let text = value_file_text(flag, file, "the value")?;
Ok(text
.strip_suffix('\n')
.map_or(text.as_str(), |rest| {
rest.strip_suffix('\r').unwrap_or(rest)
})
.to_string())
}
fn json_from_file(flag: &Flag, file: &str) -> Result<serde_json::Value, String> {
let spelling = flag_spelling(flag.arg);
let text = value_file_text(flag, file, "the value")?;
serde_json::from_str(&text).map_err(|_| {
format!(
"flag '--{spelling}' names a file that does not hold the value; '@<file>' must \
name a file holding it as JSON"
)
})
}
fn list_entries_from_file(flag: &Flag, file: &str) -> Result<Vec<serde_json::Value>, String> {
let spelling = flag_spelling(flag.arg);
let text = value_file_text(flag, file, "the entries as a JSON array")?;
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(serde_json::Value::Array(entries))
if entries.iter().all(serde_json::Value::is_string) =>
{
Ok(entries)
}
_ => Err(format!(
"flag '--{spelling}' names a file that does not hold the entries; '@<file>' must name \
a file holding them as a JSON array of strings"
)),
}
}
const EXIT_CLASS_HELP: &str = "\nExit codes:\n 0 the operation was carried out\n 1 the \
operation failed — the product refused it, carried it out only in part, or the exchange was \
severed after the call went out (an operation that may be under way is never the \
environment's class)\n 2 the environment — nothing ran: the product is not up, its \
channel was not reachable, or it could not take the operation on\n 3 this command was \
misused\nA script reads the exit code, not the text: a refusal still prints the product's \
own answer on stdout, so only the code says whether the operation happened.\n";
const RUNNING_CONTRACT_HELP: &str = "\nA frontend onto the RUNNING product, acting with the \
owner's authority and with no agent session behind it: the operation is carried into the \
instance that is already up and the product itself runs it there — the same validation, and \
the same immediate effect (a value it writes is in use at once, with no restart). The product \
must be running: there is no way to run these operations against its stores directly.\n";
const OUTPUT_HELP: &str = "\nOutput:\n stdout the product's answer, word for word\n stderr \
this command's own diagnostics\n";
const VALUE_FILE_HELP: &str = "\nValues:\n A text value, a list or a structure may also be given \
from a FILE by writing `@<file>`: a text value is the file's whole text (without one trailing \
line break), while a list and a structure must hold JSON there. Write `./@…` for a value that \
itself begins with `@` — the `./` is dropped and no file is read. A flag whose value names a \
file or a directory itself (`--path`, `--output-dir`) takes no file form.\n";
fn producing_help() -> String {
let mut out = String::from("\nWhere produced files go:\n");
out.push_str(&flag_rows(std::iter::once(&OUTPUT_DIR_FLAG)));
out.push_str(
"\nThe names are this command's own: a free one is chosen so nothing already there is \
overwritten, and an operation producing several files puts all of them there. The \
product's own data directory and its temporary areas are refused, because the product's \
own cleanup would take a file left there.\n\nPaths:\n Every path this command takes as a \
flag — the destination, a reference file, and every path a value carries, a structured \
value's own included — is resolved where this command runs: a relative one in the \
directory it was run in, and `~` against your home directory; a reference file must be \
there and readable.\n",
);
out
}
fn family_help(family: &Family) -> String {
let mut out = String::new();
let _ = writeln!(out, "mahbot {} — {}", family.word, family.purpose);
out.push_str(RUNNING_CONTRACT_HELP);
match family.surface {
Surface::Operations(operations) => {
let _ = writeln!(
out,
"\nEvery operation below is carried out inside the product, by the same code the \
owner's assistant runs — so this command line brings no behaviour of its own."
);
let _ = write!(out, "\nUsage: {}\n", family_syntax(family));
out.push_str("\nOperations:\n");
let width = operations
.iter()
.map(|operation| operation_word(family, operation).len())
.max()
.unwrap_or_default();
for operation in operations {
let _ = writeln!(
out,
" {:<width$} {}",
operation_word(family, operation),
operation.purpose
);
}
if produces_files(family) {
out.push_str(&producing_help());
}
out.push_str(if produces_files(family) {
"\nFlags:\n The one flag every operation takes is the destination above; every \
other flag belongs to the operation whose argument it names.\n\n -h, --help \
Print this help and exit\n"
} else {
"\nFlags:\n None are common to every operation: a flag belongs to the operation \
whose argument it names.\n\n -h, --help Print this help and exit\n"
});
}
Surface::Standalone(flags) => {
let _ = write!(out, "\nUsage: {}\n", family_syntax(family));
if produces_files(family) {
out.push_str(&producing_help());
}
out.push_str("\nFlags:\n");
out.push_str(&flag_table(flags));
out.push_str("\n -h, --help Print this help and exit\n");
}
}
out.push_str(EXIT_CLASS_HELP);
out.push_str(OUTPUT_HELP);
out.push_str(VALUE_FILE_HELP);
out
}
fn flag_purpose(flag: &Flag) -> String {
flag.purpose
.replace(
"{{pdf_metadata_fields}}",
&crate::docgen::pdf_metadata_fields_spelled(),
)
.replace("{{pdf_date_format}}", crate::docgen::pdf_date_format())
}
fn flag_rows<'a>(flags: impl IntoIterator<Item = &'a Flag>) -> String {
let rows: Vec<(String, String)> = flags
.into_iter()
.map(|flag| {
let mut spelling = format!("--{}", flag_spelling(flag.arg));
match flag.kind {
FlagKind::Value => spelling.push_str(" <value>"),
FlagKind::Path => spelling.push_str(" <path>"),
FlagKind::Number => spelling.push_str(" <number>"),
FlagKind::List => spelling.push_str(" <value> (repeatable; empty clears the list)"),
FlagKind::PathList => {
spelling.push_str(" <path> (repeatable; empty clears the list)");
}
FlagKind::Structured(_) => spelling.push_str(" <json>"),
FlagKind::Present => spelling.push_str("[=<true|false>]"),
FlagKind::Destination => spelling.push_str(" <dir>"),
}
let description = if flag.secret {
format!(
"{} — the value is never echoed or recorded",
flag_purpose(flag)
)
} else {
flag_purpose(flag)
};
(spelling, description)
})
.collect();
let width = rows
.iter()
.map(|(spelling, _)| spelling.len())
.max()
.unwrap_or_default();
let mut out = String::new();
for (spelling, description) in rows {
let _ = writeln!(out, " {spelling:<width$} {description}");
}
out
}
fn flag_table(flags: &[Flag]) -> String {
let flags: Vec<&Flag> = flags
.iter()
.filter(|flag| !matches!(flag.kind, FlagKind::Destination))
.collect();
if flags.is_empty() {
return " none\n".to_string();
}
flag_rows(flags)
}
fn operation_help(family: &Family, operation: &Operation) -> String {
let word = operation_word(family, operation);
let mut out = String::new();
let _ = writeln!(
out,
"mahbot {} {} — {}",
family.word, word, operation.purpose
);
let _ = write!(out, "\nUsage: mahbot {} {} [flags]\n", family.word, word);
if produces_files(family) {
out.push_str(&producing_help());
}
out.push_str("\nFlags:\n");
out.push_str(&flag_table(operation.flags));
out.push_str(RUNNING_CONTRACT_HELP);
out.push_str(EXIT_CLASS_HELP);
out.push_str(OUTPUT_HELP);
out.push_str(VALUE_FILE_HELP);
out
}
pub async fn run(cli: Cli, args: &[String]) -> i32 {
let family = cli.0;
let (tool, parsed) = match family.surface {
Surface::Operations(operations) => {
let Some(named) = args.first().map(String::as_str) else {
return misuse(family, "no operation was given");
};
if grammar::is_help(named) {
crate::util::print_stdout(&family_help(family));
return Exit::Ok.code();
}
let Some(operation) = operations
.iter()
.find(|operation| operation_word(family, operation) == named)
else {
return misuse(family, &format!("unknown operation '{named}'"));
};
match parse_flags(operation.action, named, operation.flags, &args[1..]) {
Ok(Tail::Call(parsed)) => (operation.tool, parsed),
Ok(Tail::Help) => {
crate::util::print_stdout(&operation_help(family, operation));
return Exit::Ok.code();
}
Err(reason) => return misuse(family, &reason),
}
}
Surface::Standalone(flags) => {
let tool = *family
.tools
.first()
.expect("a standalone family fronts exactly one tool");
match parse_flags(None, family.word, flags, args) {
Ok(Tail::Call(parsed)) => (tool, parsed),
Ok(Tail::Help) => {
crate::util::print_stdout(&family_help(family));
return Exit::Ok.code();
}
Err(reason) => return misuse(family, &reason),
}
}
};
let output_dir = if produces_files(family) {
match resolve_output_dir(parsed.output_dir.as_deref()) {
Ok(dir) => Some(dir),
Err(reason) => return misuse(family, &reason),
}
} else {
None
};
let root = match running_root(family).await {
Ok(root) => root,
Err(code) => return code,
};
let call = CallRequest {
tool: tool.to_string(),
args: parsed.args,
secrets: parsed.secrets,
output_dir,
};
let decision = verdict(
family.word,
crate::db::ipc::ipc_call_with_wait(&root, &call, family.protocol).await,
);
if let Some(answer) = decision.answer {
crate::util::print_stdout(&answer);
}
if let Some(diagnostic) = decision.diagnostic {
crate::util::print_stderr(&diagnostic);
}
decision.code.code()
}
async fn running_root(family: &Family) -> Result<std::path::PathBuf, i32> {
let root = match crate::config::default_config_dir() {
Ok(root) => root,
Err(e) => {
crate::util::print_stderr(&format!(
"mahbot {}: cannot resolve the storage root: {e:#}",
family.word
));
return Err(Exit::Environment.code());
}
};
let deadline = std::time::Instant::now() + crate::db::ipc::ipc_bound_timeout();
let mut attempt = 0usize;
loop {
match crate::util::lock::instance_lock_state_settled(&root) {
InstanceLockState::Held => return Ok(root),
InstanceLockState::Free if std::time::Instant::now() >= deadline => {
crate::util::print_stderr(&format!(
"mahbot {}: no instance is running against {} — these commands act on the \
running product: start mahbot and try again",
family.word,
root.display()
));
return Err(Exit::Environment.code());
}
InstanceLockState::Unknown(e) if std::time::Instant::now() >= deadline => {
crate::util::print_stderr(&format!(
"mahbot {}: cannot tell whether a mahbot instance holds {} ({e}) — an \
operation is only ever sent to a running product, so none was sent",
family.word,
root.display()
));
return Err(Exit::Environment.code());
}
InstanceLockState::Free | InstanceLockState::Unknown(_) => {}
}
tokio::time::sleep(crate::db::ipc::retry_backoff(attempt)).await;
attempt += 1;
}
}
fn misuse(family: &Family, reason: &str) -> i32 {
crate::util::print_stderr(&format!("mahbot {}: {reason}", family.word));
crate::util::print_stderr(&format!("run 'mahbot {} -h' for usage.", family.word));
Exit::Usage.code()
}
fn resolve_output_dir(named: Option<&str>) -> Result<String, String> {
let (raw, what) = match named {
Some(value) => (
crate::util::expand_tilde(value),
format!("the destination '{value}'"),
),
None => (
std::env::current_dir().map_err(|e| {
format!(
"the directory this command was run in cannot be used as the destination: {e}"
)
})?,
"the directory this command was run in".to_string(),
),
};
let resolved =
std::fs::canonicalize(&raw).map_err(|e| format!("{what} cannot be used: {e}"))?;
if !resolved.is_dir() {
return Err(format!(
"{what} is not a directory — the destination is the directory the produced files go \
into"
));
}
if let Some(place) = cleanup_governed(&resolved) {
return Err(format!(
"{what} is {place} — a produced file must go to a directory of your own"
));
}
#[cfg(unix)]
if !crate::util::dir_is_writable(&resolved) {
return Err(format!("{what} cannot be written into"));
}
Ok(crate::util::strip_verbatim_prefix(&resolved)
.to_string_lossy()
.into_owned())
}
fn cleanup_governed(dir: &std::path::Path) -> Option<&'static str> {
const DATA_DIR: &str = "the product's own data directory, where it keeps its own files";
const TEMP: &str = "the product's own temporary area, which its cleanup keeps empty";
if let Ok(root) = crate::config::default_config_dir() {
let root = std::fs::canonicalize(&root).unwrap_or(root);
if crate::util::is_within(dir, &root) {
return Some(DATA_DIR);
}
}
if crate::temp::cleanup_scan_roots_unpinned()
.iter()
.any(|root| crate::util::is_within(dir, root))
{
return Some(TEMP);
}
None
}
const NON_TEXT_ARGUMENT: &str = "an argument that is not valid text was given — no entry point \
and no value can be read from it, and it is not printed here; \
check what you passed";
#[must_use]
pub fn refuse_non_text_argument() -> i32 {
crate::util::print_stderr(&format!("mahbot: {NON_TEXT_ARGUMENT}"));
Exit::Usage.code()
}
const UNKNOWN_ENTRY_POINT: &str = "the first word names no entry point of this build — nothing \
was started; run 'mahbot -h' for the commands this build has";
#[must_use]
pub fn refuse_unknown_entry_point() -> i32 {
crate::util::print_stderr(&format!("mahbot: {UNKNOWN_ENTRY_POINT}"));
Exit::Usage.code()
}
struct Verdict {
answer: Option<String>,
diagnostic: Option<String>,
code: Exit,
}
fn verdict(word: &str, attempt: CallAttempt) -> Verdict {
match attempt {
CallAttempt::Answered(reply) => match reply.outcome {
CallOutcome::Done => Verdict {
answer: Some(reply.answer),
diagnostic: None,
code: Exit::Ok,
},
CallOutcome::Partial => Verdict {
answer: Some(reply.answer),
diagnostic: Some(format!(
"mahbot {word}: the product reports the operation was carried out only in \
part — its answer above names the part that did not succeed"
)),
code: Exit::Failed,
},
CallOutcome::Refused => Verdict {
answer: Some(reply.answer),
diagnostic: None,
code: Exit::Failed,
},
CallOutcome::NotAccepted | CallOutcome::Unavailable => Verdict {
answer: None,
diagnostic: Some(format!("mahbot {word}: {}", reply.answer)),
code: Exit::Environment,
},
},
CallAttempt::NotServed(reason) => Verdict {
answer: None,
diagnostic: Some(format!("mahbot {word}: {reason}")),
code: Exit::Environment,
},
CallAttempt::TooOld => Verdict {
answer: None,
diagnostic: Some(format!(
"mahbot {word}: the running product is older than this command line and cannot \
serve its operations — restart it from this build and try again"
)),
code: Exit::Environment,
},
CallAttempt::Severed(e) => Verdict {
answer: None,
diagnostic: Some(format!(
"mahbot {word}: the outcome of the operation could not be confirmed, so it may \
have been carried out, and it was deliberately NOT sent again ({e:#})"
)),
code: Exit::Failed,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chrome::contract::OutKind;
use crate::db::ipc::CallReply;
#[test]
fn every_entry_point_resolves_and_is_listed_in_the_top_level_usage() {
assert!(matches!(entry_point(Some("-h")), Some(EntryPoint::Help)));
assert!(matches!(
entry_point(Some("--help")),
Some(EntryPoint::Help)
));
assert!(matches!(entry_point(Some("-V")), Some(EntryPoint::Version)));
assert!(matches!(
entry_point(Some("--version")),
Some(EntryPoint::Version)
));
assert!(matches!(
entry_point(Some("paused")),
Some(EntryPoint::Paused)
));
assert!(matches!(
entry_point(Some("debug")),
Some(EntryPoint::Debug)
));
assert!(matches!(
entry_point(Some("__grep-engine")),
Some(EntryPoint::GrepEngine)
));
assert!(matches!(
entry_point(Some("__env-dump")),
Some(EntryPoint::EnvDump)
));
assert!(matches!(
entry_point(Some("bench-openrouter")),
Some(EntryPoint::BenchOpenRouter)
));
assert!(matches!(
entry_point(Some("audio")),
Some(EntryPoint::Audio)
));
assert!(matches!(
entry_point(Some("chrome")),
Some(EntryPoint::Chrome)
));
for word in ["paused", "audio", "chrome", "debug", "bench-openrouter"] {
let entry = format!(" mahbot {word} ");
assert!(
TOP_LEVEL_USAGE.lines().any(|line| line.starts_with(&entry)),
"'mahbot {word}' has no entry in the top-level usage"
);
}
for family in FAMILIES {
assert!(
matches!(entry_point(Some(family.word)), Some(EntryPoint::Family(_))),
"'{}' must resolve to its family",
family.word
);
let entry = format!(" {}", family_syntax(family));
assert!(
TOP_LEVEL_USAGE.lines().any(|line| line.starts_with(&entry)),
"'{entry}' has no entry in the top-level usage"
);
}
assert!(matches!(entry_point(None), Some(EntryPoint::Daemon)));
assert!(entry_point(Some("frobnicate")).is_none());
}
fn admin_toolset() -> Vec<Box<dyn crate::Tool>> {
crate::Role::Assistant.tools(
&crate::workspace::test_ws("test"),
true,
std::sync::Arc::new(crate::tools::chrome::ChromeRunSessions::default()),
)
}
fn tool_schema(tool: &str) -> serde_json::Value {
let tools = admin_toolset();
crate::tools::find_tool(&tools, tool)
.unwrap_or_else(|| panic!("the admin Assistant must hold '{tool}'"))
.parameters_schema()
}
fn tool_actions(schema: &serde_json::Value) -> Vec<&str> {
let action = &schema["properties"]["action"];
if let Some(actions) = action["enum"].as_array() {
return actions
.iter()
.map(|action| action.as_str().expect("action words are strings"))
.collect();
}
action["oneOf"]
.as_array()
.unwrap_or_else(|| panic!("the tool states its actions as an enum or a oneOf"))
.iter()
.flat_map(|entry| {
entry["properties"]
.as_object()
.expect("an action entry names the action")
.keys()
.map(String::as_str)
})
.collect()
}
fn declares_per_action_arguments(tool: &str) -> bool {
tool_schema(tool)["properties"]["action"]
.get("enum")
.is_none()
}
fn argument_schema(operation: &Operation) -> serde_json::Map<String, serde_json::Value> {
if operation.action == Some(DOCUMENT_READ) {
return ["path", "file_name"]
.into_iter()
.map(|name| (name.to_string(), serde_json::json!({ "type": "string" })))
.collect();
}
let schema = tool_schema(operation.tool);
let top = schema["properties"]
.as_object()
.expect("the tool's properties")
.clone();
let Some(action) = operation.action else {
return top;
};
if !declares_per_action_arguments(operation.tool) {
return top.into_iter().filter(|(key, _)| key != "action").collect();
}
let entries = schema["properties"]["action"]["oneOf"]
.as_array()
.expect("the tool's action entries");
let entry = entries
.iter()
.find_map(|entry| entry["properties"].get(action))
.unwrap_or_else(|| {
panic!(
"'{}' has no schema entry for action '{action}'",
operation.tool
)
});
let declared = entry["properties"]
.as_object()
.unwrap_or_else(|| panic!("'{action}' must declare its properties"));
let per_action: Vec<&str> = entries
.iter()
.flat_map(|entry| entry["properties"].as_object().into_iter())
.flat_map(|actions| actions.values())
.flat_map(|action| action["properties"].as_object().into_iter())
.flat_map(|properties| properties.keys().map(String::as_str))
.collect();
let mut args: serde_json::Map<String, serde_json::Value> = top
.into_iter()
.filter(|(key, _)| key != "action" && !per_action.contains(&key.as_str()))
.collect();
args.extend(declared.clone());
args
}
fn duplicates<'a>(words: impl IntoIterator<Item = &'a str>) -> Vec<&'a str> {
let mut seen: Vec<&'a str> = Vec::new();
let mut repeated: Vec<&'a str> = Vec::new();
for word in words {
if seen.contains(&word) {
if !repeated.contains(&word) {
repeated.push(word);
}
} else {
seen.push(word);
}
}
repeated
}
fn operation(family_word: &str, operation_word: &str) -> &'static Operation {
let family = family_of(family_word);
match family.surface {
Surface::Operations(operations) => operations
.iter()
.find(|entry| super::operation_word(family, entry) == operation_word)
.unwrap_or_else(|| {
panic!("no 'mahbot {family_word} {operation_word}' command line")
}),
Surface::Standalone(_) => panic!("'mahbot {family_word}' has no operation word"),
}
}
fn family_of(family_word: &str) -> &'static Family {
family(family_word).map_or_else(
|| panic!("'mahbot {family_word}' is no family"),
|cli| cli.0,
)
}
fn parse(family_word: &str, operation_word: &str, tokens: &[&str]) -> Result<Tail, String> {
let operation = operation(family_word, operation_word);
parse_flags(
operation.action,
operation_word,
operation.flags,
&tokens.iter().map(ToString::to_string).collect::<Vec<_>>(),
)
}
fn parse_standalone(family_word: &str, tokens: &[&str]) -> Result<Tail, String> {
let cli =
family(family_word).unwrap_or_else(|| panic!("'mahbot {family_word}' is no family"));
let Surface::Standalone(flags) = cli.0.surface else {
panic!("'mahbot {family_word}' is not a standalone family");
};
parse_flags(
None,
cli.0.word,
flags,
&tokens.iter().map(ToString::to_string).collect::<Vec<_>>(),
)
}
fn parsed(family: &str, operation_word: &str, tokens: &[&str]) -> Parsed {
match parse(family, operation_word, tokens) {
Ok(Tail::Call(parsed)) => parsed,
Ok(Tail::Help) => panic!("'{family} {operation_word}' must not ask for help"),
Err(e) => panic!("'{family} {operation_word}' must parse: {e}"),
}
}
fn parsed_standalone(family_word: &str, tokens: &[&str]) -> Parsed {
match parse_standalone(family_word, tokens) {
Ok(Tail::Call(parsed)) => parsed,
Ok(Tail::Help) => panic!("'mahbot {family_word}' must not ask for help"),
Err(e) => panic!("'mahbot {family_word}' must parse: {e}"),
}
}
#[test]
fn every_family_fronts_its_tools_under_unique_words() {
for family in FAMILIES {
assert!(
!family.tools.is_empty(),
"the '{}' family fronts no tool at all",
family.word
);
let Surface::Operations(operations) = family.surface else {
assert_eq!(
family.tools.len(),
1,
"the standalone '{}' family is its own operation, so it fronts one tool",
family.word
);
assert!(
tool_schema(family.tools[0])["properties"]
.get("action")
.is_none(),
"'{}' publishes no operation, but its tool takes an `action`",
family.tools[0]
);
continue;
};
for operation in operations {
assert!(
family.tools.contains(&operation.tool),
"'mahbot {} {}' runs '{}', which the family does not front",
family.word,
operation_word(family, operation),
operation.tool
);
}
let words: Vec<String> = operations
.iter()
.map(|operation| operation_word(family, operation))
.collect();
assert!(
words.iter().all(|word| !word.is_empty()),
"'mahbot {}' derives an empty operation word",
family.word
);
assert!(
duplicates(words.iter().map(String::as_str)).is_empty(),
"'mahbot {}' derives the same operation word twice, so one would shadow the other",
family.word
);
}
}
#[test]
fn operations_match_the_tools_own_actions() {
for family in FAMILIES {
let Surface::Operations(operations) = family.surface else {
continue;
};
for tool in family.tools {
let ours: Vec<Option<&str>> = operations
.iter()
.filter(|operation| {
!(operation.tool == "document" && operation.action == Some(DOCUMENT_READ))
})
.filter(|operation| operation.tool == *tool)
.map(|operation| operation.action)
.collect();
assert!(
!ours.is_empty(),
"'{tool}' is fronted by 'mahbot {}' but no operation runs it",
family.word
);
let schema = tool_schema(tool);
let taken: Vec<&str> = ours.iter().flatten().copied().collect();
if taken.is_empty() {
assert!(
schema["properties"].get("action").is_none(),
"'{tool}' takes an `action`, so its operations must name one"
);
continue;
}
assert_eq!(
taken.len(),
ours.len(),
"one of '{tool}'s command lines names an action where another does not"
);
let mut theirs: Vec<&str> = tool_actions(&schema);
assert!(
duplicates(taken.iter().copied()).is_empty(),
"the '{}' command line repeats an action of '{tool}'",
family.word
);
assert!(
duplicates(theirs.iter().copied()).is_empty(),
"'{tool}' repeats an action"
);
let mut ours = taken;
ours.sort_unstable();
theirs.sort_unstable();
assert_eq!(
ours, theirs,
"'mahbot {}' must expose exactly the actions of its tool '{tool}'",
family.word
);
}
}
}
#[test]
fn the_reader_is_the_one_operation_no_tool_backs() {
let mut readers = Vec::new();
for family in FAMILIES {
let Surface::Operations(operations) = family.surface else {
continue;
};
for operation in operations {
if operation.action == Some(DOCUMENT_READ) {
readers.push((family, operation));
}
}
}
assert_eq!(
readers.len(),
1,
"exactly one operation must name the reader's action"
);
let (family, operation) = readers[0];
assert_eq!(
family.word, "document",
"the reader belongs to the document family"
);
assert_eq!(
operation.tool, "document",
"the reader is served by the document tool"
);
assert!(
!tool_actions(&tool_schema("document")).contains(&DOCUMENT_READ),
"the reader must not be an action of the document tool"
);
}
#[test]
fn action_less_operations_are_named_from_their_tools() {
for family in FAMILIES {
let Surface::Operations(operations) = family.surface else {
continue;
};
for operation in operations.iter().filter(|o| o.action.is_none()) {
let word = operation_word(family, operation);
let held = word.replace('-', "_");
assert!(
!held.is_empty(),
"'mahbot {} {word}' derives no word",
family.word
);
assert_eq!(
operation.tool,
format!("{}_{held}", family.word),
"'mahbot {} {word}' must be its tool '{}' with the family word dropped",
family.word,
operation.tool
);
}
}
}
struct CommandLine {
called: String,
tool: &'static str,
arguments: serde_json::Map<String, serde_json::Value>,
flags: &'static [Flag],
}
fn fillable_types(kind: &FlagKind) -> &'static [&'static str] {
match kind {
FlagKind::Value | FlagKind::Path => &["string"],
FlagKind::Number => &["integer", "number"],
FlagKind::List | FlagKind::PathList => &["array"],
FlagKind::Structured(_) => &["array", "object", "string"],
FlagKind::Present => &["boolean"],
FlagKind::Destination => &[],
}
}
fn flag_kind_name(kind: &FlagKind) -> &'static str {
match kind {
FlagKind::Value => "Value",
FlagKind::Path => "Path",
FlagKind::Number => "Number",
FlagKind::List => "List",
FlagKind::PathList => "PathList",
FlagKind::Structured(_) => "Structured",
FlagKind::Present => "Present",
FlagKind::Destination => "Destination",
}
}
fn argument_types(property: &serde_json::Value) -> Vec<&str> {
match &property["type"] {
serde_json::Value::String(kind) => vec![kind.as_str()],
serde_json::Value::Array(kinds) => kinds
.iter()
.map(|kind| kind.as_str().expect("a type word is a string"))
.collect(),
_ => Vec::new(),
}
}
fn property_names(schema: &serde_json::Value) -> Vec<String> {
let mut names = Vec::new();
match schema {
serde_json::Value::Object(members) => {
if let Some(serde_json::Value::Object(properties)) = members.get("properties") {
names.extend(properties.keys().cloned());
}
for member in members.values() {
names.extend(property_names(member));
}
}
serde_json::Value::Array(items) => {
for item in items {
names.extend(property_names(item));
}
}
_ => {}
}
names
}
fn file_naming_fields(
schema: &serde_json::Value,
pictures: &[serde_json::Value],
) -> Vec<String> {
let mut names = Vec::new();
match schema {
serde_json::Value::Object(members) => {
if let Some(serde_json::Value::Object(properties)) = members.get("properties") {
for (name, property) in properties {
if pictures.contains(property) {
names.push(name.clone());
}
}
}
for member in members.values() {
names.extend(file_naming_fields(member, pictures));
}
}
serde_json::Value::Array(items) => {
for item in items {
names.extend(file_naming_fields(item, pictures));
}
}
_ => {}
}
names
}
fn command_lines(family: &'static Family) -> Vec<CommandLine> {
match family.surface {
Surface::Operations(operations) => operations
.iter()
.map(|operation| CommandLine {
called: format!("{} {}", family.word, operation_word(family, operation)),
tool: operation.tool,
arguments: argument_schema(operation),
flags: operation.flags,
})
.collect(),
Surface::Standalone(flags) => vec![CommandLine {
called: family.word.to_string(),
tool: family.tools[0],
arguments: tool_schema(family.tools[0])["properties"]
.as_object()
.expect("the tool's properties")
.clone(),
flags,
}],
}
}
#[test]
fn flags_stay_tied_to_the_tools_own_arguments() {
let mut file_fields = 0usize;
for family in FAMILIES {
let lines = command_lines(family);
for line in &lines {
assert!(
duplicates(line.flags.iter().map(|flag| flag.arg)).is_empty(),
"'mahbot {}' repeats a flag",
line.called
);
for flag in line.flags {
if matches!(flag.kind, FlagKind::Destination) {
continue;
}
let property = line.arguments.get(flag.arg).unwrap_or_else(|| {
panic!(
"'{}' is not an argument of 'mahbot {}'",
flag.arg, line.called
)
});
let kinds = argument_types(property);
assert!(
fillable_types(&flag.kind)
.iter()
.any(|fill| kinds.contains(fill)),
"the {} flag '{}' cannot fill a {kinds:?} argument of 'mahbot {}'",
flag_kind_name(&flag.kind),
flag.arg,
line.called,
);
if let FlagKind::Structured(fields) = &flag.kind {
let declared = property_names(property);
for field in *fields {
assert!(
declared.iter().any(|name| name == field),
"'{field}' is no field of the '{}' argument of 'mahbot {}'",
flag.arg,
line.called,
);
}
let naming_a_file =
file_naming_fields(property, &crate::tools::document::image_args());
file_fields += naming_a_file.len();
for field in naming_a_file {
assert!(
fields.contains(&field.as_str()),
"'{field}' of the '{}' argument of 'mahbot {}' names a file, so \
the command line must resolve it — declare it on the flag",
flag.arg,
line.called,
);
}
}
}
}
if produces_files(family) {
for line in &lines {
let carried = line
.flags
.iter()
.filter(|flag| matches!(flag.kind, FlagKind::Destination))
.count();
assert_eq!(
carried, 1,
"'mahbot {}' carries {carried} destination flags where a producing \
family's every operation carries one",
line.called
);
}
}
}
assert!(
file_fields >= 4,
"the picture fields naming a file were not all found ({file_fields}) — the tie would \
be vacuous"
);
}
#[test]
fn every_tool_argument_has_a_command_line_form() {
for family in FAMILIES {
let lines = command_lines(family);
for tool in family.tools {
let strict = declares_per_action_arguments(tool);
let mut reachable: Vec<&str> = Vec::new();
let mut declared: Vec<&str> = Vec::new();
for line in lines.iter().filter(|line| line.tool == *tool) {
let line_reachable: Vec<&str> = line
.flags
.iter()
.filter(|flag| !matches!(flag.kind, FlagKind::Destination))
.map(|flag| flag.arg)
.collect();
let line_declared: Vec<&str> = line
.arguments
.keys()
.map(String::as_str)
.filter(|key| *key != "action")
.collect();
if strict {
for argument in &line_declared {
assert!(
line_reachable.contains(argument),
"'{argument}' of '{tool}' has no command-line form on 'mahbot {}'",
line.called
);
}
}
reachable.extend(line_reachable);
declared.extend(line_declared);
}
if !strict {
for argument in &declared {
assert!(
reachable.contains(argument),
"'{argument}' of '{tool}' has no command-line form on 'mahbot {}'",
family.word
);
}
}
}
}
}
#[test]
fn every_family_carries_a_protocol_value_the_listener_serves() {
for cli in FAMILIES {
assert!(
crate::db::ipc::CALL_STORES.contains(&cli.protocol),
"'mahbot {}' carries '{}', which the listener does not serve",
cli.word,
cli.protocol
);
}
let current: Vec<&str> = FAMILIES
.iter()
.filter(|cli| cli.protocol == crate::db::ipc::CALL_STORE)
.map(|cli| cli.word)
.collect();
assert_eq!(
current,
vec!["send", "document", "image", "video", "rasterize"],
"the families this build adds"
);
}
#[test]
fn tools_no_family_publishes_are_not_served_by_name() {
for tool in ["shell", "edit", "analyze"] {
assert!(
!fronts_tool(tool),
"'{tool}' is published by no family, so the channel must not serve it by name"
);
}
}
#[test]
fn the_document_help_lists_the_reader() {
let family = family("document").expect("the document family").0;
let help = family_help(family);
assert!(
help.lines()
.any(|line| line.trim_start().starts_with(&format!("{DOCUMENT_READ} "))),
"the reader has no operation row:\n{help}"
);
assert!(
!help.contains("Produce a document"),
"the family no longer only produces:\n{help}"
);
}
#[test]
fn every_help_states_the_running_product_and_the_exit_classes() {
for family in FAMILIES {
let helps = match family.surface {
Surface::Operations(operations) => std::iter::once(family_help(family))
.chain(
operations
.iter()
.map(|operation| operation_help(family, operation)),
)
.collect(),
Surface::Standalone(flags) => {
let help = family_help(family);
let usage = format!("Usage: mahbot {} [flags]", family.word);
assert!(help.contains(&usage), "'{usage}' is missing from:\n{help}");
for flag in flags {
let spelling = format!("--{}", flag_spelling(flag.arg));
assert!(
help.contains(&spelling),
"'{spelling}' has no row in:\n{help}"
);
}
vec![help]
}
};
for help in &helps {
assert!(
!help.contains("{{"),
"a help placeholder was not expanded:\n{help}"
);
for required in [
"must be running",
"owner's authority",
"Exit codes:",
"0 the operation was carried out",
"3 this command was misused",
"Output:",
] {
assert!(
help.contains(required),
"'{required}' is missing from:\n{help}"
);
}
}
if produces_files(family) {
for help in &helps {
assert!(
help.contains("--output-dir <dir>"),
"'mahbot {}' produces files without stating the destination in a help",
family.word
);
}
}
}
}
#[test]
fn parsing_reads_both_flag_forms() {
let p = parsed("workspace", "add", &["--name", "proj", "--path=/tmp/proj"]);
assert_eq!(
p.args,
serde_json::json!({"action": "add", "name": "proj", "path": "/tmp/proj"})
);
assert!(p.secrets.is_empty());
}
#[test]
fn parsing_a_list_flag_collects_verbatim_entries_in_order() {
let p = parsed(
"config",
"create-custom-role",
&[
"--name",
"triager",
"--prompt",
"the whole definition",
"--tools",
"analyze:quick,deep",
"--tools",
"send",
"--behavior",
"one_off",
],
);
assert_eq!(
p.args["tools"],
serde_json::json!(["analyze:quick,deep", "send"])
);
assert_eq!(p.args["behavior"], "one_off");
assert_eq!(p.args["prompt"], "the whole definition");
}
#[test]
fn parsing_an_empty_list_value_is_the_empty_list() {
let cleared = parsed(
"config",
"update-custom-role",
&["--name", "triager", "--tools="],
);
assert_eq!(cleared.args["tools"], serde_json::json!([]));
let after = parsed(
"config",
"update-custom-role",
&["--name", "triager", "--tools=", "--tools", "send"],
);
assert_eq!(after.args["tools"], serde_json::json!(["send"]));
let untouched = parsed("config", "update-custom-role", &["--name", "triager"]);
assert!(untouched.args.get("tools").is_none(), "{}", untouched.args);
}
#[test]
fn parsing_a_switch_is_true_and_an_absent_flag_leaves_no_key() {
let full = parsed("workspace", "ticket", &["--ticket-id", "proj-7", "--full"]);
assert_eq!(
full.args,
serde_json::json!({"action": "ticket", "ticket_id": "proj-7", "full": true})
);
let explicit = parsed(
"workspace",
"ticket",
&["--ticket-id", "proj-7", "--full=true"],
);
assert_eq!(explicit.args["full"], serde_json::json!(true));
let silenced = parsed(
"video",
"gen",
&["--prompt", "a cat", "--generate-audio=false"],
);
assert_eq!(
silenced.args["generate_audio"],
serde_json::json!(false),
"a script must be able to ask for the silent clip the tool accepts"
);
let loud = parsed("video", "gen", &["--prompt", "a cat", "--generate-audio"]);
assert_eq!(loud.args["generate_audio"], serde_json::json!(true));
let silent = parsed("video", "gen", &["--prompt", "a cat"]);
assert!(
silent.args.get("generate_audio").is_none(),
"an absent switch leaves the provider's own default: {}",
silent.args
);
let reason = parse(
"video",
"gen",
&["--prompt", "a cat", "--generate-audio=maybe"],
)
.err()
.expect("a switch given a non-boolean is misuse");
assert!(reason.contains("--generate-audio"), "{reason}");
assert!(
reason.contains("=false"),
"the form is spelled out: {reason}"
);
assert!(
!reason.contains("maybe"),
"a reason must never carry a value: {reason}"
);
let clipped = parsed("workspace", "ticket", &["--ticket-id", "proj-7"]);
assert!(
clipped.args.get("full").is_none(),
"an absent switch must leave no key: {}",
clipped.args
);
let fleet = parsed("workspace", "pause", &[]);
assert_eq!(fleet.args, serde_json::json!({"action": "pause"}));
assert!(
fleet.args.get("workspace").is_none(),
"a fleet-wide pause must send no `workspace` key at all: {}",
fleet.args
);
}
#[test]
fn parsing_hands_secret_flag_values_over_as_secrets() {
let p = parsed("config", "setup-telegram-bot", &["--token", "NNN:AAA"]);
assert_eq!(p.args["token"], "NNN:AAA");
assert_eq!(p.secrets, vec!["NNN:AAA".to_string()]);
let p = parsed(
"config",
"setup-web-search",
&["--provider", "exa", "--key=SEKRIT"],
);
assert_eq!(p.secrets, vec!["SEKRIT".to_string()]);
}
#[test]
fn parsing_reads_a_dash_led_value_as_a_value_and_help_only_where_a_flag_stands() {
let p = parsed("config", "setup-telegram-bot", &["--token", "-h"]);
assert_eq!(p.args["token"], "-h");
assert_eq!(
p.secrets,
vec!["-h".to_string()],
"a dash-led secret is a secret like any other"
);
let p = parsed("workspace", "board", &["--phase", "--help"]);
assert_eq!(p.args["phase"], "--help");
assert!(
matches!(parse("workspace", "list", &["-h"]), Ok(Tail::Help)),
"a bare -h where a flag could stand asks for the help"
);
assert!(
matches!(
parse("workspace", "board", &["--workspace", "w", "--help"]),
Ok(Tail::Help)
),
"so does one after a flag that already has its value"
);
}
#[test]
fn parsing_refuses_misuse_without_echoing_a_value() {
struct Case {
family: &'static str,
operation: &'static str,
tokens: &'static [&'static str],
names: &'static str,
values: &'static [&'static str],
}
let cases = [
Case {
family: "workspace",
operation: "list",
tokens: &["--nope", "VAL1"],
names: "--nope",
values: &["VAL1"],
},
Case {
family: "workspace",
operation: "list",
tokens: &["--workspace"],
names: "--workspace",
values: &[],
},
Case {
family: "workspace",
operation: "list",
tokens: &["VAL3"],
names: "unexpected argument",
values: &["VAL3"],
},
Case {
family: "workspace",
operation: "ticket",
tokens: &["--ticket-id", "proj-7", "--full=VAL4"],
names: "--full",
values: &["VAL4"],
},
Case {
family: "config",
operation: "grant-tool",
tokens: &["--user", "VAL5", "--user", "VAL6"],
names: "--user",
values: &["VAL5", "VAL6"],
},
];
for Case {
family,
operation: operation_word,
tokens,
names,
values,
} in cases
{
let reason = parse(family, operation_word, tokens)
.err()
.unwrap_or_else(|| panic!("'{family} {operation_word}' {tokens:?} must be misuse"));
assert!(
reason.contains(names),
"'{family} {operation_word}' must name '{names}', got: {reason}"
);
for value in values {
assert!(
!reason.contains(value),
"a usage error must never carry '{value}': {reason}"
);
}
}
let reason = parse(
"config",
"setup-telegram-bot",
&["--token", "SEKRIT", "--nope"],
)
.err()
.expect("a stray flag is misuse");
assert!(
!reason.contains("SEKRIT"),
"a usage error must never carry a value: {reason}"
);
let reason = parse("config", "setup-telegram-bot", &["SEKRIT"])
.err()
.expect("a stray token is misuse");
assert!(
!reason.contains("SEKRIT"),
"a usage error must never carry a token: {reason}"
);
}
#[test]
fn parsing_a_number_flag_carries_the_number_and_sends_anything_else_on() {
let p = parsed(
"document",
"pdf-rotate",
&["--path", "Cargo.toml", "--degrees", "90"],
);
assert_eq!(p.args["degrees"], serde_json::json!(90));
assert!(
p.args["degrees"].is_number(),
"a number must travel as one: {}",
p.args
);
let p = parsed("video", "gen", &["--prompt", "a cat", "--seed", "7"]);
assert_eq!(p.args["seed"], serde_json::json!(7));
let p = parsed(
"document",
"pdf-rotate",
&["--path", "Cargo.toml", "--degrees", "sideways"],
);
assert_eq!(p.args["degrees"], serde_json::json!("sideways"));
}
#[test]
fn parsing_a_structured_flag_carries_the_json() {
let p = parsed(
"document",
"create",
&[
"--format",
"pdf",
"--content",
r#"[{"type": "paragraph", "text": "hi"}]"#,
],
);
assert_eq!(p.args["content"][0]["type"], "paragraph");
let p = parsed(
"document",
"fill-template",
&["--path", "Cargo.toml", "--values", r#"{"name": "Ada"}"#],
);
assert_eq!(p.args["values"]["name"], "Ada");
let p = parsed(
"document",
"create",
&["--format", "pdf", "--content", "not json"],
);
assert_eq!(p.args["content"], serde_json::json!("not json"));
}
#[test]
fn parsing_reads_a_value_from_a_file_the_caller_names() {
let dir = crate::util::test::test_root().join("cli-file-values");
std::fs::create_dir_all(&dir).expect("a place for the value files");
let list = dir.join("entries.json");
std::fs::write(&list, r#"["Cargo.toml", "Cargo.lock"]"#).expect("write the entries");
let from_file = format!("@{}", list.display());
let p = parsed(
"document",
"pdf-merge",
&["--paths", &from_file, "--paths", "README.md"],
);
let here = std::env::current_dir().expect("a working directory");
let resolved = |name: &str| here.join(name).to_string_lossy().into_owned();
assert_eq!(
p.args["paths"],
serde_json::json!([
resolved("Cargo.toml"),
resolved("Cargo.lock"),
resolved("README.md"),
])
);
let value = dir.join("values.json");
std::fs::write(&value, r#"{"name": "Ada"}"#).expect("write the value");
let from_file = format!("@{}", value.display());
let p = parsed(
"document",
"fill-template",
&["--path", "Cargo.toml", "--values", &from_file],
);
assert_eq!(p.args["values"]["name"], "Ada");
let wrong = dir.join("wrong.json");
std::fs::write(&wrong, r#"{"a": 1}"#).expect("write the wrong shape");
let from_file = format!("@{}", wrong.display());
let reason = parse("document", "pdf-merge", &["--paths", &from_file])
.err()
.expect("a file that does not hold the entries is misuse");
assert!(reason.contains("--paths"), "{reason}");
assert!(
!reason.contains(wrong.to_string_lossy().as_ref()),
"a reason must never carry a path value: {reason}"
);
let missing = format!("@{}", dir.join("nothing-here.json").display());
let reason = parse("document", "pdf-merge", &["--paths", &missing])
.err()
.expect("a file that cannot be read is misuse");
assert!(reason.contains("--paths"), "{reason}");
}
#[test]
fn parsing_reads_a_text_value_from_a_file_the_caller_names() {
let dir = crate::util::test::test_root().join("cli-text-values");
std::fs::create_dir_all(&dir).expect("a place for the value files");
let brief = dir.join("brief.txt");
std::fs::write(&brief, "a cat asleep on a keyboard\n").expect("write the brief");
let from_file = format!("@{}", brief.display());
let p = parsed("image", "gen", &["--prompt", &from_file]);
assert_eq!(p.args["prompt"], "a cat asleep on a keyboard");
std::fs::write(dir.join("alice"), "not the prompt").expect("write a decoy file");
let decoy = format!("./@{}", dir.join("alice").display());
let p = parsed("image", "gen", &["--prompt", &decoy]);
assert_eq!(
p.args["prompt"],
format!("@{}", dir.join("alice").display())
);
let missing = format!("@{}", dir.join("nothing-here.txt").display());
let reason = parse("image", "gen", &["--prompt", &missing])
.err()
.expect("a file that cannot be read is misuse");
assert!(reason.contains("--prompt"), "{reason}");
assert!(
!reason.contains("nothing-here"),
"a reason must never carry a path value: {reason}"
);
let key = dir.join("provider-key.txt");
std::fs::write(&key, "sk-secret-from-a-file\n").expect("write the key");
let from_file = format!("@{}", key.display());
let p = parsed(
"config",
"setup-web-search",
&["--provider", "exa", "--key", &from_file],
);
assert_eq!(p.args["key"], "sk-secret-from-a-file");
assert_eq!(p.secrets, vec!["sk-secret-from-a-file".to_string()]);
let named = dir.join("@report.pdf");
std::fs::write(&named, "not a pdf").expect("write a file whose name begins with @");
let p = parsed(
"document",
"pdf-rotate",
&["--path", &named.to_string_lossy(), "--degrees", "90"],
);
assert_eq!(p.args["path"], named.to_string_lossy().as_ref());
let p = parsed(
"document",
"create",
&["--format", "pdf", "--output-dir", "@mine"],
);
assert_eq!(p.output_dir.as_deref(), Some("@mine"));
}
#[test]
fn the_file_a_value_is_read_from_must_be_a_regular_file() {
let dir = crate::util::test::test_root().join("cli-value-file-guards");
std::fs::create_dir_all(&dir).expect("a place for the guard fixtures");
let reason = parse(
"image",
"gen",
&["--prompt", &format!("@{}", dir.display())],
)
.err()
.expect("a directory is misuse");
assert!(reason.contains("--prompt"), "{reason}");
assert!(reason.contains("regular file"), "{reason}");
let huge = dir.join("huge.txt");
std::fs::File::create(&huge)
.and_then(|file| file.set_len(MAX_VALUE_FILE_BYTES + 1))
.expect("a sparse file past the bound");
let reason = parse(
"image",
"gen",
&["--prompt", &format!("@{}", huge.display())],
)
.err()
.expect("a value file past the bound is misuse");
assert!(reason.contains("over 8 MB"), "{reason}");
#[cfg(unix)]
{
let fifo = dir.join("value.fifo");
let _ = std::fs::remove_file(&fifo);
let path = std::ffi::CString::new(fifo.to_string_lossy().as_bytes())
.expect("a path without a NUL");
if unsafe { libc::mkfifo(path.as_ptr(), 0o600) } == 0 {
let reason = parse(
"image",
"gen",
&["--prompt", &format!("@{}", fifo.display())],
)
.err()
.expect("a pipe is misuse");
assert!(reason.contains("regular file"), "{reason}");
}
let reason = parse("image", "gen", &["--prompt", "@/dev/zero"])
.err()
.expect("a device is misuse");
assert!(reason.contains("regular file"), "{reason}");
}
}
#[test]
fn parsing_hands_the_destination_beside_the_tools_arguments() {
let p = parsed(
"document",
"pdf-rotate",
&[
"--path",
"Cargo.toml",
"--degrees",
"90",
"--output-dir",
"/somewhere/of/my/own",
],
);
assert_eq!(p.output_dir.as_deref(), Some("/somewhere/of/my/own"));
assert!(
p.args.get("output_dir").is_none(),
"the destination is not an argument of the tool: {}",
p.args
);
assert_eq!(p.args["action"], "pdf_rotate");
assert_eq!(p.args["degrees"], serde_json::json!(90));
let p = parsed("document", "create", &["--format", "pdf"]);
assert_eq!(p.output_dir, None);
}
#[test]
fn parsing_resolves_a_path_where_the_command_runs() {
let here = std::env::current_dir().expect("a working directory");
let relative = |name: &str| here.join(name).to_string_lossy().into_owned();
let absolute = relative("Cargo.toml");
let p = parsed(
"document",
"pdf-rotate",
&["--path", "Cargo.toml", "--degrees", "90"],
);
assert_eq!(p.args["path"], serde_json::json!(absolute));
let p = parsed(
"document",
"pdf-merge",
&["--paths", "Cargo.toml", "--paths", &absolute],
);
assert_eq!(p.args["paths"][0], absolute);
assert_eq!(
p.args["paths"][1], absolute,
"an absolute path stays as it is"
);
let p = parsed(
"video",
"edit",
&[
"--instruction",
"restyle it",
"--video-url",
"https://example.test/clip.mp4",
"--images",
"Cargo.toml",
],
);
assert_eq!(p.args["video_url"], "https://example.test/clip.mp4");
assert_eq!(p.args["images"][0], relative("Cargo.toml"));
if let Ok(home) = std::env::var("HOME") {
let resolved = resolve_output_dir(Some("~")).expect("the home directory is usable");
assert_eq!(
resolved.trim_end_matches('/'),
crate::util::strip_verbatim_prefix(
&std::fs::canonicalize(&home).expect("the home directory resolves")
)
.to_string_lossy()
.trim_end_matches('/'),
"a bare ~ is the home directory"
);
}
let p = parsed(
"document",
"create",
&["--format", "docx", "--file-name", "report"],
);
assert_eq!(p.args["file_name"], "report");
let p = parsed("image", "gen", &["--prompt", "a cat by a window"]);
assert_eq!(p.args["prompt"], "a cat by a window");
}
#[test]
fn parsing_resolves_a_path_inside_a_structured_value() {
let here = std::env::current_dir().expect("a working directory");
let joined = here.join("Cargo.toml").to_string_lossy().into_owned();
let p = parsed(
"document",
"create",
&[
"--format",
"docx",
"--content",
r#"[{"type": "paragraph", "text": "hi"}, {"type": "image", "image": "Cargo.toml"}]"#,
],
);
assert_eq!(p.args["content"][0].get("image"), None);
assert_eq!(p.args["content"][1]["image"], serde_json::json!(joined));
let p = parsed(
"document",
"pptx-edit",
&[
"--path",
"Cargo.toml",
"--edits",
r#"[{"op": "add_image", "slide": 1, "image": "Cargo.toml"}]"#,
],
);
assert_eq!(p.args["edits"][0]["image"], serde_json::json!(joined));
let p = parsed(
"document",
"docx-edit",
&[
"--path",
"Cargo.toml",
"--edits",
r#"[{"op": "add_image", "after": "x", "image": "Cargo.toml"}]"#,
],
);
assert_eq!(p.args["edits"][0]["image"], serde_json::json!(joined));
let p = parsed(
"document",
"create",
&[
"--format",
"docx",
"--content",
r#"[{"type": "image", "image": "https://example.test/picture.png"}]"#,
],
);
assert_eq!(
p.args["content"][0]["image"],
"https://example.test/picture.png"
);
let p = parsed(
"document",
"create",
&[
"--format",
"xlsx",
"--sheets",
r#"[{"name": "S", "rows": [["generated/logo.png"]]}]"#,
],
);
assert_eq!(p.args["sheets"][0]["rows"][0][0], "generated/logo.png");
let reason = parse(
"document",
"create",
&[
"--format",
"docx",
"--content",
r#"[{"type": "image", "image": "no-such-picture-here.png"}]"#,
],
)
.err()
.expect("a missing reference file inside a value is misuse");
assert!(reason.contains("--content"), "{reason}");
assert!(!reason.contains("no-such-picture-here"), "{reason}");
}
#[test]
fn parsing_refuses_a_reference_path_that_is_not_there() {
let reason = parse(
"document",
"pdf-rotate",
&["--path", "./no-such-file-here.pdf"],
)
.err()
.expect("a missing reference file is misuse");
assert!(reason.contains("--path"), "{reason}");
assert!(
!reason.contains("no-such-file-here"),
"a reason must never carry a path value: {reason}"
);
let reason = parse(
"document",
"pdf-merge",
&[
"--paths",
"Cargo.toml",
"--paths",
"./no-such-file-here.pdf",
],
)
.err()
.expect("one missing entry of a path list is misuse");
assert!(reason.contains("--paths"), "{reason}");
let p = parsed(
"video",
"edit",
&[
"--instruction",
"restyle",
"--video-url",
"https://example.test/x.mp4",
],
);
assert_eq!(p.args["video_url"], "https://example.test/x.mp4");
let dir = crate::util::test::test_root().join("cli-reference-not-a-file");
std::fs::create_dir_all(&dir).expect("a directory that is no reference file");
let reason = parse(
"document",
"pdf-rotate",
&["--path", &dir.to_string_lossy()],
)
.err()
.expect("a directory is not a reference file");
assert!(reason.contains("--path"), "{reason}");
assert!(reason.contains("regular file"), "{reason}");
assert!(
!reason.contains("cli-reference-not-a-file"),
"a reason must never carry a path value: {reason}"
);
#[cfg(unix)]
{
let fifo = dir.join("clip.mp4");
let _ = std::fs::remove_file(&fifo);
let path = std::ffi::CString::new(fifo.to_string_lossy().as_bytes())
.expect("a path without a NUL");
if unsafe { libc::mkfifo(path.as_ptr(), 0o600) } == 0 {
let reason = parse("video", "edit", &["--video-url", &fifo.to_string_lossy()])
.err()
.expect("a pipe is not a reference file");
assert!(reason.contains("regular file"), "{reason}");
let content = format!(
r#"[{{"type": "image", "image": "{}"}}]"#,
fifo.to_string_lossy()
);
let reason = parse(
"document",
"create",
&["--format", "docx", "--content", &content],
)
.err()
.expect("a pipe inside a value is no reference file either");
assert!(reason.contains("--content"), "{reason}");
assert!(reason.contains("regular file"), "{reason}");
}
}
}
#[test]
fn the_destination_is_resolved_and_the_products_own_places_are_refused() {
let cwd = std::env::current_dir().expect("a working directory");
let cwd_string = cwd.to_string_lossy().into_owned();
let named = resolve_output_dir(Some(&cwd_string)).ok();
assert_eq!(resolve_output_dir(None).ok(), named);
if let Some(resolved) = named {
assert_eq!(
resolved,
crate::util::strip_verbatim_prefix(
&std::fs::canonicalize(&cwd).expect("the working directory canonicalizes")
)
.to_string_lossy()
.into_owned()
);
}
let absent = crate::util::test::test_root().join("no-such-destination");
let reason = resolve_output_dir(Some(&absent.to_string_lossy()))
.expect_err("a missing destination is misuse");
assert!(reason.contains("cannot be used"), "{reason}");
assert!(!absent.exists(), "the command must create no directory");
let file = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let reason = resolve_output_dir(Some(&file.to_string_lossy()))
.expect_err("a file is not a destination");
assert!(reason.contains("is not a directory"), "{reason}");
let temp = std::env::temp_dir();
if temp.is_dir() {
let reason = resolve_output_dir(Some(&temp.to_string_lossy()))
.expect_err("the product's temporary area is refused");
assert!(reason.contains("temporary area"), "{reason}");
#[cfg(unix)]
{
let link = crate::util::test::test_root().join("cli-destination-link");
let _ = std::fs::remove_file(&link);
if std::os::unix::fs::symlink(&temp, &link).is_ok() {
let reason = resolve_output_dir(Some(&link.to_string_lossy()))
.expect_err("a path resolving into it is refused too");
assert!(reason.contains("temporary area"), "{reason}");
}
}
}
let data = crate::config::default_config_dir().expect("the storage root resolves");
let inside = data.join("somewhere");
assert!(
cleanup_governed(&inside).is_some(),
"the product's own data directory is refused"
);
if data.is_dir() {
let reason = resolve_output_dir(Some(&data.to_string_lossy()))
.expect_err("the product's data directory is refused");
assert!(reason.contains("data directory"), "{reason}");
}
}
#[test]
fn a_family_with_no_actions_is_its_own_operation() {
let sent = parsed_standalone(
"send",
&[
"--message",
"do the thing",
"--addressee",
"manager",
"--place",
"proj",
],
);
assert_eq!(
sent.args,
serde_json::json!({
"message": "do the thing",
"addressee": "manager",
"place": "proj",
})
);
assert!(sent.secrets.is_empty(), "no flag of 'send' is a secret");
assert!(
sent.args.get("branch").is_none(),
"an absent flag leaves no key: {}",
sent.args
);
assert_eq!(parsed_standalone("send", &[]).args, serde_json::json!({}));
}
#[test]
fn exit_classes_match_the_browser_automation_command() {
fn class_of(kind: OutKind) -> Exit {
match kind {
OutKind::Ok | OutKind::Empty => Exit::Ok,
OutKind::Timeout
| OutKind::Network
| OutKind::Redesign
| OutKind::NotFound
| OutKind::Error => Exit::Failed,
OutKind::Environment => Exit::Environment,
OutKind::Usage => Exit::Usage,
}
}
for kind in [
OutKind::Ok,
OutKind::Empty,
OutKind::Timeout,
OutKind::Network,
OutKind::Redesign,
OutKind::NotFound,
OutKind::Error,
OutKind::Environment,
OutKind::Usage,
] {
assert_eq!(
kind.exit_code(),
class_of(kind).code(),
"OutKind::{} must keep its class",
kind.as_str()
);
}
}
#[test]
fn attempt_decisions_map_to_the_documented_streams_and_exit_codes() {
let done = verdict(
"config",
CallAttempt::Answered(CallReply::done("token saved")),
);
assert_eq!(done.answer.as_deref(), Some("token saved"));
assert_eq!(done.diagnostic, None);
assert_eq!(done.code, Exit::Ok);
let refused = verdict(
"config",
CallAttempt::Answered(CallReply::refused("no such user 'bob'")),
);
assert_eq!(refused.answer.as_deref(), Some("no such user 'bob'"));
assert_eq!(refused.diagnostic, None, "a refusal needs no note of ours");
assert_eq!(refused.code, Exit::Failed);
let partial = verdict(
"workspace",
CallAttempt::Answered(CallReply::partial("froze 2 of 3")),
);
assert_eq!(partial.answer.as_deref(), Some("froze 2 of 3"));
assert!(
partial.diagnostic.is_some(),
"a partly carried-out operation needs its own note"
);
assert_eq!(partial.code, Exit::Failed);
for reply in [
CallReply::not_accepted("still starting"),
CallReply::unavailable("cannot serve operations"),
] {
let wording = reply.answer.clone();
let decision = verdict("workspace", CallAttempt::Answered(reply));
assert_eq!(decision.answer, None);
assert_eq!(
decision.diagnostic.as_deref(),
Some(format!("mahbot workspace: {wording}").as_str())
);
assert_eq!(decision.code, Exit::Environment);
}
let not_served = verdict(
"workspace",
CallAttempt::NotServed("no endpoint".to_string()),
);
assert_eq!(not_served.answer, None);
assert_eq!(
not_served.diagnostic.as_deref(),
Some("mahbot workspace: no endpoint"),
"a diagnostic of ours names this command"
);
assert_eq!(not_served.code, Exit::Environment);
let too_old = verdict("workspace", CallAttempt::TooOld);
assert_eq!(too_old.answer, None);
assert!(
too_old
.diagnostic
.as_deref()
.is_some_and(|line| line.contains("older") && line.contains("restart")),
"a too-old product must say what to do: {:?}",
too_old.diagnostic
);
assert_eq!(too_old.code, Exit::Environment);
let severed = verdict(
"workspace",
CallAttempt::Severed(anyhow::anyhow!("the answer could not be read")),
);
assert_eq!(severed.answer, None);
let line = severed.diagnostic.as_deref().expect("a diagnostic");
assert!(
line.contains("could not be confirmed") && line.contains("NOT sent again"),
"an unconfirmed outcome must say plainly what is unknown: {line}"
);
assert_eq!(
line.matches("sent again").count(),
1,
"the conclusion is stated once, with the cause beside it: {line}"
);
assert_eq!(severed.code, Exit::Failed);
}
#[tokio::test]
async fn misuse_is_exit_3_before_the_environment_is_probed() {
let invocations: [&[&str]; 12] = [
&["config"],
&["config", "no-such-operation"],
&["workspace", "list", "--nope", "x"],
&["workspace", "list", "--workspace"],
&["workspace", "list", "proj"],
&["send", "--nope"],
&["document"],
&["document", "no-such-operation"],
&["image", "gen", "--nope"],
&["video", "gen", "--prompt"],
&[
"image",
"gen",
"--prompt",
"x",
"--output-dir",
"/no/such/place",
],
&["workspace", "list", "--output-dir", "/tmp"],
];
for invocation in invocations {
let args = invocation
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
let cli =
family(&args[0]).unwrap_or_else(|| panic!("'{}' must be a family word", args[0]));
let code = run(cli, &args[1..]).await;
assert_eq!(code, Exit::Usage.code(), "{invocation:?}");
}
}
}