Skip to main content

agentgear_derive/
lib.rs

1//! `#[derive(PluginHost)]` + `#[plugin(..)]`. At expansion the macro reads the
2//! shipped `plugin.json` (existence, JSON validity, and a name cross-check against
3//! the `name` attr), bakes the compressed tree in via `include_bytes!` of the blob
4//! the build.rs produced (`AGENTGEAR_BLOB`), implements the `PluginHost` trait from
5//! the attrs, and emits a const-panic guard that fires if the host forgot its
6//! `build.rs` (design §7, §10). `embed = false` bakes nothing (an empty blob).
7
8#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
9
10use std::path::Path;
11
12use proc_macro::TokenStream;
13use proc_macro2::TokenStream as TokenStream2;
14use quote::{format_ident, quote};
15use syn::punctuated::Punctuated;
16use syn::{DeriveInput, Expr, ExprLit, Lit, MetaNameValue, Token, parse_macro_input};
17
18mod known_agents;
19use known_agents::{KNOWN_AGENTS, feature_const_ident};
20
21/// Derive `PluginHost` for a host unit struct, wiring the plugin's
22/// install/update/self-heal lifecycle from one `#[plugin(..)]` attribute.
23///
24/// At expansion the macro reads the shipped `plugin.json` (it must exist, be valid
25/// JSON, and its `name` must equal the `name` key), bakes the plugin tree in via
26/// `include_bytes!` of the `build.rs` blob, emits the `impl PluginHost` (the consts,
27/// `embedded_blob`, and the optional `instructions` override; the lifecycle methods
28/// are trait defaults), and plants const-eval guards: one fires if the host forgot
29/// its `build.rs`, one more per listed agent whose cargo feature is off.
30///
31/// The host also writes the one-line build script the guard checks for:
32///
33/// ```ignore
34/// fn main() { agentgear::build::assert_plugin_version(); }
35/// ```
36///
37/// # `#[plugin(..)]` keys
38///
39/// | key | required | default | meaning |
40/// |---|---|---|---|
41/// | `name` | yes | n/a | plugin id; must equal `plugin.json`'s `name` |
42/// | `marketplace` | no | value of `name` | marketplace id in `<name>@<marketplace>` |
43/// | `version` | no | `env!("CARGO_PKG_VERSION")` | spliced verbatim (a str literal or an `env!(..)` call), never evaluated at macro time |
44/// | `tree` | no | `"$CARGO_MANIFEST_DIR/plugin"` | plugin tree dir; `$CARGO_MANIFEST_DIR` is substituted |
45/// | `default_source` | no | `"embedded"` | `"embedded"` or `"github"`; sets `PluginHost::DEFAULT_SOURCE` |
46/// | `github_repo` | with `default_source = "github"` | n/a | `"owner/repo"`; the tracked ref is `v<version>` |
47/// | `agents` | no | `["claude"]` | backend ids to fan out to; each must be a known id with its cargo feature on |
48/// | `embed` | no | `true` | `false` bakes an empty blob for a github/path-source host |
49/// | `instructions_fn` | no | none | path to a `fn() -> Option<String>` feeding `PluginHost::instructions` |
50///
51/// # Adopting into a host that already ships a plugin
52///
53/// The keys above wire a NEW host. A host whose users already hold a GitHub-sourced
54/// registration needs three more things, none of them an attr:
55///
56/// 1. keep the committed root `.claude-plugin/marketplace.json`. That file is what
57///    their registration reads, and a marketplace that stops loading serves 0 hooks,
58///    `self_heal` among them;
59/// 2. expect the adoption release itself to migrate nobody through the `SessionStart`
60///    hook, which ships inside the plugin and so only reaches releases that already
61///    carried it;
62/// 3. call `self_heal()` from somewhere in the binary too (a daemon tick, an MCP
63///    server's startup, a launcher pre-flight), because a plugin that fails to load
64///    cannot repair itself.
65///
66/// Full path: <https://github.com/uwuclxdy/agentgear/wiki/Adopting-An-Existing-Plugin>
67///
68/// # Compile-time errors
69///
70/// - an unknown `agents` id, or an empty `agents` list;
71/// - a listed agent whose cargo feature is off (the message names the `features = [..]` fix);
72/// - `default_source = "github"` without `github_repo`;
73/// - a `plugin.json` that is missing, is invalid JSON, or whose `name` disagrees with the attr;
74/// - a missing `build.rs` (the `AGENTGEAR_GUARD` guard).
75///
76/// # Example
77///
78/// ```ignore
79/// use agentgear::PluginHost;
80///
81/// #[derive(PluginHost)]
82/// #[plugin(name = "claudix", agents = ["claude", "codex", "gemini"])]
83/// struct ClaudixHost;
84/// ```
85#[proc_macro_derive(PluginHost, attributes(plugin))]
86pub fn derive_plugin_host(input: TokenStream) -> TokenStream {
87    let input = parse_macro_input!(input as DeriveInput);
88    expand(input).unwrap_or_else(|e| e.to_compile_error()).into()
89}
90
91struct Attrs {
92    name: String,
93    marketplace: String,
94    version: TokenStream2,
95    tree: String,
96    default_source: String,
97    github_repo: Option<String>,
98    agents: Vec<String>,
99    embed: bool,
100    /// A path to a `fn() -> Option<String>` the emitted impl calls from
101    /// `PluginHost::instructions` (the only override seam, since the derive owns the
102    /// sole impl block). Spliced verbatim like `version`; `None` inherits the trait
103    /// default (`None`, no instructions surface).
104    instructions_fn: Option<TokenStream2>,
105    span: proc_macro2::Span,
106}
107
108fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
109    let ident = &input.ident;
110    let attrs = parse_attrs(&input)?;
111
112    verify_plugin_json(&attrs)?;
113
114    let name = &attrs.name;
115    let marketplace = &attrs.marketplace;
116    let version = &attrs.version;
117    let agents = &attrs.agents;
118    let default_source = default_source_tokens(&attrs)?;
119
120    // Override `PluginHost::instructions` only when the host set `instructions_fn`;
121    // otherwise the emitted impl omits the method and inherits the trait default
122    // (`None`). The attr is a path to a `fn() -> Option<String>`, called verbatim.
123    let instructions_method = match &attrs.instructions_fn {
124        Some(path) => quote! {
125            fn instructions() -> ::core::option::Option<::std::string::String> {
126                #path()
127            }
128        },
129        None => quote! {},
130    };
131
132    // `embed = true` (default): bake the build.rs blob via `include_bytes!` of the
133    // `AGENTGEAR_BLOB` path. `embed = false`: an empty slice (`Source::Embedded`
134    // then errors at materialize). Pairs with the lib's `embed` feature: turning
135    // that off but leaving this on makes `env!("AGENTGEAR_BLOB")` a compile error.
136    let embedded_blob_body = if attrs.embed {
137        quote! { ::core::include_bytes!(::core::env!("AGENTGEAR_BLOB")) }
138    } else {
139        quote! { &[] }
140    };
141
142    // One const-eval check per listed agent: `agents = ["codex"]` with the
143    // `codex` cargo feature off fails HERE (host compile) with the fix, instead
144    // of on the end user's machine at first `setup`. Same const-panic trick as
145    // the AGENTGEAR_GUARD block below; the bools live in the lib because a
146    // panicking unit const would fire eagerly in agentgear's own build.
147    let feature_checks = agents.iter().map(|id| {
148        let const_ident = format_ident!("{}", feature_const_ident(id));
149        let msg = format!(
150            "agentgear: agent `{id}` is listed in #[plugin(agents = [...])] but its `{id}` cargo feature is off; add `features = [\"{id}\"]` to your agentgear dependency"
151        );
152        quote! {
153            const _: () = {
154                if !::agentgear::__feature_check::#const_ident {
155                    ::core::panic!(#msg);
156                }
157            };
158        }
159    });
160
161    Ok(quote! {
162        impl ::agentgear::PluginHost for #ident {
163            const NAME: &'static str = #name;
164            const MARKETPLACE: &'static str = #marketplace;
165            const VERSION: &'static str = #version;
166            const DEFAULT_SOURCE: ::agentgear::Source = #default_source;
167            const AGENTS: &'static [&'static str] = &[#(#agents),*];
168
169            fn embedded_blob() -> &'static [u8] {
170                #embedded_blob_body
171            }
172
173            #instructions_method
174        }
175
176        // Fires only if the host forgot its build.rs, which would also lose the
177        // version guard and the include_dir rebuild tracking. A compile error with
178        // the fix beats a silent no-op.
179        const _: () = {
180            if ::core::option_env!("AGENTGEAR_GUARD").is_none() {
181                ::core::panic!(
182                    "agentgear: host is missing its build.rs calling agentgear::build::assert_plugin_version() (see the crate docs)"
183                );
184            }
185        };
186
187        #(#feature_checks)*
188    })
189}
190
191fn parse_attrs(input: &DeriveInput) -> syn::Result<Attrs> {
192    let span = input.ident.span();
193    let mut name: Option<String> = None;
194    let mut marketplace: Option<String> = None;
195    let mut version: Option<TokenStream2> = None;
196    let mut tree: Option<String> = None;
197    let mut default_source: Option<String> = None;
198    let mut github_repo: Option<String> = None;
199    let mut agents: Option<Vec<String>> = None;
200    let mut embed: Option<bool> = None;
201    let mut instructions_fn: Option<TokenStream2> = None;
202
203    let attr = input
204        .attrs
205        .iter()
206        .find(|a| a.path().is_ident("plugin"))
207        .ok_or_else(|| syn::Error::new(span, "missing `#[plugin(name = \"...\", ...)]` attribute"))?;
208
209    let pairs = attr.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)?;
210    for pair in pairs {
211        let key = pair.path.get_ident().map(ToString::to_string).unwrap_or_default();
212        match key.as_str() {
213            "name" => name = Some(lit_str(&pair.value)?),
214            "marketplace" => marketplace = Some(lit_str(&pair.value)?),
215            "version" => {
216                // Splice the expression through verbatim (a literal or an
217                // `env!(..)` call); never evaluate it at proc-macro time.
218                let value = &pair.value;
219                version = Some(quote! { #value });
220            }
221            "tree" => tree = Some(lit_str(&pair.value)?),
222            "default_source" => default_source = Some(lit_str(&pair.value)?),
223            "github_repo" => github_repo = Some(lit_str(&pair.value)?),
224            "agents" => {
225                let list = str_array(&pair.value)?;
226                // The registry is closed (an external backend never joins the
227                // derive fan-out), so a typo fails here with the known set — the
228                // feature-off diagnostic below must never fire for a misspelling.
229                if let Some(bad) = list.iter().find(|id| !KNOWN_AGENTS.contains(&id.as_str())) {
230                    return Err(syn::Error::new_spanned(
231                        &pair.value,
232                        format!("unknown agent id `{bad}`; known ids: {}", KNOWN_AGENTS.join(", ")),
233                    ));
234                }
235                agents = Some(list);
236            }
237            "embed" => embed = Some(lit_bool(&pair.value)?),
238            "instructions_fn" => {
239                // A fn path (e.g. `guidance::session_block_opt`), spliced through
240                // verbatim and called from the emitted `instructions()`.
241                let value = &pair.value;
242                instructions_fn = Some(quote! { #value });
243            }
244            other => return Err(syn::Error::new_spanned(&pair.path, format!("unknown `plugin` key `{other}`"))),
245        }
246    }
247
248    let name = name.ok_or_else(|| syn::Error::new(span, "`#[plugin(..)]` requires `name = \"...\"`"))?;
249    let marketplace = marketplace.unwrap_or_else(|| name.clone());
250    let version = version.unwrap_or_else(|| quote! { ::core::env!("CARGO_PKG_VERSION") });
251    let tree = tree.unwrap_or_else(|| "$CARGO_MANIFEST_DIR/plugin".to_string());
252    let default_source = default_source.unwrap_or_else(|| "embedded".to_string());
253    let agents = agents.unwrap_or_else(|| vec!["claude".to_string()]);
254    if agents.is_empty() {
255        return Err(syn::Error::new(span, "`agents` must list at least one backend; an empty list is a silent no-op host"));
256    }
257    let embed = embed.unwrap_or(true);
258
259    Ok(Attrs { name, marketplace, version, tree, default_source, github_repo, agents, embed, instructions_fn, span })
260}
261
262fn default_source_tokens(attrs: &Attrs) -> syn::Result<TokenStream2> {
263    let version = &attrs.version;
264    match attrs.default_source.as_str() {
265        "embedded" => Ok(quote! { ::agentgear::Source::Embedded }),
266        "github" => {
267            let repo = attrs
268                .github_repo
269                .as_ref()
270                .ok_or_else(|| syn::Error::new(attrs.span, "`default_source = \"github\"` requires `github_repo = \"owner/repo\"`"))?;
271            Ok(quote! {
272                ::agentgear::Source::GitHub { repo: #repo, ref_: ::core::concat!("v", #version) }
273            })
274        }
275        other => Err(syn::Error::new(attrs.span, format!("`default_source` must be \"embedded\" or \"github\", got {other:?}"))),
276    }
277}
278
279/// Read `<tree>/.claude-plugin/plugin.json` at expansion: it must exist, be valid
280/// JSON, and its `name` must match the `name` attr. build.rs owns the version
281/// equality check, so it is not duplicated here.
282fn verify_plugin_json(attrs: &Attrs) -> syn::Result<()> {
283    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
284    let resolved = attrs.tree.replace("$CARGO_MANIFEST_DIR", &manifest_dir);
285    let plugin_json = Path::new(&resolved).join(".claude-plugin").join("plugin.json");
286
287    let bytes = std::fs::read(&plugin_json).map_err(|e| {
288        syn::Error::new(
289            attrs.span,
290            format!("cannot read {} ({e}); ship the plugin tree there or set `tree = \"...\"`", plugin_json.display()),
291        )
292    })?;
293    let json: serde_json::Value = serde_json::from_slice(&bytes)
294        .map_err(|e| syn::Error::new(attrs.span, format!("{} is not valid JSON: {e}", plugin_json.display())))?;
295    let json_name = json
296        .get("name")
297        .and_then(|v| v.as_str())
298        .ok_or_else(|| syn::Error::new(attrs.span, format!("{} has no string `name`", plugin_json.display())))?;
299    if json_name != attrs.name {
300        return Err(syn::Error::new(attrs.span, format!("`name = {:?}` does not match plugin.json name {json_name:?}", attrs.name)));
301    }
302    Ok(())
303}
304
305fn lit_str(expr: &Expr) -> syn::Result<String> {
306    match expr {
307        Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) => Ok(s.value()),
308        other => Err(syn::Error::new_spanned(other, "expected a string literal")),
309    }
310}
311
312fn lit_bool(expr: &Expr) -> syn::Result<bool> {
313    match expr {
314        Expr::Lit(ExprLit { lit: Lit::Bool(b), .. }) => Ok(b.value),
315        other => Err(syn::Error::new_spanned(other, "expected a bool literal (`true` or `false`)")),
316    }
317}
318
319fn str_array(expr: &Expr) -> syn::Result<Vec<String>> {
320    match expr {
321        Expr::Array(arr) => arr.elems.iter().map(lit_str).collect(),
322        other => Err(syn::Error::new_spanned(other, "expected an array of string literals, e.g. [\"claude\"]")),
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn unknown_agent_id_is_rejected_at_expansion() {
332        let input: DeriveInput = syn::parse_quote! {
333            #[plugin(name = "x", agents = ["claude", "codx"])]
334            struct Host;
335        };
336        let Err(err) = parse_attrs(&input) else {
337            panic!("`codx` must be rejected");
338        };
339        let msg = err.to_string();
340        assert!(msg.contains("unknown agent id `codx`"), "got: {msg}");
341        assert!(msg.contains("known ids: claude,"), "must list the known ids: {msg}");
342    }
343
344    #[test]
345    fn known_agent_ids_parse() {
346        let input: DeriveInput = syn::parse_quote! {
347            #[plugin(name = "x", agents = ["claude", "copilot-cli", "qwen-code"])]
348            struct Host;
349        };
350        let Ok(attrs) = parse_attrs(&input) else {
351            panic!("known ids must parse");
352        };
353        assert_eq!(attrs.agents, ["claude", "copilot-cli", "qwen-code"]);
354    }
355}