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