use std::collections::{BTreeMap, HashMap};
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use std::sync::Arc;
use futures::Stream;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::{Mutex as TokioMutex, RwLock};
use crate::errors::FieldError;
use crate::middleware::{self, MiddlewareContext, MiddlewareFn};
use crate::output::*;
use crate::schema::FieldMeta;
use crate::streaming;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseMode {
Argv,
Split,
Flat,
}
#[derive(Debug, Clone)]
pub struct Example {
pub command: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpAnnotations {
pub title: Option<String>,
pub read_only_hint: Option<bool>,
pub destructive_hint: Option<bool>,
pub idempotent_hint: Option<bool>,
pub open_world_hint: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct McpCommandOptions {
pub enabled: bool,
pub name: Option<String>,
pub description: Option<String>,
pub instructions: Option<String>,
pub annotations: Option<McpAnnotations>,
pub destructive: bool,
}
impl Default for McpCommandOptions {
fn default() -> Self {
Self {
enabled: true,
name: None,
description: None,
instructions: None,
annotations: None,
destructive: false,
}
}
}
pub struct CommandDef {
pub name: String,
pub description: Option<String>,
pub args_fields: Vec<FieldMeta>,
pub options_fields: Vec<FieldMeta>,
pub env_fields: Vec<FieldMeta>,
pub aliases: HashMap<String, char>,
pub command_aliases: Vec<String>,
pub examples: Vec<Example>,
pub hint: Option<String>,
pub format: Option<Format>,
pub output_policy: Option<OutputPolicy>,
pub handler: Box<dyn CommandHandler>,
pub middleware: Vec<MiddlewareFn>,
pub output_schema: Option<Value>,
}
impl CommandDef {
pub fn build(
name: impl Into<String>,
handler: impl CommandHandler + 'static,
) -> CommandBuilder {
CommandBuilder {
def: CommandDef {
name: name.into(),
description: None,
args_fields: Vec::new(),
options_fields: Vec::new(),
env_fields: Vec::new(),
aliases: HashMap::new(),
command_aliases: Vec::new(),
examples: Vec::new(),
hint: None,
format: None,
output_policy: None,
handler: Box::new(handler),
middleware: Vec::new(),
output_schema: None,
},
mcp: None,
}
}
pub fn typed<Args, Options, Env, Output, Handler, HandlerFuture>(
name: impl Into<String>,
handler: Handler,
) -> CommandBuilder
where
Args: crate::schema::IncurSchema + Send + Sync + 'static,
Options: crate::schema::IncurSchema + Send + Sync + 'static,
Env: crate::schema::IncurSchema + Send + Sync + 'static,
Output: JsonSchema + Serialize + Send + Sync + 'static,
Handler: Fn(TypedContext<Args, Options, Env>) -> HandlerFuture + Send + Sync + 'static,
HandlerFuture: Future<Output = TypedResult<Output>> + Send + 'static,
{
let output_schema = serde_json::to_value(schemars::schema_for!(Output))
.expect("schemars output must serialize to JSON");
let mut builder = Self::build(
name,
TypedHandler::<Args, Options, Env, Output, Handler> {
handler,
marker: PhantomData,
},
)
.args::<Args>()
.options::<Options>()
.env::<Env>();
builder.def.output_schema = Some(output_schema);
builder
}
}
pub struct TypedContext<Args, Options, Env> {
pub agent: bool,
pub args: Args,
pub display_name: String,
pub env: Env,
pub globals: Value,
pub options: Options,
pub request: Option<RequestContext>,
pub format: Format,
pub format_explicit: bool,
pub name: String,
pub vars: Value,
pub version: Option<String>,
}
pub enum TypedResult<Output> {
Ok {
data: Output,
cta: Option<CtaBlock>,
},
Error {
code: String,
message: String,
retryable: bool,
exit_code: Option<i32>,
cta: Option<CtaBlock>,
},
}
impl<Output> TypedResult<Output> {
pub fn ok(data: Output) -> Self {
Self::Ok { data, cta: None }
}
pub fn ok_with_cta(data: Output, cta: CtaBlock) -> Self {
Self::Ok {
data,
cta: Some(cta),
}
}
pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
Self::Error {
code: code.into(),
message: message.into(),
retryable: false,
exit_code: Some(1),
cta: None,
}
}
}
type TypedHandlerMarker<Args, Options, Env, Output> =
PhantomData<fn() -> (Args, Options, Env, Output)>;
struct TypedHandler<Args, Options, Env, Output, Handler> {
handler: Handler,
marker: TypedHandlerMarker<Args, Options, Env, Output>,
}
#[async_trait::async_trait]
impl<Args, Options, Env, Output, Handler, HandlerFuture> CommandHandler
for TypedHandler<Args, Options, Env, Output, Handler>
where
Args: crate::schema::IncurSchema + Send + Sync + 'static,
Options: crate::schema::IncurSchema + Send + Sync + 'static,
Env: crate::schema::IncurSchema + Send + Sync + 'static,
Output: Serialize + Send + Sync + 'static,
Handler: Fn(TypedContext<Args, Options, Env>) -> HandlerFuture + Send + Sync + 'static,
HandlerFuture: Future<Output = TypedResult<Output>> + Send + 'static,
{
async fn run(&self, ctx: CommandContext) -> CommandResult {
let args = match typed_input::<Args>(&ctx.args, "args") {
Ok(value) => value,
Err(result) => return result,
};
let options = match typed_input::<Options>(&ctx.options, "options") {
Ok(value) => value,
Err(result) => return result,
};
let env = match typed_input::<Env>(&ctx.env, "env") {
Ok(value) => value,
Err(result) => return result,
};
match (self.handler)(TypedContext {
agent: ctx.agent,
args,
display_name: ctx.display_name,
env,
globals: ctx.globals,
options,
request: ctx.request,
format: ctx.format,
format_explicit: ctx.format_explicit,
name: ctx.name,
vars: ctx.vars,
version: ctx.version,
})
.await
{
TypedResult::Ok { data, cta } => match serde_json::to_value(data) {
Ok(data) => CommandResult::Ok { data, cta },
Err(error) => CommandResult::Error {
code: "SERIALIZATION_ERROR".to_string(),
message: error.to_string(),
retryable: false,
exit_code: Some(1),
cta: None,
},
},
TypedResult::Error {
code,
message,
retryable,
exit_code,
cta,
} => CommandResult::Error {
code,
message,
retryable,
exit_code,
cta,
},
}
}
}
fn typed_input<Input: crate::schema::IncurSchema>(
value: &Value,
kind: &str,
) -> std::result::Result<Input, CommandResult> {
let raw = value
.as_object()
.into_iter()
.flatten()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
Input::from_raw(&raw).map_err(|error| CommandResult::Error {
code: "VALIDATION_ERROR".to_string(),
message: format!("Failed to parse typed {kind}: {error}"),
retryable: false,
exit_code: Some(1),
cta: None,
})
}
pub struct CommandBuilder {
def: CommandDef,
mcp: Option<McpCommandOptions>,
}
impl CommandBuilder {
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.def.description = Some(desc.into());
self
}
pub fn args<T: crate::schema::IncurSchema>(mut self) -> Self {
self.def.args_fields = T::fields();
self
}
pub fn command_aliases(mut self, aliases: Vec<String>) -> Self {
self.def.command_aliases = aliases;
self
}
pub fn options<T: crate::schema::IncurSchema>(mut self) -> Self {
let fields = T::fields();
for field in &fields {
if let Some(alias) = field.alias {
self.def.aliases.insert(field.name.to_string(), alias);
}
}
self.def.options_fields = fields;
self
}
pub fn env<T: crate::schema::IncurSchema>(mut self) -> Self {
self.def.env_fields = T::fields();
self
}
pub fn examples(mut self, examples: Vec<Example>) -> Self {
self.def.examples = examples;
self
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.def.hint = Some(hint.into());
self
}
pub fn format(mut self, format: crate::output::Format) -> Self {
self.def.format = Some(format);
self
}
pub fn mcp(mut self, options: McpCommandOptions) -> Self {
self.mcp = Some(options);
self
}
pub fn destructive(mut self, destructive: bool) -> Self {
self.mcp.get_or_insert_with(Default::default).destructive = destructive;
self
}
pub fn done(mut self) -> CommandDef {
if let Some(options) = self.mcp {
self.def.handler = Box::new(McpHandler {
handler: self.def.handler,
options,
});
}
self.def
}
}
struct McpHandler {
handler: Box<dyn CommandHandler>,
options: McpCommandOptions,
}
#[async_trait::async_trait]
impl CommandHandler for McpHandler {
async fn run(&self, ctx: CommandContext) -> CommandResult {
self.handler.run(ctx).await
}
fn mcp_options(&self) -> Option<&McpCommandOptions> {
Some(&self.options)
}
fn mcp_input_schema(&self) -> Option<&Value> {
self.handler.mcp_input_schema()
}
}
#[async_trait::async_trait]
pub trait CommandHandler: Send + Sync {
async fn run(&self, ctx: CommandContext) -> CommandResult;
fn mcp_options(&self) -> Option<&McpCommandOptions> {
None
}
fn mcp_input_schema(&self) -> Option<&Value> {
None
}
}
#[derive(Debug, Clone, Default)]
pub struct RequestContext {
pub headers: HashMap<String, String>,
pub method: String,
pub path: String,
}
pub struct CommandContext {
pub agent: bool,
pub args: Value,
pub env: Value,
pub display_name: String,
pub globals: Value,
pub options: Value,
pub request: Option<RequestContext>,
pub format: Format,
pub format_explicit: bool,
pub name: String,
pub vars: Value,
pub version: Option<String>,
}
pub struct ExecuteOptions {
pub agent: bool,
pub argv: Vec<String>,
pub defaults: Option<BTreeMap<String, Value>>,
pub display_name: String,
pub env_fields: Vec<FieldMeta>,
pub env_source: HashMap<String, String>,
pub format: Format,
pub format_explicit: bool,
pub globals: Value,
pub input_options: BTreeMap<String, Value>,
pub middlewares: Vec<MiddlewareFn>,
pub name: String,
pub parse_mode: ParseMode,
pub path: String,
pub request: Option<RequestContext>,
pub vars_fields: Vec<FieldMeta>,
pub version: Option<String>,
}
pub enum InternalResult {
Ok { data: Value, cta: Option<CtaBlock> },
Error {
code: String,
message: String,
retryable: Option<bool>,
field_errors: Option<Vec<FieldError>>,
cta: Option<CtaBlock>,
exit_code: Option<i32>,
},
Stream(Pin<Box<dyn Stream<Item = Value> + Send>>),
RecordStream(Pin<Box<dyn Stream<Item = crate::output::StreamRecord> + Send>>),
}
struct InputError {
message: String,
field_errors: Option<Vec<FieldError>>,
}
impl From<crate::errors::ParseError> for InputError {
fn from(error: crate::errors::ParseError) -> Self {
Self {
message: error.to_string(),
field_errors: None,
}
}
}
pub async fn execute(command: Arc<CommandDef>, options: ExecuteOptions) -> InternalResult {
let ExecuteOptions {
agent,
argv,
defaults,
display_name,
env_fields,
env_source,
format,
format_explicit,
globals,
input_options,
middlewares,
name,
parse_mode,
path,
request,
vars_fields: _,
version,
} = options;
let env_source_for_cli = env_source.clone();
let name_for_mw = name.clone();
let version_for_mw = version.clone();
let globals_for_mw = globals.clone();
let display_name_for_mw = display_name.clone();
let request_for_mw = request.clone();
let vars_map = Arc::new(RwLock::new(serde_json::Map::new()));
let result: Arc<TokioMutex<Option<InternalResult>>> = Arc::new(TokioMutex::new(None));
let (stream_consumed_tx, stream_consumed_rx) = tokio::sync::oneshot::channel::<()>();
let stream_consumed_tx = Arc::new(tokio::sync::Mutex::new(Some(stream_consumed_tx)));
let (result_ready_tx, result_ready_rx) = tokio::sync::oneshot::channel::<()>();
let result_ready_tx = Arc::new(tokio::sync::Mutex::new(Some(result_ready_tx)));
let result_inner = Arc::clone(&result);
let result_ready_inner = Arc::clone(&result_ready_tx);
let stream_consumed_inner = Arc::clone(&stream_consumed_tx);
let vars_map_inner = Arc::clone(&vars_map);
let has_middleware = !middlewares.is_empty();
let command_inner = Arc::clone(&command);
let run_command = move || -> middleware::BoxFuture<()> {
let command = command_inner;
Box::pin(async move {
let parsed = match parse_mode {
ParseMode::Argv => {
parse_argv_mode(
&argv,
&command.args_fields,
&command.options_fields,
&command.aliases,
&defaults,
)
}
ParseMode::Split => {
let args = parse_args_from_argv(&argv, &command.args_fields);
let parsed_options = input_options_to_value(&input_options);
validate_parsed_input(args, parsed_options, &command)
}
ParseMode::Flat => {
let (args, parsed_options) = split_flat_params(
&input_options,
&command.args_fields,
&command.options_fields,
);
validate_parsed_input(args, parsed_options, &command)
}
};
let (args, parsed_options) = match parsed {
Ok(parsed) => parsed,
Err(error) => {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Error {
code: "VALIDATION_ERROR".to_string(),
message: error.message,
retryable: None,
field_errors: error.field_errors,
cta: None,
exit_code: None,
});
return;
}
};
let command_env = parse_env_fields(&command.env_fields, &env_source);
let vars_value = {
let vars_guard = vars_map_inner.read().await;
Value::Object(vars_guard.clone())
};
let ctx = CommandContext {
agent,
args,
display_name,
env: command_env,
globals,
options: parsed_options,
request,
format,
format_explicit,
name: name.clone(),
vars: vars_value,
version: version.clone(),
};
let handler_result = command.handler.run(ctx).await;
match handler_result {
CommandResult::Ok { data, cta } => {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Ok { data, cta });
}
CommandResult::Error {
code,
message,
retryable,
exit_code,
cta,
} => {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Error {
code,
message,
retryable: if retryable { Some(true) } else { None },
field_errors: None,
cta,
exit_code,
});
}
CommandResult::Stream(stream) => {
if has_middleware {
let signal = {
let mut tx = stream_consumed_inner.lock().await;
tx.take()
};
let wrapped = if let Some(signal) = signal {
streaming::wrap_stream_with_signal(stream, signal)
} else {
stream
};
{
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Stream(wrapped));
}
if let Some(tx) = result_ready_inner.lock().await.take() {
let _ = tx.send(());
}
let _ = stream_consumed_rx.await;
} else {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Stream(stream));
}
}
CommandResult::RecordStream(stream) => {
if has_middleware {
let signal = {
let mut tx = stream_consumed_inner.lock().await;
tx.take()
};
let wrapped = if let Some(signal) = signal {
streaming::wrap_record_stream_with_signal(stream, signal)
} else {
stream
};
{
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::RecordStream(wrapped));
}
if let Some(tx) = result_ready_inner.lock().await.take() {
let _ = tx.send(());
}
let _ = stream_consumed_rx.await;
} else {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::RecordStream(stream));
}
}
}
})
};
let cli_env = parse_env_fields(&env_fields, &env_source_for_cli);
if !middlewares.is_empty() {
let mw_ctx = MiddlewareContext {
agent,
command: path,
display_name: display_name_for_mw,
env: cli_env,
format,
format_explicit,
globals: globals_for_mw,
name: name_for_mw,
request: request_for_mw,
vars: Arc::clone(&vars_map),
version: version_for_mw,
};
let chain = middleware::compose(&middlewares, mw_ctx, run_command);
tokio::select! {
_ = chain => {},
_ = result_ready_rx => {},
}
} else {
run_command().await;
}
let result_guard = result.lock().await;
match result_guard.as_ref() {
Some(_) => {
drop(result_guard);
let mut result_guard = result.lock().await;
result_guard.take().unwrap_or(InternalResult::Ok {
data: Value::Null,
cta: None,
})
}
None => InternalResult::Ok {
data: Value::Null,
cta: None,
},
}
}
fn parse_argv_mode(
argv: &[String],
args_fields: &[FieldMeta],
options_fields: &[FieldMeta],
aliases: &HashMap<String, char>,
defaults: &Option<BTreeMap<String, Value>>,
) -> Result<(Value, Value), InputError> {
let parsed = crate::parser::parse(
argv,
&crate::parser::ParseOptions {
args_fields: args_fields.to_vec(),
options_fields: options_fields.to_vec(),
aliases: aliases.clone(),
defaults: defaults.clone(),
},
)?;
Ok((
Value::Object(parsed.args.into_iter().collect()),
Value::Object(parsed.options.into_iter().collect()),
))
}
fn validate_parsed_input(
args: Value,
options: Value,
command: &CommandDef,
) -> Result<(Value, Value), InputError> {
let args_map = args
.as_object()
.map(|values| values.clone().into_iter().collect())
.unwrap_or_default();
let options_map = options
.as_object()
.map(|values| values.clone().into_iter().collect())
.unwrap_or_default();
let args_map = crate::parser::coerce_fields(args_map, &command.args_fields);
let options_map = crate::parser::coerce_fields(options_map, &command.options_fields);
let field_errors = crate::parser::field_errors(&args_map, &command.args_fields)
.into_iter()
.chain(crate::parser::field_errors(
&options_map,
&command.options_fields,
))
.collect::<Vec<_>>();
if !field_errors.is_empty() {
return Err(InputError {
message: "Validation failed".to_string(),
field_errors: Some(field_errors),
});
}
Ok((
Value::Object(args_map.into_iter().collect()),
Value::Object(options_map.into_iter().collect()),
))
}
fn parse_args_from_argv(argv: &[String], args_fields: &[FieldMeta]) -> Value {
let mut args_map = serde_json::Map::new();
for (i, token) in argv.iter().enumerate() {
if i < args_fields.len() {
let field = &args_fields[i];
args_map.insert(field.name.to_string(), parse_option_value(token));
}
}
Value::Object(args_map)
}
fn input_options_to_value(options: &BTreeMap<String, Value>) -> Value {
let map: serde_json::Map<String, Value> = options
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Value::Object(map)
}
fn split_flat_params(
params: &BTreeMap<String, Value>,
args_fields: &[FieldMeta],
options_fields: &[FieldMeta],
) -> (Value, Value) {
let arg_names: std::collections::HashSet<&str> = args_fields.iter().map(|f| f.name).collect();
let _option_names: std::collections::HashSet<&str> =
options_fields.iter().map(|f| f.name).collect();
let mut args_map = serde_json::Map::new();
let mut opts_map = serde_json::Map::new();
for (key, value) in params {
let snake_key = crate::schema::to_snake(key);
if arg_names.contains(snake_key.as_str()) {
args_map.insert(snake_key, value.clone());
} else {
opts_map.insert(snake_key, value.clone());
}
}
(Value::Object(args_map), Value::Object(opts_map))
}
fn parse_env_fields(env_fields: &[FieldMeta], env_source: &HashMap<String, String>) -> Value {
let mut env_map = serde_json::Map::new();
for field in env_fields {
let env_name = field.env_name.unwrap_or(field.name);
if let Some(value) = env_source.get(env_name) {
env_map.insert(
field.name.to_string(),
parse_env_value(value, &field.field_type),
);
} else if let Some(default) = &field.default {
env_map.insert(field.name.to_string(), default.clone());
}
}
Value::Object(env_map)
}
fn parse_env_value(value: &str, field_type: &crate::schema::FieldType) -> Value {
match field_type {
crate::schema::FieldType::Boolean => Value::Bool(matches!(value, "1" | "true" | "yes")),
crate::schema::FieldType::Number => {
if let Ok(n) = value.parse::<f64>() {
Value::from(n)
} else {
Value::String(value.to_string())
}
}
_ => Value::String(value.to_string()),
}
}
fn parse_option_value(value: &str) -> Value {
if let Ok(n) = value.parse::<i64>() {
return Value::from(n);
}
if let Ok(n) = value.parse::<f64>() {
return Value::from(n);
}
match value {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
Value::String(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_option_value() {
assert_eq!(parse_option_value("42"), Value::from(42));
assert_eq!(
parse_option_value("3.14"),
Value::from("3.14".parse::<f64>().unwrap())
);
assert_eq!(parse_option_value("true"), Value::Bool(true));
assert_eq!(parse_option_value("false"), Value::Bool(false));
assert_eq!(
parse_option_value("hello"),
Value::String("hello".to_string())
);
}
#[test]
fn test_split_flat_params() {
let mut params = BTreeMap::new();
params.insert("name".to_string(), Value::String("alice".to_string()));
params.insert("verbose".to_string(), Value::Bool(true));
let args_fields = vec![FieldMeta {
name: "name",
cli_name: "name".to_string(),
description: None,
field_type: crate::schema::FieldType::String,
required: true,
default: None,
alias: None,
deprecated: false,
env_name: None,
}];
let options_fields = vec![FieldMeta {
name: "verbose",
cli_name: "verbose".to_string(),
description: None,
field_type: crate::schema::FieldType::Boolean,
required: false,
default: None,
alias: None,
deprecated: false,
env_name: None,
}];
let (args, opts) = split_flat_params(¶ms, &args_fields, &options_fields);
assert_eq!(args["name"], Value::String("alice".to_string()));
assert_eq!(opts["verbose"], Value::Bool(true));
}
#[test]
fn test_parse_env_fields() {
let fields = vec![FieldMeta {
name: "api_key",
cli_name: "api-key".to_string(),
description: Some("API key"),
field_type: crate::schema::FieldType::String,
required: true,
default: None,
alias: None,
deprecated: false,
env_name: Some("API_KEY"),
}];
let mut env_source = HashMap::new();
env_source.insert("API_KEY".to_string(), "secret123".to_string());
let result = parse_env_fields(&fields, &env_source);
assert_eq!(result["api_key"], Value::String("secret123".to_string()));
}
}