mod blocks;
mod knl;
mod mcp_serve;
mod serve;
mod vendor;
use anyhow::Context as _;
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use std::time::Duration;
use agent_block_core::host::{PromptSource, ScriptSource, SecretKeySource};
use agent_block_core::sandbox::{self, SandboxConfig};
use agent_block_core::{run_capture, BlockConfig, BlockError};
use agent_block_mcp::DEFAULT_RPC_TIMEOUT;
#[derive(Parser, Debug)]
#[command(
name = "agent-block",
about = "Single-purpose agent building block with built-in mesh communication"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(short = 's', long, conflicts_with = "block")]
script: Option<PathBuf>,
#[arg(short = 'b', long, value_name = "NAME")]
block: Option<String>,
#[arg(short = 'r', long)]
relay: Option<String>,
#[arg(long, env = "AGENT_BLOCK_MESH_SECRET_KEY")]
secret_key: Option<String>,
#[arg(short = 'p', long, default_value = ".", global = true)]
project: PathBuf,
#[arg(long, value_name = "SECS", value_parser = clap::value_parser!(u64).range(1..), global = true)]
mcp_timeout_secs: Option<u64>,
#[arg(long)]
prompt: Option<String>,
#[arg(short = 'c', long)]
context: Option<String>,
#[arg(long, value_name = "FILE", conflicts_with = "prompt")]
prompt_file: Option<PathBuf>,
#[arg(long, value_name = "FILE", conflicts_with = "context")]
context_file: Option<PathBuf>,
#[arg(long)]
sandbox: bool,
#[arg(long, value_name = "FILE")]
result: Option<PathBuf>,
#[arg(long = "label", value_name = "KEY=VALUE")]
labels: Vec<String>,
#[arg(long, value_name = "FILE")]
config: Option<PathBuf>,
}
#[derive(Subcommand, Debug)]
enum Command {
Mcp(mcp_serve::McpArgs),
Serve(serve::ServeArgs),
Knl(knl::KnlArgs),
Vendor(vendor::VendorArgs),
}
fn main() {
if let Err(err) = startup() {
if let Some(BlockError::Deferred(reason)) = err.downcast_ref::<BlockError>() {
eprintln!("deferred: {reason}");
std::process::exit(EX_TEMPFAIL);
}
eprintln!("error: {err}");
for cause in err.chain().skip(1) {
eprintln!("caused by: {cause}");
}
std::process::exit(1);
}
}
const EX_TEMPFAIL: i32 = 75;
fn startup() -> anyhow::Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default();
let cli = Cli::parse();
let log_to_stderr = matches!(
cli.command,
Some(Command::Mcp(_))
| Some(Command::Serve(_))
| Some(Command::Knl(_))
| Some(Command::Vendor(_))
);
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
if log_to_stderr {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
} else {
tracing_subscriber::fmt().with_env_filter(filter).init();
}
let _ = dotenvy::from_path(cli.project.join(".env"));
let sandbox_config = SandboxConfig::from_env(cli.sandbox);
if sandbox_config.enabled {
sandbox::apply(&sandbox_config, &cli.project)
.context("failed to enter sandbox mode (--sandbox / AGENT_BLOCK_SANDBOX)")?;
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to build the tokio runtime")?;
runtime.block_on(run_cli(cli))
}
async fn run_cli(cli: Cli) -> anyhow::Result<()> {
let mcp_rpc_timeout = cli
.mcp_timeout_secs
.map(Duration::from_secs)
.unwrap_or(DEFAULT_RPC_TIMEOUT);
match cli.command {
Some(Command::Mcp(args)) => {
return mcp_serve::serve(args, &cli.project, mcp_rpc_timeout).await;
}
Some(Command::Serve(args)) => {
return serve::serve(args, &cli.project, mcp_rpc_timeout).await;
}
Some(Command::Knl(args)) => {
return knl::run(args, &cli.project).await;
}
Some(Command::Vendor(args)) => {
return vendor::run(args, &cli.project);
}
None => {}
}
let script = match (cli.script, cli.block) {
(Some(path), None) => path,
(None, Some(name)) => {
let registered = blocks::scan(&blocks::dirs(&cli.project, &[]));
blocks::find(®istered, &name)
.map(|b| b.path.clone())
.with_context(|| {
format!(
"unknown block '{name}'; registered: [{}] (looked in \
<project>/.agent-block/blocks/, <project>/blocks/ and \
$AGENT_BLOCK_HOME/blocks/)",
blocks::names(®istered)
)
})?
}
(Some(_), Some(_)) => {
anyhow::bail!("--script and --block are mutually exclusive");
}
(None, None) => anyhow::bail!(
"no script given: pass -s/--script <PATH>, -b/--block <NAME>, or use a subcommand (see --help)"
),
};
let file = read_run_config(cli.config.as_deref())?;
let cli_prompt = cli.prompt.or(file.prompt);
let cli_context = cli.context.or(file.context);
let result_path = cli.result.or(file.result);
let prompt = match (cli_prompt, cli.prompt_file) {
(None, None) => None,
(Some(s), None) => Some(PromptSource::Inline(s)),
(None, Some(p)) => {
let content = std::fs::read_to_string(&p)
.with_context(|| format!("failed to read --prompt-file '{}'", p.display()))?;
Some(PromptSource::Inline(content))
}
(Some(_), Some(_)) => {
anyhow::bail!("--prompt and --prompt-file are mutually exclusive");
}
};
let context = match (cli_context, cli.context_file) {
(None, None) => None,
(Some(s), None) => Some(PromptSource::Inline(s)),
(None, Some(p)) => {
let content = std::fs::read_to_string(&p)
.with_context(|| format!("failed to read --context-file '{}'", p.display()))?;
Some(PromptSource::Inline(content))
}
(Some(_), Some(_)) => {
anyhow::bail!("--context and --context-file are mutually exclusive");
}
};
let mut builder = BlockConfig::builder(ScriptSource::Path(script), cli.project)
.mcp_rpc_timeout(mcp_rpc_timeout);
if let Some(relay) = cli.relay {
builder = builder.relay_url(relay);
}
if let Some(secret_key) = cli.secret_key {
builder = builder.secret_key(SecretKeySource::Inline(secret_key));
}
if let Some(prompt) = prompt {
builder = builder.prompt(prompt);
}
if let Some(context) = context {
builder = builder.context(context);
}
for (key, value) in file.labels {
builder = builder.session_label(key, value);
}
for (key, value) in parse_labels(&cli.labels)? {
builder = builder.session_label(key, value);
}
let config = builder.build();
let value = run_capture(config).await?;
if let Some(path) = result_path {
std::fs::write(&path, &value)
.with_context(|| format!("writing the script's result to '{}'", path.display()))?;
}
Ok(())
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RunConfig {
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
context: Option<String>,
#[serde(default)]
result: Option<PathBuf>,
#[serde(default)]
labels: serde_json::Map<String, serde_json::Value>,
}
fn read_run_config(path: Option<&Path>) -> anyhow::Result<RunConfig> {
let Some(path) = path else {
return Ok(RunConfig::default());
};
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading --config '{}'", path.display()))?;
serde_json::from_str(&text)
.with_context(|| format!("parsing --config '{}' as JSON", path.display()))
}
fn parse_labels(pairs: &[String]) -> anyhow::Result<Vec<(String, serde_json::Value)>> {
pairs
.iter()
.map(|pair| {
let (key, value) = pair.split_once('=').ok_or_else(|| {
anyhow::anyhow!("--label takes key=value, got '{pair}' (no '=' in it)")
})?;
if key.is_empty() {
anyhow::bail!("--label takes key=value, got '{pair}' (the key is empty)");
}
let value = match serde_json::from_str::<serde_json::Value>(value) {
Ok(v @ (serde_json::Value::Number(_) | serde_json::Value::Bool(_))) => v,
_ => serde_json::Value::from(value),
};
Ok((key.to_string(), value))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::parse_labels;
use serde_json::Value;
#[test]
fn a_label_value_is_read_as_the_scalar_it_looks_like() {
let labels =
parse_labels(&["run=r-7".into(), "n=2".into(), "retried=true".into()]).expect("labels");
assert_eq!(
labels,
vec![
("run".to_string(), Value::from("r-7")),
("n".to_string(), Value::from(2)),
("retried".to_string(), Value::from(true)),
]
);
}
#[test]
fn a_label_value_that_is_not_a_scalar_stays_text() {
let labels = parse_labels(&[
"note=2 items".into(),
"shape={\"a\":1}".into(),
"path=/tmp/x".into(),
"empty=".into(),
])
.expect("labels");
assert_eq!(
labels,
vec![
("note".to_string(), Value::from("2 items")),
("shape".to_string(), Value::from("{\"a\":1}")),
("path".to_string(), Value::from("/tmp/x")),
("empty".to_string(), Value::from("")),
]
);
}
#[test]
fn a_pair_without_a_value_is_refused() {
let err = parse_labels(&["run".into()]).expect_err("no '=' in it");
assert!(err.to_string().contains("key=value"), "{err}");
let err = parse_labels(&["=r-7".into()]).expect_err("the key is empty");
assert!(err.to_string().contains("the key is empty"), "{err}");
}
}