Build & test · plugin authoring

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:

  1. Check the stamp file — if no watched input has changed since the last successful run, skip the plugin entirely.
  2. Download any declared Maven artifacts that are not already cached.
  3. Call generate-sources, passing the local paths to the downloaded artifacts.
  4. 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

shell
curie-<name> manifest --project <absolute-path-to-project-root>

Stdin

stdin (manifest)
{
  "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

stdout (manifest response)
{
  "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

FieldTypeRequiredNotes
namestringYesPlugin name, shown in build output
descriptionstringYesOne-line description; shown in curie build progress
versionstringYesPlugin binary version; informational only
typesstring[]YesCapability list. Only "source-generator" is acted on today; include future roles here as the protocol evolves
inputs.dirsstring[]NoDirectories 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_regexstringNoRegex filter applied to files inside inputs.dirs. Only matching files contribute to the stamp. Omit to watch all files in the directories
inputs.filesstring[]NoExplicit file list, relative to project root. Watched in addition to dirs. Useful for a single spec file like "api/spec.yaml"
outputs.source_dirsstring[]YesDirectories added to javac's source roots after generation. Must be relative to the project root. Create them in generate-sources even when empty
artifacts[].idstringYesLogical key used in the generate-sources stdin's artifacts map
artifacts[].groupstringYesMaven group ID
artifacts[].artifactstringYesMaven artifact ID
artifacts[].versionstringYesConcrete version — no ranges
artifacts[].classifierstringNoMaven classifier; omit for JARs, use platform-specific values for native binaries
artifacts[].extensionstringNoFile extension; defaults to "jar"
artifacts[].executableboolNoWhen 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

shell
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

stdin (generate-sources)
{
  "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.

src/main.rs — platform classifier
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.

src/main.rs
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:

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:

terminal
# 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:

terminal
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

Error handling

Publishing

Name your crate curie-<name> on crates.io. Users install it with:

terminal
cargo install curie-myplugin

In your Cargo.toml include a description, homepage, and repository so the crates.io listing is discoverable:

Cargo.toml
[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.