agpm_cli/config/agent.rs
1//! Agent and snippet configuration structures.
2//!
3//! This module defines the configuration structures for AGPM resources (agents and snippets).
4//! These structures can be used standalone or embedded as frontmatter in Markdown files.
5//! The configuration system supports rich metadata, dependency management, and platform-specific
6//! requirements.
7//!
8//! # Resource Types
9//!
10//! AGPM supports two main types of resources:
11//!
12//! ## Agents
13//!
14//! AI agents are sophisticated Claude Code resources that provide specialized functionality.
15//! They typically include:
16//! - Complex prompt engineering
17//! - Multi-step workflows
18//! - Context management
19//! - Integration with external tools
20//!
21//! ## Snippets
22//!
23//! Code snippets are reusable pieces of code or configuration that can be:
24//! - Language-specific code patterns
25//! - Configuration templates
26//! - Documentation examples
27//! - Utility functions
28//!
29//! # Configuration Formats
30//!
31//! Resource configuration can be specified in multiple ways:
32//!
33//! ## Standalone TOML Files
34//!
35//! Dedicated configuration files (e.g., `agent.toml`, `snippet.toml`):
36//!
37//! ```toml
38//! [metadata]
39//! name = "rust-expert"
40//! description = "Expert Rust development agent"
41//! author = "AGPM Community"
42//! license = "MIT"
43//! homepage = "https://github.com/agpm-community/rust-expert"
44//! keywords = ["rust", "programming", "expert", "development"]
45//! categories = ["development", "programming-languages"]
46//!
47//! [requirements]
48//! agpm_version = ">=0.1.0"
49//! claude_version = "latest"
50//! platforms = ["windows", "macos", "linux"]
51//!
52//! [[requirements.dependencies]]
53//! name = "code-formatter"
54//! version = "^1.0"
55//! type = "snippet"
56//! source = "community"
57//!
58//! [config]
59//! max_context_length = 8000
60//! preferred_style = "verbose"
61//! ```
62//!
63//! ## Markdown Frontmatter
64//!
65//! Configuration embedded in `.md` files using TOML frontmatter:
66//!
67//! ```markdown
68//! +++
69//! [metadata]
70//! name = "python-expert"
71//! description = "Expert Python development agent"
72//! author = "Jane Developer <jane@example.com>"
73//! license = "Apache-2.0"
74//! keywords = ["python", "expert", "development"]
75//!
76//! [requirements]
77//! agpm_version = ">=0.1.0"
78//! +++
79//!
80//! # Python Expert Agent
81//!
82//! You are an expert Python developer with deep knowledge...
83//! ```
84//!
85//! # Metadata Fields
86//!
87//! All resources support common metadata fields:
88//!
89//! - **name**: Unique identifier for the resource
90//! - **description**: Human-readable description
91//! - **author**: Author information (name and optional email)
92//! - **license**: SPDX license identifier
93//! - **homepage**: Optional homepage URL
94//! - **repository**: Optional source repository URL
95//! - **keywords**: List of searchable keywords
96//! - **categories**: Hierarchical categorization
97//!
98//! # Dependency Management
99//!
100//! Resources can declare dependencies on other resources:
101//!
102//! ```toml
103//! [[requirements.dependencies]]
104//! name = "base-formatter" # Name of dependency
105//! version = "^1.2" # Version constraint
106//! type = "snippet" # Resource type (agent/snippet)
107//! source = "community" # Source repository
108//! optional = false # Required vs optional
109//! ```
110//!
111//! # Version Constraints
112//!
113//! Dependencies support semantic versioning constraints:
114//!
115//! - `"1.2.3"` - Exact version
116//! - `"^1.2"` - Compatible version (>=1.2.0, <2.0.0)
117//! - `"~1.2.3"` - Patch-level changes (>=1.2.3, <1.3.0)
118//! - `">=1.0.0"` - Minimum version
119//! - `"latest"` - Latest available version
120//!
121//! # Platform Support
122//!
123//! Resources can specify platform requirements:
124//!
125//! ```toml
126//! [requirements]
127//! platforms = ["windows", "macos", "linux", "web"]
128//! ```
129//!
130//! Available platforms:
131//! - `windows` - Windows operating system
132//! - `macos` - macOS operating system
133//! - `linux` - Linux distributions
134//! - `web` - Web-based environments
135//!
136//! # Custom Configuration
137//!
138//! Resources can include custom configuration using the `config` section:
139//!
140//! ```toml
141//! [config]
142//! max_tokens = 4000
143//! temperature = 0.7
144//! style = "concise"
145//! features = ["formatting", "linting"]
146//!
147//! [config.advanced]
148//! retry_count = 3
149//! timeout = 30
150//! ```
151//!
152//! # Examples
153//!
154//! ## Loading Agent Configuration
155//!
156//! ```rust,no_run
157//! use agpm_cli::config::AgentManifest;
158//! use std::path::Path;
159//!
160//! # fn example() -> anyhow::Result<()> {
161//! let manifest = AgentManifest::load(Path::new("agent.toml"))?;
162//!
163//! println!("Agent: {} by {}",
164//! manifest.metadata.name,
165//! manifest.metadata.author);
166//!
167//! if let Some(requirements) = &manifest.requirements {
168//! println!("Dependencies: {}", requirements.dependencies.len());
169//! }
170//! # Ok(())
171//! # }
172//! ```
173//!
174//! ## Creating Default Configuration
175//!
176//! ```rust,ignore
177//! use agpm_cli::config::create_agent_manifest;
178//!
179//! let manifest = create_agent_manifest(
180//! "my-agent".to_string(),
181//! "John Developer <john@example.com>".to_string()
182//! );
183//!
184//! assert_eq!(manifest.metadata.name, "my-agent");
185//! assert_eq!(manifest.metadata.license, "MIT");
186//! ```
187
188use anyhow::{Context, Result};
189use serde::{Deserialize, Serialize};
190use std::collections::HashMap;
191use std::path::Path;
192
193/// Agent configuration manifest.
194///
195/// Represents the complete configuration for a AGPM agent, including metadata,
196/// requirements, and custom configuration. This structure can be loaded from
197/// standalone TOML files or extracted from Markdown frontmatter.
198///
199/// # Structure
200///
201/// - [`metadata`](Self::metadata): Core information about the agent
202/// - [`requirements`](Self::requirements): Optional dependency and platform requirements
203/// - [`config`](Self::config): Custom configuration as key-value pairs
204///
205/// # Examples
206///
207/// ## Minimal Agent
208///
209/// ```rust,no_run
210/// use agpm_cli::config::{AgentManifest, AgentMetadata};
211/// use std::collections::HashMap;
212///
213/// let manifest = AgentManifest {
214/// metadata: AgentMetadata {
215/// name: "simple-agent".to_string(),
216/// description: "A simple agent".to_string(),
217/// author: "Developer".to_string(),
218/// license: "MIT".to_string(),
219/// homepage: None,
220/// repository: None,
221/// keywords: vec![],
222/// categories: vec![],
223/// },
224/// requirements: None,
225/// config: HashMap::new(),
226/// };
227/// ```
228///
229/// ## Loading from File
230///
231/// ```rust,no_run
232/// use agpm_cli::config::AgentManifest;
233/// use std::path::Path;
234///
235/// # fn example() -> anyhow::Result<()> {
236/// let manifest = AgentManifest::load(Path::new("my-agent.toml"))?;
237/// println!("Loaded agent: {}", manifest.metadata.name);
238/// # Ok(())
239/// # }
240/// ```
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct AgentManifest {
243 /// Core metadata about the agent.
244 ///
245 /// Contains essential information like name, description, author, and categorization.
246 /// This metadata is used for discovery, documentation, and dependency resolution.
247 pub metadata: AgentMetadata,
248
249 /// Optional requirements and dependencies.
250 ///
251 /// Specifies version requirements, platform constraints, and dependencies on other
252 /// resources. If `None`, the agent has no special requirements.
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub requirements: Option<Requirements>,
255
256 /// Custom configuration values.
257 ///
258 /// Arbitrary key-value pairs that can be used by the agent for configuration.
259 /// Values can be any valid TOML type (string, number, boolean, array, table).
260 ///
261 /// # Examples
262 ///
263 /// ```toml
264 /// [config]
265 /// max_tokens = 4000
266 /// style = "verbose"
267 /// features = ["linting", "formatting"]
268 ///
269 /// [config.advanced]
270 /// retry_attempts = 3
271 /// timeout_seconds = 30
272 /// ```
273 #[serde(default)]
274 pub config: HashMap<String, toml::Value>,
275}
276
277impl AgentManifest {
278 /// Load agent manifest from a TOML file.
279 ///
280 /// Reads and parses an agent configuration file from the specified path.
281 ///
282 /// # Parameters
283 ///
284 /// - `path`: Path to the TOML configuration file
285 ///
286 /// # Examples
287 ///
288 /// ```rust,no_run
289 /// use agpm_cli::config::AgentManifest;
290 /// use std::path::Path;
291 ///
292 /// # fn example() -> anyhow::Result<()> {
293 /// let manifest = AgentManifest::load(Path::new("agents/rust-expert.toml"))?;
294 /// println!("Agent: {}", manifest.metadata.name);
295 /// # Ok(())
296 /// # }
297 /// ```
298 ///
299 /// # Errors
300 ///
301 /// Returns an error if:
302 /// - The file cannot be read (not found, permissions, etc.)
303 /// - The file contains invalid TOML syntax
304 /// - The TOML structure doesn't match the expected schema
305 pub fn load(path: &Path) -> Result<Self> {
306 let content = std::fs::read_to_string(path)
307 .with_context(|| format!("Failed to read agent manifest: {}", path.display()))?;
308 let manifest: Self = toml::from_str(&content)
309 .with_context(|| format!("Failed to parse agent manifest: {}", path.display()))?;
310 Ok(manifest)
311 }
312}
313
314/// Agent metadata
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct AgentMetadata {
317 /// Agent name
318 pub name: String,
319
320 /// Agent description
321 pub description: String,
322
323 /// Author information
324 pub author: String,
325
326 /// License
327 pub license: String,
328
329 /// Homepage URL
330 #[serde(skip_serializing_if = "Option::is_none")]
331 pub homepage: Option<String>,
332
333 /// Repository URL
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub repository: Option<String>,
336
337 /// Keywords for discovery
338 #[serde(default)]
339 pub keywords: Vec<String>,
340
341 /// Categories
342 #[serde(default)]
343 pub categories: Vec<String>,
344}
345
346/// Snippet manifest (snippet.toml)
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct SnippetManifest {
349 /// Snippet metadata
350 pub metadata: SnippetMetadata,
351
352 /// Snippet content (can be inline or file reference)
353 pub content: SnippetContent,
354
355 /// Custom configuration values specific to this snippet.
356 ///
357 /// Similar to agent configuration, this allows arbitrary key-value pairs
358 /// for snippet-specific settings like formatting options, execution parameters,
359 /// or integration settings.
360 #[serde(default)]
361 pub config: HashMap<String, toml::Value>,
362}
363
364impl SnippetManifest {
365 /// Loads a snippet manifest from a TOML file
366 ///
367 /// # Arguments
368 ///
369 /// * `path` - Path to the snippet manifest file
370 ///
371 /// # Returns
372 ///
373 /// Returns the parsed `SnippetManifest` on success
374 ///
375 /// # Errors
376 ///
377 /// Returns an error if:
378 /// - The file cannot be read
379 /// - The TOML content is invalid
380 pub fn load(path: &Path) -> Result<Self> {
381 let content = std::fs::read_to_string(path)
382 .with_context(|| format!("Failed to read snippet manifest: {}", path.display()))?;
383 let manifest: Self = toml::from_str(&content)
384 .with_context(|| format!("Failed to parse snippet manifest: {}", path.display()))?;
385 Ok(manifest)
386 }
387}
388
389/// Snippet metadata
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct SnippetMetadata {
392 /// Snippet name
393 pub name: String,
394
395 /// Snippet description
396 pub description: String,
397
398 /// Author information
399 pub author: String,
400
401 /// Programming language
402 pub language: String,
403
404 /// Tags for categorization
405 #[serde(default)]
406 pub tags: Vec<String>,
407
408 /// Keywords for discovery
409 #[serde(default)]
410 pub keywords: Vec<String>,
411}
412
413/// Snippet content specification
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(untagged)]
416pub enum SnippetContent {
417 /// Inline snippet content
418 Inline {
419 /// The snippet content as a string
420 content: String,
421 },
422
423 /// File-based snippet content
424 File {
425 /// Path to the file containing the snippet
426 file: String,
427 },
428
429 /// Multiple files
430 Files {
431 /// List of file paths containing snippet parts
432 files: Vec<String>,
433 },
434}
435
436/// Requirements and dependencies
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct Requirements {
439 /// Minimum AGPM version required
440 #[serde(skip_serializing_if = "Option::is_none")]
441 pub agpm_version: Option<String>,
442
443 /// Required Claude version/features
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub claude_version: Option<String>,
446
447 /// Dependencies on other resources
448 #[serde(default)]
449 pub dependencies: Vec<Dependency>,
450
451 /// Platform requirements
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub platforms: Option<Vec<String>>,
454}
455
456/// Resource dependency
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct Dependency {
459 /// Dependency name
460 pub name: String,
461
462 /// Version constraint
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub version: Option<String>,
465
466 /// Dependency type
467 #[serde(skip_serializing_if = "Option::is_none")]
468 pub r#type: Option<String>,
469
470 /// Source repository
471 #[serde(skip_serializing_if = "Option::is_none")]
472 pub source: Option<String>,
473
474 /// Optional dependency
475 #[serde(default)]
476 pub optional: bool,
477}
478
479/// Load agent manifest from file
480#[allow(dead_code)]
481pub fn load_agent_manifest(path: &Path) -> Result<AgentManifest> {
482 let content = std::fs::read_to_string(path)?;
483 let manifest: AgentManifest = toml::from_str(&content)?;
484 Ok(manifest)
485}
486
487/// Load snippet manifest from file
488#[allow(dead_code)]
489pub fn load_snippet_manifest(path: &Path) -> Result<SnippetManifest> {
490 let content = std::fs::read_to_string(path)?;
491 let manifest: SnippetManifest = toml::from_str(&content)?;
492 Ok(manifest)
493}
494
495/// Create a default agent manifest
496#[allow(dead_code)]
497pub fn create_agent_manifest(name: String, author: String) -> AgentManifest {
498 AgentManifest {
499 metadata: AgentMetadata {
500 name: name.clone(),
501 description: format!("{name} agent for Claude Code"),
502 author,
503 license: "MIT".to_string(),
504 homepage: None,
505 repository: None,
506 keywords: vec![],
507 categories: vec![],
508 },
509 requirements: None,
510 config: HashMap::new(),
511 }
512}
513
514/// Create a default snippet manifest
515#[allow(dead_code)]
516pub fn create_snippet_manifest(name: String, author: String, language: String) -> SnippetManifest {
517 SnippetManifest {
518 metadata: SnippetMetadata {
519 name: name.clone(),
520 description: format!("{name} snippet"),
521 author,
522 language,
523 tags: vec![],
524 keywords: vec![],
525 },
526 content: SnippetContent::File {
527 file: "snippet.md".to_string(),
528 },
529 config: HashMap::new(),
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use tempfile::tempdir;
537
538 #[test]
539 fn test_create_agent_manifest() {
540 let manifest = create_agent_manifest("test-agent".to_string(), "John Doe".to_string());
541 assert_eq!(manifest.metadata.name, "test-agent");
542 assert_eq!(manifest.metadata.author, "John Doe");
543 assert_eq!(manifest.metadata.license, "MIT");
544 assert_eq!(manifest.metadata.description, "test-agent agent for Claude Code");
545 }
546
547 #[test]
548 fn test_create_snippet_manifest() {
549 let manifest = create_snippet_manifest(
550 "test-snippet".to_string(),
551 "Jane Doe".to_string(),
552 "python".to_string(),
553 );
554 assert_eq!(manifest.metadata.name, "test-snippet");
555 assert_eq!(manifest.metadata.author, "Jane Doe");
556 assert_eq!(manifest.metadata.language, "python");
557 assert_eq!(manifest.metadata.description, "test-snippet snippet");
558 }
559
560 #[test]
561 fn test_snippet_content_variants() {
562 let inline = SnippetContent::Inline {
563 content: "print('hello')".to_string(),
564 };
565
566 let file = SnippetContent::File {
567 file: "snippet.py".to_string(),
568 };
569
570 let files = SnippetContent::Files {
571 files: vec!["file1.py".to_string(), "file2.py".to_string()],
572 };
573
574 // Test serialization
575 let inline_json = serde_json::to_string(&inline).unwrap();
576 assert!(inline_json.contains("content"));
577
578 let file_json = serde_json::to_string(&file).unwrap();
579 assert!(file_json.contains("file"));
580
581 let files_json = serde_json::to_string(&files).unwrap();
582 assert!(files_json.contains("files"));
583 }
584
585 #[test]
586 fn test_dependency() {
587 let dep = Dependency {
588 name: "test-dep".to_string(),
589 version: Some("^1.0.0".to_string()),
590 r#type: Some("agent".to_string()),
591 source: Some("github".to_string()),
592 optional: false,
593 };
594
595 assert_eq!(dep.name, "test-dep");
596 assert_eq!(dep.version, Some("^1.0.0".to_string()));
597 assert!(!dep.optional);
598 }
599
600 #[test]
601 fn test_requirements() {
602 let req = Requirements {
603 agpm_version: Some(">=0.1.0".to_string()),
604 claude_version: Some("latest".to_string()),
605 dependencies: vec![Dependency {
606 name: "dep1".to_string(),
607 version: None,
608 r#type: None,
609 source: None,
610 optional: false,
611 }],
612 platforms: Some(vec!["windows".to_string(), "macos".to_string()]),
613 };
614
615 assert_eq!(req.agpm_version, Some(">=0.1.0".to_string()));
616 assert_eq!(req.dependencies.len(), 1);
617 assert_eq!(req.platforms.as_ref().unwrap().len(), 2);
618 }
619
620 #[test]
621 fn test_save_and_load_agent_manifest() {
622 let temp = tempdir().unwrap();
623 let manifest_path = temp.path().join("agent.toml");
624
625 let manifest = create_agent_manifest("test".to_string(), "author".to_string());
626
627 let toml_str = toml::to_string(&manifest).unwrap();
628 std::fs::write(&manifest_path, toml_str).unwrap();
629
630 let loaded = load_agent_manifest(&manifest_path).unwrap();
631 assert_eq!(loaded.metadata.name, "test");
632 assert_eq!(loaded.metadata.author, "author");
633 }
634
635 #[test]
636 fn test_save_and_load_snippet_manifest() {
637 let temp = tempdir().unwrap();
638 let manifest_path = temp.path().join("snippet.toml");
639
640 let manifest =
641 create_snippet_manifest("test".to_string(), "author".to_string(), "rust".to_string());
642
643 let toml_str = toml::to_string(&manifest).unwrap();
644 std::fs::write(&manifest_path, toml_str).unwrap();
645
646 let loaded = load_snippet_manifest(&manifest_path).unwrap();
647 assert_eq!(loaded.metadata.name, "test");
648 assert_eq!(loaded.metadata.language, "rust");
649 }
650}