Plugin authoring
A Curie plugin is any executable named curie-<name> that implements two subcommands over stdin/stdout JSON. Curie owns the network, the cache, and the stamp file — the plugin owns only the generation logic.
For how users activate and configure plugins, see Plugins. This page is the implementer's reference.
How it works
Every build, Curie calls manifest on every active plugin. The plugin returns a JSON document declaring what it watches, what it needs from Maven, and where it writes output. Curie uses the manifest to:
- Check the stamp file — if no watched input has changed since the last successful run, skip the plugin entirely.
- Download any declared Maven artifacts that are not already cached.
- Call
generate-sources, passing the local paths to the downloaded artifacts. - Write the stamp on success and add the plugin's output directories to
javac's source roots.
The plugin binary is never resident between builds. Curie spawns it fresh for each invocation. This makes plugins simple, stateless, and safe to update without restarting anything.
Naming and discovery
Name your binary curie-<name> where <name> matches the key in the user's [plugin.<name>] table. Curie looks for the binary on PATH. There is no registry — installation is cargo install or any other mechanism that puts the binary on the path.
By convention, Curie plugins are published to crates.io so users can install them with a single cargo install curie-<name> command. Rust is the natural language because it produces fast, self-contained binaries with no JVM dependency, but any language works — the protocol is stdin/stdout JSON.
Subcommand 1: manifest
Called on every build, even when the output is up to date. Must be fast — read stdin, print to stdout, exit. No network, no disk writes.
Invocation
curie-<name> manifest --project <absolute-path-to-project-root>
Stdin
{
"curie_version": "0.6.0",
"config": { /* the [plugin.name] TOML table, converted to JSON */ }
}
config is the user's entire [plugin.<name>] block, including nested subtables, converted to JSON with TOML-to-JSON rules (inline tables and dotted keys both become objects). Ignore unknown top-level envelope fields for forward-compatibility.
Stdout
{
"name": "myplugin",
"description": "one-line description shown in curie build output",
"version": "0.1.0",
"types": ["source-generator"],
"inputs": {
"dirs": ["proto"],
"file_regex": "\\.proto$",
"files": []
},
"outputs": {
"source_dirs": ["target/generated-sources/myplugin"]
},
"artifacts": [
{
"id": "mytool",
"group": "com.example",
"artifact": "mytool",
"version": "1.0.0",
"classifier":"linux-x86_64",
"extension": "exe",
"executable":true
}
]
}
Manifest field reference
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Plugin name, shown in build output |
description | string | Yes | One-line description; shown in curie build progress |
version | string | Yes | Plugin binary version; informational only |
types | string[] | Yes | Capability list. Only "source-generator" is acted on today; include future roles here as the protocol evolves |
inputs.dirs | string[] | No | Directories Curie watches. Paths are relative to the project root. The stamp is invalidated when any file under any of these dirs is added, removed, or modified |
inputs.file_regex | string | No | Regex filter applied to files inside inputs.dirs. Only matching files contribute to the stamp. Omit to watch all files in the directories |
inputs.files | string[] | No | Explicit file list, relative to project root. Watched in addition to dirs. Useful for a single spec file like "api/spec.yaml" |
outputs.source_dirs | string[] | Yes | Directories added to javac's source roots after generation. Must be relative to the project root. Create them in generate-sources even when empty |
artifacts[].id | string | Yes | Logical key used in the generate-sources stdin's artifacts map |
artifacts[].group | string | Yes | Maven group ID |
artifacts[].artifact | string | Yes | Maven artifact ID |
artifacts[].version | string | Yes | Concrete version — no ranges |
artifacts[].classifier | string | No | Maven classifier; omit for JARs, use platform-specific values for native binaries |
artifacts[].extension | string | No | File extension; defaults to "jar" |
artifacts[].executable | bool | No | When true, Curie chmod +xes the cached file before passing its path to generate-sources |
Subcommand 2: generate-sources
Called only when the stamp is stale. All declared artifacts have been downloaded before this call. Write generated sources to disk; write progress to stderr; exit 0 on success.
Invocation
curie-<name> generate-sources --project <absolute-path> [--offline]
--offline is present when the user ran curie build --offline. Curie has already skipped downloading for offline builds; pass the flag along to any network calls your plugin makes internally.
Stdin
{
"curie_version": "0.6.0",
"config": { /* same as manifest */ },
"artifacts": {
"mytool": "/home/user/.m2/repository/com/example/mytool/1.0.0/mytool-1.0.0-linux-x86_64.exe"
}
}
The artifacts map uses the same id strings declared in the manifest, resolved to the absolute local cache path. The file exists and (when executable: true) is already marked executable.
Stdout and stderr
Write user-visible progress to stderr — Curie displays it during the build. Stdout is ignored. Exit 0 on success; any non-zero exit code is treated as a build error and the stamp is not written.
Stamp file
Curie manages the stamp at target/.curie-plugins/<name>.stamp. The plugin never reads or writes it directly. Curie writes the stamp after a successful generate-sources exit, recording the mtimes (and hashes for small files) of every input file matched by inputs.dirs + inputs.file_regex + inputs.files. On subsequent builds Curie compares current state against the stamp; if nothing changed, generate-sources is not called.
The stamp also records a SHA-256 hash of the config envelope (the curie_version plus the entire [plugin.<name>] config) together with the plugin's own version field from the manifest. Changing any of these — a config option that does not touch a watched file (for example modelPackage), upgrading Curie, or upgrading the plugin binary — changes the hash and invalidates the stamp, so the next build regenerates cleanly.
Platform detection
When your plugin wraps a native tool distributed as platform-specific Maven artifacts (like protoc), detect the current OS and architecture in the manifest handler and emit the correct classifier. The plugin binary itself is native and has access to std::env::consts (Rust) or equivalent, so no external detection step is needed.
fn platform_classifier() -> &'static str { match (std::env::consts::OS, std::env::consts::ARCH) { ("linux", "x86_64") => "linux-x86_64", ("linux", "aarch64") => "linux-aarch_64", ("macos", "x86_64") => "osx-x86_64", ("macos", "aarch64") => "osx-aarch_64", ("windows", "x86_64") => "windows-x86_64", _ => panic!("unsupported platform"), } }
For tools distributed as cross-platform JARs (like openapi-generator-cli), omit the classifier entirely — just set "extension": "jar".
Minimal Rust implementation
A plugin needs very little code. Below is a complete skeleton — add your generation logic in run_generate.
use serde::{Deserialize, Serialize}; use serde_json::Value; use std::io::{self, Read}; use std::path::PathBuf; // ── stdin envelope (same shape for both subcommands) ───────────────────── #[derive(Deserialize)] struct Envelope { curie_version: String, config: Value, #[serde(default)] artifacts: std::collections::HashMap<String, String>, } // ── manifest response ───────────────────────────────────────────────────── #[derive(Serialize)] struct Manifest { name: &'static str, description: &'static str, version: &'static str, types: Vec<&'static str>, inputs: Inputs, outputs: Outputs, artifacts: Vec<Artifact>, } #[derive(Serialize)] struct Inputs { dirs: Vec<String>, file_regex: String } #[derive(Serialize)] struct Outputs { source_dirs: Vec<String> } #[derive(Serialize)] struct Artifact { id: String, group: String, artifact: String, version: String, classifier: String, extension: String, executable: bool, } fn main() { let args: Vec<String> = std::env::args().collect(); let subcommand = args.get(1).map(String::as_str).unwrap_or_default(); let project = project_root(&args); let mut stdin = String::new(); io::stdin().read_to_string(&mut stdin).unwrap(); let env: Envelope = serde_json::from_str(&stdin).unwrap(); match subcommand { "manifest" => run_manifest(&env, &project), "generate-sources" => run_generate(&env, &project), other => { eprintln!("unknown subcommand: {other}"); std::process::exit(1); } } } fn project_root(args: &[String]) -> PathBuf { args.windows(2) .find(|w| w[0] == "--project") .map(|w| PathBuf::from(&w[1])) .unwrap_or_else(|| std::env::current_dir().unwrap()) } fn run_manifest(env: &Envelope, project: &PathBuf) { let version = env.config["version"].as_str().unwrap_or("1.0.0"); let source_dir = env.config["sourceDir"].as_str().unwrap_or("input"); let out_dir = format!("target/generated-sources/myplugin"); let manifest = Manifest { name: "myplugin", description: "Generate sources with mytool", version: env!("CARGO_PKG_VERSION"), types: vec!["source-generator"], inputs: Inputs { dirs: vec![source_dir.to_string()], file_regex: "\\.myext$".to_string(), }, outputs: Outputs { source_dirs: vec![out_dir] }, artifacts: vec![Artifact { id: "mytool".to_string(), group: "com.example".to_string(), artifact: "mytool".to_string(), version: version.to_string(), classifier: platform_classifier().to_string(), extension: "exe".to_string(), executable: true, }], }; println!("{}", serde_json::to_string(&manifest).unwrap()); } fn run_generate(env: &Envelope, project: &PathBuf) { let mytool = &env.artifacts["mytool"]; let out = project.join("target/generated-sources/myplugin"); std::fs::create_dir_all(&out).unwrap(); // invoke the tool; write progress to stderr eprintln!("Running mytool..."); let status = std::process::Command::new(mytool) .arg("--output") .arg(&out) .status() .expect("failed to run mytool"); if !status.success() { std::process::exit(status.code().unwrap_or(1)); } } fn platform_classifier() -> &'static str { match (std::env::consts::OS, std::env::consts::ARCH) { ("linux", "x86_64") => "linux-x86_64", ("linux", "aarch64") => "linux-aarch_64", ("macos", "x86_64") => "osx-x86_64", ("macos", "aarch64") => "osx-aarch_64", ("windows", "x86_64") => "windows-x86_64", _ => panic!("unsupported platform"), } }
Add to Cargo.toml:
[package] name = "curie-myplugin" version = "0.1.0" edition = "2021" [[bin]] name = "curie-myplugin" path = "src/main.rs" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1"
Testing the plugin locally
Build and install your plugin into the local cargo bin directory, then point a test project at it:
# in your plugin repo cargo build export PATH="$PWD/target/debug:$PATH" # in your test project curie build # picks up curie-myplugin from PATH
You can also test the subcommands directly by piping JSON on stdin:
echo '{"curie_version":"0.6.0","config":{"version":"1.0.0","sourceDir":"input"}}' \
| curie-myplugin manifest --project /tmp/testproject
echo '{"curie_version":"0.6.0","config":{"version":"1.0.0"},"artifacts":{"mytool":"/usr/local/bin/mytool"}}' \
| curie-myplugin generate-sources --project /tmp/testproject
Output directory conventions
- Always create
outputs.source_dirsingenerate-sources, even when generation produces no files. Curie tries to add them tojavac's source path; a missing directory causes a compile error. - Use
target/generated-sources/<plugin-name>/as the output root. This keeps generated sources out of version control (target/is always in.gitignore) and makes them easy to find. - Delete stale generated files before regenerating, or use a deterministic write (overwrite every file). Curie does not clean the output directory between runs.
Error handling
- manifest errors — exit non-zero with a message on stderr. Curie treats this as a build error and stops.
- generate-sources errors — exit non-zero. Curie reports the error and does not write the stamp. The user will see the plugin's stderr output in the build log.
- Unknown config keys — ignore them silently. New Curie.toml keys added in future Curie versions should not break old plugins.
- Missing required config — exit non-zero from
manifestwith a helpful error. For example:"error: [plugin.myplugin] 'version' is required".
Publishing
Name your crate curie-<name> on crates.io. Users install it with:
cargo install curie-myplugin
In your Cargo.toml include a description, homepage, and repository so the crates.io listing is discoverable:
[package] name = "curie-myplugin" description = "Curie plugin: generate Java sources from .myext files" repository = "https://github.com/example/curie-myplugin" homepage = "https://github.com/example/curie-myplugin" keywords = ["curie", "build", "codegen"] categories = ["development-tools"]
Reference implementation
curie-protobuf is the canonical example. Read its source to see platform detection, gRPC conditional artifacts, and multi-flag protoc invocation in a real plugin.