1#![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#[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 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 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 let embedded_blob_body = if attrs.embed {
137 quote! { ::core::include_bytes!(::core::env!("AGENTGEAR_BLOB")) }
138 } else {
139 quote! { &[] }
140 };
141
142 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 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 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 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 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
279fn 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}