agpm_cli/manifest/mod.rs
1//! Manifest file parsing and validation for AGPM projects.
2//!
3//! This module handles the `agpm.toml` manifest file that defines project
4//! dependencies and configuration. The manifest uses TOML format and follows
5//! a structure similar to Cargo.toml, providing a lockfile-based dependency
6//! management system for Claude Code resources.
7//!
8//! # Overview
9//!
10//! The manifest system enables:
11//! - Declarative dependency management through `agpm.toml`
12//! - Reproducible installations via lockfile generation
13//! - Support for multiple Git-based source repositories
14//! - Local and remote dependency resolution
15//! - Version constraint specification and validation
16//! - Transitive dependency resolution from resource metadata
17//! - Cross-platform path handling and installation
18//! - MCP (Model Context Protocol) server configuration management
19//! - Atomic file operations for reliability
20//!
21//! # Complete TOML Format Specification
22//!
23//! ## Basic Structure
24//!
25//! A `agpm.toml` manifest file consists of four main sections:
26//!
27//! ```toml
28//! # Named source repositories (optional)
29//! [sources]
30//! # Git repository URLs mapped to convenient names
31//! official = "https://github.com/example-org/agpm-official.git"
32//! community = "https://github.com/community/agpm-resources.git"
33//! private = "git@github.com:company/private-resources.git"
34//!
35//! # Installation target directories (optional)
36//! [target]
37//! # Where agents should be installed (default: ".claude/agents")
38//! agents = ".claude/agents"
39//! # Where snippets should be installed (default: ".agpm/snippets")
40//! snippets = ".agpm/snippets"
41//! # Where commands should be installed (default: ".claude/commands")
42//! commands = ".claude/commands"
43//!
44//! # Agent dependencies (optional)
45//! [agents]
46//! # Various dependency specification formats
47//! simple-agent = "../local/agents/helper.md" # Local path
48//! remote-agent = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
49//! latest-agent = { source = "community", path = "agents/utils.md", version = "latest" }
50//! branch-agent = { source = "private", path = "agents/internal.md", git = "develop" }
51//! commit-agent = { source = "official", path = "agents/stable.md", git = "abc123..." }
52//! # Custom target installation directory (relative to .claude)
53//! custom-agent = { source = "official", path = "agents/special.md", version = "v1.0.0", target = "integrations/ai" }
54//!
55//! # Snippet dependencies (optional)
56//! [snippets]
57//! # Same formats as agents
58//! local-snippet = "../shared/snippets/common.md"
59//! remote-snippet = { source = "community", path = "snippets/utils.md", version = "v2.1.0" }
60//! # Custom target for special snippets
61//! integration-snippet = { source = "community", path = "snippets/api.md", version = "v1.0.0", target = "tools/snippets" }
62//!
63//! # Command dependencies (optional)
64//! [commands]
65//! # Same formats as agents and snippets
66//! local-command = "../shared/commands/helper.md"
67//! remote-command = { source = "community", path = "commands/build.md", version = "v1.0.0" }
68//! ```
69//!
70//! ## Sources Section
71//!
72//! The `[sources]` section maps convenient names to Git repository URLs:
73//!
74//! ```toml
75//! [sources]
76//! # HTTPS URLs (recommended for public repositories)
77//! official = "https://github.com/owner/agpm-resources.git"
78//! community = "https://gitlab.com/group/agpm-community.git"
79//!
80//! # SSH URLs (for private repositories with key authentication)
81//! private = "git@github.com:company/private-resources.git"
82//! internal = "git@gitlab.company.com:team/internal-resources.git"
83//!
84//! # Local Git repository URLs
85//! local-repo = "file:///absolute/path/to/local/repo"
86//!
87//! # Environment variable expansion (useful for CI/CD)
88//! dynamic = "https://github.com/${GITHUB_ORG}/resources.git"
89//! home-repo = "file://${HOME}/git/resources"
90//! ```
91//!
92//! ## Target Section
93//!
94//! The `[target]` section configures where resources are installed:
95//!
96//! ```toml
97//! [target]
98//! # Default values shown - these can be customized
99//! agents = ".claude/agents" # Where agent .md files are copied
100//! snippets = ".agpm/snippets" # Where snippet .md files are copied
101//! commands = ".claude/commands" # Where command .md files are copied
102//!
103//! # Alternative configurations
104//! agents = "resources/agents"
105//! snippets = "resources/snippets"
106//! commands = "resources/commands"
107//!
108//! # Absolute paths are supported
109//! agents = "/opt/claude/agents"
110//! snippets = "/opt/claude/snippets"
111//! commands = "/opt/claude/commands"
112//! ```
113//!
114//! ## Dependency Sections
115//!
116//! Both `[agents]` and `[snippets]` sections support multiple dependency formats:
117//!
118//! ### 1. Local Path Dependencies
119//!
120//! For resources in your local filesystem:
121//!
122//! ```toml
123//! [agents]
124//! # Relative paths from manifest directory
125//! local-helper = "../shared/agents/helper.md"
126//! nearby-agent = "./local-agents/custom.md"
127//!
128//! # Absolute paths (not recommended for portability)
129//! system-agent = "/usr/local/share/claude/agents/system.md"
130//! ```
131//!
132//! Local dependencies:
133//! - Do not support version constraints
134//! - Are copied directly from the filesystem
135//! - Are not cached or managed through Git
136//! - Must exist at install time
137//!
138//! ### 2. Remote Source Dependencies
139//!
140//! For resources from Git repositories:
141//!
142//! ```toml
143//! [agents]
144//! # Basic remote dependency with semantic version
145//! code-reviewer = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
146//!
147//! # Using latest version (not recommended for production)
148//! utils = { source = "community", path = "agents/utils.md", version = "latest" }
149//!
150//! # Specific Git branch
151//! bleeding-edge = { source = "official", path = "agents/experimental.md", git = "develop" }
152//!
153//! # Specific Git commit (maximum reproducibility)
154//! stable = { source = "official", path = "agents/stable.md", git = "a1b2c3d4e5f6..." }
155//!
156//! # Git tag (alternative to version field)
157//! tagged = { source = "community", path = "agents/tagged.md", git = "release-2.0" }
158//! ```
159//!
160//! ### 3. Custom Target Installation
161//!
162//! Dependencies can specify a custom installation directory using the `target` field:
163//!
164//! ```toml
165//! [agents]
166//! # Install to .claude/integrations/ai/ instead of .claude/agents/
167//! integration-agent = {
168//! source = "official",
169//! path = "agents/integration.md",
170//! version = "v1.0.0",
171//! target = "integrations/ai"
172//! }
173//!
174//! # Organize tools in a custom structure
175//! debug-tool = {
176//! source = "community",
177//! path = "agents/debugger.md",
178//! version = "v2.0.0",
179//! target = "development/tools"
180//! }
181//!
182//! [snippets]
183//! # Custom location for API snippets
184//! api-helper = {
185//! source = "community",
186//! path = "snippets/api.md",
187//! version = "v1.0.0",
188//! target = "api/snippets"
189//! }
190//! ```
191//!
192//! Custom targets:
193//! - Are always relative to the `.claude` directory
194//! - Leading `.claude/` or `/` are automatically stripped
195//! - Directories are created if they don't exist
196//! - Help organize resources in complex projects
197//!
198//! ### 4. Custom Filenames
199//!
200//! Dependencies can specify a custom filename using the `filename` field:
201//!
202//! ```toml
203//! [agents]
204//! # Install as "ai-assistant.md" instead of "my-ai.md"
205//! my-ai = {
206//! source = "official",
207//! path = "agents/complex-long-name-v2.md",
208//! version = "v1.0.0",
209//! filename = "ai-assistant.md"
210//! }
211//!
212//! # Change both filename and extension
213//! doc-helper = {
214//! source = "community",
215//! path = "agents/documentation.md",
216//! version = "v2.0.0",
217//! filename = "docs.txt"
218//! }
219//!
220//! # Combine custom target and filename
221//! special-tool = {
222//! source = "official",
223//! path = "agents/debug-analyzer-enhanced.md",
224//! version = "v1.0.0",
225//! target = "tools/debugging",
226//! filename = "analyzer.markdown"
227//! }
228//!
229//! [scripts]
230//! # Rename script during installation
231//! data-processor = {
232//! source = "community",
233//! path = "scripts/data-processor-v3.py",
234//! version = "v1.0.0",
235//! filename = "process.py"
236//! }
237//! ```
238//!
239//! Custom filenames:
240//! - Include the full filename with extension
241//! - Override the default name (based on dependency key)
242//! - Work with any resource type
243//! - Can be combined with custom targets
244//!
245//! ## Version Constraint Syntax
246//!
247//! AGPM supports flexible version constraints:
248//!
249//! - `"v1.0.0"` - Exact semantic version
250//! - `"1.0.0"` - Exact version (v prefix optional)
251//! - `"latest"` - Always use the latest available version
252//! - `"main"` - Use the main/master branch HEAD
253//! - `"develop"` - Use a specific branch
254//! - `"a1b2c3d4..."` - Use a specific commit SHA
255//! - `"release-1.0"` - Use a specific Git tag
256//!
257//! ## Complete Examples
258//!
259//! ### Minimal Manifest
260//!
261//! ```toml
262//! [agents]
263//! helper = "../agents/helper.md"
264//! ```
265//!
266//! ### Production Manifest
267//!
268//! ```toml
269//! [sources]
270//! official = "https://github.com/claude-org/official-resources.git"
271//! community = "https://github.com/claude-community/resources.git"
272//! company = "git@github.com:mycompany/claude-resources.git"
273//!
274//! [target]
275//! agents = "resources/agents"
276//! snippets = "resources/snippets"
277//!
278//! [agents]
279//! # Production agents with pinned versions
280//! code-reviewer = { source = "official", path = "agents/code-reviewer.md", version = "v2.1.0" }
281//! documentation = { source = "community", path = "agents/doc-writer.md", version = "v1.5.2" }
282//! internal-helper = { source = "company", path = "agents/helper.md", version = "v1.0.0" }
283//!
284//! # Local customizations
285//! custom-agent = "./local/agents/custom.md"
286//!
287//! [snippets]
288//! # Utility snippets
289//! common-patterns = { source = "community", path = "snippets/patterns.md", version = "v1.2.0" }
290//! company-templates = { source = "company", path = "snippets/templates.md", version = "latest" }
291//! ```
292//!
293//! ## Security Considerations
294//!
295//! **CRITICAL**: Never include authentication credentials in `agpm.toml`:
296//!
297//! ```toml
298//! # ❌ NEVER DO THIS - credentials will be committed to git
299//! [sources]
300//! private = "https://token:ghp_xxxx@github.com/company/repo.git"
301//!
302//! # ✅ Instead, use global configuration in ~/.agpm/config.toml
303//! # Or use SSH keys with git@ URLs
304//! [sources]
305//! private = "git@github.com:company/repo.git"
306//! ```
307//!
308//! Authentication should be configured globally in `~/.agpm/config.toml` or
309//! through SSH keys for `git@` URLs. See [`crate::config`] for details.
310//!
311//! ## Relationship to Lockfile
312//!
313//! The manifest works together with the lockfile (`agpm.lock`):
314//!
315//! - **Manifest (`agpm.toml`)**: Declares dependencies and constraints
316//! - **Lockfile (`agpm.lock`)**: Records exact resolved versions and checksums
317//!
318//! When you run `agpm install`:
319//! 1. Reads dependencies from `agpm.toml`
320//! 2. Resolves versions within constraints
321//! 3. Generates/updates `agpm.lock` with exact commits
322//! 4. Installs resources to target directories
323//!
324//! See [`crate::lockfile`] for lockfile format details.
325//!
326//! ## Cross-Platform Compatibility
327//!
328//! AGPM handles platform differences automatically:
329//! - Path separators (/ vs \\) are normalized
330//! - Home directory expansion (~) is supported
331//! - Environment variable expansion is available
332//! - Git commands work on Windows, macOS, and Linux
333//! - Long path support on Windows (>260 characters)
334//! - Unicode filenames and paths are fully supported
335//!
336//! ## Best Practices
337//!
338//! 1. **Use semantic versions**: Prefer `v1.0.0` over `latest`
339//! 2. **Pin production dependencies**: Use exact versions in production
340//! 3. **Organize sources logically**: Group by organization or purpose
341//! 4. **Document dependencies**: Add comments explaining why each is needed
342//! 5. **Keep manifests simple**: Avoid overly complex dependency trees
343//! 6. **Use SSH for private repos**: More secure than HTTPS tokens
344//! 7. **Test across platforms**: Verify paths work on all target systems
345//! 8. **Version control manifests**: Always commit `agpm.toml` to git
346//! 9. **Validate regularly**: Run `agpm validate` before commits
347//! 10. **Use lockfiles**: Commit `agpm.lock` for reproducible builds
348//!
349//! ## Transitive Dependencies
350//!
351//! Resources can declare their own dependencies within their files using structured
352//! metadata. This enables automatic dependency resolution without manual manifest updates.
353//!
354//! ### Supported Formats
355//!
356//! #### Markdown Files (YAML Frontmatter)
357//!
358//! ```markdown
359//! ---
360//! dependencies:
361//! agents:
362//! - path: agents/helper.md
363//! version: v1.0.0
364//! - path: agents/reviewer.md
365//! snippets:
366//! - path: snippets/utils.md
367//! ---
368//!
369//! # My Command Documentation
370//! ...
371//! ```
372//!
373//! #### JSON Files (Top-Level Field)
374//!
375//! ```json
376//! {
377//! "events": ["UserPromptSubmit"],
378//! "type": "command",
379//! "command": ".claude/scripts/test.js",
380//! "dependencies": {
381//! "scripts": [
382//! { "path": "scripts/test-runner.sh", "version": "v1.0.0" },
383//! { "path": "scripts/validator.py" }
384//! ],
385//! "agents": [
386//! { "path": "agents/code-analyzer.md", "version": "~1.2.0" }
387//! ]
388//! }
389//! }
390//! ```
391//!
392//! ### Key Features
393//!
394//! - **Automatic Discovery**: Dependencies extracted during resolution
395//! - **Version Inheritance**: If no version specified, parent's version is used
396//! - **Same-Source Model**: Transitive deps inherit parent's source repository
397//! - **Cycle Detection**: Circular dependency loops are detected and prevented
398//! - **Topological Ordering**: Dependencies installed in correct order
399//! - **Optional Resolution**: Can be disabled with `--no-transitive` flag
400//!
401//! ### Data Structures
402//!
403//! Transitive dependencies are represented by:
404//! - [`DependencySpec`]: Individual dependency specification (path + optional version)
405//! - [`DependencyMetadata`]: Collection of dependencies by resource type
406//! - [`DetailedDependency::dependencies`]: Field storing extracted transitive deps
407//!
408//! ### Processing Flow
409//!
410//! 1. Manifest dependencies are resolved first
411//! 2. Resource files are checked for metadata (YAML frontmatter or JSON fields)
412//! 3. Discovered dependencies are added to dependency graph
413//! 4. Graph is validated for cycles
414//! 5. Dependencies are resolved in topological order
415//! 6. All resources (direct + transitive) are installed
416//!
417//! See [`dependency_spec`] module for detailed specification formats.
418//!
419//! ## Error Handling
420//!
421//! The manifest module provides comprehensive error handling with:
422//! - **Context-rich errors**: Detailed messages with actionable suggestions
423//! - **Validation errors**: Clear explanations of manifest problems
424//! - **I/O errors**: Helpful context for file system issues
425//! - **TOML parsing errors**: Specific syntax error locations
426//! - **Security validation**: Detection of potential security issues
427//!
428//! All errors implement [`std::error::Error`] and provide both user-friendly
429//! messages and programmatic access to error details.
430//!
431//! ## Performance Characteristics
432//!
433//! - **Parsing**: O(n) where n is the manifest file size
434//! - **Validation**: O(d) where d is the number of dependencies
435//! - **Serialization**: O(n) where n is the total data size
436//! - **Memory usage**: Proportional to manifest complexity
437//! - **Thread safety**: All operations are thread-safe
438//!
439//! ## Integration with Other Modules
440//!
441//! The manifest module works closely with other AGPM modules:
442//!
443//! ### With [`crate::resolver`]
444//!
445//! ```rust,ignore
446//! use agpm_cli::manifest::Manifest;
447//! use agpm_cli::resolver::DependencyResolver;
448//!
449//! let manifest = Manifest::load(&project_path.join("agpm.toml"))?;
450//! let resolver = DependencyResolver::new(&manifest);
451//! let resolved = resolver.resolve_all().await?;
452//! ```
453//!
454//! ### With [`crate::lockfile`]
455//!
456//! ```rust,ignore
457//! use agpm_cli::manifest::Manifest;
458//! use agpm_cli::lockfile::LockFile;
459//!
460//! let manifest = Manifest::load(&project_path.join("agpm.toml"))?;
461//! let lockfile = LockFile::generate_from_manifest(&manifest).await?;
462//! lockfile.save(&project_path.join("agpm.lock"))?;
463//! ```
464//!
465//! ### With [`crate::git`] for Source Management
466//!
467//! ```rust,ignore
468//! use agpm_cli::manifest::Manifest;
469//! use agpm_cli::git::GitManager;
470//!
471//! let manifest = Manifest::load(&project_path.join("agpm.toml"))?;
472//! let git = GitManager::new(&cache_dir);
473//!
474//! for (name, url) in &manifest.sources {
475//! git.clone_or_update(name, url).await?;
476//! }
477//! ```
478
479pub mod dependency_spec;
480pub mod helpers;
481pub mod patches;
482pub mod resource_dependency;
483pub mod tool_config;
484
485#[cfg(test)]
486mod manifest_tests;
487#[cfg(test)]
488mod tool_config_tests;
489
490use anyhow::{Context, Result};
491use serde::{Deserialize, Serialize};
492use std::collections::{BTreeMap, HashMap};
493use std::path::{Path, PathBuf};
494
495pub use dependency_spec::{DependencyMetadata, DependencySpec};
496pub use helpers::{expand_url, find_manifest, find_manifest_from, find_manifest_with_optional};
497pub use patches::{ManifestPatches, PatchConflict, PatchData, PatchOrigin};
498pub use resource_dependency::{DetailedDependency, ResourceDependency};
499pub use tool_config::{ArtifactTypeConfig, ResourceConfig, ToolsConfig, WellKnownTool};
500
501/// The main manifest file structure representing a complete `agpm.toml` file.
502///
503/// This struct encapsulates all configuration for a AGPM project, including
504/// source repositories, installation targets, and resource dependencies.
505/// It provides the foundation for declarative dependency management similar
506/// to Cargo's `Cargo.toml`.
507///
508/// # Structure
509///
510/// - **Sources**: Named Git repositories that can be referenced by dependencies
511/// - **Target**: Installation directories for different resource types
512/// - **Agents**: AI agent dependencies (`.md` files with agent definitions)
513/// - **Snippets**: Code snippet dependencies (`.md` files with reusable code)
514/// - **Commands**: Claude Code command dependencies (`.md` files with slash commands)
515///
516/// # Serialization
517///
518/// The struct uses Serde for TOML serialization/deserialization with these behaviors:
519/// - Empty collections are omitted from serialized output for cleaner files
520/// - Default values are automatically applied for missing fields
521/// - Field names match TOML section names exactly
522///
523/// # Thread Safety
524///
525/// This struct is thread-safe and can be shared across async tasks safely.
526///
527/// # Examples
528///
529/// ```rust,no_run
530/// use agpm_cli::manifest::{Manifest, ResourceDependency};
531///
532/// // Create a new empty manifest
533/// let mut manifest = Manifest::new();
534///
535/// // Add a source repository
536/// manifest.add_source(
537/// "community".to_string(),
538/// "https://github.com/claude-community/resources.git".to_string()
539/// );
540///
541/// // Add a dependency
542/// manifest.add_dependency(
543/// "helper".to_string(),
544/// ResourceDependency::Simple("../local/helper.md".to_string()),
545/// true // is_agent = true
546/// );
547/// ```
548/// Project-specific template variables for AI coding assistants.
549///
550/// An arbitrary map of user-defined variables that can be referenced in resource templates.
551/// This provides maximum flexibility for teams to organize project context however they want,
552/// without imposing any predefined structure.
553///
554/// # Use Case: AI Agent Context
555///
556/// When AI agents work on your codebase, they need context about:
557/// - Where to find coding standards and style guides
558/// - What conventions to follow (formatting, naming, patterns)
559/// - Where architecture and design docs are located
560/// - Project-specific requirements (testing, security, performance)
561///
562/// # Template Access
563///
564/// All variables are accessible in templates under the `agpm.project` namespace.
565/// The structure is completely user-defined.
566///
567/// # Examples
568///
569/// ## Flexible Structure - Organize However You Want
570/// ```toml
571/// [project]
572/// # Top-level variables
573/// style_guide = "docs/STYLE_GUIDE.md"
574/// max_line_length = 100
575/// test_framework = "pytest"
576///
577/// # Nested sections (optional, just for organization)
578/// [project.paths]
579/// architecture = "docs/ARCHITECTURE.md"
580/// conventions = "docs/CONVENTIONS.md"
581///
582/// [project.standards]
583/// indent_style = "spaces"
584/// indent_size = 4
585/// ```
586///
587/// ## Template Usage
588/// ```markdown
589/// # Code Reviewer
590/// Follow guidelines at: {{ agpm.project.style_guide }}
591/// Max line length: {{ agpm.project.max_line_length }}
592/// Architecture: {{ agpm.project.paths.architecture }}
593/// ```
594///
595/// ## Any Structure Works
596/// ```toml
597/// [project]
598/// whatever = "you want"
599/// numbers = 42
600/// arrays = ["work", "too"]
601///
602/// [project.deeply.nested.structure]
603/// is_allowed = true
604/// ```
605#[derive(Debug, Clone, Serialize, Deserialize, Default)]
606pub struct ProjectConfig(toml::map::Map<String, toml::Value>);
607
608impl ProjectConfig {
609 /// Convert this ProjectConfig to a serde_json::Value for template rendering.
610 ///
611 /// This method handles conversion of TOML values to JSON values, which is necessary
612 /// for proper Tera template rendering.
613 ///
614 /// # Examples
615 ///
616 /// ```rust,no_run
617 /// use agpm_cli::manifest::ProjectConfig;
618 ///
619 /// let mut config_map = toml::map::Map::new();
620 /// config_map.insert("style_guide".to_string(), toml::Value::String("docs/STYLE.md".into()));
621 /// let config = ProjectConfig::from(config_map);
622 ///
623 /// let json = config.to_json_value();
624 /// // Use json in Tera template context
625 /// ```
626 pub fn to_json_value(&self) -> serde_json::Value {
627 toml_value_to_json(&toml::Value::Table(self.0.clone()))
628 }
629}
630
631impl From<toml::map::Map<String, toml::Value>> for ProjectConfig {
632 fn from(map: toml::map::Map<String, toml::Value>) -> Self {
633 Self(map)
634 }
635}
636
637/// Convert a toml::Value to serde_json::Value.
638pub(crate) fn toml_value_to_json(value: &toml::Value) -> serde_json::Value {
639 match value {
640 toml::Value::String(s) => serde_json::Value::String(s.clone()),
641 toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
642 toml::Value::Float(f) => serde_json::Number::from_f64(*f)
643 .map(serde_json::Value::Number)
644 .unwrap_or(serde_json::Value::Null),
645 toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
646 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
647 toml::Value::Array(arr) => {
648 serde_json::Value::Array(arr.iter().map(toml_value_to_json).collect())
649 }
650 toml::Value::Table(table) => {
651 // Sort keys to ensure deterministic JSON serialization
652 let mut keys: Vec<_> = table.keys().collect();
653 keys.sort();
654 let map: serde_json::Map<String, serde_json::Value> =
655 keys.into_iter().map(|k| (k.clone(), toml_value_to_json(&table[k]))).collect();
656 serde_json::Value::Object(map)
657 }
658 }
659}
660
661/// Convert JSON value to TOML value for template variable merging.
662///
663/// Handles JSON null as empty string since TOML lacks a null type.
664/// Used when merging template_vars (JSON) with project config (TOML).
665#[cfg(test)]
666pub(crate) fn json_value_to_toml(value: &serde_json::Value) -> toml::Value {
667 match value {
668 serde_json::Value::String(s) => toml::Value::String(s.clone()),
669 serde_json::Value::Number(n) => {
670 if let Some(i) = n.as_i64() {
671 toml::Value::Integer(i)
672 } else if let Some(f) = n.as_f64() {
673 toml::Value::Float(f)
674 } else {
675 // Fallback for numbers that don't fit i64 or f64
676 toml::Value::String(n.to_string())
677 }
678 }
679 serde_json::Value::Bool(b) => toml::Value::Boolean(*b),
680 serde_json::Value::Null => {
681 // TOML doesn't have a null type - represent as empty string
682 toml::Value::String(String::new())
683 }
684 serde_json::Value::Array(arr) => {
685 toml::Value::Array(arr.iter().map(json_value_to_toml).collect())
686 }
687 serde_json::Value::Object(obj) => {
688 let table: toml::map::Map<String, toml::Value> =
689 obj.iter().map(|(k, v)| (k.clone(), json_value_to_toml(v))).collect();
690 toml::Value::Table(table)
691 }
692 }
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct Manifest {
697 /// Named source repositories mapped to their Git URLs.
698 ///
699 /// Keys are short, convenient names used in dependency specifications.
700 /// Values are Git repository URLs (HTTPS, SSH, or local file:// URLs).
701 ///
702 /// **Security Note**: Never include authentication tokens in these URLs.
703 /// Use SSH keys or configure authentication in the global config file.
704 ///
705 /// # Examples
706 ///
707 /// ```toml
708 /// [sources]
709 /// official = "https://github.com/claude-org/official.git"
710 /// private = "git@github.com:company/private.git"
711 /// local = "file:///home/user/local-repo"
712 /// ```
713 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
714 pub sources: HashMap<String, String>,
715
716 /// Tool type configurations for multi-tool support.
717 ///
718 /// Maps tool type names (claude-code, opencode, agpm, custom) to their
719 /// installation configurations. This replaces the old `target` field and
720 /// enables support for multiple tools and custom tool types.
721 ///
722 /// See [`ToolsConfig`] for details on configuration format.
723 #[serde(rename = "tools", skip_serializing_if = "Option::is_none")]
724 pub tools: Option<ToolsConfig>,
725
726 /// Agent dependencies mapping names to their specifications.
727 ///
728 /// Agents are typically AI model definitions, prompts, or behavioral
729 /// specifications stored as Markdown files. Each dependency can be
730 /// either local (filesystem path) or remote (from a Git source).
731 ///
732 /// See [`ResourceDependency`] for specification format details.
733 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
734 pub agents: HashMap<String, ResourceDependency>,
735
736 /// Snippet dependencies mapping names to their specifications.
737 ///
738 /// Snippets are typically reusable code templates, examples, or
739 /// documentation stored as Markdown files. They follow the same
740 /// dependency format as agents.
741 ///
742 /// See [`ResourceDependency`] for specification format details.
743 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
744 pub snippets: HashMap<String, ResourceDependency>,
745
746 /// Command dependencies mapping names to their specifications.
747 ///
748 /// Commands are Claude Code slash commands that provide custom functionality
749 /// and automation within the Claude Code interface. They follow the same
750 /// dependency format as agents and snippets.
751 ///
752 /// See [`ResourceDependency`] for specification format details.
753 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
754 pub commands: HashMap<String, ResourceDependency>,
755
756 /// MCP server configurations mapping names to their specifications.
757 ///
758 /// MCP servers provide integrations with external systems and services,
759 /// allowing Claude Code to connect to databases, APIs, and other tools.
760 /// MCP servers are JSON configuration files that get installed to
761 /// `.mcp.json` (no separate directory - configurations are merged into the JSON file).
762 ///
763 /// See [`ResourceDependency`] for specification format details.
764 #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "mcp-servers")]
765 pub mcp_servers: HashMap<String, ResourceDependency>,
766
767 /// Script dependencies mapping names to their specifications.
768 ///
769 /// Scripts are executable files (.sh, .js, .py, etc.) that can be run by hooks
770 /// or independently. They are installed to `.claude/scripts/` and can be
771 /// referenced by hook configurations.
772 ///
773 /// See [`ResourceDependency`] for specification format details.
774 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
775 pub scripts: HashMap<String, ResourceDependency>,
776
777 /// Hook dependencies mapping names to their specifications.
778 ///
779 /// Hooks are JSON configuration files that define event-based automation
780 /// in Claude Code. They specify when to run scripts based on tool usage,
781 /// prompts, and other events. Hook configurations are merged into
782 /// `settings.local.json`.
783 ///
784 /// See [`ResourceDependency`] for specification format details.
785 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
786 pub hooks: HashMap<String, ResourceDependency>,
787
788 /// Patches for overriding resource metadata.
789 ///
790 /// Patches allow overriding YAML frontmatter fields (like `model`) in
791 /// resources without forking upstream repositories. They are keyed by
792 /// resource type and manifest alias.
793 ///
794 /// # Examples
795 ///
796 /// ```toml
797 /// [patch.agents.my-agent]
798 /// model = "claude-3-haiku"
799 /// temperature = "0.7"
800 /// ```
801 #[serde(default, skip_serializing_if = "ManifestPatches::is_empty", rename = "patch")]
802 pub patches: ManifestPatches,
803
804 /// Project-level patches (from agpm.toml).
805 ///
806 /// This field is not serialized - it's populated during loading to track
807 /// which patches came from the project manifest vs private config.
808 #[serde(skip)]
809 pub project_patches: ManifestPatches,
810
811 /// Private patches (from agpm.private.toml).
812 ///
813 /// This field is not serialized - it's populated during loading to track
814 /// which patches came from private config. These are kept separate from
815 /// project patches to maintain deterministic lockfiles.
816 #[serde(skip)]
817 pub private_patches: ManifestPatches,
818
819 /// Default tool overrides for resource types.
820 ///
821 /// Allows users to override which tool is used by default when a dependency
822 /// doesn't explicitly specify a tool. Keys are resource type names (agents,
823 /// snippets, commands, scripts, hooks, mcp-servers), values are tool names
824 /// (claude-code, opencode, agpm, or custom tool names).
825 ///
826 /// # Examples
827 ///
828 /// ```toml
829 /// [default-tools]
830 /// snippets = "claude-code" # Override default for Claude-only users
831 /// agents = "claude-code" # Explicit (already the default)
832 /// commands = "opencode" # Use OpenCode by default for commands
833 /// ```
834 ///
835 /// # Built-in Defaults (when not configured)
836 ///
837 /// - `snippets` → `"agpm"` (shared infrastructure)
838 /// - All other resource types → `"claude-code"`
839 #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "default-tools")]
840 pub default_tools: HashMap<String, String>,
841
842 /// Project-specific template variables.
843 ///
844 /// Custom project configuration that can be referenced in resource templates
845 /// via Tera template syntax. This allows teams to define project-specific
846 /// values like paths, standards, and conventions that are then available
847 /// throughout all installed resources.
848 ///
849 /// Template access: `{{ agpm.project.name }}`, `{{ agpm.project.paths.style_guide }}`
850 ///
851 /// # Examples
852 ///
853 /// ```toml
854 /// [project]
855 /// name = "My Project"
856 /// version = "2.0.0"
857 ///
858 /// [project.paths]
859 /// style_guide = "docs/STYLE_GUIDE.md"
860 /// ```
861 #[serde(skip_serializing_if = "Option::is_none")]
862 pub project: Option<ProjectConfig>,
863
864 /// Directory containing the manifest file (for resolving relative paths).
865 ///
866 /// This field is populated when loading the manifest and is used to resolve
867 /// relative paths in dependencies, particularly for path-only dependencies
868 /// and their transitive dependencies.
869 ///
870 /// This field is not serialized and only exists at runtime.
871 #[serde(skip)]
872 pub manifest_dir: Option<std::path::PathBuf>,
873}
874
875/// A resource dependency specification supporting multiple formats.
876///
877/// Dependencies can be specified in two main formats to balance simplicity
878/// with flexibility. The enum uses Serde's `untagged` attribute to automatically
879/// deserialize the correct variant based on the TOML structure.
880///
881/// # Variants
882///
883/// ## Simple Dependencies
884///
885/// For local file dependencies, just specify the path directly:
886///
887/// ```toml
888/// [agents]
889/// local-helper = "../shared/agents/helper.md"
890/// nearby-agent = "./local/custom-agent.md"
891/// ```
892///
893/// ## Detailed Dependencies
894///
895/// For remote dependencies or when you need more control:
896///
897/// ```toml
898/// [agents]
899/// # Remote dependency with version
900/// code-reviewer = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
901///
902/// # Remote dependency with git reference
903/// experimental = { source = "community", path = "agents/new.md", git = "develop" }
904///
905/// # Local dependency with explicit path (equivalent to simple form)
906/// local-tool = { path = "../tools/agent.md" }
907/// ```
908///
909/// # Validation Rules
910///
911/// - **Local dependencies** (no source): Cannot have version constraints
912/// - **Remote dependencies** (with source): Must have either `version` or `git` field
913/// - **Path field**: Required and cannot be empty
914/// - **Source field**: Must reference an existing source in the `[sources]` section
915///
916/// # Type Safety
917///
918/// The enum ensures type safety at compile time while providing runtime
919/// validation through the [`Manifest::validate`] method.
920///
921/// # Serialization Behavior
922///
923/// - Simple paths serialize directly as strings
924/// - Detailed specs serialize as TOML inline tables
925/// - Empty optional fields are omitted for cleaner output
926/// - Deserialization is automatic based on TOML structure
927///
928/// # Memory Layout
929///
930/// This enum uses `#[serde(untagged)]` for automatic variant detection,
931/// which means deserialization tries the `Detailed` variant first, then
932/// falls back to `Simple`. This is efficient for the expected usage patterns
933/// where detailed dependencies are more common in larger projects.
934impl Manifest {
935 /// Create a new empty manifest with default configuration.
936 ///
937 /// The new manifest will have:
938 /// - No sources defined
939 /// - Default target directories (`.claude/agents` and `.agpm/snippets`)
940 /// - No dependencies
941 ///
942 /// This is typically used when programmatically building a manifest or
943 /// as a starting point for adding dependencies.
944 ///
945 /// # Examples
946 ///
947 /// ```rust,no_run
948 /// use agpm_cli::manifest::Manifest;
949 ///
950 /// let manifest = Manifest::new();
951 /// assert!(manifest.sources.is_empty());
952 /// assert!(manifest.agents.is_empty());
953 /// assert!(manifest.snippets.is_empty());
954 /// assert!(manifest.commands.is_empty());
955 /// assert!(manifest.mcp_servers.is_empty());
956 /// ```
957 #[must_use]
958 #[allow(deprecated)]
959 pub fn new() -> Self {
960 Self {
961 sources: HashMap::new(),
962 tools: None,
963 agents: HashMap::new(),
964 snippets: HashMap::new(),
965 commands: HashMap::new(),
966 mcp_servers: HashMap::new(),
967 scripts: HashMap::new(),
968 hooks: HashMap::new(),
969 patches: ManifestPatches::new(),
970 project_patches: ManifestPatches::new(),
971 private_patches: ManifestPatches::new(),
972 default_tools: HashMap::new(),
973 project: None,
974 manifest_dir: None,
975 }
976 }
977
978 /// Load and parse a manifest from a TOML file.
979 ///
980 /// This method reads the specified file, parses it as TOML, deserializes
981 /// it into a [`Manifest`] struct, and validates the result. The entire
982 /// operation is atomic - either the manifest loads successfully or an
983 /// error is returned.
984 ///
985 /// # Validation
986 ///
987 /// After parsing, the manifest is automatically validated to ensure:
988 /// - All dependency sources reference valid entries in the `[sources]` section
989 /// - Required fields are present and non-empty
990 /// - Version constraints are properly specified for remote dependencies
991 /// - Source URLs use supported protocols
992 /// - No version conflicts exist between dependencies
993 ///
994 /// # Error Handling
995 ///
996 /// Returns detailed errors for common problems:
997 /// - **File I/O errors**: File not found, permission denied, etc.
998 /// - **TOML syntax errors**: Invalid TOML format with helpful suggestions
999 /// - **Validation errors**: Logical inconsistencies in the manifest
1000 /// - **Security errors**: Unsafe URL patterns or credential leakage
1001 ///
1002 /// All errors include contextual information and actionable suggestions.
1003 ///
1004 /// # Examples
1005 ///
1006 /// ```rust,no_run,ignore
1007 /// use agpm_cli::manifest::Manifest;
1008 /// use std::path::Path;
1009 ///
1010 /// // Load a manifest file
1011 /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
1012 ///
1013 /// // Access parsed data
1014 /// println!("Found {} sources", manifest.sources.len());
1015 /// println!("Found {} agents", manifest.agents.len());
1016 /// println!("Found {} snippets", manifest.snippets.len());
1017 /// # Ok::<(), anyhow::Error>(())
1018 /// ```
1019 ///
1020 /// # File Format
1021 ///
1022 /// Expects a valid TOML file following the AGPM manifest format.
1023 /// See the module-level documentation for complete format specification.
1024 pub fn load(path: &Path) -> Result<Self> {
1025 let content = std::fs::read_to_string(path).with_context(|| {
1026 format!(
1027 "Cannot read manifest file: {}\n\n\
1028 Possible causes:\n\
1029 - File doesn't exist or has been moved\n\
1030 - Permission denied (check file ownership)\n\
1031 - File is locked by another process",
1032 path.display()
1033 )
1034 })?;
1035
1036 let mut manifest: Self = toml::from_str(&content)
1037 .map_err(|e| crate::core::AgpmError::ManifestParseError {
1038 file: path.display().to_string(),
1039 reason: e.to_string(),
1040 })
1041 .with_context(|| {
1042 format!(
1043 "Invalid TOML syntax in manifest file: {}\n\n\
1044 Common TOML syntax errors:\n\
1045 - Missing quotes around strings\n\
1046 - Unmatched brackets [ ] or braces {{ }}\n\
1047 - Invalid characters in keys or values\n\
1048 - Incorrect indentation or structure",
1049 path.display()
1050 )
1051 })?;
1052
1053 // Apply resource-type-specific defaults for tool
1054 // Snippets default to "agpm" (shared infrastructure) instead of "claude-code"
1055 manifest.apply_tool_defaults();
1056
1057 // Store the manifest directory for resolving relative paths
1058 manifest.manifest_dir = Some(
1059 path.parent()
1060 .ok_or_else(|| anyhow::anyhow!("Manifest path has no parent directory"))?
1061 .to_path_buf(),
1062 );
1063
1064 manifest.validate()?;
1065
1066 Ok(manifest)
1067 }
1068
1069 /// Load manifest with private config merged.
1070 ///
1071 /// Loads the project manifest from `agpm.toml` and then attempts to load
1072 /// `agpm.private.toml` from the same directory. If a private config exists,
1073 /// its patches are merged with the project patches (private silently takes precedence).
1074 ///
1075 /// Any conflicts (same field defined in both files with different values) are
1076 /// returned for informational purposes only. Private patches always override
1077 /// project patches without raising an error.
1078 ///
1079 /// # Arguments
1080 ///
1081 /// * `path` - Path to the project manifest file (`agpm.toml`)
1082 ///
1083 /// # Returns
1084 ///
1085 /// A manifest with merged patches and a list of any conflicts detected (for
1086 /// informational/debugging purposes).
1087 ///
1088 /// # Examples
1089 ///
1090 /// ```no_run
1091 /// use agpm_cli::manifest::Manifest;
1092 /// use std::path::Path;
1093 ///
1094 /// let (manifest, conflicts) = Manifest::load_with_private(Path::new("agpm.toml"))?;
1095 /// // Conflicts are informational only - private patches already won
1096 /// if !conflicts.is_empty() {
1097 /// eprintln!("Note: {} private patch(es) override project settings", conflicts.len());
1098 /// }
1099 /// # Ok::<(), anyhow::Error>(())
1100 /// ```
1101 pub fn load_with_private(path: &Path) -> Result<(Self, Vec<PatchConflict>)> {
1102 // Load the main project manifest
1103 let mut manifest = Self::load(path)?;
1104
1105 // Store project patches before merging
1106 manifest.project_patches = manifest.patches.clone();
1107
1108 // Try to load private config
1109 let private_path = if let Some(parent) = path.parent() {
1110 parent.join("agpm.private.toml")
1111 } else {
1112 PathBuf::from("agpm.private.toml")
1113 };
1114
1115 if private_path.exists() {
1116 let private_manifest = Self::load_private(&private_path)?;
1117
1118 // Store private patches
1119 manifest.private_patches = private_manifest.patches.clone();
1120
1121 // Merge patches (private takes precedence)
1122 let (merged_patches, conflicts) =
1123 manifest.patches.merge_with(&private_manifest.patches);
1124 manifest.patches = merged_patches;
1125
1126 Ok((manifest, conflicts))
1127 } else {
1128 // No private config, keep private_patches empty
1129 manifest.private_patches = ManifestPatches::new();
1130 Ok((manifest, Vec::new()))
1131 }
1132 }
1133
1134 /// Load a private manifest file.
1135 ///
1136 /// Private manifests can only contain patches - they cannot define sources,
1137 /// tools, or dependencies. This method loads and validates that the private
1138 /// config follows these rules.
1139 ///
1140 /// # Arguments
1141 ///
1142 /// * `path` - Path to the private manifest file (`agpm.private.toml`)
1143 ///
1144 /// # Errors
1145 ///
1146 /// Returns an error if:
1147 /// - The file cannot be read
1148 /// - The TOML syntax is invalid
1149 /// - The private config contains non-patch fields
1150 fn load_private(path: &Path) -> Result<Self> {
1151 let content = std::fs::read_to_string(path).with_context(|| {
1152 format!(
1153 "Cannot read private manifest file: {}\n\n\
1154 Possible causes:\n\
1155 - File doesn't exist or has been moved\n\
1156 - Permission denied (check file ownership)\n\
1157 - File is locked by another process",
1158 path.display()
1159 )
1160 })?;
1161
1162 let manifest: Self = toml::from_str(&content)
1163 .map_err(|e| crate::core::AgpmError::ManifestParseError {
1164 file: path.display().to_string(),
1165 reason: e.to_string(),
1166 })
1167 .with_context(|| {
1168 format!(
1169 "Invalid TOML syntax in private manifest file: {}\n\n\
1170 Common TOML syntax errors:\n\
1171 - Missing quotes around strings\n\
1172 - Unmatched brackets [ ] or braces {{ }}\n\
1173 - Invalid characters in keys or values\n\
1174 - Incorrect indentation or structure",
1175 path.display()
1176 )
1177 })?;
1178
1179 // Validate that private config only contains patches
1180 if !manifest.sources.is_empty()
1181 || manifest.tools.is_some()
1182 || !manifest.agents.is_empty()
1183 || !manifest.snippets.is_empty()
1184 || !manifest.commands.is_empty()
1185 || !manifest.mcp_servers.is_empty()
1186 || !manifest.scripts.is_empty()
1187 || !manifest.hooks.is_empty()
1188 {
1189 anyhow::bail!(
1190 "Private manifest file ({}) can only contain [patch] sections, not sources, tools, or dependencies",
1191 path.display()
1192 );
1193 }
1194
1195 Ok(manifest)
1196 }
1197
1198 /// Get the default tool for a resource type.
1199 ///
1200 /// Checks the `[default-tools]` configuration first, then falls back to
1201 /// the built-in defaults:
1202 /// - `snippets` → `"agpm"` (shared infrastructure)
1203 /// - All other resource types → `"claude-code"`
1204 ///
1205 /// # Arguments
1206 ///
1207 /// * `resource_type` - The resource type to get the default tool for
1208 ///
1209 /// # Returns
1210 ///
1211 /// The default tool name as a string.
1212 ///
1213 /// # Examples
1214 ///
1215 /// ```rust,no_run
1216 /// use agpm_cli::manifest::Manifest;
1217 /// use agpm_cli::core::ResourceType;
1218 ///
1219 /// let manifest = Manifest::new();
1220 /// assert_eq!(manifest.get_default_tool(ResourceType::Snippet), "agpm");
1221 /// assert_eq!(manifest.get_default_tool(ResourceType::Agent), "claude-code");
1222 /// ```
1223 #[must_use]
1224 pub fn get_default_tool(&self, resource_type: crate::core::ResourceType) -> String {
1225 // Get the resource name in plural form for consistency with TOML section names
1226 // (agents, snippets, commands, etc.)
1227 let resource_name = match resource_type {
1228 crate::core::ResourceType::Agent => "agents",
1229 crate::core::ResourceType::Snippet => "snippets",
1230 crate::core::ResourceType::Command => "commands",
1231 crate::core::ResourceType::Script => "scripts",
1232 crate::core::ResourceType::Hook => "hooks",
1233 crate::core::ResourceType::McpServer => "mcp-servers",
1234 };
1235
1236 // Check if there's a configured override
1237 if let Some(tool) = self.default_tools.get(resource_name) {
1238 return tool.clone();
1239 }
1240
1241 // Fall back to built-in defaults
1242 resource_type.default_tool().to_string()
1243 }
1244
1245 fn apply_tool_defaults(&mut self) {
1246 // Apply resource-type-specific defaults only when tool is not explicitly specified
1247 for resource_type in [
1248 crate::core::ResourceType::Snippet,
1249 crate::core::ResourceType::Agent,
1250 crate::core::ResourceType::Command,
1251 crate::core::ResourceType::Script,
1252 crate::core::ResourceType::Hook,
1253 crate::core::ResourceType::McpServer,
1254 ] {
1255 // Get the default tool before the mutable borrow to avoid borrow conflicts
1256 let default_tool = self.get_default_tool(resource_type);
1257
1258 if let Some(deps) = self.get_dependencies_mut(resource_type) {
1259 for dependency in deps.values_mut() {
1260 if let ResourceDependency::Detailed(details) = dependency {
1261 if details.tool.is_none() {
1262 details.tool = Some(default_tool.clone());
1263 }
1264 }
1265 }
1266 }
1267 }
1268 }
1269
1270 /// Save the manifest to a TOML file with pretty formatting.
1271 ///
1272 /// This method serializes the manifest to TOML format and writes it to the
1273 /// specified file path. The output is pretty-printed for human readability
1274 /// and follows TOML best practices.
1275 ///
1276 /// # Formatting
1277 ///
1278 /// The generated TOML file will:
1279 /// - Use consistent indentation and spacing
1280 /// - Omit empty sections for cleaner output
1281 /// - Order sections logically (sources, target, agents, snippets)
1282 /// - Include inline tables for detailed dependencies
1283 ///
1284 /// # Atomic Operation
1285 ///
1286 /// The save operation is atomic - the file is either completely written
1287 /// or left unchanged. This prevents corruption if the operation fails
1288 /// partway through.
1289 ///
1290 /// # Error Handling
1291 ///
1292 /// Returns detailed errors for common problems:
1293 /// - **Permission denied**: Insufficient write permissions
1294 /// - **Directory doesn't exist**: Parent directory missing
1295 /// - **Disk full**: Insufficient storage space
1296 /// - **File locked**: Another process has the file open
1297 ///
1298 /// # Examples
1299 ///
1300 /// ```rust,no_run
1301 /// use agpm_cli::manifest::Manifest;
1302 /// use std::path::Path;
1303 ///
1304 /// let mut manifest = Manifest::new();
1305 /// manifest.add_source(
1306 /// "official".to_string(),
1307 /// "https://github.com/claude-org/resources.git".to_string()
1308 /// );
1309 ///
1310 /// // Save to file
1311 /// # use tempfile::tempdir;
1312 /// # let temp_dir = tempdir()?;
1313 /// # let manifest_path = temp_dir.path().join("agpm.toml");
1314 /// manifest.save(&manifest_path)?;
1315 /// # Ok::<(), anyhow::Error>(())
1316 /// ```
1317 ///
1318 /// # Output Format
1319 ///
1320 /// The generated file will follow this structure:
1321 ///
1322 /// ```toml
1323 /// [sources]
1324 /// official = "https://github.com/claude-org/resources.git"
1325 ///
1326 /// [target]
1327 /// agents = ".claude/agents"
1328 /// snippets = ".agpm/snippets"
1329 ///
1330 /// [agents]
1331 /// helper = { source = "official", path = "agents/helper.md", version = "v1.0.0" }
1332 ///
1333 /// [snippets]
1334 /// utils = { source = "official", path = "snippets/utils.md", version = "v1.0.0" }
1335 /// ```
1336 pub fn save(&self, path: &Path) -> Result<()> {
1337 // Serialize to a document first so we can control formatting
1338 let mut doc = toml_edit::ser::to_document(self)
1339 .with_context(|| "Failed to serialize manifest data to TOML format")?;
1340
1341 // Convert top-level inline tables to regular tables (section headers)
1342 // This keeps [sources], [agents], etc. as sections but nested values stay inline
1343 for (_key, value) in doc.iter_mut() {
1344 if let Some(inline_table) = value.as_inline_table() {
1345 // Convert inline table to regular table
1346 let table = inline_table.clone().into_table();
1347 *value = toml_edit::Item::Table(table);
1348 }
1349 }
1350
1351 let content = doc.to_string();
1352
1353 std::fs::write(path, content).with_context(|| {
1354 format!(
1355 "Cannot write manifest file: {}\n\n\
1356 Possible causes:\n\
1357 - Permission denied (try running with elevated permissions)\n\
1358 - Directory doesn't exist\n\
1359 - Disk is full or read-only\n\
1360 - File is locked by another process",
1361 path.display()
1362 )
1363 })?;
1364
1365 Ok(())
1366 }
1367
1368 /// Validate the manifest structure and enforce business rules.
1369 ///
1370 /// This method performs comprehensive validation of the manifest to ensure
1371 /// logical consistency, security best practices, and correct dependency
1372 /// relationships. It's automatically called during [`Self::load`] but can
1373 /// also be used independently to validate programmatically constructed manifests.
1374 ///
1375 /// # Validation Rules
1376 ///
1377 /// ## Source Validation
1378 /// - All source URLs must use supported protocols (HTTPS, SSH, git://, file://)
1379 /// - No plain directory paths allowed as sources (must use file:// URLs)
1380 /// - No authentication tokens embedded in URLs (security check)
1381 /// - Environment variable expansion is validated for syntax
1382 ///
1383 /// ## Dependency Validation
1384 /// - All dependency paths must be non-empty
1385 /// - Remote dependencies must reference existing sources
1386 /// - Remote dependencies must specify version constraints
1387 /// - Local dependencies cannot have version constraints
1388 /// - No version conflicts between dependencies with the same name
1389 ///
1390 /// ## Path Validation
1391 /// - Local dependency paths are checked for proper format
1392 /// - Remote dependency paths are validated as repository-relative
1393 /// - Path traversal attempts are detected and rejected
1394 ///
1395 /// # Error Types
1396 ///
1397 /// Returns specific error types for different validation failures:
1398 /// - [`crate::core::AgpmError::SourceNotFound`]: Referenced source doesn't exist
1399 /// - [`crate::core::AgpmError::ManifestValidationError`]: General validation failures
1400 /// - Context errors for specific issues with actionable suggestions
1401 ///
1402 /// # Examples
1403 ///
1404 /// ```rust,no_run
1405 /// use agpm_cli::manifest::{Manifest, ResourceDependency, DetailedDependency};
1406 ///
1407 /// let mut manifest = Manifest::new();
1408 ///
1409 /// // This will pass validation (local dependency)
1410 /// manifest.add_dependency(
1411 /// "local".to_string(),
1412 /// ResourceDependency::Simple("../local/helper.md".to_string()),
1413 /// true
1414 /// );
1415 /// assert!(manifest.validate().is_ok());
1416 ///
1417 /// // This will fail validation (missing source)
1418 /// manifest.add_dependency(
1419 /// "remote".to_string(),
1420 /// ResourceDependency::Detailed(Box::new(DetailedDependency {
1421 /// source: Some("missing".to_string()),
1422 /// path: "agent.md".to_string(),
1423 /// version: Some("v1.0.0".to_string()),
1424 /// branch: None,
1425 /// rev: None,
1426 /// command: None,
1427 /// args: None,
1428 /// target: None,
1429 /// filename: None,
1430 /// dependencies: None,
1431 /// tool: Some("claude-code".to_string()),
1432 /// flatten: None,
1433 /// install: None,
1434 /// template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1435 /// })),
1436 /// true
1437 /// );
1438 /// assert!(manifest.validate().is_err());
1439 /// ```
1440 ///
1441 /// # Security Considerations
1442 ///
1443 /// This method enforces critical security rules:
1444 /// - Prevents credential leakage in version-controlled files
1445 /// - Blocks path traversal attacks in local dependencies
1446 /// - Validates URL schemes to prevent protocol confusion
1447 /// - Checks for malicious patterns in dependency specifications
1448 ///
1449 /// # Performance
1450 ///
1451 /// Validation is designed to be fast and is safe to call frequently.
1452 /// Complex validations (like network connectivity) are not performed
1453 /// here - those are handled during dependency resolution.
1454 pub fn validate(&self) -> Result<()> {
1455 // Validate artifact type names
1456 for artifact_type in self.get_tools_config().types.keys() {
1457 if artifact_type.contains('/') || artifact_type.contains('\\') {
1458 return Err(crate::core::AgpmError::ManifestValidationError {
1459 reason: format!(
1460 "Artifact type name '{artifact_type}' cannot contain path separators ('/' or '\\\\'). \n\
1461 Artifact type names must be simple identifiers without special characters."
1462 ),
1463 }
1464 .into());
1465 }
1466
1467 // Also check for other potentially problematic characters
1468 if artifact_type.contains("..") {
1469 return Err(crate::core::AgpmError::ManifestValidationError {
1470 reason: format!(
1471 "Artifact type name '{artifact_type}' cannot contain '..' (path traversal). \n\
1472 Artifact type names must be simple identifiers."
1473 ),
1474 }
1475 .into());
1476 }
1477 }
1478
1479 // Check that all referenced sources exist and dependencies have required fields
1480 for (name, dep) in self.all_dependencies() {
1481 // Check for empty path
1482 if dep.get_path().is_empty() {
1483 return Err(crate::core::AgpmError::ManifestValidationError {
1484 reason: format!("Missing required field 'path' for dependency '{name}'"),
1485 }
1486 .into());
1487 }
1488
1489 // Validate pattern safety if it's a pattern dependency
1490 if dep.is_pattern() {
1491 crate::pattern::validate_pattern_safety(dep.get_path()).map_err(|e| {
1492 crate::core::AgpmError::ManifestValidationError {
1493 reason: format!("Invalid pattern in dependency '{name}': {e}"),
1494 }
1495 })?;
1496 }
1497
1498 // Check for version when source is specified (non-local dependencies)
1499 if let Some(source) = dep.get_source() {
1500 if !self.sources.contains_key(source) {
1501 return Err(crate::core::AgpmError::SourceNotFound {
1502 name: source.to_string(),
1503 }
1504 .into());
1505 }
1506
1507 // Check if the source URL is a local path
1508 let source_url = self.sources.get(source).unwrap();
1509 let _is_local_source = source_url.starts_with('/')
1510 || source_url.starts_with("./")
1511 || source_url.starts_with("../");
1512
1513 // Git dependencies can optionally have a version (defaults to 'main' if not specified)
1514 // Local path sources don't need versions
1515 // We no longer require versions for Git dependencies - they'll default to 'main'
1516 } else {
1517 // For local path dependencies (no source), version is not allowed
1518 // Skip directory check for pattern dependencies
1519 if !dep.is_pattern() {
1520 let path = dep.get_path();
1521 let is_plain_dir =
1522 path.starts_with('/') || path.starts_with("./") || path.starts_with("../");
1523
1524 if is_plain_dir && dep.get_version().is_some() {
1525 return Err(crate::core::AgpmError::ManifestValidationError {
1526 reason: format!(
1527 "Version specified for plain directory dependency '{name}' with path '{path}'. \n\
1528 Plain directory dependencies do not support versions. \n\
1529 Remove the 'version' field or use a git source instead."
1530 ),
1531 }
1532 .into());
1533 }
1534 }
1535 }
1536 }
1537
1538 // Check for version conflicts (same dependency name with different versions)
1539 let mut seen_deps: std::collections::HashMap<String, String> =
1540 std::collections::HashMap::new();
1541 for (name, dep) in self.all_dependencies() {
1542 if let Some(version) = dep.get_version() {
1543 if let Some(existing_version) = seen_deps.get(name) {
1544 if existing_version != version {
1545 return Err(crate::core::AgpmError::ManifestValidationError {
1546 reason: format!(
1547 "Version conflict for dependency '{name}': found versions '{existing_version}' and '{version}'"
1548 ),
1549 }
1550 .into());
1551 }
1552 } else {
1553 seen_deps.insert(name.to_string(), version.to_string());
1554 }
1555 }
1556 }
1557
1558 // Validate URLs in sources
1559 for (name, url) in &self.sources {
1560 // Expand environment variables and home directory in URL
1561 let expanded_url = expand_url(url)?;
1562
1563 if !expanded_url.starts_with("http://")
1564 && !expanded_url.starts_with("https://")
1565 && !expanded_url.starts_with("git@")
1566 && !expanded_url.starts_with("file://")
1567 // Plain directory paths not allowed as sources
1568 && !expanded_url.starts_with('/')
1569 && !expanded_url.starts_with("./")
1570 && !expanded_url.starts_with("../")
1571 {
1572 return Err(crate::core::AgpmError::ManifestValidationError {
1573 reason: format!("Source '{name}' has invalid URL: '{url}'. Must be HTTP(S), SSH (git@...), or file:// URL"),
1574 }
1575 .into());
1576 }
1577
1578 // Check if plain directory path is used as a source
1579 if expanded_url.starts_with('/')
1580 || expanded_url.starts_with("./")
1581 || expanded_url.starts_with("../")
1582 {
1583 return Err(crate::core::AgpmError::ManifestValidationError {
1584 reason: format!(
1585 "Plain directory path '{url}' cannot be used as source '{name}'. \n\
1586 Sources must be git repositories. Use one of:\n\
1587 - Remote URL: https://github.com/owner/repo.git\n\
1588 - Local git repo: file:///absolute/path/to/repo\n\
1589 - Or use direct path dependencies without a source"
1590 ),
1591 }
1592 .into());
1593 }
1594 }
1595
1596 // Check for case-insensitive conflicts on all platforms
1597 // This ensures manifests are portable across different filesystems
1598 // Even though Linux supports case-sensitive files, we reject conflicts
1599 // to ensure the manifest works on Windows and macOS too
1600 let mut normalized_names: std::collections::HashSet<String> =
1601 std::collections::HashSet::new();
1602
1603 for (name, _) in self.all_dependencies() {
1604 let normalized = name.to_lowercase();
1605 if !normalized_names.insert(normalized.clone()) {
1606 // Find the original conflicting name
1607 for (other_name, _) in self.all_dependencies() {
1608 if other_name != name && other_name.to_lowercase() == normalized {
1609 return Err(crate::core::AgpmError::ManifestValidationError {
1610 reason: format!(
1611 "Case conflict: '{name}' and '{other_name}' would map to the same file on case-insensitive filesystems. To ensure portability across platforms, resource names must be case-insensitively unique."
1612 ),
1613 }
1614 .into());
1615 }
1616 }
1617 }
1618 }
1619
1620 // Validate artifact types and resource type support
1621 for resource_type in crate::core::ResourceType::all() {
1622 if let Some(deps) = self.get_dependencies(*resource_type) {
1623 for (name, dep) in deps {
1624 // Get tool from dependency (defaults based on resource type)
1625 let tool_string = dep
1626 .get_tool()
1627 .map(|s| s.to_string())
1628 .unwrap_or_else(|| self.get_default_tool(*resource_type));
1629 let tool = tool_string.as_str();
1630
1631 // Check if tool is configured
1632 if self.get_tool_config(tool).is_none() {
1633 return Err(crate::core::AgpmError::ManifestValidationError {
1634 reason: format!(
1635 "Unknown tool '{tool}' for dependency '{name}'.\n\
1636 Available types: {}\n\
1637 Configure custom types in [tools] section or use a standard type.",
1638 self.get_tools_config()
1639 .types
1640 .keys()
1641 .map(|s| format!("'{s}'"))
1642 .collect::<Vec<_>>()
1643 .join(", ")
1644 ),
1645 }
1646 .into());
1647 }
1648
1649 // Check if resource type is supported by this tool
1650 if !self.is_resource_supported(tool, *resource_type) {
1651 let artifact_config = self.get_tool_config(tool).unwrap();
1652 let resource_plural = resource_type.to_plural();
1653
1654 // Check if this is a malformed configuration (resource exists but not properly configured)
1655 let is_malformed = artifact_config.resources.contains_key(resource_plural);
1656
1657 let supported_types: Vec<String> = artifact_config
1658 .resources
1659 .iter()
1660 .filter(|(_, res_config)| {
1661 res_config.path.is_some() || res_config.merge_target.is_some()
1662 })
1663 .map(|(s, _)| s.to_string())
1664 .collect();
1665
1666 // Build resource-type-specific suggestions
1667 let mut suggestions = Vec::new();
1668
1669 if is_malformed {
1670 // Resource type exists but is malformed
1671 suggestions.push(format!(
1672 "Resource type '{}' is configured for tool '{}' but missing required 'path' or 'merge_target' field",
1673 resource_plural, tool
1674 ));
1675
1676 // Provide specific fix suggestions based on resource type
1677 match resource_type {
1678 crate::core::ResourceType::Hook => {
1679 suggestions.push("For hooks, add: merge_target = '.claude/settings.local.json'".to_string());
1680 }
1681 crate::core::ResourceType::McpServer => {
1682 suggestions.push(
1683 "For MCP servers, add: merge_target = '.mcp.json'"
1684 .to_string(),
1685 );
1686 }
1687 _ => {
1688 suggestions.push(format!(
1689 "For {}, add: path = '{}'",
1690 resource_plural, resource_plural
1691 ));
1692 }
1693 }
1694 } else {
1695 // Resource type not supported at all
1696 match resource_type {
1697 crate::core::ResourceType::Snippet => {
1698 suggestions.push("Snippets work best with the 'agpm' tool (shared infrastructure)".to_string());
1699 suggestions.push(
1700 "Add tool='agpm' to this dependency to use shared snippets"
1701 .to_string(),
1702 );
1703 }
1704 _ => {
1705 // Find which tool types DO support this resource type
1706 let default_config = ToolsConfig::default();
1707 let tools_config =
1708 self.tools.as_ref().unwrap_or(&default_config);
1709 let supporting_types: Vec<String> = tools_config
1710 .types
1711 .iter()
1712 .filter(|(_, config)| {
1713 config.resources.contains_key(resource_plural)
1714 && config
1715 .resources
1716 .get(resource_plural)
1717 .map(|res| {
1718 res.path.is_some()
1719 || res.merge_target.is_some()
1720 })
1721 .unwrap_or(false)
1722 })
1723 .map(|(type_name, _)| format!("'{}'", type_name))
1724 .collect();
1725
1726 if !supporting_types.is_empty() {
1727 suggestions.push(format!(
1728 "This resource type is supported by tools: {}",
1729 supporting_types.join(", ")
1730 ));
1731 }
1732 }
1733 }
1734 }
1735
1736 let mut reason = if is_malformed {
1737 format!(
1738 "Resource type '{}' is improperly configured for tool '{}' for dependency '{}'.\n\n",
1739 resource_plural, tool, name
1740 )
1741 } else {
1742 format!(
1743 "Resource type '{}' is not supported by tool '{}' for dependency '{}'.\n\n",
1744 resource_plural, tool, name
1745 )
1746 };
1747
1748 reason.push_str(&format!(
1749 "Tool '{}' properly supports: {}\n\n",
1750 tool,
1751 supported_types.join(", ")
1752 ));
1753
1754 if !suggestions.is_empty() {
1755 reason.push_str("💡 Suggestions:\n");
1756 for suggestion in &suggestions {
1757 reason.push_str(&format!(" • {}\n", suggestion));
1758 }
1759 reason.push('\n');
1760 }
1761
1762 reason.push_str(
1763 "You can fix this by:\n\
1764 1. Changing the 'tool' field to a supported tool\n\
1765 2. Using a different resource type\n\
1766 3. Removing this dependency from your manifest",
1767 );
1768
1769 return Err(crate::core::AgpmError::ManifestValidationError {
1770 reason,
1771 }
1772 .into());
1773 }
1774 }
1775 }
1776 }
1777
1778 // Validate patches reference valid aliases
1779 self.validate_patches()?;
1780
1781 Ok(())
1782 }
1783
1784 /// Validate that patches reference valid manifest aliases.
1785 ///
1786 /// This method checks that all patch aliases correspond to actual dependencies
1787 /// defined in the manifest. Patches for non-existent aliases are rejected.
1788 ///
1789 /// # Errors
1790 ///
1791 /// Returns an error if a patch references an alias that doesn't exist in the manifest.
1792 fn validate_patches(&self) -> Result<()> {
1793 use crate::core::ResourceType;
1794
1795 // Helper to check if an alias exists for a resource type
1796 let check_patch_aliases = |resource_type: ResourceType,
1797 patches: &BTreeMap<String, PatchData>|
1798 -> Result<()> {
1799 let deps = self.get_dependencies(resource_type);
1800
1801 for alias in patches.keys() {
1802 // Check if this alias exists in the manifest
1803 let exists = if let Some(deps) = deps {
1804 deps.contains_key(alias)
1805 } else {
1806 false
1807 };
1808
1809 if !exists {
1810 return Err(crate::core::AgpmError::ManifestValidationError {
1811 reason: format!(
1812 "Patch references unknown alias '{alias}' in [patch.{}] section.\n\
1813 The alias must be defined in [{}] section of agpm.toml.\n\
1814 To patch a transitive dependency, first add it explicitly to your manifest.",
1815 resource_type.to_plural(),
1816 resource_type.to_plural()
1817 ),
1818 }
1819 .into());
1820 }
1821 }
1822 Ok(())
1823 };
1824
1825 // Validate patches for each resource type
1826 check_patch_aliases(ResourceType::Agent, &self.patches.agents)?;
1827 check_patch_aliases(ResourceType::Snippet, &self.patches.snippets)?;
1828 check_patch_aliases(ResourceType::Command, &self.patches.commands)?;
1829 check_patch_aliases(ResourceType::Script, &self.patches.scripts)?;
1830 check_patch_aliases(ResourceType::McpServer, &self.patches.mcp_servers)?;
1831 check_patch_aliases(ResourceType::Hook, &self.patches.hooks)?;
1832
1833 Ok(())
1834 }
1835
1836 /// Get all dependencies from both agents and snippets sections.
1837 ///
1838 /// Returns a vector of tuples containing dependency names and their
1839 /// specifications. This is useful for iteration over all dependencies
1840 /// without needing to handle agents and snippets separately.
1841 ///
1842 /// # Return Value
1843 ///
1844 /// Each tuple contains:
1845 /// - `&str`: The dependency name (key from TOML)
1846 /// - `&ResourceDependency`: The dependency specification
1847 ///
1848 /// # Examples
1849 ///
1850 /// ```rust,no_run
1851 /// use agpm_cli::manifest::Manifest;
1852 ///
1853 /// let manifest = Manifest::new();
1854 /// // ... add some dependencies
1855 ///
1856 /// for (name, dep) in manifest.all_dependencies() {
1857 /// println!("Dependency: {} -> {}", name, dep.get_path());
1858 /// if let Some(source) = dep.get_source() {
1859 /// println!(" Source: {}", source);
1860 /// }
1861 /// }
1862 /// ```
1863 ///
1864 /// # Order
1865 ///
1866 /// Dependencies are returned in the order they appear in the underlying
1867 /// `HashMaps` (agents first, then snippets, then commands), which means the order is not
1868 /// guaranteed to be stable across runs.
1869 /// Get dependencies for a specific resource type
1870 ///
1871 /// Returns the `HashMap` of dependencies for the specified resource type.
1872 /// Note: MCP servers return None as they use a different dependency type.
1873 pub const fn get_dependencies(
1874 &self,
1875 resource_type: crate::core::ResourceType,
1876 ) -> Option<&HashMap<String, ResourceDependency>> {
1877 use crate::core::ResourceType;
1878 match resource_type {
1879 ResourceType::Agent => Some(&self.agents),
1880 ResourceType::Snippet => Some(&self.snippets),
1881 ResourceType::Command => Some(&self.commands),
1882 ResourceType::Script => Some(&self.scripts),
1883 ResourceType::Hook => Some(&self.hooks),
1884 ResourceType::McpServer => Some(&self.mcp_servers),
1885 }
1886 }
1887
1888 /// Get mutable dependencies for a specific resource type
1889 ///
1890 /// Returns a mutable reference to the `HashMap` of dependencies for the specified resource type.
1891 #[must_use]
1892 pub fn get_dependencies_mut(
1893 &mut self,
1894 resource_type: crate::core::ResourceType,
1895 ) -> Option<&mut HashMap<String, ResourceDependency>> {
1896 use crate::core::ResourceType;
1897 match resource_type {
1898 ResourceType::Agent => Some(&mut self.agents),
1899 ResourceType::Snippet => Some(&mut self.snippets),
1900 ResourceType::Command => Some(&mut self.commands),
1901 ResourceType::Script => Some(&mut self.scripts),
1902 ResourceType::Hook => Some(&mut self.hooks),
1903 ResourceType::McpServer => Some(&mut self.mcp_servers),
1904 }
1905 }
1906
1907 /// Get the tools configuration, returning default if not specified.
1908 ///
1909 /// This method provides access to the tool configurations which define
1910 /// where resources are installed for different tools (claude-code, opencode, agpm).
1911 ///
1912 /// Returns the configured tools or the default configuration if not specified.
1913 pub fn get_tools_config(&self) -> &ToolsConfig {
1914 self.tools.as_ref().unwrap_or_else(|| {
1915 // Return a static default - this is safe because ToolsConfig::default() is deterministic
1916 static DEFAULT: std::sync::OnceLock<ToolsConfig> = std::sync::OnceLock::new();
1917 DEFAULT.get_or_init(ToolsConfig::default)
1918 })
1919 }
1920
1921 /// Get configuration for a specific tool type.
1922 ///
1923 /// Returns None if the tool is not configured.
1924 pub fn get_tool_config(&self, tool: &str) -> Option<&ArtifactTypeConfig> {
1925 self.get_tools_config().types.get(tool)
1926 }
1927
1928 /// Get the installation path for a resource within a tool.
1929 ///
1930 /// Returns the full installation directory path by combining:
1931 /// - Tool's base directory (e.g., ".claude", ".opencode")
1932 /// - Resource type's subdirectory (e.g., "agents", "command")
1933 ///
1934 /// Returns None if:
1935 /// - The tool is not configured
1936 /// - The resource type is not supported by this tool
1937 /// - The resource has no configured path (special handling like MCP merge)
1938 pub fn get_artifact_resource_path(
1939 &self,
1940 tool: &str,
1941 resource_type: crate::core::ResourceType,
1942 ) -> Option<std::path::PathBuf> {
1943 let artifact_config = self.get_tool_config(tool)?;
1944 let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
1945
1946 resource_config.path.as_ref().map(|subdir| artifact_config.path.join(subdir))
1947 }
1948
1949 /// Get the merge target configuration file path for a resource type.
1950 ///
1951 /// Returns the path to the configuration file where resources of this type
1952 /// should be merged (e.g., hooks, MCP servers). Returns None if the resource
1953 /// type doesn't use merge targets or if the tool doesn't support this resource type.
1954 ///
1955 /// # Arguments
1956 ///
1957 /// * `tool` - The tool name (e.g., "claude-code", "opencode")
1958 /// * `resource_type` - The resource type to look up
1959 ///
1960 /// # Returns
1961 ///
1962 /// The merge target path if configured, otherwise None.
1963 ///
1964 /// # Examples
1965 ///
1966 /// ```rust,no_run
1967 /// use agpm_cli::manifest::Manifest;
1968 /// use agpm_cli::core::ResourceType;
1969 ///
1970 /// let manifest = Manifest::new();
1971 ///
1972 /// // Hooks merge into .claude/settings.local.json
1973 /// let hook_target = manifest.get_merge_target("claude-code", ResourceType::Hook);
1974 /// assert_eq!(hook_target, Some(".claude/settings.local.json".into()));
1975 ///
1976 /// // MCP servers merge into .mcp.json for claude-code
1977 /// let mcp_target = manifest.get_merge_target("claude-code", ResourceType::McpServer);
1978 /// assert_eq!(mcp_target, Some(".mcp.json".into()));
1979 ///
1980 /// // MCP servers merge into .opencode/opencode.json for opencode
1981 /// let opencode_mcp = manifest.get_merge_target("opencode", ResourceType::McpServer);
1982 /// assert_eq!(opencode_mcp, Some(".opencode/opencode.json".into()));
1983 /// ```
1984 pub fn get_merge_target(
1985 &self,
1986 tool: &str,
1987 resource_type: crate::core::ResourceType,
1988 ) -> Option<PathBuf> {
1989 let artifact_config = self.get_tool_config(tool)?;
1990 let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
1991
1992 resource_config.merge_target.as_ref().map(PathBuf::from)
1993 }
1994
1995 /// Check if a resource type is supported by a tool.
1996 ///
1997 /// A resource type is considered supported if it has either:
1998 /// - A configured installation path (for file-based resources)
1999 /// - A configured merge target (for resources that merge into config files)
2000 ///
2001 /// Returns true if the tool has valid configuration for the given resource type.
2002 pub fn is_resource_supported(
2003 &self,
2004 tool: &str,
2005 resource_type: crate::core::ResourceType,
2006 ) -> bool {
2007 self.get_tool_config(tool)
2008 .and_then(|config| config.resources.get(resource_type.to_plural()))
2009 .map(|res_config| res_config.path.is_some() || res_config.merge_target.is_some())
2010 .unwrap_or(false)
2011 }
2012
2013 /// Returns all dependencies from all resource types.
2014 ///
2015 /// This method collects dependencies from agents, snippets, commands,
2016 /// scripts, hooks, and MCP servers into a single vector. It's commonly used for:
2017 /// - Manifest validation across all dependency types
2018 /// - Dependency resolution operations
2019 /// - Generating reports of all configured dependencies
2020 /// - Bulk operations on all dependencies
2021 ///
2022 /// # Returns
2023 ///
2024 /// A vector of tuples containing the dependency name and its configuration.
2025 /// Each tuple is `(name, dependency)` where:
2026 /// - `name`: The dependency name as specified in the manifest
2027 /// - `dependency`: Reference to the [`ResourceDependency`] configuration
2028 ///
2029 /// The order follows the resource type order defined in [`crate::core::ResourceType::all()`].
2030 ///
2031 /// # Examples
2032 ///
2033 /// ```rust,no_run
2034 /// # use agpm_cli::manifest::Manifest;
2035 /// # let manifest = Manifest::new();
2036 /// for (name, dep) in manifest.all_dependencies() {
2037 /// println!("Dependency: {} -> {}", name, dep.get_path());
2038 /// if let Some(source) = dep.get_source() {
2039 /// println!(" Source: {}", source);
2040 /// }
2041 /// }
2042 /// ```
2043 #[must_use]
2044 pub fn all_dependencies(&self) -> Vec<(&str, &ResourceDependency)> {
2045 let mut deps = Vec::new();
2046
2047 // Use ResourceType::all() to iterate through all resource types
2048 for resource_type in crate::core::ResourceType::all() {
2049 if let Some(type_deps) = self.get_dependencies(*resource_type) {
2050 // CRITICAL: Sort for deterministic iteration order
2051 let mut sorted_deps: Vec<_> = type_deps.iter().collect();
2052 sorted_deps.sort_by_key(|(name, _)| name.as_str());
2053
2054 for (name, dep) in sorted_deps {
2055 deps.push((name.as_str(), dep));
2056 }
2057 }
2058 }
2059
2060 deps
2061 }
2062
2063 /// Get all dependencies including MCP servers.
2064 ///
2065 /// All resource types now use standard `ResourceDependency`, so no conversion needed.
2066 #[must_use]
2067 pub fn all_dependencies_with_mcp(
2068 &self,
2069 ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>)> {
2070 let mut deps = Vec::new();
2071
2072 // Use ResourceType::all() to iterate through all resource types
2073 for resource_type in crate::core::ResourceType::all() {
2074 if let Some(type_deps) = self.get_dependencies(*resource_type) {
2075 // CRITICAL: Sort for deterministic iteration order
2076 let mut sorted_deps: Vec<_> = type_deps.iter().collect();
2077 sorted_deps.sort_by_key(|(name, _)| name.as_str());
2078
2079 for (name, dep) in sorted_deps {
2080 deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep)));
2081 }
2082 }
2083 }
2084
2085 deps
2086 }
2087
2088 /// Get all dependencies with their resource types.
2089 ///
2090 /// Returns a vector of tuples containing the dependency name, dependency details,
2091 /// and the resource type. This preserves type information that is lost in
2092 /// `all_dependencies_with_mcp()`.
2093 ///
2094 /// This is used by the resolver to correctly type transitive dependencies without
2095 /// falling back to manifest section order lookups.
2096 ///
2097 /// Dependencies for disabled tools are automatically filtered out.
2098 pub fn all_dependencies_with_types(
2099 &self,
2100 ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>, crate::core::ResourceType)> {
2101 let mut deps = Vec::new();
2102
2103 // Use ResourceType::all() to iterate through all resource types
2104 for resource_type in crate::core::ResourceType::all() {
2105 if let Some(type_deps) = self.get_dependencies(*resource_type) {
2106 // CRITICAL: Sort dependencies for deterministic iteration order!
2107 // HashMap iteration is non-deterministic, so we must sort by name
2108 // to ensure consistent lockfile generation across runs.
2109 let mut sorted_deps: Vec<_> = type_deps.iter().collect();
2110 sorted_deps.sort_by_key(|(name, _)| name.as_str());
2111
2112 for (name, dep) in sorted_deps {
2113 // Determine the tool for this dependency
2114 let tool_string = dep
2115 .get_tool()
2116 .map(|s| s.to_string())
2117 .unwrap_or_else(|| self.get_default_tool(*resource_type));
2118 let tool = tool_string.as_str();
2119
2120 // Check if the tool is enabled
2121 if let Some(tool_config) = self.get_tools_config().types.get(tool) {
2122 if !tool_config.enabled {
2123 // Skip dependencies for disabled tools
2124 tracing::debug!(
2125 "Skipping dependency '{}' for disabled tool '{}'",
2126 name,
2127 tool
2128 );
2129 continue;
2130 }
2131 }
2132
2133 // Ensure the tool is set on the dependency (apply default if not explicitly set)
2134 let dep_with_tool = if dep.get_tool().is_none() {
2135 tracing::debug!(
2136 "Setting default tool '{}' for dependency '{}' (type: {:?})",
2137 tool,
2138 name,
2139 resource_type
2140 );
2141 // Need to set the tool - create a modified copy
2142 let mut dep_owned = dep.clone();
2143 dep_owned.set_tool(Some(tool_string.clone()));
2144 std::borrow::Cow::Owned(dep_owned)
2145 } else {
2146 std::borrow::Cow::Borrowed(dep)
2147 };
2148
2149 deps.push((name.as_str(), dep_with_tool, *resource_type));
2150 }
2151 }
2152 }
2153
2154 deps
2155 }
2156
2157 /// Check if a dependency with the given name exists in any section.
2158 ///
2159 /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
2160 /// with the specified name. This is useful for avoiding duplicate names
2161 /// across different resource types.
2162 ///
2163 /// # Examples
2164 ///
2165 /// ```rust,no_run
2166 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2167 ///
2168 /// let mut manifest = Manifest::new();
2169 /// manifest.add_dependency(
2170 /// "helper".to_string(),
2171 /// ResourceDependency::Simple("../helper.md".to_string()),
2172 /// true // is_agent
2173 /// );
2174 ///
2175 /// assert!(manifest.has_dependency("helper"));
2176 /// assert!(!manifest.has_dependency("nonexistent"));
2177 /// ```
2178 ///
2179 /// # Performance
2180 ///
2181 /// This method performs two `HashMap` lookups, so it's O(1) on average.
2182 #[must_use]
2183 pub fn has_dependency(&self, name: &str) -> bool {
2184 self.agents.contains_key(name)
2185 || self.snippets.contains_key(name)
2186 || self.commands.contains_key(name)
2187 }
2188
2189 /// Get a dependency by name from any section.
2190 ///
2191 /// Searches both the `[agents]` and `[snippets]` sections for a dependency
2192 /// with the specified name, returning the first match found. Agents are
2193 /// searched before snippets.
2194 ///
2195 /// # Examples
2196 ///
2197 /// ```rust,no_run
2198 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2199 ///
2200 /// let mut manifest = Manifest::new();
2201 /// manifest.add_dependency(
2202 /// "helper".to_string(),
2203 /// ResourceDependency::Simple("../helper.md".to_string()),
2204 /// true // is_agent
2205 /// );
2206 ///
2207 /// if let Some(dep) = manifest.get_dependency("helper") {
2208 /// println!("Found dependency: {}", dep.get_path());
2209 /// }
2210 /// ```
2211 ///
2212 /// # Search Order
2213 ///
2214 /// Dependencies are searched in this order:
2215 /// 1. `[agents]` section
2216 /// 2. `[snippets]` section
2217 /// 3. `[commands]` section
2218 ///
2219 /// If the same name exists in multiple sections, the first match is returned.
2220 #[must_use]
2221 pub fn get_dependency(&self, name: &str) -> Option<&ResourceDependency> {
2222 self.agents
2223 .get(name)
2224 .or_else(|| self.snippets.get(name))
2225 .or_else(|| self.commands.get(name))
2226 }
2227
2228 /// Find a dependency by name from any section (alias for `get_dependency`).
2229 ///
2230 /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
2231 /// with the specified name, returning the first match found.
2232 ///
2233 /// # Examples
2234 ///
2235 /// ```rust,no_run
2236 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2237 ///
2238 /// let mut manifest = Manifest::new();
2239 /// manifest.add_dependency(
2240 /// "helper".to_string(),
2241 /// ResourceDependency::Simple("../helper.md".to_string()),
2242 /// true // is_agent
2243 /// );
2244 ///
2245 /// if let Some(dep) = manifest.find_dependency("helper") {
2246 /// println!("Found dependency: {}", dep.get_path());
2247 /// }
2248 /// ```
2249 pub fn find_dependency(&self, name: &str) -> Option<&ResourceDependency> {
2250 self.get_dependency(name)
2251 }
2252
2253 /// Add or update a source repository in the `[sources]` section.
2254 ///
2255 /// Sources map convenient names to Git repository URLs. These names can
2256 /// then be referenced in dependency specifications to avoid repeating
2257 /// long URLs throughout the manifest.
2258 ///
2259 /// # Parameters
2260 ///
2261 /// - `name`: Short, convenient name for the source (e.g., "official", "community")
2262 /// - `url`: Git repository URL (HTTPS, SSH, or file:// protocol)
2263 ///
2264 /// # URL Validation
2265 ///
2266 /// The URL is not validated when added - validation occurs during
2267 /// [`Self::validate`]. Supported URL formats:
2268 /// - `https://github.com/owner/repo.git`
2269 /// - `git@github.com:owner/repo.git`
2270 /// - `file:///absolute/path/to/repo`
2271 /// - `file:///path/to/local/repo`
2272 ///
2273 /// # Examples
2274 ///
2275 /// ```rust,no_run
2276 /// use agpm_cli::manifest::Manifest;
2277 ///
2278 /// let mut manifest = Manifest::new();
2279 ///
2280 /// // Add public repository
2281 /// manifest.add_source(
2282 /// "community".to_string(),
2283 /// "https://github.com/claude-community/resources.git".to_string()
2284 /// );
2285 ///
2286 /// // Add private repository (SSH)
2287 /// manifest.add_source(
2288 /// "private".to_string(),
2289 /// "git@github.com:company/private-resources.git".to_string()
2290 /// );
2291 ///
2292 /// // Add local repository
2293 /// manifest.add_source(
2294 /// "local".to_string(),
2295 /// "file:///home/user/my-resources".to_string()
2296 /// );
2297 /// ```
2298 ///
2299 /// # Security Note
2300 ///
2301 /// Never include authentication tokens in the URL. Use SSH keys or
2302 /// configure authentication globally in `~/.agpm/config.toml`.
2303 pub fn add_source(&mut self, name: String, url: String) {
2304 self.sources.insert(name, url);
2305 }
2306
2307 /// Add or update a dependency in the appropriate section.
2308 ///
2309 /// Adds the dependency to either the `[agents]`, `[snippets]`, or `[commands]` section
2310 /// based on the `is_agent` parameter. If a dependency with the same name
2311 /// already exists in the target section, it will be replaced.
2312 ///
2313 /// **Note**: This method is deprecated in favor of [`Self::add_typed_dependency`]
2314 /// which provides explicit control over resource types.
2315 ///
2316 /// # Parameters
2317 ///
2318 /// - `name`: Unique name for the dependency within its section
2319 /// - `dep`: The dependency specification (Simple or Detailed)
2320 /// - `is_agent`: If true, adds to `[agents]`; if false, adds to `[snippets]`
2321 /// (Note: Use [`Self::add_typed_dependency`] for commands and other resource types)
2322 ///
2323 /// # Validation
2324 ///
2325 /// The dependency is not validated when added - validation occurs during
2326 /// [`Self::validate`]. This allows for building manifests incrementally
2327 /// before all sources are defined.
2328 ///
2329 /// # Examples
2330 ///
2331 /// ```rust,no_run
2332 /// use agpm_cli::manifest::{Manifest, ResourceDependency, DetailedDependency};
2333 ///
2334 /// let mut manifest = Manifest::new();
2335 ///
2336 /// // Add local agent dependency
2337 /// manifest.add_dependency(
2338 /// "helper".to_string(),
2339 /// ResourceDependency::Simple("../local/helper.md".to_string()),
2340 /// true // is_agent = true
2341 /// );
2342 ///
2343 /// // Add remote snippet dependency
2344 /// manifest.add_dependency(
2345 /// "utils".to_string(),
2346 /// ResourceDependency::Detailed(Box::new(DetailedDependency {
2347 /// source: Some("community".to_string()),
2348 /// path: "snippets/utils.md".to_string(),
2349 /// version: Some("v1.0.0".to_string()),
2350 /// branch: None,
2351 /// rev: None,
2352 /// command: None,
2353 /// args: None,
2354 /// target: None,
2355 /// filename: None,
2356 /// dependencies: None,
2357 /// tool: Some("claude-code".to_string()),
2358 /// flatten: None,
2359 /// install: None,
2360 /// template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
2361 /// })),
2362 /// false // is_agent = false (snippet)
2363 /// );
2364 /// ```
2365 ///
2366 /// # Name Conflicts
2367 ///
2368 /// This method allows the same dependency name to exist in both the
2369 /// `[agents]` and `[snippets]` sections. However, some operations like
2370 /// [`Self::get_dependency`] will prefer agents over snippets when
2371 /// searching by name.
2372 pub fn add_dependency(&mut self, name: String, dep: ResourceDependency, is_agent: bool) {
2373 if is_agent {
2374 self.agents.insert(name, dep);
2375 } else {
2376 self.snippets.insert(name, dep);
2377 }
2378 }
2379
2380 /// Add or update a dependency with specific resource type.
2381 ///
2382 /// This is the preferred method for adding dependencies as it explicitly
2383 /// specifies the resource type using the `ResourceType` enum.
2384 ///
2385 /// # Examples
2386 ///
2387 /// ```rust,no_run
2388 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2389 /// use agpm_cli::core::ResourceType;
2390 ///
2391 /// let mut manifest = Manifest::new();
2392 ///
2393 /// // Add command dependency
2394 /// manifest.add_typed_dependency(
2395 /// "build".to_string(),
2396 /// ResourceDependency::Simple("../commands/build.md".to_string()),
2397 /// ResourceType::Command
2398 /// );
2399 /// ```
2400 pub fn add_typed_dependency(
2401 &mut self,
2402 name: String,
2403 dep: ResourceDependency,
2404 resource_type: crate::core::ResourceType,
2405 ) {
2406 match resource_type {
2407 crate::core::ResourceType::Agent => {
2408 self.agents.insert(name, dep);
2409 }
2410 crate::core::ResourceType::Snippet => {
2411 self.snippets.insert(name, dep);
2412 }
2413 crate::core::ResourceType::Command => {
2414 self.commands.insert(name, dep);
2415 }
2416 crate::core::ResourceType::McpServer => {
2417 // MCP servers don't use ResourceDependency, they have their own type
2418 // This method shouldn't be called for MCP servers
2419 panic!("Use add_mcp_server() for MCP server dependencies");
2420 }
2421 crate::core::ResourceType::Script => {
2422 self.scripts.insert(name, dep);
2423 }
2424 crate::core::ResourceType::Hook => {
2425 self.hooks.insert(name, dep);
2426 }
2427 }
2428 }
2429
2430 /// Get resource dependencies by type.
2431 ///
2432 /// Returns a reference to the HashMap of dependencies for the specified resource type.
2433 /// This provides a unified interface for accessing different resource collections,
2434 /// similar to `LockFile::get_resources()`.
2435 ///
2436 /// # Examples
2437 ///
2438 /// ```rust,no_run
2439 /// use agpm_cli::manifest::Manifest;
2440 /// use agpm_cli::core::ResourceType;
2441 ///
2442 /// let manifest = Manifest::new();
2443 /// let agents = manifest.get_resources(&ResourceType::Agent);
2444 /// println!("Found {} agent dependencies", agents.len());
2445 /// ```
2446 #[must_use]
2447 pub fn get_resources(
2448 &self,
2449 resource_type: &crate::core::ResourceType,
2450 ) -> &HashMap<String, ResourceDependency> {
2451 use crate::core::ResourceType;
2452 match resource_type {
2453 ResourceType::Agent => &self.agents,
2454 ResourceType::Snippet => &self.snippets,
2455 ResourceType::Command => &self.commands,
2456 ResourceType::Script => &self.scripts,
2457 ResourceType::Hook => &self.hooks,
2458 ResourceType::McpServer => &self.mcp_servers,
2459 }
2460 }
2461
2462 /// Get all resource dependencies across all types.
2463 ///
2464 /// Returns a vector of tuples containing the resource type, manifest key (name),
2465 /// and the dependency specification. This provides a unified way to iterate over
2466 /// all resources regardless of type.
2467 ///
2468 /// # Returns
2469 ///
2470 /// A vector of `(ResourceType, &str, &ResourceDependency)` tuples where:
2471 /// - The first element is the type of resource (Agent, Snippet, etc.)
2472 /// - The second element is the manifest key (the name in the TOML file)
2473 /// - The third element is the resource dependency specification
2474 ///
2475 /// # Examples
2476 ///
2477 /// ```rust,no_run
2478 /// use agpm_cli::manifest::Manifest;
2479 ///
2480 /// let manifest = Manifest::new();
2481 /// let all = manifest.all_resources();
2482 ///
2483 /// for (resource_type, name, dep) in all {
2484 /// println!("{:?}: {}", resource_type, name);
2485 /// }
2486 /// ```
2487 #[must_use]
2488 pub fn all_resources(&self) -> Vec<(crate::core::ResourceType, &str, &ResourceDependency)> {
2489 use crate::core::ResourceType;
2490
2491 let mut resources = Vec::new();
2492
2493 for resource_type in ResourceType::all() {
2494 let type_resources = self.get_resources(resource_type);
2495 for (name, dep) in type_resources {
2496 resources.push((*resource_type, name.as_str(), dep));
2497 }
2498 }
2499
2500 resources
2501 }
2502
2503 /// Add or update an MCP server configuration.
2504 ///
2505 /// MCP servers now use standard `ResourceDependency` format,
2506 /// pointing to JSON configuration files in source repositories.
2507 ///
2508 /// # Examples
2509 ///
2510 /// ```rust,no_run,ignore
2511 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2512 ///
2513 /// let mut manifest = Manifest::new();
2514 ///
2515 /// // Add MCP server from source repository
2516 /// manifest.add_mcp_server(
2517 /// "filesystem".to_string(),
2518 /// ResourceDependency::Simple("../local/mcp-servers/filesystem.json".to_string())
2519 /// );
2520 /// ```
2521 pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
2522 self.mcp_servers.insert(name, dependency);
2523 }
2524}
2525
2526impl Default for Manifest {
2527 fn default() -> Self {
2528 Self::new()
2529 }
2530}