agpm_cli/manifest/
mod.rs

1//! Manifest file parsing and validation for AGPM projects.
2//!
3//! This module handles `agpm.toml` manifest files for declarative dependency management
4//! using a lockfile-based system similar to Cargo.
5//!
6//! # Features
7//!
8//! - Git-based source repositories with version constraints
9//! - Local and remote dependency resolution with transitive support
10//! - Multi-tool support (claude-code, opencode, agpm, custom)
11//! - MCP server and hook configuration management
12//! - TOML patches for customization without forking
13//! - Cross-platform path handling
14//!
15//! # Basic Structure
16//!
17//! ```toml
18//! [sources]
19//! official = "https://github.com/owner/agpm-resources.git"
20//!
21//! [agents]
22//! helper = { source = "official", path = "agents/helper.md", version = "v1.0.0" }
23//!
24//! [snippets]
25//! utils = "../local/snippets/utils.md"
26//! ```
27//!
28//! # Dependency Formats
29//!
30//! - **Simple**: `helper = "../local/helper.md"` (local path only)
31//! - **Detailed**: `{ source = "name", path = "path/to/file.md", version = "v1.0.0" }`
32//! - **Custom target**: Add `target = "custom/dir"` (relative to tool directory)
33//! - **Custom filename**: Add `filename = "custom-name.md"`
34//!
35//! # Version Constraints
36//!
37//! Supports semantic versions (`v1.0.0`), `latest`, branches (`main`), commits, and tags.
38//!
39//! # Transitive Dependencies
40//!
41//! Resources can declare dependencies in YAML frontmatter (Markdown) or JSON fields:
42//!
43//! ```yaml
44//! dependencies:
45//!   agents:
46//!     - path: agents/helper.md
47//!       version: v1.0.0
48//! ```
49//!
50//! # Security
51//!
52//! **Never** include credentials in `agpm.toml`. Use `~/.agpm/config.toml` for authentication
53//! or SSH keys for `git@` URLs.
54//!
55//! # Integration
56//!
57//! Works with [`crate::resolver`] for dependency resolution, [`crate::lockfile`] for
58//! reproducible installations, and [`crate::git`] for source management.
59
60pub mod dependency_spec;
61pub mod helpers;
62pub mod patches;
63pub mod resource_dependency;
64pub mod tool_config;
65
66#[cfg(test)]
67mod manifest_flatten_tests;
68#[cfg(test)]
69mod manifest_hash_tests;
70#[cfg(test)]
71mod manifest_mutable_tests;
72#[cfg(test)]
73mod manifest_template_tests;
74#[cfg(test)]
75mod manifest_tests;
76#[cfg(test)]
77mod manifest_tool_tests;
78#[cfg(test)]
79mod manifest_validation_tests;
80#[cfg(test)]
81mod resource_dependency_tests;
82#[cfg(test)]
83mod tool_config_tests;
84
85use crate::core::file_error::{FileOperation, FileResultExt};
86use anyhow::{Context, Result};
87use serde::{Deserialize, Serialize};
88use std::collections::{BTreeMap, HashMap};
89use std::path::{Path, PathBuf};
90
91pub use dependency_spec::{DependencyMetadata, DependencySpec};
92pub use helpers::{expand_url, find_manifest, find_manifest_from, find_manifest_with_optional};
93pub use patches::{ManifestPatches, PatchConflict, PatchData, PatchOrigin};
94pub use resource_dependency::{DetailedDependency, ResourceDependency};
95pub use tool_config::{ArtifactTypeConfig, ResourceConfig, ToolsConfig, WellKnownTool};
96
97/// The main manifest file structure representing a complete `agpm.toml` file.
98///
99/// This struct encapsulates all configuration for a AGPM project, including
100/// source repositories, installation targets, and resource dependencies.
101/// It provides the foundation for declarative dependency management similar
102/// to Cargo's `Cargo.toml`.
103///
104/// # Structure
105///
106/// - **Sources**: Named Git repositories that can be referenced by dependencies
107/// - **Target**: Installation directories for different resource types
108/// - **Agents**: AI agent dependencies (`.md` files with agent definitions)
109/// - **Snippets**: Code snippet dependencies (`.md` files with reusable code)
110/// - **Commands**: Claude Code command dependencies (`.md` files with slash commands)
111///
112/// # Serialization
113///
114/// The struct uses Serde for TOML serialization/deserialization with these behaviors:
115/// - Empty collections are omitted from serialized output for cleaner files
116/// - Default values are automatically applied for missing fields
117/// - Field names match TOML section names exactly
118///
119/// # Thread Safety
120///
121/// This struct is thread-safe and can be shared across async tasks safely.
122///
123/// # Use Case: AI Agent Context
124///
125/// When AI agents work on your codebase, they need context about:
126/// - Where to find coding standards and style guides
127/// - What conventions to follow (formatting, naming, patterns)
128/// - Where architecture and design docs are located
129/// - Project-specific requirements (testing, security, performance)
130///
131/// # Template Access
132///
133/// All variables are accessible in templates under the `agpm.project` namespace.
134/// The structure is completely user-defined.
135///
136/// # Top-level variables
137/// style_guide = "docs/STYLE_GUIDE.md"
138/// max_line_length = 100
139/// test_framework = "pytest"
140///
141/// # Nested sections (optional, just for organization)
142/// [project.paths]
143/// architecture = "docs/ARCHITECTURE.md"
144/// conventions = "docs/CONVENTIONS.md"
145///
146/// [project.standards]
147/// indent_style = "spaces"
148/// indent_size = 4
149/// # Code Reviewer
150/// Follow guidelines at: {{ agpm.project.style_guide }}
151/// Max line length: {{ agpm.project.max_line_length }}
152/// Architecture: {{ agpm.project.paths.architecture }}
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154pub struct ProjectConfig(toml::map::Map<String, toml::Value>);
155
156impl ProjectConfig {
157    /// Convert this ProjectConfig to a serde_json::Value for template rendering.
158    ///
159    /// This method handles conversion of TOML values to JSON values, which is necessary
160    /// for proper Tera template rendering.
161    ///
162    ///
163    /// ```rust,no_run
164    /// use agpm_cli::manifest::ProjectConfig;
165    ///
166    /// let mut config_map = toml::map::Map::new();
167    /// config_map.insert("style_guide".to_string(), toml::Value::String("docs/STYLE.md".into()));
168    pub fn to_json_value(&self) -> serde_json::Value {
169        toml_value_to_json(&toml::Value::Table(self.0.clone()))
170    }
171}
172
173impl From<toml::map::Map<String, toml::Value>> for ProjectConfig {
174    fn from(map: toml::map::Map<String, toml::Value>) -> Self {
175        Self(map)
176    }
177}
178
179/// Convert a toml::Value to serde_json::Value.
180pub(crate) fn toml_value_to_json(value: &toml::Value) -> serde_json::Value {
181    match value {
182        toml::Value::String(s) => serde_json::Value::String(s.clone()),
183        toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
184        toml::Value::Float(f) => serde_json::Number::from_f64(*f)
185            .map(serde_json::Value::Number)
186            .unwrap_or(serde_json::Value::Null),
187        toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
188        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
189        toml::Value::Array(arr) => {
190            serde_json::Value::Array(arr.iter().map(toml_value_to_json).collect())
191        }
192        toml::Value::Table(table) => {
193            // Sort keys to ensure deterministic JSON serialization
194            let mut keys: Vec<_> = table.keys().collect();
195            keys.sort();
196            let map: serde_json::Map<String, serde_json::Value> =
197                keys.into_iter().map(|k| (k.clone(), toml_value_to_json(&table[k]))).collect();
198            serde_json::Value::Object(map)
199        }
200    }
201}
202
203/// Convert JSON value to TOML value for template variable merging.
204///
205/// Handles JSON null as empty string since TOML lacks a null type.
206/// Used when merging template_vars (JSON) with project config (TOML).
207#[cfg(test)]
208pub(crate) fn json_value_to_toml(value: &serde_json::Value) -> toml::Value {
209    match value {
210        serde_json::Value::String(s) => toml::Value::String(s.clone()),
211        serde_json::Value::Number(n) => {
212            if let Some(i) = n.as_i64() {
213                toml::Value::Integer(i)
214            } else if let Some(f) = n.as_f64() {
215                toml::Value::Float(f)
216            } else {
217                // Fallback for numbers that don't fit i64 or f64
218                toml::Value::String(n.to_string())
219            }
220        }
221        serde_json::Value::Bool(b) => toml::Value::Boolean(*b),
222        serde_json::Value::Null => {
223            // TOML doesn't have a null type - represent as empty string
224            toml::Value::String(String::new())
225        }
226        serde_json::Value::Array(arr) => {
227            toml::Value::Array(arr.iter().map(json_value_to_toml).collect())
228        }
229        serde_json::Value::Object(obj) => {
230            let table: toml::map::Map<String, toml::Value> =
231                obj.iter().map(|(k, v)| (k.clone(), json_value_to_toml(v))).collect();
232            toml::Value::Table(table)
233        }
234    }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Manifest {
239    /// Named source repositories mapped to their Git URLs.
240    ///
241    /// Keys are short, convenient names used in dependency specifications.
242    /// Values are Git repository URLs (HTTPS, SSH, or local file:// URLs).
243    ///
244    /// **Security Note**: Never include authentication tokens in these URLs.
245    /// Use SSH keys or configure authentication in the global config file.
246    ///
247    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
248    pub sources: HashMap<String, String>,
249
250    /// Tool type configurations for multi-tool support.
251    ///
252    /// Maps tool type names (claude-code, opencode, agpm, custom) to their
253    /// installation configurations. This replaces the old `target` field and
254    /// enables support for multiple tools and custom tool types.
255    ///
256    /// See [`ToolsConfig`] for details on configuration format.
257    #[serde(rename = "tools", skip_serializing_if = "Option::is_none")]
258    pub tools: Option<ToolsConfig>,
259
260    /// Agent dependencies mapping names to their specifications.
261    ///
262    /// Agents are typically AI model definitions, prompts, or behavioral
263    /// specifications stored as Markdown files. Each dependency can be
264    /// either local (filesystem path) or remote (from a Git source).
265    ///
266    /// See [`ResourceDependency`] for specification format details.
267    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
268    pub agents: HashMap<String, ResourceDependency>,
269
270    /// Snippet dependencies mapping names to their specifications.
271    ///
272    /// Snippets are typically reusable code templates, examples, or
273    /// documentation stored as Markdown files. They follow the same
274    /// dependency format as agents.
275    ///
276    /// See [`ResourceDependency`] for specification format details.
277    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
278    pub snippets: HashMap<String, ResourceDependency>,
279
280    /// Command dependencies mapping names to their specifications.
281    ///
282    /// Commands are Claude Code slash commands that provide custom functionality
283    /// and automation within the Claude Code interface. They follow the same
284    /// dependency format as agents and snippets.
285    ///
286    /// See [`ResourceDependency`] for specification format details.
287    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
288    pub commands: HashMap<String, ResourceDependency>,
289
290    /// MCP server configurations mapping names to their specifications.
291    ///
292    /// MCP servers provide integrations with external systems and services,
293    /// allowing Claude Code to connect to databases, APIs, and other tools.
294    /// MCP servers are JSON configuration files that get installed to
295    /// `.mcp.json` (no separate directory - configurations are merged into the JSON file).
296    ///
297    /// See [`ResourceDependency`] for specification format details.
298    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "mcp-servers")]
299    pub mcp_servers: HashMap<String, ResourceDependency>,
300
301    /// Script dependencies mapping names to their specifications.
302    ///
303    /// Scripts are executable files (.sh, .js, .py, etc.) that can be run by hooks
304    /// or independently. They are installed to `.claude/scripts/` and can be
305    /// referenced by hook configurations.
306    ///
307    /// See [`ResourceDependency`] for specification format details.
308    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
309    pub scripts: HashMap<String, ResourceDependency>,
310
311    /// Hook dependencies mapping names to their specifications.
312    ///
313    /// Hooks are JSON configuration files that define event-based automation
314    /// in Claude Code. They specify when to run scripts based on tool usage,
315    /// prompts, and other events. Hook configurations are merged into
316    /// `settings.local.json`.
317    ///
318    /// See [`ResourceDependency`] for specification format details.
319    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
320    pub hooks: HashMap<String, ResourceDependency>,
321
322    /// Skill dependencies mapping names to their specifications.
323    ///
324    /// Skills are directory-based resources (unlike single-file agents/snippets)
325    /// that contain a `SKILL.md` file plus supporting files (scripts, templates,
326    /// examples). They are installed to `.claude/skills/<name>/` as complete
327    /// directory structures.
328    ///
329    /// See [`ResourceDependency`] for specification format details.
330    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
331    pub skills: HashMap<String, ResourceDependency>,
332
333    /// Patches for overriding resource metadata.
334    ///
335    /// Patches allow overriding YAML frontmatter fields (like `model`) in
336    /// resources without forking upstream repositories. They are keyed by
337    /// resource type and manifest alias.
338    ///
339    #[serde(default, skip_serializing_if = "ManifestPatches::is_empty", rename = "patch")]
340    pub patches: ManifestPatches,
341
342    /// Project-level patches (from agpm.toml).
343    ///
344    /// This field is not serialized - it's populated during loading to track
345    /// which patches came from the project manifest vs private config.
346    #[serde(skip)]
347    pub project_patches: ManifestPatches,
348
349    /// Private patches (from agpm.private.toml).
350    ///
351    /// This field is not serialized - it's populated during loading to track
352    /// which patches came from private config. These are kept separate from
353    /// project patches to maintain deterministic lockfiles.
354    #[serde(skip)]
355    pub private_patches: ManifestPatches,
356
357    /// Default tool overrides for resource types.
358    ///
359    /// Allows users to override which tool is used by default when a dependency
360    /// doesn't explicitly specify a tool. Keys are resource type names (agents,
361    /// snippets, commands, scripts, hooks, mcp-servers), values are tool names
362    /// (claude-code, opencode, agpm, or custom tool names).
363    ///
364    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "default-tools")]
365    pub default_tools: HashMap<String, String>,
366
367    /// Project-specific template variables.
368    ///
369    /// Custom project configuration that can be referenced in resource templates
370    /// via Tera template syntax. This allows teams to define project-specific
371    /// values like paths, standards, and conventions that are then available
372    /// throughout all installed resources.
373    ///
374    /// Template access: `{{ agpm.project.name }}`, `{{ agpm.project.paths.style_guide }}`
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub project: Option<ProjectConfig>,
377
378    /// Directory containing the manifest file (for resolving relative paths).
379    ///
380    /// This field is populated when loading the manifest and is used to resolve
381    /// relative paths in dependencies, particularly for path-only dependencies
382    /// and their transitive dependencies.
383    ///
384    /// This field is not serialized and only exists at runtime.
385    #[serde(skip)]
386    pub manifest_dir: Option<std::path::PathBuf>,
387
388    /// Names of dependencies that came from agpm.private.toml.
389    ///
390    /// These dependencies will be installed to `{resource_path}/private/` subdirectory
391    /// and tracked in `agpm.private.lock` instead of `agpm.lock`.
392    ///
393    /// This field is populated by `load_with_private()` when merging private dependencies.
394    /// The HashSet contains `(resource_type, name)` pairs where resource_type is one of
395    /// "agents", "snippets", "commands", "scripts", "hooks", "mcp-servers".
396    #[serde(skip)]
397    pub private_dependency_names: std::collections::HashSet<(String, String)>,
398
399    /// Whether to enable gitignore validation.
400    ///
401    /// When true (default), AGPM validates that required .gitignore entries exist
402    /// and warns if they're missing. Set to false for private/personal setups
403    /// where you don't want gitignore management.
404    ///
405    /// Example:
406    /// ```toml
407    /// gitignore = false  # Disable gitignore validation
408    /// ```
409    #[serde(default = "default_gitignore")]
410    pub gitignore: bool,
411}
412
413/// Default value for gitignore field (true = enabled).
414fn default_gitignore() -> bool {
415    true
416}
417
418/// A resource dependency specification supporting multiple formats.
419///
420/// Dependencies can be specified in two main formats to balance simplicity
421/// with flexibility. The enum uses Serde's `untagged` attribute to automatically
422/// deserialize the correct variant based on the TOML structure.
423///
424/// # Variants
425///
426/// ## Simple Dependencies
427///
428/// For local file dependencies, just specify the path directly:
429///
430/// # Remote dependency with version
431/// code-reviewer = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
432///
433/// # Remote dependency with git reference
434/// experimental = { source = "community", path = "agents/new.md", git = "develop" }
435///
436/// # Local dependency with explicit path (equivalent to simple form)
437/// local-tool = { path = "../tools/agent.md" }
438/// # Validation Rules
439///
440/// - **Local dependencies** (no source): Cannot have version constraints
441/// - **Remote dependencies** (with source): Must have either `version` or `git` field
442/// - **Path field**: Required and cannot be empty
443/// - **Source field**: Must reference an existing source in the `[sources]` section
444///
445/// # Type Safety
446///
447/// The enum ensures type safety at compile time while providing runtime
448/// validation through the [`Manifest::validate`] method.
449///
450impl Manifest {
451    /// Create a new empty manifest with default configuration.
452    ///
453    /// The new manifest will have:
454    /// - No sources defined
455    /// - Default target directories (`.claude/agents` and `.agpm/snippets`)
456    /// - No dependencies
457    ///
458    /// This is typically used when programmatically building a manifest or
459    /// as a starting point for adding dependencies.
460    ///
461    ///
462    #[must_use]
463    #[allow(deprecated)]
464    pub fn new() -> Self {
465        Self {
466            sources: HashMap::new(),
467            tools: None,
468            agents: HashMap::new(),
469            snippets: HashMap::new(),
470            commands: HashMap::new(),
471            mcp_servers: HashMap::new(),
472            scripts: HashMap::new(),
473            hooks: HashMap::new(),
474            skills: HashMap::new(),
475            patches: ManifestPatches::new(),
476            project_patches: ManifestPatches::new(),
477            private_patches: ManifestPatches::new(),
478            default_tools: HashMap::new(),
479            project: None,
480            manifest_dir: None,
481            private_dependency_names: std::collections::HashSet::new(),
482            gitignore: true,
483        }
484    }
485
486    /// Load and parse a manifest from a TOML file.
487    ///
488    /// This method reads the specified file, parses it as TOML, deserializes
489    /// it into a [`Manifest`] struct, and validates the result. The entire
490    /// operation is atomic - either the manifest loads successfully or an
491    /// error is returned.
492    ///
493    /// # Validation
494    ///
495    /// After parsing, the manifest is automatically validated to ensure:
496    /// - All dependency sources reference valid entries in the `[sources]` section
497    /// - Required fields are present and non-empty
498    /// - Version constraints are properly specified for remote dependencies
499    /// - Source URLs use supported protocols
500    /// - No version conflicts exist between dependencies
501    ///
502    /// # Error Handling
503    ///
504    /// Returns detailed errors for common problems:
505    /// - **File I/O errors**: File not found, permission denied, etc.
506    /// - **TOML syntax errors**: Invalid TOML format with helpful suggestions
507    /// - **Validation errors**: Logical inconsistencies in the manifest
508    /// - **Security errors**: Unsafe URL patterns or credential leakage
509    ///
510    /// All errors include contextual information and actionable suggestions.
511    ///
512    /// # Ok::<(), anyhow::Error>(())
513    /// # File Format
514    ///
515    /// Expects a valid TOML file following the AGPM manifest format.
516    /// See the module-level documentation for complete format specification.
517    pub fn load(path: &Path) -> Result<Self> {
518        let content = std::fs::read_to_string(path).with_file_context(
519            FileOperation::Read,
520            path,
521            "reading manifest file",
522            "manifest_module",
523        )?;
524
525        let mut manifest: Self = toml::from_str(&content)
526            .map_err(|e| crate::core::AgpmError::ManifestParseError {
527                file: path.display().to_string(),
528                reason: e.to_string(),
529            })
530            .with_context(|| {
531                format!(
532                    "Invalid TOML syntax in manifest file: {}\n\n\
533                    Common TOML syntax errors:\n\
534                    - Missing quotes around strings\n\
535                    - Unmatched brackets [ ] or braces {{ }}\n\
536                    - Invalid characters in keys or values\n\
537                    - Incorrect indentation or structure",
538                    path.display()
539                )
540            })?;
541
542        // Apply resource-type-specific defaults for tool
543        // Snippets default to "agpm" (shared infrastructure) instead of "claude-code"
544        manifest.apply_tool_defaults();
545
546        // Store the manifest directory for resolving relative paths
547        manifest.manifest_dir = Some(
548            path.parent()
549                .ok_or_else(|| anyhow::anyhow!("Manifest path has no parent directory"))?
550                .to_path_buf(),
551        );
552
553        manifest.validate()?;
554
555        Ok(manifest)
556    }
557
558    /// Load manifest with private config merged.
559    ///
560    /// Loads the project manifest from `agpm.toml` and then attempts to load
561    /// `agpm.private.toml` from the same directory. If a private config exists:
562    /// - **Sources** are merged (private sources can use same names, which shadows project sources)
563    /// - **Dependencies** are merged (private deps tracked via `private_dependency_names`)
564    /// - **Patches** are merged (private patches take precedence)
565    ///
566    /// Any conflicts (same field defined in both files with different values) are
567    /// returned for informational purposes only. Private patches always override
568    /// project patches without raising an error.
569    ///
570    /// # Arguments
571    ///
572    /// * `path` - Path to the project manifest file (`agpm.toml`)
573    ///
574    /// # Returns
575    ///
576    /// A manifest with merged sources, dependencies, patches, and a list of any
577    /// patch conflicts detected (for informational/debugging purposes).
578    pub fn load_with_private(path: &Path) -> Result<(Self, Vec<PatchConflict>)> {
579        // Load the main project manifest
580        let mut manifest = Self::load(path)?;
581
582        // Store project patches before merging
583        manifest.project_patches = manifest.patches.clone();
584
585        // Try to load private config
586        let private_path = if let Some(parent) = path.parent() {
587            parent.join("agpm.private.toml")
588        } else {
589            PathBuf::from("agpm.private.toml")
590        };
591
592        if private_path.exists() {
593            let private_manifest = Self::load_private(&private_path)?;
594
595            // Merge sources (private can shadow project sources with same name)
596            for (name, url) in private_manifest.sources {
597                manifest.sources.insert(name, url);
598            }
599
600            // Track which dependencies are from private manifest and merge them
601            let mut private_names = std::collections::HashSet::new();
602
603            // Merge agents
604            for (name, dep) in private_manifest.agents {
605                private_names.insert(("agents".to_string(), name.clone()));
606                manifest.agents.insert(name, dep);
607            }
608
609            // Merge snippets
610            for (name, dep) in private_manifest.snippets {
611                private_names.insert(("snippets".to_string(), name.clone()));
612                manifest.snippets.insert(name, dep);
613            }
614
615            // Merge commands
616            for (name, dep) in private_manifest.commands {
617                private_names.insert(("commands".to_string(), name.clone()));
618                manifest.commands.insert(name, dep);
619            }
620
621            // Merge scripts
622            for (name, dep) in private_manifest.scripts {
623                private_names.insert(("scripts".to_string(), name.clone()));
624                manifest.scripts.insert(name, dep);
625            }
626
627            // Merge hooks
628            for (name, dep) in private_manifest.hooks {
629                private_names.insert(("hooks".to_string(), name.clone()));
630                manifest.hooks.insert(name, dep);
631            }
632
633            // Merge MCP servers
634            for (name, dep) in private_manifest.mcp_servers {
635                private_names.insert(("mcp-servers".to_string(), name.clone()));
636                manifest.mcp_servers.insert(name, dep);
637            }
638
639            manifest.private_dependency_names = private_names;
640
641            // Store private patches
642            manifest.private_patches = private_manifest.patches.clone();
643
644            // Merge patches (private takes precedence)
645            let (merged_patches, conflicts) =
646                manifest.patches.merge_with(&private_manifest.patches);
647            manifest.patches = merged_patches;
648
649            // Re-validate after merge to ensure private dependencies reference valid sources
650            manifest.validate().with_context(|| {
651                format!(
652                    "Validation failed after merging private manifest: {}",
653                    private_path.display()
654                )
655            })?;
656
657            Ok((manifest, conflicts))
658        } else {
659            // No private config, keep private_patches empty
660            manifest.private_patches = ManifestPatches::new();
661            Ok((manifest, Vec::new()))
662        }
663    }
664
665    /// Load a private manifest file.
666    ///
667    /// Private manifests can contain:
668    /// - **Sources**: Private Git repositories with authentication
669    /// - **Dependencies**: User-only resources (agents, snippets, commands, etc.)
670    /// - **Patches**: Customizations to project or private dependencies
671    ///
672    /// Private manifests **cannot** contain:
673    /// - **Tools**: Tool configuration must be in the main manifest
674    ///
675    /// # Arguments
676    ///
677    /// * `path` - Path to the private manifest file (`agpm.private.toml`)
678    ///
679    /// # Errors
680    ///
681    /// Returns an error if:
682    /// - The file cannot be read
683    /// - The TOML syntax is invalid
684    /// - The private config contains tools configuration
685    fn load_private(path: &Path) -> Result<Self> {
686        let content = std::fs::read_to_string(path).with_file_context(
687            FileOperation::Read,
688            path,
689            "reading private manifest file",
690            "manifest_module",
691        )?;
692
693        let mut manifest: Self = toml::from_str(&content)
694            .map_err(|e| crate::core::AgpmError::ManifestParseError {
695                file: path.display().to_string(),
696                reason: e.to_string(),
697            })
698            .with_context(|| {
699                format!(
700                    "Invalid TOML syntax in private manifest file: {}\n\n\
701                    Common TOML syntax errors:\n\
702                    - Missing quotes around strings\n\
703                    - Unmatched brackets [ ] or braces {{ }}\n\
704                    - Invalid characters in keys or values\n\
705                    - Incorrect indentation or structure",
706                    path.display()
707                )
708            })?;
709
710        // Validate that private config doesn't contain tools
711        if manifest.tools.is_some() {
712            anyhow::bail!(
713                "Private manifest file ({}) cannot contain [tools] section. \
714                 Tool configuration must be defined in the project manifest (agpm.toml).",
715                path.display()
716            );
717        }
718
719        // Apply resource-type-specific defaults for tool
720        manifest.apply_tool_defaults();
721
722        // Store the manifest directory for resolving relative paths
723        manifest.manifest_dir = Some(
724            path.parent()
725                .ok_or_else(|| anyhow::anyhow!("Private manifest path has no parent directory"))?
726                .to_path_buf(),
727        );
728
729        Ok(manifest)
730    }
731
732    /// Get the default tool for a resource type.
733    ///
734    /// Checks the `[default-tools]` configuration first, then falls back to
735    /// the built-in defaults:
736    /// - `snippets` → `"agpm"` (shared infrastructure)
737    /// - All other resource types → `"claude-code"`
738    ///
739    /// # Arguments
740    ///
741    /// * `resource_type` - The resource type to get the default tool for
742    ///
743    /// # Returns
744    ///
745    /// The default tool name as a string.
746    ///
747    #[must_use]
748    pub fn get_default_tool(&self, resource_type: crate::core::ResourceType) -> String {
749        // Get the resource name in plural form for consistency with TOML section names
750        // (agents, snippets, commands, etc.)
751        let resource_name = match resource_type {
752            crate::core::ResourceType::Agent => "agents",
753            crate::core::ResourceType::Snippet => "snippets",
754            crate::core::ResourceType::Command => "commands",
755            crate::core::ResourceType::Script => "scripts",
756            crate::core::ResourceType::Hook => "hooks",
757            crate::core::ResourceType::McpServer => "mcp-servers",
758            crate::core::ResourceType::Skill => "skills",
759        };
760
761        // Check if there's a configured override
762        if let Some(tool) = self.default_tools.get(resource_name) {
763            return tool.clone();
764        }
765
766        // Fall back to built-in defaults
767        resource_type.default_tool().to_string()
768    }
769
770    fn apply_tool_defaults(&mut self) {
771        // Apply resource-type-specific defaults only when tool is not explicitly specified
772        for resource_type in [
773            crate::core::ResourceType::Snippet,
774            crate::core::ResourceType::Agent,
775            crate::core::ResourceType::Command,
776            crate::core::ResourceType::Script,
777            crate::core::ResourceType::Hook,
778            crate::core::ResourceType::McpServer,
779        ] {
780            // Get the default tool before the mutable borrow to avoid borrow conflicts
781            let default_tool = self.get_default_tool(resource_type);
782
783            if let Some(deps) = self.get_dependencies_mut(resource_type) {
784                for dependency in deps.values_mut() {
785                    if let ResourceDependency::Detailed(details) = dependency {
786                        if details.tool.is_none() {
787                            details.tool = Some(default_tool.clone());
788                        }
789                    }
790                }
791            }
792        }
793    }
794
795    /// Save the manifest to a TOML file with pretty formatting.
796    ///
797    /// This method serializes the manifest to TOML format and writes it to the
798    /// specified file path. The output is pretty-printed for human readability
799    /// and follows TOML best practices.
800    ///
801    /// # Formatting
802    ///
803    /// The generated TOML file will:
804    /// - Use consistent indentation and spacing
805    /// - Omit empty sections for cleaner output
806    /// - Order sections logically (sources, target, agents, snippets)
807    /// - Include inline tables for detailed dependencies
808    ///
809    /// # Atomic Operation
810    ///
811    /// The save operation is atomic - the file is either completely written
812    /// or left unchanged. This prevents corruption if the operation fails
813    /// partway through.
814    ///
815    /// # Error Handling
816    ///
817    /// Returns detailed errors for common problems:
818    /// - **Permission denied**: Insufficient write permissions
819    /// - **Directory doesn't exist**: Parent directory missing  
820    /// - **Disk full**: Insufficient storage space
821    /// - **File locked**: Another process has the file open
822    ///
823    /// # use tempfile::tempdir;
824    /// # let temp_dir = tempdir()?;
825    /// # let manifest_path = temp_dir.path().join("agpm.toml");
826    /// manifest.save(&manifest_path)?;
827    /// # Ok::<(), anyhow::Error>(())
828    /// # Output Format
829    ///
830    /// The generated file will follow this structure:
831    ///
832    pub fn save(&self, path: &Path) -> Result<()> {
833        // Serialize to a document first so we can control formatting
834        let mut doc = toml_edit::ser::to_document(self)
835            .with_context(|| "Failed to serialize manifest data to TOML format")?;
836
837        // Convert top-level inline tables to regular tables (section headers)
838        // This keeps [sources], [agents], etc. as sections but nested values stay inline
839        for (_key, value) in doc.iter_mut() {
840            if let Some(inline_table) = value.as_inline_table() {
841                // Convert inline table to regular table
842                let table = inline_table.clone().into_table();
843                *value = toml_edit::Item::Table(table);
844            }
845        }
846
847        let content = doc.to_string();
848
849        std::fs::write(path, content).with_file_context(
850            FileOperation::Write,
851            path,
852            "writing manifest file",
853            "manifest_module",
854        )?;
855
856        Ok(())
857    }
858
859    /// Validate the manifest structure and enforce business rules.
860    ///
861    /// This method performs comprehensive validation of the manifest to ensure
862    /// logical consistency, security best practices, and correct dependency
863    /// relationships. It's automatically called during [`Self::load`] but can
864    /// also be used independently to validate programmatically constructed manifests.
865    ///
866    /// # Validation Rules
867    ///
868    /// ## Source Validation
869    /// - All source URLs must use supported protocols (HTTPS, SSH, git://, file://)
870    /// - No plain directory paths allowed as sources (must use file:// URLs)
871    /// - No authentication tokens embedded in URLs (security check)
872    /// - Environment variable expansion is validated for syntax
873    ///
874    /// ## Dependency Validation  
875    /// - All dependency paths must be non-empty
876    /// - Remote dependencies must reference existing sources
877    /// - Remote dependencies must specify version constraints
878    /// - Local dependencies cannot have version constraints
879    /// - No version conflicts between dependencies with the same name
880    ///
881    /// ## Path Validation
882    /// - Local dependency paths are checked for proper format
883    /// - Remote dependency paths are validated as repository-relative
884    /// - Path traversal attempts are detected and rejected
885    /// # Error Types
886    ///
887    /// Returns specific error types for different validation failures:
888    /// - [`crate::core::AgpmError::SourceNotFound`]: Referenced source doesn't exist
889    /// - [`crate::core::AgpmError::ManifestValidationError`]: General validation failures
890    /// - Context errors for specific issues with actionable suggestions
891    ///
892    /// # Performance
893    ///
894    /// Validation is designed to be fast and is safe to call frequently.
895    /// Complex validations (like network connectivity) are not performed
896    /// here - those are handled during dependency resolution.
897    pub fn validate(&self) -> Result<()> {
898        // Validate artifact type names
899        for artifact_type in self.get_tools_config().types.keys() {
900            if artifact_type.contains('/') || artifact_type.contains('\\') {
901                return Err(crate::core::AgpmError::ManifestValidationError {
902                    reason: format!(
903                        "Artifact type name '{artifact_type}' cannot contain path separators ('/' or '\\\\'). \n\
904                        Artifact type names must be simple identifiers without special characters."
905                    ),
906                }
907                .into());
908            }
909
910            // Also check for other potentially problematic characters
911            if artifact_type.contains("..") {
912                return Err(crate::core::AgpmError::ManifestValidationError {
913                    reason: format!(
914                        "Artifact type name '{artifact_type}' cannot contain '..' (path traversal). \n\
915                        Artifact type names must be simple identifiers."
916                    ),
917                }
918                .into());
919            }
920        }
921
922        // Check that all referenced sources exist and dependencies have required fields
923        for (name, dep) in self.all_dependencies() {
924            // Check for empty path
925            if dep.get_path().is_empty() {
926                return Err(crate::core::AgpmError::ManifestValidationError {
927                    reason: format!("Missing required field 'path' for dependency '{name}'"),
928                }
929                .into());
930            }
931
932            // Validate pattern safety if it's a pattern dependency
933            if dep.is_pattern() {
934                crate::pattern::validate_pattern_safety(dep.get_path()).map_err(|e| {
935                    crate::core::AgpmError::ManifestValidationError {
936                        reason: format!("Invalid pattern in dependency '{name}': {e}"),
937                    }
938                })?;
939            }
940
941            // Check for version when source is specified (non-local dependencies)
942            if let Some(source) = dep.get_source() {
943                if !self.sources.contains_key(source) {
944                    return Err(crate::core::AgpmError::SourceNotFound {
945                        name: source.to_string(),
946                    }
947                    .into());
948                }
949
950                // Check if the source URL is a local path
951                let source_url = self.sources.get(source).unwrap();
952                let _is_local_source = source_url.starts_with('/')
953                    || source_url.starts_with("./")
954                    || source_url.starts_with("../");
955
956                // Git dependencies can optionally have a version (defaults to 'main' if not specified)
957                // Local path sources don't need versions
958                // We no longer require versions for Git dependencies - they'll default to 'main'
959            } else {
960                // For local path dependencies (no source), version is not allowed
961                // Skip directory check for pattern dependencies
962                if !dep.is_pattern() {
963                    let path = dep.get_path();
964                    let is_plain_dir =
965                        path.starts_with('/') || path.starts_with("./") || path.starts_with("../");
966
967                    if is_plain_dir && dep.get_version().is_some() {
968                        return Err(crate::core::AgpmError::ManifestValidationError {
969                            reason: format!(
970                                "Version specified for plain directory dependency '{name}' with path '{path}'. \n\
971                                Plain directory dependencies do not support versions. \n\
972                            Remove the 'version' field or use a git source instead."
973                            ),
974                        }
975                        .into());
976                    }
977                }
978            }
979        }
980
981        // Check for version conflicts (same dependency name with different versions)
982        let mut seen_deps: std::collections::HashMap<String, String> =
983            std::collections::HashMap::new();
984        for (name, dep) in self.all_dependencies() {
985            if let Some(version) = dep.get_version() {
986                if let Some(existing_version) = seen_deps.get(name) {
987                    if existing_version != version {
988                        return Err(crate::core::AgpmError::ManifestValidationError {
989                            reason: format!(
990                                "Version conflict for dependency '{name}': found versions '{existing_version}' and '{version}'"
991                            ),
992                        }
993                        .into());
994                    }
995                } else {
996                    seen_deps.insert(name.to_string(), version.to_string());
997                }
998            }
999        }
1000
1001        // Validate URLs in sources
1002        for (name, url) in &self.sources {
1003            // Expand environment variables and home directory in URL
1004            let expanded_url = expand_url(url)?;
1005
1006            if !expanded_url.starts_with("http://")
1007                && !expanded_url.starts_with("https://")
1008                && !expanded_url.starts_with("git@")
1009                && !expanded_url.starts_with("file://")
1010            // Plain directory paths not allowed as sources
1011            && !expanded_url.starts_with('/')
1012            && !expanded_url.starts_with("./")
1013            && !expanded_url.starts_with("../")
1014            {
1015                return Err(crate::core::AgpmError::ManifestValidationError {
1016                    reason: format!("Source '{name}' has invalid URL: '{url}'. Must be HTTP(S), SSH (git@...), or file:// URL"),
1017                }
1018                .into());
1019            }
1020
1021            // Check if plain directory path is used as a source
1022            if expanded_url.starts_with('/')
1023                || expanded_url.starts_with("./")
1024                || expanded_url.starts_with("../")
1025            {
1026                return Err(crate::core::AgpmError::ManifestValidationError {
1027                    reason: format!(
1028                        "Plain directory path '{url}' cannot be used as source '{name}'. \n\
1029                        Sources must be git repositories. Use one of:\n\
1030                        - Remote URL: https://github.com/owner/repo.git\n\
1031                        - Local git repo: file:///absolute/path/to/repo\n\
1032                        - Or use direct path dependencies without a source"
1033                    ),
1034                }
1035                .into());
1036            }
1037        }
1038
1039        // Check for case-insensitive conflicts on all platforms
1040        // This ensures manifests are portable across different filesystems
1041        // Even though Linux supports case-sensitive files, we reject conflicts
1042        // to ensure the manifest works on Windows and macOS too
1043        let mut normalized_names: std::collections::HashSet<String> =
1044            std::collections::HashSet::new();
1045
1046        for (name, _) in self.all_dependencies() {
1047            let normalized = name.to_lowercase();
1048            if !normalized_names.insert(normalized.clone()) {
1049                // Find the original conflicting name
1050                for (other_name, _) in self.all_dependencies() {
1051                    if other_name != name && other_name.to_lowercase() == normalized {
1052                        return Err(crate::core::AgpmError::ManifestValidationError {
1053                            reason: format!(
1054                                "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."
1055                            ),
1056                        }
1057                        .into());
1058                    }
1059                }
1060            }
1061        }
1062
1063        // Validate artifact types and resource type support
1064        for resource_type in crate::core::ResourceType::all() {
1065            if let Some(deps) = self.get_dependencies(*resource_type) {
1066                for (name, dep) in deps {
1067                    // Get tool from dependency (defaults based on resource type)
1068                    let tool_string = dep
1069                        .get_tool()
1070                        .map(|s| s.to_string())
1071                        .unwrap_or_else(|| self.get_default_tool(*resource_type));
1072                    let tool = tool_string.as_str();
1073
1074                    // Check if tool is configured
1075                    if self.get_tool_config(tool).is_none() {
1076                        return Err(crate::core::AgpmError::ManifestValidationError {
1077                            reason: format!(
1078                                "Unknown tool '{tool}' for dependency '{name}'.\n\
1079                                Available types: {}\n\
1080                                Configure custom types in [tools] section or use a standard type.",
1081                                self.get_tools_config()
1082                                    .types
1083                                    .keys()
1084                                    .map(|s| format!("'{s}'"))
1085                                    .collect::<Vec<_>>()
1086                                    .join(", ")
1087                            ),
1088                        }
1089                        .into());
1090                    }
1091
1092                    // Check if resource type is supported by this tool
1093                    if !self.is_resource_supported(tool, *resource_type) {
1094                        let artifact_config = self.get_tool_config(tool).unwrap();
1095                        let resource_plural = resource_type.to_plural();
1096
1097                        // Check if this is a malformed configuration (resource exists but not properly configured)
1098                        let is_malformed = artifact_config.resources.contains_key(resource_plural);
1099
1100                        let supported_types: Vec<String> = artifact_config
1101                            .resources
1102                            .iter()
1103                            .filter(|(_, res_config)| {
1104                                res_config.path.is_some() || res_config.merge_target.is_some()
1105                            })
1106                            .map(|(s, _)| s.to_string())
1107                            .collect();
1108
1109                        // Build resource-type-specific suggestions
1110                        let mut suggestions = Vec::new();
1111
1112                        if is_malformed {
1113                            // Resource type exists but is malformed
1114                            suggestions.push(format!(
1115                                "Resource type '{}' is configured for tool '{}' but missing required 'path' or 'merge_target' field",
1116                                resource_plural, tool
1117                            ));
1118
1119                            // Provide specific fix suggestions based on resource type
1120                            match resource_type {
1121                                crate::core::ResourceType::Hook => {
1122                                    suggestions.push("For hooks, add: merge_target = '.claude/settings.local.json'".to_string());
1123                                }
1124                                crate::core::ResourceType::McpServer => {
1125                                    suggestions.push(
1126                                        "For MCP servers, add: merge_target = '.mcp.json'"
1127                                            .to_string(),
1128                                    );
1129                                }
1130                                _ => {
1131                                    suggestions.push(format!(
1132                                        "For {}, add: path = '{}'",
1133                                        resource_plural, resource_plural
1134                                    ));
1135                                }
1136                            }
1137                        } else {
1138                            // Resource type not supported at all
1139                            match resource_type {
1140                                crate::core::ResourceType::Snippet => {
1141                                    suggestions.push("Snippets work best with the 'agpm' tool (shared infrastructure)".to_string());
1142                                    suggestions.push(
1143                                        "Add tool='agpm' to this dependency to use shared snippets"
1144                                            .to_string(),
1145                                    );
1146                                }
1147                                _ => {
1148                                    // Find which tool types DO support this resource type
1149                                    let default_config = ToolsConfig::default();
1150                                    let tools_config =
1151                                        self.tools.as_ref().unwrap_or(&default_config);
1152                                    let supporting_types: Vec<String> = tools_config
1153                                        .types
1154                                        .iter()
1155                                        .filter(|(_, config)| {
1156                                            config.resources.contains_key(resource_plural)
1157                                                && config
1158                                                    .resources
1159                                                    .get(resource_plural)
1160                                                    .map(|res| {
1161                                                        res.path.is_some()
1162                                                            || res.merge_target.is_some()
1163                                                    })
1164                                                    .unwrap_or(false)
1165                                        })
1166                                        .map(|(type_name, _)| format!("'{}'", type_name))
1167                                        .collect();
1168
1169                                    if !supporting_types.is_empty() {
1170                                        suggestions.push(format!(
1171                                            "This resource type is supported by tools: {}",
1172                                            supporting_types.join(", ")
1173                                        ));
1174                                    }
1175                                }
1176                            }
1177                        }
1178
1179                        let mut reason = if is_malformed {
1180                            format!(
1181                                "Resource type '{}' is improperly configured for tool '{}' for dependency '{}'.\n\n",
1182                                resource_plural, tool, name
1183                            )
1184                        } else {
1185                            format!(
1186                                "Resource type '{}' is not supported by tool '{}' for dependency '{}'.\n\n",
1187                                resource_plural, tool, name
1188                            )
1189                        };
1190
1191                        reason.push_str(&format!(
1192                            "Tool '{}' properly supports: {}\n\n",
1193                            tool,
1194                            supported_types.join(", ")
1195                        ));
1196
1197                        if !suggestions.is_empty() {
1198                            reason.push_str("💡 Suggestions:\n");
1199                            for suggestion in &suggestions {
1200                                reason.push_str(&format!("  • {}\n", suggestion));
1201                            }
1202                            reason.push('\n');
1203                        }
1204
1205                        reason.push_str(
1206                            "You can fix this by:\n\
1207                            1. Changing the 'tool' field to a supported tool\n\
1208                            2. Using a different resource type\n\
1209                            3. Removing this dependency from your manifest",
1210                        );
1211
1212                        return Err(crate::core::AgpmError::ManifestValidationError {
1213                            reason,
1214                        }
1215                        .into());
1216                    }
1217                }
1218            }
1219        }
1220
1221        // Validate patches reference valid aliases
1222        self.validate_patches()?;
1223
1224        Ok(())
1225    }
1226
1227    /// Validate that patches reference valid manifest aliases.
1228    ///
1229    /// This method checks that all patch aliases correspond to actual dependencies
1230    /// defined in the manifest. Patches for non-existent aliases are rejected.
1231    ///
1232    /// # Errors
1233    ///
1234    /// Returns an error if a patch references an alias that doesn't exist in the manifest.
1235    fn validate_patches(&self) -> Result<()> {
1236        use crate::core::ResourceType;
1237
1238        // Helper to check if an alias exists for a resource type
1239        let check_patch_aliases = |resource_type: ResourceType,
1240                                   patches: &BTreeMap<String, PatchData>|
1241         -> Result<()> {
1242            let deps = self.get_dependencies(resource_type);
1243
1244            for alias in patches.keys() {
1245                // Check if this alias exists in the manifest
1246                let exists = if let Some(deps) = deps {
1247                    deps.contains_key(alias)
1248                } else {
1249                    false
1250                };
1251
1252                if !exists {
1253                    return Err(crate::core::AgpmError::ManifestValidationError {
1254                            reason: format!(
1255                                "Patch references unknown alias '{alias}' in [patch.{}] section.\n\
1256                                The alias must be defined in [{}] section of agpm.toml.\n\
1257                                To patch a transitive dependency, first add it explicitly to your manifest.",
1258                                resource_type.to_plural(),
1259                                resource_type.to_plural()
1260                            ),
1261                        }
1262                        .into());
1263                }
1264            }
1265            Ok(())
1266        };
1267
1268        // Validate patches for each resource type
1269        check_patch_aliases(ResourceType::Agent, &self.patches.agents)?;
1270        check_patch_aliases(ResourceType::Snippet, &self.patches.snippets)?;
1271        check_patch_aliases(ResourceType::Command, &self.patches.commands)?;
1272        check_patch_aliases(ResourceType::Script, &self.patches.scripts)?;
1273        check_patch_aliases(ResourceType::McpServer, &self.patches.mcp_servers)?;
1274        check_patch_aliases(ResourceType::Hook, &self.patches.hooks)?;
1275
1276        Ok(())
1277    }
1278
1279    /// Get all dependencies from both agents and snippets sections.
1280    ///
1281    /// Returns a vector of tuples containing dependency names and their
1282    /// specifications. This is useful for iteration over all dependencies
1283    /// without needing to handle agents and snippets separately.
1284    ///
1285    /// # Return Value
1286    ///
1287    /// Each tuple contains:
1288    /// - `&str`: The dependency name (key from TOML)
1289    /// - `&ResourceDependency`: The dependency specification
1290    ///
1291    /// # Order
1292    ///
1293    /// Dependencies are returned in the order they appear in the underlying
1294    /// `HashMaps` (agents first, then snippets, then commands), which means the order is not
1295    /// guaranteed to be stable across runs.
1296    /// Get dependencies for a specific resource type
1297    ///
1298    /// Returns the `HashMap` of dependencies for the specified resource type.
1299    /// Note: MCP servers return None as they use a different dependency type.
1300    pub fn get_dependencies(
1301        &self,
1302        resource_type: crate::core::ResourceType,
1303    ) -> Option<&HashMap<String, ResourceDependency>> {
1304        use crate::core::ResourceType;
1305        match resource_type {
1306            ResourceType::Agent => Some(&self.agents),
1307            ResourceType::Snippet => Some(&self.snippets),
1308            ResourceType::Command => Some(&self.commands),
1309            ResourceType::Script => Some(&self.scripts),
1310            ResourceType::Hook => Some(&self.hooks),
1311            ResourceType::McpServer => Some(&self.mcp_servers),
1312            ResourceType::Skill => Some(&self.skills),
1313        }
1314    }
1315
1316    /// Get mutable dependencies for a specific resource type
1317    ///
1318    /// Returns a mutable reference to the `HashMap` of dependencies for the specified resource type.
1319    #[must_use]
1320    pub fn get_dependencies_mut(
1321        &mut self,
1322        resource_type: crate::core::ResourceType,
1323    ) -> Option<&mut HashMap<String, ResourceDependency>> {
1324        use crate::core::ResourceType;
1325        match resource_type {
1326            ResourceType::Agent => Some(&mut self.agents),
1327            ResourceType::Snippet => Some(&mut self.snippets),
1328            ResourceType::Command => Some(&mut self.commands),
1329            ResourceType::Script => Some(&mut self.scripts),
1330            ResourceType::Hook => Some(&mut self.hooks),
1331            ResourceType::McpServer => Some(&mut self.mcp_servers),
1332            ResourceType::Skill => Some(&mut self.skills),
1333        }
1334    }
1335
1336    /// Get the tools configuration, returning default if not specified.
1337    ///
1338    /// This method provides access to the tool configurations which define
1339    /// where resources are installed for different tools (claude-code, opencode, agpm).
1340    ///
1341    /// Returns the configured tools or the default configuration if not specified.
1342    pub fn get_tools_config(&self) -> &ToolsConfig {
1343        self.tools.as_ref().unwrap_or_else(|| {
1344            // Return a static default - this is safe because ToolsConfig::default() is deterministic
1345            static DEFAULT: std::sync::OnceLock<ToolsConfig> = std::sync::OnceLock::new();
1346            DEFAULT.get_or_init(ToolsConfig::default)
1347        })
1348    }
1349
1350    /// Get configuration for a specific tool type.
1351    ///
1352    /// Returns None if the tool is not configured.
1353    pub fn get_tool_config(&self, tool: &str) -> Option<&ArtifactTypeConfig> {
1354        self.get_tools_config().types.get(tool)
1355    }
1356
1357    /// Get the installation path for a resource within a tool.
1358    ///
1359    /// Returns the full installation directory path by combining:
1360    /// - Tool's base directory (e.g., ".claude", ".opencode")
1361    /// - Resource type's subdirectory (e.g., "agents", "command")
1362    ///
1363    /// Returns None if:
1364    /// - The tool is not configured
1365    /// - The resource type is not supported by this tool
1366    /// - The resource has no configured path (special handling like MCP merge)
1367    pub fn get_artifact_resource_path(
1368        &self,
1369        tool: &str,
1370        resource_type: crate::core::ResourceType,
1371    ) -> Option<std::path::PathBuf> {
1372        let artifact_config = self.get_tool_config(tool)?;
1373        let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
1374
1375        resource_config.path.as_ref().map(|subdir| {
1376            // Split on forward slashes and join with PathBuf for proper platform handling
1377            // This ensures all separators are platform-native (backslashes on Windows)
1378            let mut result = artifact_config.path.clone();
1379            for component in subdir.split('/') {
1380                result = result.join(component);
1381            }
1382            result
1383        })
1384    }
1385
1386    /// Get the merge target configuration file path for a resource type.
1387    ///
1388    /// Returns the path to the configuration file where resources of this type
1389    /// should be merged (e.g., hooks, MCP servers). Returns None if the resource
1390    /// type doesn't use merge targets or if the tool doesn't support this resource type.
1391    ///
1392    /// # Arguments
1393    ///
1394    /// * `tool` - The tool name (e.g., "claude-code", "opencode")
1395    /// * `resource_type` - The resource type to look up
1396    ///
1397    /// # Returns
1398    ///
1399    /// The merge target path if configured, otherwise None.
1400    ///
1401    pub fn get_merge_target(
1402        &self,
1403        tool: &str,
1404        resource_type: crate::core::ResourceType,
1405    ) -> Option<PathBuf> {
1406        let artifact_config = self.get_tool_config(tool)?;
1407        let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
1408
1409        resource_config.merge_target.as_ref().map(PathBuf::from)
1410    }
1411
1412    /// Check if a resource type is supported by a tool.
1413    ///
1414    /// A resource type is considered supported if it has either:
1415    /// - A configured installation path (for file-based resources)
1416    /// - A configured merge target (for resources that merge into config files)
1417    ///
1418    /// Returns true if the tool has valid configuration for the given resource type.
1419    pub fn is_resource_supported(
1420        &self,
1421        tool: &str,
1422        resource_type: crate::core::ResourceType,
1423    ) -> bool {
1424        self.get_tool_config(tool)
1425            .and_then(|config| config.resources.get(resource_type.to_plural()))
1426            .map(|res_config| res_config.path.is_some() || res_config.merge_target.is_some())
1427            .unwrap_or(false)
1428    }
1429
1430    /// Returns all dependencies from all resource types.
1431    ///
1432    /// This method collects dependencies from agents, snippets, commands,
1433    /// scripts, hooks, and MCP servers into a single vector. It's commonly used for:
1434    /// - Manifest validation across all dependency types
1435    /// - Dependency resolution operations
1436    /// - Generating reports of all configured dependencies
1437    /// - Bulk operations on all dependencies
1438    ///
1439    /// # Returns
1440    ///
1441    /// A vector of tuples containing the dependency name and its configuration.
1442    /// Each tuple is `(name, dependency)` where:
1443    /// - `name`: The dependency name as specified in the manifest
1444    /// - `dependency`: Reference to the [`ResourceDependency`] configuration
1445    ///
1446    /// The order follows the resource type order defined in [`crate::core::ResourceType::all()`].
1447    ///
1448    /// # use agpm_cli::manifest::Manifest;
1449    /// # let manifest = Manifest::new();
1450    /// for (name, dep) in manifest.all_dependencies() {
1451    ///     println!("Dependency: {} -> {}", name, dep.get_path());
1452    ///     if let Some(source) = dep.get_source() {
1453    ///         println!("  Source: {}", source);
1454    ///     }
1455    /// }
1456    #[must_use]
1457    pub fn all_dependencies(&self) -> Vec<(&str, &ResourceDependency)> {
1458        let mut deps = Vec::new();
1459
1460        // Use ResourceType::all() to iterate through all resource types
1461        for resource_type in crate::core::ResourceType::all() {
1462            if let Some(type_deps) = self.get_dependencies(*resource_type) {
1463                // CRITICAL: Sort for deterministic iteration order
1464                let mut sorted_deps: Vec<_> = type_deps.iter().collect();
1465                sorted_deps.sort_by_key(|(name, _)| name.as_str());
1466
1467                for (name, dep) in sorted_deps {
1468                    deps.push((name.as_str(), dep));
1469                }
1470            }
1471        }
1472
1473        deps
1474    }
1475
1476    /// Get all dependencies including MCP servers.
1477    ///
1478    /// All resource types now use standard `ResourceDependency`, so no conversion needed.
1479    #[must_use]
1480    pub fn all_dependencies_with_mcp(
1481        &self,
1482    ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>)> {
1483        let mut deps = Vec::new();
1484
1485        // Use ResourceType::all() to iterate through all resource types
1486        for resource_type in crate::core::ResourceType::all() {
1487            if let Some(type_deps) = self.get_dependencies(*resource_type) {
1488                // CRITICAL: Sort for deterministic iteration order
1489                let mut sorted_deps: Vec<_> = type_deps.iter().collect();
1490                sorted_deps.sort_by_key(|(name, _)| name.as_str());
1491
1492                for (name, dep) in sorted_deps {
1493                    deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep)));
1494                }
1495            }
1496        }
1497
1498        deps
1499    }
1500
1501    /// Get all dependencies with their resource types.
1502    ///
1503    /// Returns a vector of tuples containing the dependency name, dependency details,
1504    /// and the resource type. This preserves type information that is lost in
1505    /// `all_dependencies_with_mcp()`.
1506    ///
1507    /// This is used by the resolver to correctly type transitive dependencies without
1508    /// falling back to manifest section order lookups.
1509    ///
1510    /// Dependencies for disabled tools are automatically filtered out.
1511    pub fn all_dependencies_with_types(
1512        &self,
1513    ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>, crate::core::ResourceType)> {
1514        let mut deps = Vec::new();
1515
1516        // Use ResourceType::all() to iterate through all resource types
1517        for resource_type in crate::core::ResourceType::all() {
1518            if let Some(type_deps) = self.get_dependencies(*resource_type) {
1519                // CRITICAL: Sort dependencies for deterministic iteration order!
1520                // HashMap iteration is non-deterministic, so we must sort by name
1521                // to ensure consistent lockfile generation across runs.
1522                let mut sorted_deps: Vec<_> = type_deps.iter().collect();
1523                sorted_deps.sort_by_key(|(name, _)| name.as_str());
1524
1525                for (name, dep) in sorted_deps {
1526                    // Determine the tool for this dependency
1527                    let tool_string = dep
1528                        .get_tool()
1529                        .map(|s| s.to_string())
1530                        .unwrap_or_else(|| self.get_default_tool(*resource_type));
1531                    let tool = tool_string.as_str();
1532
1533                    // Check if the tool is enabled
1534                    if let Some(tool_config) = self.get_tools_config().types.get(tool) {
1535                        if !tool_config.enabled {
1536                            // Skip dependencies for disabled tools
1537                            tracing::debug!(
1538                                "Skipping dependency '{}' for disabled tool '{}'",
1539                                name,
1540                                tool
1541                            );
1542                            continue;
1543                        }
1544                    }
1545
1546                    // Ensure the tool is set on the dependency (apply default if not explicitly set)
1547                    let dep_with_tool = if dep.get_tool().is_none() {
1548                        tracing::debug!(
1549                            "Setting default tool '{}' for dependency '{}' (type: {:?})",
1550                            tool,
1551                            name,
1552                            resource_type
1553                        );
1554                        // Need to set the tool - create a modified copy
1555                        let mut dep_owned = dep.clone();
1556                        dep_owned.set_tool(Some(tool_string.clone()));
1557                        std::borrow::Cow::Owned(dep_owned)
1558                    } else {
1559                        std::borrow::Cow::Borrowed(dep)
1560                    };
1561
1562                    deps.push((name.as_str(), dep_with_tool, *resource_type));
1563                }
1564            }
1565        }
1566
1567        deps
1568    }
1569
1570    /// Check if a dependency with the given name exists in any section.
1571    ///
1572    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
1573    /// with the specified name. This is useful for avoiding duplicate names
1574    /// across different resource types.
1575    ///
1576    /// # Performance
1577    ///
1578    /// This method performs up to three `HashMap` lookups, so it's O(1) on average.
1579    ///
1580    /// # Examples
1581    ///
1582    /// ```no_run
1583    /// # use agpm_cli::manifest::Manifest;
1584    /// let manifest = Manifest::new();
1585    /// if manifest.has_dependency("my-agent") {
1586    ///     println!("Dependency exists!");
1587    /// }
1588    /// ```
1589    #[must_use]
1590    pub fn has_dependency(&self, name: &str) -> bool {
1591        self.agents.contains_key(name)
1592            || self.snippets.contains_key(name)
1593            || self.commands.contains_key(name)
1594    }
1595
1596    /// Get a dependency by name from any section.
1597    ///
1598    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
1599    /// with the specified name, returning the first match found.
1600    ///
1601    /// # Search Order
1602    ///
1603    /// Dependencies are searched in this order:
1604    /// 1. `[agents]` section
1605    /// 2. `[snippets]` section
1606    /// 3. `[commands]` section
1607    ///
1608    /// If the same name exists in multiple sections, the first match is returned.
1609    ///
1610    /// # Examples
1611    ///
1612    /// ```no_run
1613    /// # use agpm_cli::manifest::Manifest;
1614    /// let manifest = Manifest::new();
1615    /// if let Some(dep) = manifest.get_dependency("my-agent") {
1616    ///     println!("Found dependency!");
1617    /// }
1618    /// ```
1619    #[must_use]
1620    pub fn get_dependency(&self, name: &str) -> Option<&ResourceDependency> {
1621        self.agents
1622            .get(name)
1623            .or_else(|| self.snippets.get(name))
1624            .or_else(|| self.commands.get(name))
1625    }
1626
1627    /// Find a dependency by name from any section (alias for `get_dependency`).
1628    ///
1629    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
1630    /// with the specified name, returning the first match found.
1631    ///
1632    /// # Examples
1633    ///
1634    /// ```no_run
1635    /// # use agpm_cli::manifest::Manifest;
1636    /// let manifest = Manifest::new();
1637    /// if let Some(dep) = manifest.find_dependency("my-agent") {
1638    ///     println!("Found dependency!");
1639    /// }
1640    /// ```
1641    pub fn find_dependency(&self, name: &str) -> Option<&ResourceDependency> {
1642        self.get_dependency(name)
1643    }
1644
1645    /// Add or update a source repository in the `[sources]` section.
1646    ///
1647    /// Sources map convenient names to Git repository URLs. These names can
1648    /// then be referenced in dependency specifications to avoid repeating
1649    /// long URLs throughout the manifest.
1650    ///
1651    /// # Parameters
1652    ///
1653    /// - `name`: Short, convenient name for the source (e.g., "official", "community")
1654    /// - `url`: Git repository URL (HTTPS, SSH, or file:// protocol)
1655    ///
1656    /// # URL Validation
1657    ///
1658    /// The URL is not validated when added - validation occurs during
1659    /// [`Self::validate`]. Supported URL formats:
1660    /// - `https://github.com/owner/repo.git`
1661    /// - `git@github.com:owner/repo.git`
1662    /// - `file:///absolute/path/to/repo`
1663    /// - `file:///path/to/local/repo`
1664    ///
1665    /// # Security Note
1666    ///
1667    /// Never include authentication tokens in the URL. Use SSH keys or
1668    /// configure authentication globally in `~/.agpm/config.toml`.
1669    pub fn add_source(&mut self, name: String, url: String) {
1670        self.sources.insert(name, url);
1671    }
1672
1673    /// Add or update a dependency in the appropriate section.
1674    ///
1675    /// Adds the dependency to either the `[agents]` or `[snippets]` section
1676    /// based on the `is_agent` parameter. If a dependency with the same name
1677    /// already exists in the target section, it will be replaced.
1678    ///
1679    /// For commands and other resource types, use [`Self::add_typed_dependency`]
1680    /// which provides explicit control over resource types.
1681    ///
1682    /// # Parameters
1683    ///
1684    /// - `name`: Unique name for the dependency within its section
1685    /// - `dep`: The dependency specification (Simple or Detailed)
1686    /// - `is_agent`: If true, adds to `[agents]`; if false, adds to `[snippets]`
1687    ///
1688    /// # Validation
1689    ///
1690    /// The dependency is not validated when added - validation occurs during
1691    /// [`Self::validate`]. This allows for building manifests incrementally
1692    /// before all sources are defined.
1693    ///
1694    /// # Name Conflicts
1695    ///
1696    /// This method allows the same dependency name to exist in both the
1697    /// `[agents]` and `[snippets]` sections. However, some operations like
1698    /// [`Self::get_dependency`] will prefer agents over snippets when
1699    /// searching by name.
1700    pub fn add_dependency(&mut self, name: String, dep: ResourceDependency, is_agent: bool) {
1701        if is_agent {
1702            self.agents.insert(name, dep);
1703        } else {
1704            self.snippets.insert(name, dep);
1705        }
1706    }
1707
1708    /// Add or update a dependency with specific resource type.
1709    ///
1710    /// This is the preferred method for adding dependencies as it explicitly
1711    /// specifies the resource type using the `ResourceType` enum.
1712    ///
1713    ///
1714    /// ```rust,no_run
1715    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
1716    /// use agpm_cli::core::ResourceType;
1717    ///
1718    /// let mut manifest = Manifest::new();
1719    pub fn add_typed_dependency(
1720        &mut self,
1721        name: String,
1722        dep: ResourceDependency,
1723        resource_type: crate::core::ResourceType,
1724    ) {
1725        match resource_type {
1726            crate::core::ResourceType::Agent => {
1727                self.agents.insert(name, dep);
1728            }
1729            crate::core::ResourceType::Snippet => {
1730                self.snippets.insert(name, dep);
1731            }
1732            crate::core::ResourceType::Command => {
1733                self.commands.insert(name, dep);
1734            }
1735            crate::core::ResourceType::McpServer => {
1736                // MCP servers don't use ResourceDependency, they have their own type
1737                // This method shouldn't be called for MCP servers
1738                panic!("Use add_mcp_server() for MCP server dependencies");
1739            }
1740            crate::core::ResourceType::Script => {
1741                self.scripts.insert(name, dep);
1742            }
1743            crate::core::ResourceType::Hook => {
1744                self.hooks.insert(name, dep);
1745            }
1746            crate::core::ResourceType::Skill => {
1747                self.skills.insert(name, dep);
1748            }
1749        }
1750    }
1751
1752    /// Get resource dependencies by type.
1753    ///
1754    /// Returns a reference to the HashMap of dependencies for the specified resource type.
1755    /// This provides a unified interface for accessing different resource collections,
1756    /// similar to `LockFile::get_resources()`.
1757    ///
1758    ///
1759    /// ```rust,no_run
1760    /// use agpm_cli::manifest::Manifest;
1761    /// use agpm_cli::core::ResourceType;
1762    ///
1763    #[must_use]
1764    pub fn get_resources(
1765        &self,
1766        resource_type: &crate::core::ResourceType,
1767    ) -> &HashMap<String, ResourceDependency> {
1768        use crate::core::ResourceType;
1769        match resource_type {
1770            ResourceType::Agent => &self.agents,
1771            ResourceType::Snippet => &self.snippets,
1772            ResourceType::Command => &self.commands,
1773            ResourceType::Script => &self.scripts,
1774            ResourceType::Hook => &self.hooks,
1775            ResourceType::McpServer => &self.mcp_servers,
1776            ResourceType::Skill => &self.skills,
1777        }
1778    }
1779
1780    /// Get all resource dependencies across all types.
1781    ///
1782    /// Returns a vector of tuples containing the resource type, manifest key (name),
1783    /// and the dependency specification. This provides a unified way to iterate over
1784    /// all resources regardless of type.
1785    ///
1786    /// # Returns
1787    ///
1788    /// A vector of `(ResourceType, &str, &ResourceDependency)` tuples where:
1789    /// - The first element is the type of resource (Agent, Snippet, etc.)
1790    /// - The second element is the manifest key (the name in the TOML file)
1791    /// - The third element is the resource dependency specification
1792    ///
1793    #[must_use]
1794    pub fn all_resources(&self) -> Vec<(crate::core::ResourceType, &str, &ResourceDependency)> {
1795        use crate::core::ResourceType;
1796
1797        let mut resources = Vec::new();
1798
1799        for resource_type in ResourceType::all() {
1800            let type_resources = self.get_resources(resource_type);
1801            for (name, dep) in type_resources {
1802                resources.push((*resource_type, name.as_str(), dep));
1803            }
1804        }
1805
1806        resources
1807    }
1808
1809    /// Add or update an MCP server configuration.
1810    ///
1811    /// MCP servers now use standard `ResourceDependency` format,
1812    /// pointing to JSON configuration files in source repositories.
1813    ///
1814    ///
1815    /// ```rust,no_run,ignore
1816    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
1817    ///
1818    /// let mut manifest = Manifest::new();
1819    ///
1820    pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
1821        self.mcp_servers.insert(name, dependency);
1822    }
1823
1824    /// Compute a hash of all manifest dependency specifications.
1825    ///
1826    /// This hash is used for fast path detection during subsequent installs.
1827    /// If the hash matches the one stored in the lockfile, and there are no
1828    /// mutable dependencies, we can skip resolution entirely.
1829    ///
1830    /// The hash includes:
1831    /// - All source definitions (name + URL)
1832    /// - All dependency specifications (serialized to canonical JSON)
1833    /// - Patch configurations
1834    /// - Tools configuration
1835    ///
1836    /// # Returns
1837    ///
1838    /// A SHA-256 hash string in "sha256:hex" format
1839    ///
1840    /// # Determinism
1841    ///
1842    /// Direct `serde_json::to_string()` on structs with HashMaps produces non-deterministic
1843    /// output because HashMap iteration order varies between runs. We use the two-step
1844    /// `to_value()` then `to_string()` approach because `serde_json::Map` (used internally
1845    /// by `Value`) is backed by `BTreeMap` when `preserve_order` is disabled (our default),
1846    /// which keeps keys sorted. See: <https://docs.rs/serde_json/latest/serde_json/struct.Map.html>
1847    ///
1848    /// # Stability
1849    ///
1850    /// The hash format is stable across AGPM versions within the same major version.
1851    /// Changes to hash computation require a lockfile format version bump and migration
1852    /// strategy to ensure existing lockfiles continue to work correctly.
1853    #[must_use]
1854    pub fn compute_dependency_hash(&self) -> String {
1855        use sha2::{Digest, Sha256};
1856
1857        let mut hasher = Sha256::new();
1858
1859        // Hash sources (sorted by name)
1860        let mut sources: Vec<_> = self.sources.iter().collect();
1861        sources.sort_by_key(|(k, _)| *k);
1862        for (name, url) in sources {
1863            hasher.update(b"source:");
1864            hasher.update(name.as_bytes());
1865            hasher.update(b"=");
1866            hasher.update(url.as_bytes());
1867            hasher.update(b"\n");
1868        }
1869
1870        // Hash each resource type (sorted by name, then by dependency fields)
1871        for resource_type in crate::core::ResourceType::all() {
1872            let resources = self.get_resources(resource_type);
1873            let mut sorted_resources: Vec<_> = resources.iter().collect();
1874            sorted_resources.sort_by_key(|(k, _)| *k);
1875
1876            for (name, dep) in sorted_resources {
1877                hasher.update(format!("{}:", resource_type).as_bytes());
1878                hasher.update(name.as_bytes());
1879                hasher.update(b"=");
1880                // Convert to Value first, then serialize - serde_json::Map keeps keys sorted
1881                // by default (without preserve_order feature), ensuring deterministic output
1882                match serde_json::to_value(dep).and_then(|v| serde_json::to_string(&v)) {
1883                    Ok(json) => hasher.update(json.as_bytes()),
1884                    Err(e) => {
1885                        tracing::warn!(
1886                            "Failed to serialize dependency '{}' for hashing: {}. Using name fallback.",
1887                            name,
1888                            e
1889                        );
1890                        // Include name in fallback to avoid hash collisions between different deps
1891                        hasher.update(b"<serialization_failed:");
1892                        hasher.update(name.as_bytes());
1893                        hasher.update(b">");
1894                    }
1895                }
1896                hasher.update(b"\n");
1897            }
1898        }
1899
1900        // Hash patches (they affect resolution)
1901        // ManifestPatches uses BTreeMap which is already deterministic
1902        if !self.patches.is_empty() {
1903            match serde_json::to_value(&self.patches).and_then(|v| serde_json::to_string(&v)) {
1904                Ok(json) => {
1905                    hasher.update(b"patches=");
1906                    hasher.update(json.as_bytes());
1907                    hasher.update(b"\n");
1908                }
1909                Err(e) => {
1910                    tracing::warn!(
1911                        "Failed to serialize patches for hashing: {}. Using fallback.",
1912                        e
1913                    );
1914                    hasher.update(b"patches=<serialization_failed>\n");
1915                }
1916            }
1917        }
1918
1919        // Hash tools configuration (affects installation paths)
1920        // Convert to Value first for deterministic HashMap serialization
1921        if let Some(tools) = &self.tools {
1922            match serde_json::to_value(tools).and_then(|v| serde_json::to_string(&v)) {
1923                Ok(json) => {
1924                    hasher.update(b"tools=");
1925                    hasher.update(json.as_bytes());
1926                    hasher.update(b"\n");
1927                }
1928                Err(e) => {
1929                    tracing::warn!("Failed to serialize tools for hashing: {}. Using fallback.", e);
1930                    hasher.update(b"tools=<serialization_failed>\n");
1931                }
1932            }
1933        }
1934
1935        let result = hasher.finalize();
1936        format!("sha256:{}", hex::encode(result))
1937    }
1938
1939    /// Check if any dependencies are mutable (local files or branches).
1940    ///
1941    /// Mutable dependencies can change between installs without manifest changes:
1942    /// - **Local sources**: Files on disk can change at any time
1943    /// - **Branch references**: Git branches can be updated
1944    ///
1945    /// When mutable dependencies exist, the fast path cannot be used because
1946    /// we must re-validate that the content hasn't changed.
1947    ///
1948    /// # Returns
1949    ///
1950    /// - `true` if any dependency uses a local source or branch reference
1951    /// - `false` if all dependencies use immutable references (semver tags, pinned SHAs)
1952    #[must_use]
1953    pub fn has_mutable_dependencies(&self) -> bool {
1954        self.all_resources().into_iter().any(|(_, _, dep)| dep.is_mutable())
1955    }
1956
1957    /// Check if a dependency is from the private manifest (agpm.private.toml).
1958    ///
1959    /// Private dependencies:
1960    /// - Install to `{resource_path}/private/` subdirectory
1961    /// - Are tracked in `agpm.private.lock` instead of `agpm.lock`
1962    /// - Don't affect team lockfile consistency
1963    ///
1964    /// # Arguments
1965    ///
1966    /// * `resource_type` - The resource type (accepts both singular "agent" and plural "agents")
1967    /// * `name` - The dependency name as specified in the manifest
1968    ///
1969    /// # Returns
1970    ///
1971    /// `true` if the dependency came from `agpm.private.toml`, `false` otherwise.
1972    #[must_use]
1973    pub fn is_private_dependency(&self, resource_type: &str, name: &str) -> bool {
1974        // Normalize resource type to plural form (as stored in private_dependency_names)
1975        let plural_type = match resource_type {
1976            "agent" => "agents",
1977            "snippet" => "snippets",
1978            "command" => "commands",
1979            "script" => "scripts",
1980            "hook" => "hooks",
1981            "mcp-server" => "mcp-servers",
1982            // Already plural or unknown
1983            other => other,
1984        };
1985        self.private_dependency_names.contains(&(plural_type.to_string(), name.to_string()))
1986    }
1987}
1988
1989impl Default for Manifest {
1990    fn default() -> Self {
1991        Self::new()
1992    }
1993}