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}