One struct. Three interfaces. Zero boilerplate.
Lilyco is a Rust framework that generates CLI, TUI, and Web UI — plus AI function-calling schemas — from a single struct definition. You write the business logic once; the framework handles everything else.
- Why Lilyco
- Quick Start
- Architecture
- Crate Reference
- Type -> Widget Mapping
- AI Integration
- Progress Protocol
- Examples
- Testing
- Installation
- Limitations & Roadmap
A typical Rust CLI tool needs about 200 lines of clap boilerplate before the first line of actual logic. Add a TUI? Another 400 lines. A web dashboard? A different codebase entirely. Want LLMs to call your tool? You're writing JSON Schema by hand.
Lilyco collapses all of this into a single #[derive]:
#[derive(App)]
#[app(about = "Compress image files", run = "compress")]
struct ImgCompress {
#[arg(about = "Input file", must_exist = true)]
input: PathBuf,
#[arg(about = "Quality 1-100", default = 75, range = 1..=100)]
quality: u8,
#[arg(about = "Output format", default = "jpeg")]
format: Format,
#[arg(about = "Dry run")]
dry_run: bool,
}From this you get:
imgpress --input photo.jpg --quality 50 --format webp— CLI- Interactive TUI form with live command preview — TUI
- Browser-based form with SSE progress — Web
- Valid Anthropic/OpenAI tool definition — AI
Create a new project and add the dependencies:
cargo new imgpress && cd imgpress
cargo add lilyco-core lilyco-macros lilyco-cli serde serde_json imagePaste this into src/main.rs:
use std::path::PathBuf;
use std::time::Instant;
use image::{DynamicImage, GenericImageView};
use image::imageops::FilterType;
use lilyco_core::prelude::*;
use lilyco_macros::{App, ValueEnum};
// 1. Define your types
#[derive(Debug, ValueEnum)]
enum Format { Jpeg, Png, Webp }
#[derive(App)]
#[app(about = "Compress image files", run = "compress")]
struct ImgCompress {
#[arg(about = "Input image", must_exist = true)]
input: PathBuf,
#[arg(about = "Quality 1-100", default = 75, range = 1..=100)]
quality: u8,
#[arg(about = "Output format", default = "jpeg")]
format: Format,
#[arg(about = "Max width, 0 = no resize")]
width: u32,
#[arg(about = "Dry run")]
dry_run: bool,
}
// 2. Write your business logic
fn compress(app: &ImgCompress, ctx: &Context) -> Result<serde_json::Value, AppError> {
let start = Instant::now();
ctx.emit(Progress::Started { total: Some(3), message: None });
let data = std::fs::read(&app.input)?;
let img = image::load_from_memory(&data)
.map_err(|e| AppError::Runtime(format!("decode: {e}")))?;
ctx.tick(1, Some(3), "Resizing...");
let img = if app.width > 0 && app.width < img.width() {
let ratio = app.width as f64 / img.width() as f64;
let h = (img.height() as f64 * ratio) as u32;
img.resize_exact(app.width, h.max(1), FilterType::Lanczos3)
} else { img };
ctx.tick(2, Some(3), "Encoding...");
let out_path = app.input.with_file_name(format!("compressed.{}",
if matches!(app.format, Format::Jpeg) { "jpg" } else { "png" }));
img.save(&out_path).map_err(|e| AppError::Runtime(format!("save: {e}")))?;
ctx.tick(3, Some(3), "Done");
ctx.done(serde_json::json!({"output": out_path.to_string_lossy()}),
start.elapsed().as_millis() as u64);
Ok(serde_json::json!({"status": "ok"}))
}
// 3. Wire up — the framework handles everything else
fn main() {
let schema = ImgCompress::schema();
let cmd = lilyco_cli::CliRenderer::new().render(&schema);
let matches = cmd.get_matches();
if lilyco_cli::CliRenderer::handle_builtin_flags(&schema, &matches) { return; }
let output_format = lilyco_cli::CliRenderer::output_format(&matches);
let args = lilyco_cli::CliRenderer::extract_args(&schema, &matches);
let app = ImgCompress::from_args(&args).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let ctx = Context::new(tx, Arc::new(false.into()), output_format.clone());
std::thread::spawn(move || compress(&app, &ctx));
for event in rx {
match output_format {
OutputFormat::JsonStream => println!("{}", serde_json::to_string(&event).unwrap()),
_ => if let Progress::Log { message, .. } = &event { eprintln!(" {message}"); },
}
if matches!(event, Progress::Done { .. } | Progress::Error { .. }) { break; }
}
}Run it:
$ cargo run -- --input photo.jpg --quality 50 --format webp
$ cargo run -- --schema # JSON Schema
$ cargo run -- --anthropic-tool # AI tool definition
$ cargo run -- --json-stream # Machine-readable progress+--------------------------------------------------+
| Your Struct |
| #[derive(App)] |
| struct MyTool { ... } |
+--------+----------+----------+------------------+
| | |
+----v---+ +---v----+ +--v----------+
| CLI | | TUI | | Web UI |
| (clap) | |(ratatui| |(axum + HTML)|
+----+---+ +---+----+ +--v----------+
| | |
+----v---------v---------v----+
| lilyco-core |
| CommandSchema -> clap::Cmd |
| Progress -> TUI widgets |
| Progress -> SSE events |
+------------------------------+
- Type-driven:
bool-> checkbox,u8-> number input, custom enum -> dropdown. No manual widget mapping. - CLI-first: CLI is the most structured interface. TUI and Web are derived from the same schema.
- Progress as first-class citizen: Every interface understands
Progress::Tick/Log/Done. - Zero-cost: Feature flags gate TUI and Web dependencies. CLI-only builds need only
clap. - AI-native: Every Lilyco app can export its interface as an LLM function-calling schema.
The foundation. No UI dependencies.
use lilyco_core::prelude::*;| Trait | Method | Purpose |
|---|---|---|
App |
schema() -> CommandSchema |
Returns the full command schema |
App |
from_args(&HashMap) -> Result<Self, AppError> |
Construct from parsed CLI/AI args |
App |
run(&self, &Context) -> Result<Value, AppError> |
Execute business logic |
Renderer |
render(&self, &CommandSchema) -> Output |
Convert schema to a UI representation |
ValueEnum |
variants() -> Vec<&str> |
All possible string values |
ValueEnum |
from_str(&str) -> Option<Self> |
Parse from string |
| Type | Purpose |
|---|---|
CommandSchema |
Full command description: name, about, args, subcommands |
ArgSchema |
Single argument: name, about, kind, required, default |
ArgKind |
`Flag |
Progress |
`Started |
LogLevel |
`Debug |
Context |
Runtime: progress channel, cancel signal, output format |
OutputFormat |
`Human |
AppError |
`InvalidArg |
schema.to_json_schema() // JSON Schema (generic)
schema.to_openai_tool() // OpenAI function calling format
schema.to_anthropic_tool() // Anthropic tool use formatProc macros for deriving boilerplate.
use lilyco_macros::{App, ValueEnum};Generates schema(), from_args(), and run(). Reads these attributes:
Struct-level:
| Attribute | Example | Purpose |
|---|---|---|
#[app(about = "...")] |
#[app(about = "Compress images")] |
Command description |
#[app(run = "fn")] |
#[app(run = "compress")] |
Wire up run() to a business-logic function |
Field-level:
| Attribute | Example | Purpose |
|---|---|---|
#[arg(about = "...")] |
#[arg(about = "Input file")] |
Argument description |
#[arg(default = expr)] |
#[arg(default = 75)] |
Default value |
#[arg(range = lo..=hi)] |
#[arg(range = 1..=100)] |
Number range |
#[arg(min = n)] |
#[arg(min = 0)] |
Min value |
#[arg(max = n)] |
#[arg(max = 255)] |
Max value |
#[arg(must_exist = bool)] |
#[arg(must_exist = true)] |
Path existence check |
Auto-converts PascalCase variants to snake_case strings:
#[derive(ValueEnum)]
enum Codec { H264, H265, Av1 }
// -> variants: ["h264", "h265", "av1"]
// -> from_str("h265") -> Some(Codec::H265)| Rust Type | Inferred ArgKind |
required |
|---|---|---|
bool |
Flag |
false |
String |
Text |
true |
u8/i32/f64/... |
Number |
true |
PathBuf |
Path |
true |
Option<T> |
same as T |
false |
Vec<T> |
List { item: infer(T) } |
true |
Custom enum |
Enum |
true |
Generates a clap::Command from CommandSchema. Adds built-in flags automatically.
let schema = MyTool::schema();
let renderer = lilyco_cli::CliRenderer::new();
let cmd = renderer.render(&schema);
let matches = cmd.get_matches();| Flag | Behavior |
|---|---|
--schema |
Print JSON Schema and exit |
--openai-tool |
Print OpenAI function definition and exit |
--anthropic-tool |
Print Anthropic tool definition and exit |
--json |
OutputFormat::Json |
--json-stream |
OutputFormat::JsonStream (one JSON per line) |
impl CliRenderer {
fn new() -> Self;
fn render(&self, schema: &CommandSchema) -> clap::Command;
fn handle_builtin_flags(schema: &CommandSchema, matches: &ArgMatches) -> bool;
fn output_format(matches: &ArgMatches) -> OutputFormat;
fn extract_args(schema: &CommandSchema, matches: &ArgMatches)
-> HashMap<String, serde_json::Value>;
}Interactive terminal form built on ratatui.
Transcode -- Transcode video files
$ transcode --input video.mp4 --codec h265 --quality 18
---------------------------------------------------------
(*) input: [video.mp4________________________]
codec: [h264] h265 [Av1] <->
quality: [18] ^v
dry_run: [x] Space
---------------------------------------------------------
[Tab] Switch [Enter] Confirm [Esc] Quit [F1] Help
| ArgKind | Key | Behavior |
|---|---|---|
| Flag | Space |
Toggle on/off |
| Text | Type + Backspace |
Edit text |
| Number | ^ v |
+/-1. Type digits to edit |
| Enum | < > |
Cycle through options |
| Path | Type + Backspace |
Edit path |
| List | Enter / Delete |
Add/remove item |
Form --Enter--> Confirm --Enter--> Running --done--> Done
^ | | |
| Esc | |
+---------------+ v v
Error <---------- Enter
The bottom bar shows a live CLI command preview that updates as you edit values. It auto-omits:
falseflags (e.g.,--dry-runonly appears when checked)- Values matching their defaults
- Empty optional fields
- Path values are auto-quoted if they contain spaces
Web server with embedded HTML, similar to Gradio in spirit.
let gui = lilyco_gui::GuiRenderer::new(8080);
gui.serve(schema, Arc::new(|args| Box::pin(async move {
// process args, return result
Ok(serde_json::json!({"status": "ok"}))
}))).await;+-------------------------------------+
| ImgCompress -- Compress images |
| |
| Input: [___________________] |
| Quality: [75_______________] |
| Format: [jpeg v] |
| Width: [0________________] |
| Dry run: [ ] |
| |
| [> Run] [Copy CLI] |
| |
| $ imgcompress --quality 75 |
+-------------------------------------+
| Output |
| ████████░░░░░░░ 50% |
| Encoding frame 50/100 |
| Done in 1.2s |
+-------------------------------------+
Flow: Form POST -> spawn task -> SSE stream -> progress bar + log
Experimental JSON-to-React declarative UI generator. Write a Chinese-language JSON spec; get a full React frontend — no Rust code required.
use lilyco_ultra_ui::UltraUiServer;
#[tokio::main]
async fn main() {
UltraUiServer::new(9090).serve().await;
}The JSON spec uses Chinese field names for an Excel-like feel:
{
"窗口": {
"标题": "My App",
"大小": "中等",
"元素": [
{ "类型": "标题", "内容": "Welcome" },
{ "类型": "文本输入", "标签": "Name", "占位符": "Enter name..." },
{ "类型": "数字", "标签": "Quantity", "最小值": 0, "最大值": 100 },
{ "类型": "按钮", "文本": "Submit", "样式": "primary" },
{ "类型": "进度", "标签": "Progress" }
]
}
}| Type (Chinese) | English | Description |
|---|---|---|
文本 |
Text | Static text block |
标题 |
Heading | H1-H4 heading |
按钮 |
Button | Clickable button with style variants |
文本输入 |
Text Input | Single-line text input |
数字 |
Number | Numeric input with min/max |
下拉 |
Select | Dropdown select |
复选框 |
Checkbox | Boolean toggle |
多行文本 |
Textarea | Multi-line text input |
图片 |
Image | Image display |
分割线 |
Divider | Visual separator |
进度 |
Progress | Progress bar |
链接 |
Link | Hyperlink |
计算器 |
Calculator | Built-in calculator widget |
| Rust Type | CLI | TUI | Web |
|---|---|---|---|
bool |
--flag |
[x] Space toggle |
<input type=checkbox> |
String |
--name <val> |
text input | <input type=text> |
u8/i32/f64/... |
--count <num> |
^v +/-1 + digit input | <input type=number> |
| Custom enum | --mode <choice> |
<-> cycle | <select> |
PathBuf |
--file <path> |
text input | <input type=text> |
Vec<T> |
--tag a --tag b |
Enter/Delete multi-line | dynamic inputs |
Option<T> |
optional | optional (not required) | optional |
Every Lilyco app is an AI tool:
$ imgpress --anthropic-tool{
"name": "ImgCompress",
"description": "Compress image files",
"input_schema": {
"type": "object",
"properties": {
"input": { "type": "string", "description": "Input image file" },
"quality": { "type": "number", "minimum": 1, "maximum": 100, "description": "Quality" },
"format": { "type": "string", "enum": ["jpeg", "png", "webp"], "description": "Format" },
"dry_run": { "type": "boolean", "description": "Dry run" }
},
"required": ["input"]
}
}This is a valid Anthropic tool-use definition. Drop it into your Claude API call, and the model can invoke your Rust tool directly.
$ imgpress --openai-tool # OpenAI format
$ imgpress --schema # Generic JSON Schema (for other LLMs)
$ imgpress --json-stream # Each Progress event as one JSON line — ideal for agent consumption{"type":"started","total":5,"message":"Loading photo.jpg..."}
{"type":"tick","current":1,"total":5,"message":"Reading input file","percent":0.2}
{"type":"tick","current":2,"total":5,"message":"Original: 4000x3000","percent":0.4}
{"type":"tick","current":3,"total":5,"message":"Encoding...","percent":0.6}
{"type":"tick","current":4,"total":5,"message":"Writing compressed.jpg","percent":0.8}
{"type":"done","result":{"output_size":142000,"compression_ratio":35.5},"duration_ms":1200}Every interface consumes the same Progress events:
ctx.emit(Progress::Started { total: Some(100), message: Some("Starting...".into()) });
for i in 0..=100 {
if ctx.is_cancelled() { return Err(AppError::Cancelled); }
ctx.tick(i, Some(100), format!("Processing frame {i}"));
}
ctx.log(LogLevel::Info, "Compression complete");
ctx.done(serde_json::json!({"size_mb": 4.2}), 3200);| Interface | Started |
Tick |
Log |
Done |
|---|---|---|---|---|
CLI (--json-stream) |
JSON line | JSON line with percent | JSON line | JSON line + exit |
| CLI (Human) | -- | \r progress line |
[INFO] line |
summary + exit |
| TUI | Progress bar at 0% | Bar fills + message | Scroll log | Result screen |
| Web | SSE: bar at 0% | SSE: bar fills | SSE: log append | SSE: result JSON |
cd lilyco-example
cargo run -- --input photo.jpg --quality 50 --format webp
cargo run -- --input photo.jpg --dry-run --json
cargo run -- --schemaSee lilyco-example/src/main.rs for the full source (~230 lines).
use lilyco_macros::{App, ValueEnum};
use lilyco_core::prelude::*;
use std::path::PathBuf;
#[derive(ValueEnum)]
enum Codec { H264, H265, Av1 }
#[derive(App)]
#[app(about = "Transcode video files")]
struct Transcode {
#[arg(about = "Input file", must_exist = true)]
input: PathBuf,
#[arg(about = "Codec", default = "h264")]
codec: Codec,
#[arg(about = "Quality 0-51", default = 23, range = 0..=51)]
quality: u8,
}cargo run -p lilyco-ultra-ui-example
# Open http://localhost:9090 in your browserEdit the JSON spec in the browser; the React UI updates in real time.
# Run all tests
cargo test --workspace
# Run a specific crate
cargo test -p lilyco-core
cargo test -p lilyco-cli
cargo test -p lilyco-tui
cargo test -p lilyco-macros
cargo test -p lilyco-ultra-uiCurrent coverage: 84 tests across all crates.
[dependencies]
lilyco-core = "0.2"
lilyco-macros = "0.2"
lilyco-cli = "0.2"[dependencies]
lilyco-core = { git = "https://github.com/lilyco-42/lilyco" }
lilyco-macros = { git = "https://github.com/lilyco-42/lilyco" }
lilyco-cli = { git = "https://github.com/lilyco-42/lilyco" }
lilyco-tui = { git = "https://github.com/lilyco-42/lilyco" }#[derive(App)]only works on named-field structs (no tuple structs or enums)#[app(run = "fn")]requires the function to be in scope. Without this attribute,run()panics with a helpful message directing you to add it.- Number range validation works at the CLI layer (clap) but not in TUI/Web widgets
- Subcommands are supported in CLI only — TUI and Web renderers do not handle them yet
- Windows TUI not yet tested (crossterm backend should work but hasn't been verified)
- Ultra UI is experimental — JSON spec format may change
-
Real— done viarun()dispatch in Web GUI with progress streamingGuiRenderer::serve_app::<A>() -
— wire business logic with zero boilerplate#[app(run = "fn")]macro attribute -
Integration tests that exercise all three interfaces end-to-end— 12 tests inlilyco-example - Subcommand navigation in TUI and Web GUI
- Input validation in TUI/Web widgets (range, required, enum)
- Path auto-complete in TUI (Tab triggers directory listing)
-
#[app(subcommands)]macro support - Publish to crates.io
- Performance benchmarks for schema generation
MIT OR Apache-2.0, at your option.