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: ".claude/agpm/snippets")
40//! snippets = ".claude/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 = ".claude/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/agpm/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;
480
481use anyhow::{Context, Result};
482use serde::{Deserialize, Serialize};
483use std::collections::HashMap;
484use std::path::{Path, PathBuf};
485
486pub use dependency_spec::{DependencyMetadata, DependencySpec};
487
488/// The main manifest file structure representing a complete `agpm.toml` file.
489///
490/// This struct encapsulates all configuration for a AGPM project, including
491/// source repositories, installation targets, and resource dependencies.
492/// It provides the foundation for declarative dependency management similar
493/// to Cargo's `Cargo.toml`.
494///
495/// # Structure
496///
497/// - **Sources**: Named Git repositories that can be referenced by dependencies
498/// - **Target**: Installation directories for different resource types
499/// - **Agents**: AI agent dependencies (`.md` files with agent definitions)
500/// - **Snippets**: Code snippet dependencies (`.md` files with reusable code)
501/// - **Commands**: Claude Code command dependencies (`.md` files with slash commands)
502///
503/// # Serialization
504///
505/// The struct uses Serde for TOML serialization/deserialization with these behaviors:
506/// - Empty collections are omitted from serialized output for cleaner files
507/// - Default values are automatically applied for missing fields
508/// - Field names match TOML section names exactly
509///
510/// # Thread Safety
511///
512/// This struct is thread-safe and can be shared across async tasks safely.
513///
514/// # Examples
515///
516/// ```rust,no_run
517/// use agpm_cli::manifest::{Manifest, ResourceDependency};
518///
519/// // Create a new empty manifest
520/// let mut manifest = Manifest::new();
521///
522/// // Add a source repository
523/// manifest.add_source(
524/// "community".to_string(),
525/// "https://github.com/claude-community/resources.git".to_string()
526/// );
527///
528/// // Add a dependency
529/// manifest.add_dependency(
530/// "helper".to_string(),
531/// ResourceDependency::Simple("../local/helper.md".to_string()),
532/// true // is_agent = true
533/// );
534/// ```
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct Manifest {
537 /// Named source repositories mapped to their Git URLs.
538 ///
539 /// Keys are short, convenient names used in dependency specifications.
540 /// Values are Git repository URLs (HTTPS, SSH, or local file:// URLs).
541 ///
542 /// **Security Note**: Never include authentication tokens in these URLs.
543 /// Use SSH keys or configure authentication in the global config file.
544 ///
545 /// # Examples
546 ///
547 /// ```toml
548 /// [sources]
549 /// official = "https://github.com/claude-org/official.git"
550 /// private = "git@github.com:company/private.git"
551 /// local = "file:///home/user/local-repo"
552 /// ```
553 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
554 pub sources: HashMap<String, String>,
555
556 /// Installation target directory configuration.
557 ///
558 /// Specifies where different resource types should be installed relative
559 /// to the project root. Uses sensible defaults if not specified.
560 ///
561 /// See [`TargetConfig`] for details on default values and customization.
562 #[deprecated(since = "0.4.0", note = "Use tools configuration instead")]
563 #[serde(default)]
564 pub target: TargetConfig,
565
566 /// Tool type configurations for multi-tool support.
567 ///
568 /// Maps tool type names (claude-code, opencode, agpm, custom) to their
569 /// installation configurations. This replaces the old `target` field and
570 /// enables support for multiple tools and custom tool types.
571 ///
572 /// See [`ToolsConfig`] for details on configuration format.
573 #[serde(rename = "tools", skip_serializing_if = "Option::is_none")]
574 pub tools: Option<ToolsConfig>,
575
576 /// Agent dependencies mapping names to their specifications.
577 ///
578 /// Agents are typically AI model definitions, prompts, or behavioral
579 /// specifications stored as Markdown files. Each dependency can be
580 /// either local (filesystem path) or remote (from a Git source).
581 ///
582 /// See [`ResourceDependency`] for specification format details.
583 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
584 pub agents: HashMap<String, ResourceDependency>,
585
586 /// Snippet dependencies mapping names to their specifications.
587 ///
588 /// Snippets are typically reusable code templates, examples, or
589 /// documentation stored as Markdown files. They follow the same
590 /// dependency format as agents.
591 ///
592 /// See [`ResourceDependency`] for specification format details.
593 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
594 pub snippets: HashMap<String, ResourceDependency>,
595
596 /// Command dependencies mapping names to their specifications.
597 ///
598 /// Commands are Claude Code slash commands that provide custom functionality
599 /// and automation within the Claude Code interface. They follow the same
600 /// dependency format as agents and snippets.
601 ///
602 /// See [`ResourceDependency`] for specification format details.
603 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
604 pub commands: HashMap<String, ResourceDependency>,
605
606 /// MCP server configurations mapping names to their specifications.
607 ///
608 /// MCP servers provide integrations with external systems and services,
609 /// allowing Claude Code to connect to databases, APIs, and other tools.
610 /// MCP servers are JSON configuration files that get installed to
611 /// `.claude/agpm/mcp-servers/` and configured in `.mcp.json`.
612 ///
613 /// See [`ResourceDependency`] for specification format details.
614 #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "mcp-servers")]
615 pub mcp_servers: HashMap<String, ResourceDependency>,
616
617 /// Script dependencies mapping names to their specifications.
618 ///
619 /// Scripts are executable files (.sh, .js, .py, etc.) that can be run by hooks
620 /// or independently. They are installed to `.claude/agpm/scripts/` and can be
621 /// referenced by hook configurations.
622 ///
623 /// See [`ResourceDependency`] for specification format details.
624 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
625 pub scripts: HashMap<String, ResourceDependency>,
626
627 /// Hook dependencies mapping names to their specifications.
628 ///
629 /// Hooks are JSON configuration files that define event-based automation
630 /// in Claude Code. They specify when to run scripts based on tool usage,
631 /// prompts, and other events. Hook configurations are merged into
632 /// `settings.local.json`.
633 ///
634 /// See [`ResourceDependency`] for specification format details.
635 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
636 pub hooks: HashMap<String, ResourceDependency>,
637}
638
639/// Resource configuration within a tool.
640///
641/// Defines the installation path for a specific resource type within a tool.
642/// The path can be omitted for resources that have special handling (e.g., MCP servers
643/// that merge into configuration files instead of being installed as separate files).
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
645pub struct ResourceConfig {
646 /// Subdirectory path for this resource type relative to the tool's base directory.
647 ///
648 /// None means special handling (e.g., MCP servers that merge into config files)
649 #[serde(skip_serializing_if = "Option::is_none")]
650 pub path: Option<String>,
651}
652
653/// Tool configuration.
654///
655/// Defines how a specific tool (e.g., claude-code, opencode, agpm)
656/// organizes its resources. Each tool has a base directory and
657/// a map of resource types to their subdirectory configurations.
658#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct ArtifactTypeConfig {
660 /// Base directory for this tool (e.g., ".claude", ".opencode", ".agpm")
661 pub path: PathBuf,
662
663 /// Map of resource type -> configuration
664 pub resources: HashMap<String, ResourceConfig>,
665}
666
667/// Top-level tools configuration.
668///
669/// Maps tool type names to their configurations. This replaces the old
670/// `[target]` section and enables multi-tool support.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct ToolsConfig {
673 /// Map of tool type name -> configuration
674 #[serde(flatten)]
675 pub types: HashMap<String, ArtifactTypeConfig>,
676}
677
678impl Default for ToolsConfig {
679 fn default() -> Self {
680 use crate::core::ResourceType;
681 let mut types = HashMap::new();
682
683 // Claude Code configuration
684 let mut claude_resources = HashMap::new();
685 claude_resources.insert(
686 ResourceType::Agent.to_plural().to_string(),
687 ResourceConfig {
688 path: Some("agents".to_string()),
689 },
690 );
691 claude_resources.insert(
692 ResourceType::Snippet.to_plural().to_string(),
693 ResourceConfig {
694 path: Some("agpm/snippets".to_string()),
695 },
696 );
697 claude_resources.insert(
698 ResourceType::Command.to_plural().to_string(),
699 ResourceConfig {
700 path: Some("commands".to_string()),
701 },
702 );
703 claude_resources.insert(
704 ResourceType::Script.to_plural().to_string(),
705 ResourceConfig {
706 path: Some("agpm/scripts".to_string()),
707 },
708 );
709 claude_resources.insert(
710 ResourceType::Hook.to_plural().to_string(),
711 ResourceConfig {
712 path: Some("agpm/hooks".to_string()),
713 },
714 );
715 claude_resources.insert(
716 ResourceType::McpServer.to_plural().to_string(),
717 ResourceConfig {
718 path: Some("agpm/mcp-servers".to_string()),
719 },
720 );
721
722 types.insert(
723 "claude-code".to_string(),
724 ArtifactTypeConfig {
725 path: PathBuf::from(".claude"),
726 resources: claude_resources,
727 },
728 );
729
730 // OpenCode configuration
731 let mut opencode_resources = HashMap::new();
732 opencode_resources.insert(
733 ResourceType::Agent.to_plural().to_string(),
734 ResourceConfig {
735 path: Some("agent".to_string()), // Singular
736 },
737 );
738 opencode_resources.insert(
739 ResourceType::Command.to_plural().to_string(),
740 ResourceConfig {
741 path: Some("command".to_string()), // Singular
742 },
743 );
744 opencode_resources.insert(
745 ResourceType::McpServer.to_plural().to_string(),
746 ResourceConfig {
747 path: Some("agpm/mcp-servers".to_string()), // Temporary staging area for merge
748 },
749 );
750
751 types.insert(
752 "opencode".to_string(),
753 ArtifactTypeConfig {
754 path: PathBuf::from(".opencode"),
755 resources: opencode_resources,
756 },
757 );
758
759 // AGPM configuration (snippets only)
760 let mut agpm_resources = HashMap::new();
761 agpm_resources.insert(
762 ResourceType::Snippet.to_plural().to_string(),
763 ResourceConfig {
764 path: Some("snippets".to_string()),
765 },
766 );
767
768 types.insert(
769 "agpm".to_string(),
770 ArtifactTypeConfig {
771 path: PathBuf::from(".agpm"),
772 resources: agpm_resources,
773 },
774 );
775
776 Self {
777 types,
778 }
779 }
780}
781
782/// Target directories configuration specifying where resources are installed.
783///
784/// This struct defines the installation destinations for different resource types
785/// within a AGPM project. All paths are relative to the project root (where
786/// `agpm.toml` is located) unless they are absolute paths.
787///
788/// # Default Values
789///
790/// - **Agents**: `.claude/agents` - Following Claude Code conventions
791/// - **Snippets**: `.claude/agpm/snippets` - Following Claude Code conventions
792/// - **Commands**: `.claude/commands` - Following Claude Code conventions
793///
794/// # Path Resolution
795///
796/// - Relative paths are resolved from the manifest directory
797/// - Absolute paths are used as-is (not recommended for portability)
798/// - Path separators are automatically normalized for the target platform
799/// - Directories are created automatically during installation if they don't exist
800///
801/// # Examples
802///
803/// ```toml
804/// # Default configuration (can be omitted)
805/// [target]
806/// agents = ".claude/agents"
807/// snippets = ".claude/agpm/snippets"
808/// commands = ".claude/commands"
809///
810/// # Custom configuration
811/// [target]
812/// agents = "resources/ai-agents"
813/// snippets = "templates/code-snippets"
814/// commands = "resources/commands"
815///
816/// # Absolute paths (use with caution)
817/// [target]
818/// agents = "/opt/claude/agents"
819/// snippets = "/opt/claude/snippets"
820/// commands = "/opt/claude/commands"
821/// ```
822///
823/// # Cross-Platform Considerations
824///
825/// AGPM automatically handles platform differences:
826/// - Forward slashes work on all platforms (Windows, macOS, Linux)
827/// - Path separators are normalized during installation
828/// - Long path support on Windows is handled automatically
829#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct TargetConfig {
831 /// Directory where agent `.md` files should be installed.
832 ///
833 /// Agents are AI model definitions, prompts, or behavioral specifications.
834 /// This directory will contain copies of agent files from dependencies.
835 ///
836 /// **Default**: `.claude/agents` (following Claude Code conventions)
837 #[serde(default = "default_agents_dir")]
838 pub agents: String,
839
840 /// Directory where snippet `.md` files should be installed.
841 ///
842 /// Snippets are reusable code templates, examples, or documentation.
843 /// This directory will contain copies of snippet files from dependencies.
844 ///
845 /// **Default**: `.claude/agpm/snippets` (following Claude Code conventions)
846 #[serde(default = "default_snippets_dir")]
847 pub snippets: String,
848
849 /// Directory where command `.md` files should be installed.
850 ///
851 /// Commands are Claude Code slash commands that provide custom functionality.
852 /// This directory will contain copies of command files from dependencies.
853 ///
854 /// **Default**: `.claude/commands` (following Claude Code conventions)
855 #[serde(default = "default_commands_dir")]
856 pub commands: String,
857
858 /// Directory where MCP server configurations should be tracked.
859 ///
860 /// Note: MCP servers are configured in `.mcp.json` at the project root,
861 /// not installed to this directory. This directory is used for tracking
862 /// metadata about installed servers.
863 ///
864 /// **Default**: `.claude/agpm/mcp-servers` (following Claude Code conventions)
865 #[serde(default = "default_mcp_servers_dir", rename = "mcp-servers")]
866 pub mcp_servers: String,
867
868 /// Directory where script files should be installed.
869 ///
870 /// Scripts are executable files (.sh, .js, .py, etc.) that can be referenced
871 /// by hooks or run independently.
872 ///
873 /// **Default**: `.claude/agpm/scripts` (following Claude Code conventions)
874 #[serde(default = "default_scripts_dir")]
875 pub scripts: String,
876
877 /// Directory where hook configuration files should be installed.
878 ///
879 /// Hooks are JSON configuration files that define event-based automation
880 /// in Claude Code.
881 ///
882 /// **Default**: `.claude/agpm/hooks` (following Claude Code conventions)
883 #[serde(default = "default_hooks_dir")]
884 pub hooks: String,
885
886 /// Whether to automatically add installed files to `.gitignore`.
887 ///
888 /// When enabled (default), AGPM will create or update `.gitignore`
889 /// to exclude all installed files from version control. This prevents
890 /// installed dependencies from being committed to your repository.
891 ///
892 /// Set to `false` if you want to commit installed resources to version control.
893 ///
894 /// **Default**: `true`
895 #[serde(default = "default_gitignore")]
896 pub gitignore: bool,
897}
898
899impl Default for TargetConfig {
900 fn default() -> Self {
901 Self {
902 agents: default_agents_dir(),
903 snippets: default_snippets_dir(),
904 commands: default_commands_dir(),
905 mcp_servers: default_mcp_servers_dir(),
906 scripts: default_scripts_dir(),
907 hooks: default_hooks_dir(),
908 gitignore: default_gitignore(),
909 }
910 }
911}
912
913fn default_agents_dir() -> String {
914 ".claude/agents".to_string()
915}
916
917fn default_snippets_dir() -> String {
918 ".claude/agpm/snippets".to_string()
919}
920
921fn default_commands_dir() -> String {
922 ".claude/commands".to_string()
923}
924
925fn default_mcp_servers_dir() -> String {
926 ".claude/agpm/mcp-servers".to_string()
927}
928
929fn default_scripts_dir() -> String {
930 ".claude/agpm/scripts".to_string()
931}
932
933fn default_hooks_dir() -> String {
934 ".claude/agpm/hooks".to_string()
935}
936
937const fn default_gitignore() -> bool {
938 true
939}
940
941/// A resource dependency specification supporting multiple formats.
942///
943/// Dependencies can be specified in two main formats to balance simplicity
944/// with flexibility. The enum uses Serde's `untagged` attribute to automatically
945/// deserialize the correct variant based on the TOML structure.
946///
947/// # Variants
948///
949/// ## Simple Dependencies
950///
951/// For local file dependencies, just specify the path directly:
952///
953/// ```toml
954/// [agents]
955/// local-helper = "../shared/agents/helper.md"
956/// nearby-agent = "./local/custom-agent.md"
957/// ```
958///
959/// ## Detailed Dependencies
960///
961/// For remote dependencies or when you need more control:
962///
963/// ```toml
964/// [agents]
965/// # Remote dependency with version
966/// code-reviewer = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
967///
968/// # Remote dependency with git reference
969/// experimental = { source = "community", path = "agents/new.md", git = "develop" }
970///
971/// # Local dependency with explicit path (equivalent to simple form)
972/// local-tool = { path = "../tools/agent.md" }
973/// ```
974///
975/// # Validation Rules
976///
977/// - **Local dependencies** (no source): Cannot have version constraints
978/// - **Remote dependencies** (with source): Must have either `version` or `git` field
979/// - **Path field**: Required and cannot be empty
980/// - **Source field**: Must reference an existing source in the `[sources]` section
981///
982/// # Type Safety
983///
984/// The enum ensures type safety at compile time while providing runtime
985/// validation through the [`Manifest::validate`] method.
986///
987/// # Serialization Behavior
988///
989/// - Simple paths serialize directly as strings
990/// - Detailed specs serialize as TOML inline tables
991/// - Empty optional fields are omitted for cleaner output
992/// - Deserialization is automatic based on TOML structure
993///
994/// # Memory Layout
995///
996/// This enum uses `#[serde(untagged)]` for automatic variant detection,
997/// which means deserialization tries the `Detailed` variant first, then
998/// falls back to `Simple`. This is efficient for the expected usage patterns
999/// where detailed dependencies are more common in larger projects.
1000///
1001/// # Memory Layout
1002///
1003/// The `Detailed` variant is boxed to reduce the size of the enum from 264 bytes
1004/// to 24 bytes, improving memory efficiency when many dependencies are stored.
1005#[derive(Debug, Clone, Serialize, Deserialize)]
1006#[serde(untagged)]
1007pub enum ResourceDependency {
1008 /// Simple path-only dependency, typically for local files.
1009 ///
1010 /// This variant represents the simplest dependency format where only
1011 /// a file path is specified. It's primarily used for local dependencies
1012 /// that exist in the filesystem relative to the project.
1013 ///
1014 /// # Format
1015 ///
1016 /// ```toml
1017 /// dependency-name = "path/to/file.md"
1018 /// ```
1019 ///
1020 /// # Examples
1021 ///
1022 /// ```toml
1023 /// [agents]
1024 /// # Relative paths from manifest directory
1025 /// helper = "../shared/helper.md"
1026 /// custom = "./local/custom.md"
1027 ///
1028 /// # Absolute paths (not recommended)
1029 /// system = "/usr/local/share/agent.md"
1030 /// ```
1031 ///
1032 /// # Limitations
1033 ///
1034 /// - Cannot specify version constraints
1035 /// - Cannot reference remote Git sources
1036 /// - Must be a valid filesystem path
1037 /// - Path must exist at installation time
1038 Simple(String),
1039
1040 /// Detailed dependency specification with full control.
1041 ///
1042 /// This variant provides complete control over dependency specification,
1043 /// supporting both local and remote dependencies with version constraints,
1044 /// Git references, and explicit source mapping.
1045 ///
1046 /// See [`DetailedDependency`] for field-level documentation.
1047 ///
1048 /// Note: This variant is boxed to reduce the overall size of the enum.
1049 Detailed(Box<DetailedDependency>),
1050}
1051
1052/// Detailed dependency specification with full control over source resolution.
1053///
1054/// This struct provides fine-grained control over dependency specification,
1055/// supporting both local filesystem paths and remote Git repository resources
1056/// with flexible version constraints and Git reference handling.
1057///
1058/// # Field Relationships
1059///
1060/// The fields work together with specific validation rules:
1061/// - If `source` is specified: Must have either `version` or `git`
1062/// - If `source` is omitted: Dependency is local, `version` and `git` are ignored
1063/// - `path` is always required and cannot be empty
1064///
1065/// # Examples
1066///
1067/// ## Remote Dependencies
1068///
1069/// ```toml
1070/// [agents]
1071/// # Semantic version constraint
1072/// stable = { source = "official", path = "agents/stable.md", version = "v1.0.0" }
1073///
1074/// # Latest version (not recommended for production)
1075/// latest = { source = "community", path = "agents/utils.md", version = "latest" }
1076///
1077/// # Specific Git branch
1078/// cutting-edge = { source = "official", path = "agents/new.md", git = "develop" }
1079///
1080/// # Specific commit SHA (maximum reproducibility)
1081/// pinned = { source = "community", path = "agents/tool.md", git = "a1b2c3d4e5f6..." }
1082///
1083/// # Git tag
1084/// release = { source = "official", path = "agents/release.md", git = "v2.0-release" }
1085/// ```
1086///
1087/// ## Local Dependencies
1088///
1089/// ```toml
1090/// [agents]
1091/// # Local file (version/git fields ignored if present)
1092/// local-helper = { path = "../shared/helper.md" }
1093/// custom = { path = "./local/custom.md" }
1094/// ```
1095///
1096/// # Version Resolution Priority
1097///
1098/// When both `version` and `git` are specified, `git` takes precedence:
1099///
1100/// ```toml
1101/// # This will use the "develop" branch, not "v1.0.0"
1102/// conflicted = { source = "repo", path = "file.md", version = "v1.0.0", git = "develop" }
1103/// ```
1104///
1105/// # Path Format
1106///
1107/// Paths are interpreted differently based on context:
1108/// - **Remote dependencies**: Path within the Git repository
1109/// - **Local dependencies**: Filesystem path relative to manifest directory
1110#[derive(Debug, Clone, Serialize, Deserialize)]
1111pub struct DetailedDependency {
1112 /// Source repository name referencing the `[sources]` section.
1113 ///
1114 /// When specified, this dependency will be resolved from a Git repository.
1115 /// The name must exactly match a key in the manifest's `[sources]` table.
1116 ///
1117 /// **Omit this field** to create a local filesystem dependency.
1118 ///
1119 /// # Examples
1120 ///
1121 /// ```toml
1122 /// # References this source definition:
1123 /// [sources]
1124 /// official = "https://github.com/org/repo.git"
1125 ///
1126 /// [agents]
1127 /// remote-agent = { source = "official", path = "agents/tool.md", version = "v1.0.0" }
1128 /// local-agent = { path = "../local/tool.md" } # No source = local dependency
1129 /// ```
1130 #[serde(skip_serializing_if = "Option::is_none")]
1131 pub source: Option<String>,
1132
1133 /// Path to the resource file or glob pattern for multiple resources.
1134 ///
1135 /// For **remote dependencies**: Path within the Git repository\
1136 /// For **local dependencies**: Filesystem path relative to manifest directory\
1137 /// For **pattern dependencies**: Glob pattern to match multiple resources
1138 ///
1139 /// This field supports both individual file paths and glob patterns:
1140 /// - Individual file: `"agents/helper.md"`
1141 /// - Pattern matching: `"agents/*.md"`, `"**/*.md"`, `"agents/[a-z]*.md"`
1142 ///
1143 /// Pattern dependencies are detected by the presence of glob characters
1144 /// (`*`, `?`, `[`) in the path. When a pattern is detected, AGPM will
1145 /// expand it to match all resources in the source repository.
1146 ///
1147 /// # Examples
1148 ///
1149 /// ```toml
1150 /// # Remote: single file in git repo
1151 /// remote = { source = "repo", path = "agents/helper.md", version = "v1.0.0" }
1152 ///
1153 /// # Local: filesystem path
1154 /// local = { path = "../shared/helper.md" }
1155 ///
1156 /// # Pattern: all agents in AI folder
1157 /// ai_agents = { source = "repo", path = "agents/ai/*.md", version = "v1.0.0" }
1158 ///
1159 /// # Pattern: all agents recursively
1160 /// all_agents = { source = "repo", path = "agents/**/*.md", version = "v1.0.0" }
1161 /// ```
1162 pub path: String,
1163
1164 /// Version constraint for Git tag resolution.
1165 ///
1166 /// Specifies which version of the resource to use when resolving from
1167 /// a Git repository. This field is ignored for local dependencies.
1168 ///
1169 /// **Note**: If both `version` and `git` are specified, `git` takes precedence.
1170 ///
1171 /// # Supported Formats
1172 ///
1173 /// - `"v1.0.0"` - Exact semantic version tag
1174 /// - `"1.0.0"` - Exact version (v prefix optional)
1175 /// - `"^1.0.0"` - Semantic version constraint (highest compatible 1.x.x)
1176 /// - `"latest"` - Git tag or branch named "latest" (not special - just a name)
1177 /// - `"main"` - Use main/master branch HEAD
1178 ///
1179 /// # Examples
1180 ///
1181 /// ```toml
1182 /// [agents]
1183 /// stable = { source = "repo", path = "agent.md", version = "v1.0.0" }
1184 /// flexible = { source = "repo", path = "agent.md", version = "^1.0.0" }
1185 /// latest-tag = { source = "repo", path = "agent.md", version = "latest" } # If repo has a "latest" tag
1186 /// main = { source = "repo", path = "agent.md", version = "main" }
1187 /// ```
1188 #[serde(skip_serializing_if = "Option::is_none")]
1189 pub version: Option<String>,
1190
1191 /// Git branch to track.
1192 ///
1193 /// Specifies a Git branch to use when resolving the dependency.
1194 /// Branch references are mutable and will update to the latest commit on each update.
1195 /// This field is ignored for local dependencies.
1196 ///
1197 /// # Examples
1198 ///
1199 /// ```toml
1200 /// [agents]
1201 /// # Track the main branch
1202 /// dev = { source = "repo", path = "agent.md", branch = "main" }
1203 ///
1204 /// # Track a feature branch
1205 /// experimental = { source = "repo", path = "agent.md", branch = "feature/new-capability" }
1206 /// ```
1207 #[serde(skip_serializing_if = "Option::is_none")]
1208 pub branch: Option<String>,
1209
1210 /// Git commit hash (revision).
1211 ///
1212 /// Specifies an exact Git commit to use when resolving the dependency.
1213 /// Provides maximum reproducibility as commits are immutable.
1214 /// This field is ignored for local dependencies.
1215 ///
1216 /// # Examples
1217 ///
1218 /// ```toml
1219 /// [agents]
1220 /// # Pin to exact commit (full hash)
1221 /// pinned = { source = "repo", path = "agent.md", rev = "a1b2c3d4e5f67890abcdef1234567890abcdef12" }
1222 ///
1223 /// # Pin to exact commit (abbreviated)
1224 /// stable = { source = "repo", path = "agent.md", rev = "abc123def" }
1225 /// ```
1226 #[serde(skip_serializing_if = "Option::is_none")]
1227 pub rev: Option<String>,
1228
1229 /// Command to execute for MCP servers.
1230 ///
1231 /// This field is specific to MCP server dependencies and specifies
1232 /// the command that will be executed to run the MCP server.
1233 /// Only used for entries in the `[mcp-servers]` section.
1234 ///
1235 /// # Examples
1236 ///
1237 /// ```toml
1238 /// [mcp-servers]
1239 /// github = { source = "repo", path = "mcp/github.toml", version = "v1.0.0", command = "npx" }
1240 /// sqlite = { path = "./local/sqlite.toml", command = "uvx" }
1241 /// ```
1242 #[serde(skip_serializing_if = "Option::is_none")]
1243 pub command: Option<String>,
1244
1245 /// Arguments to pass to the MCP server command.
1246 ///
1247 /// This field is specific to MCP server dependencies and provides
1248 /// the arguments that will be passed to the command when starting
1249 /// the MCP server. Only used for entries in the `[mcp-servers]` section.
1250 ///
1251 /// # Examples
1252 ///
1253 /// ```toml
1254 /// [mcp-servers]
1255 /// github = {
1256 /// source = "repo",
1257 /// path = "mcp/github.toml",
1258 /// version = "v1.0.0",
1259 /// command = "npx",
1260 /// args = ["-y", "@modelcontextprotocol/server-github"]
1261 /// }
1262 /// sqlite = {
1263 /// path = "./local/sqlite.toml",
1264 /// command = "uvx",
1265 /// args = ["mcp-server-sqlite", "--db", "./data/local.db"]
1266 /// }
1267 /// ```
1268 #[serde(skip_serializing_if = "Option::is_none")]
1269 pub args: Option<Vec<String>>,
1270 /// Custom target directory for this dependency.
1271 ///
1272 /// Overrides the default installation directory for this specific dependency.
1273 /// The path is relative to the `.claude` directory for consistency and security.
1274 /// If not specified, the dependency will be installed to the default location
1275 /// based on its resource type.
1276 ///
1277 /// # Examples
1278 ///
1279 /// ```toml
1280 /// [agents]
1281 /// # Install to .claude/custom/tools/ instead of default .claude/agents/
1282 /// special-agent = {
1283 /// source = "repo",
1284 /// path = "agent.md",
1285 /// version = "v1.0.0",
1286 /// target = "custom/tools"
1287 /// }
1288 ///
1289 /// # Install to .claude/integrations/ai/
1290 /// integration = {
1291 /// source = "repo",
1292 /// path = "integration.md",
1293 /// version = "v2.0.0",
1294 /// target = "integrations/ai"
1295 /// }
1296 /// ```
1297 #[serde(skip_serializing_if = "Option::is_none")]
1298 pub target: Option<String>,
1299
1300 /// Custom filename for this dependency.
1301 ///
1302 /// Overrides the default filename (which is based on the dependency key).
1303 /// The filename should include the desired file extension. If not specified,
1304 /// the dependency will be installed using the key name with an automatically
1305 /// determined extension based on the resource type.
1306 ///
1307 /// # Examples
1308 ///
1309 /// ```toml
1310 /// [agents]
1311 /// # Install as "ai-assistant.md" instead of "my-ai.md"
1312 /// my-ai = {
1313 /// source = "repo",
1314 /// path = "agent.md",
1315 /// version = "v1.0.0",
1316 /// filename = "ai-assistant.md"
1317 /// }
1318 ///
1319 /// # Install with a different extension
1320 /// doc-agent = {
1321 /// source = "repo",
1322 /// path = "documentation.md",
1323 /// version = "v2.0.0",
1324 /// filename = "docs-helper.txt"
1325 /// }
1326 ///
1327 /// [scripts]
1328 /// # Rename a script during installation
1329 /// analyzer = {
1330 /// source = "repo",
1331 /// path = "scripts/data-analyzer-v3.py",
1332 /// version = "v1.0.0",
1333 /// filename = "analyze.py"
1334 /// }
1335 /// ```
1336 #[serde(skip_serializing_if = "Option::is_none")]
1337 pub filename: Option<String>,
1338
1339 /// Transitive dependencies on other resources.
1340 ///
1341 /// This field is populated from metadata extracted from the resource file itself
1342 /// (YAML frontmatter in .md files or JSON fields in .json files).
1343 /// Maps resource type to list of dependency specifications.
1344 ///
1345 /// Example:
1346 /// ```toml
1347 /// # This would be extracted from the file's frontmatter/JSON, not specified in agpm.toml
1348 /// # { "agents": [{"path": "agents/helper.md", "version": "v1.0.0"}] }
1349 /// ```
1350 #[serde(skip_serializing_if = "Option::is_none")]
1351 pub dependencies: Option<HashMap<String, Vec<DependencySpec>>>,
1352
1353 /// Tool type (claude-code, opencode, agpm, or custom).
1354 ///
1355 /// Specifies which target AI coding assistant tool this resource is for. This determines
1356 /// where the resource is installed and how it's configured.
1357 ///
1358 /// **Defaults to "claude-code"** for backward compatibility with existing manifests.
1359 ///
1360 /// Omitted from TOML serialization when the value is "claude-code" (default).
1361 #[serde(default = "default_tool", skip_serializing_if = "is_default_tool")]
1362 pub tool: String,
1363}
1364
1365fn default_tool() -> String {
1366 "claude-code".to_string()
1367}
1368
1369fn is_default_tool(tool: &str) -> bool {
1370 tool == "claude-code"
1371}
1372
1373impl Manifest {
1374 /// Create a new empty manifest with default configuration.
1375 ///
1376 /// The new manifest will have:
1377 /// - No sources defined
1378 /// - Default target directories (`.claude/agents` and `.claude/agpm/snippets`)
1379 /// - No dependencies
1380 ///
1381 /// This is typically used when programmatically building a manifest or
1382 /// as a starting point for adding dependencies.
1383 ///
1384 /// # Examples
1385 ///
1386 /// ```rust,no_run
1387 /// use agpm_cli::manifest::Manifest;
1388 ///
1389 /// let manifest = Manifest::new();
1390 /// assert!(manifest.sources.is_empty());
1391 /// assert!(manifest.agents.is_empty());
1392 /// assert!(manifest.snippets.is_empty());
1393 /// assert!(manifest.commands.is_empty());
1394 /// assert!(manifest.mcp_servers.is_empty());
1395 /// assert_eq!(manifest.target.agents, ".claude/agents");
1396 /// ```
1397 #[must_use]
1398 #[allow(deprecated)]
1399 pub fn new() -> Self {
1400 Self {
1401 sources: HashMap::new(),
1402 target: TargetConfig::default(),
1403 tools: None,
1404 agents: HashMap::new(),
1405 snippets: HashMap::new(),
1406 commands: HashMap::new(),
1407 mcp_servers: HashMap::new(),
1408 scripts: HashMap::new(),
1409 hooks: HashMap::new(),
1410 }
1411 }
1412
1413 /// Load and parse a manifest from a TOML file.
1414 ///
1415 /// This method reads the specified file, parses it as TOML, deserializes
1416 /// it into a [`Manifest`] struct, and validates the result. The entire
1417 /// operation is atomic - either the manifest loads successfully or an
1418 /// error is returned.
1419 ///
1420 /// # Validation
1421 ///
1422 /// After parsing, the manifest is automatically validated to ensure:
1423 /// - All dependency sources reference valid entries in the `[sources]` section
1424 /// - Required fields are present and non-empty
1425 /// - Version constraints are properly specified for remote dependencies
1426 /// - Source URLs use supported protocols
1427 /// - No version conflicts exist between dependencies
1428 ///
1429 /// # Error Handling
1430 ///
1431 /// Returns detailed errors for common problems:
1432 /// - **File I/O errors**: File not found, permission denied, etc.
1433 /// - **TOML syntax errors**: Invalid TOML format with helpful suggestions
1434 /// - **Validation errors**: Logical inconsistencies in the manifest
1435 /// - **Security errors**: Unsafe URL patterns or credential leakage
1436 ///
1437 /// All errors include contextual information and actionable suggestions.
1438 ///
1439 /// # Examples
1440 ///
1441 /// ```rust,no_run,ignore
1442 /// use agpm_cli::manifest::Manifest;
1443 /// use std::path::Path;
1444 ///
1445 /// // Load a manifest file
1446 /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
1447 ///
1448 /// // Access parsed data
1449 /// println!("Found {} sources", manifest.sources.len());
1450 /// println!("Found {} agents", manifest.agents.len());
1451 /// println!("Found {} snippets", manifest.snippets.len());
1452 /// # Ok::<(), anyhow::Error>(())
1453 /// ```
1454 ///
1455 /// # File Format
1456 ///
1457 /// Expects a valid TOML file following the AGPM manifest format.
1458 /// See the module-level documentation for complete format specification.
1459 pub fn load(path: &Path) -> Result<Self> {
1460 let content = std::fs::read_to_string(path).with_context(|| {
1461 format!(
1462 "Cannot read manifest file: {}\n\n\
1463 Possible causes:\n\
1464 - File doesn't exist or has been moved\n\
1465 - Permission denied (check file ownership)\n\
1466 - File is locked by another process",
1467 path.display()
1468 )
1469 })?;
1470
1471 let mut manifest: Self = toml::from_str(&content)
1472 .map_err(|e| crate::core::AgpmError::ManifestParseError {
1473 file: path.display().to_string(),
1474 reason: e.to_string(),
1475 })
1476 .with_context(|| {
1477 format!(
1478 "Invalid TOML syntax in manifest file: {}\n\n\
1479 Common TOML syntax errors:\n\
1480 - Missing quotes around strings\n\
1481 - Unmatched brackets [ ] or braces {{ }}\n\
1482 - Invalid characters in keys or values\n\
1483 - Incorrect indentation or structure",
1484 path.display()
1485 )
1486 })?;
1487
1488 // Apply resource-type-specific defaults for tool
1489 // Snippets default to "agpm" (shared infrastructure) instead of "claude-code"
1490 manifest.apply_tool_defaults();
1491
1492 manifest.validate()?;
1493
1494 Ok(manifest)
1495 }
1496
1497 /// Apply resource-type-specific defaults for tool.
1498 ///
1499 /// This method adjusts the tool field based on the resource type to provide
1500 /// more sensible defaults:
1501 /// - **Snippets**: Default to "agpm" (shared infrastructure) instead of "claude-code"
1502 /// - **All other resources**: Keep "claude-code" as the default
1503 ///
1504 /// This is called automatically after deserialization in `load()`.
1505 ///
1506 /// # Rationale
1507 ///
1508 /// Snippets are designed to be shared content across multiple tools (Claude Code,
1509 /// OpenCode, etc.). The `.agpm/snippets/` location provides a shared infrastructure
1510 /// that can be referenced by resources from different tools. Therefore, snippets
1511 /// should default to the "agpm" tool type.
1512 ///
1513 /// Users can still explicitly set `tool = "claude-code"` for a snippet if they want
1514 /// it installed to `.claude/agpm/snippets/` instead.
1515 fn apply_tool_defaults(&mut self) {
1516 // Apply snippet-specific default: "agpm" instead of "claude-code"
1517 for dependency in self.snippets.values_mut() {
1518 if let ResourceDependency::Detailed(details) = dependency {
1519 // Only change if it's still the serde default ("claude-code")
1520 // This means: no explicit type was specified in the manifest
1521 if details.tool == "claude-code" {
1522 details.tool = "agpm".to_string();
1523 }
1524 }
1525 }
1526 }
1527
1528 /// Save the manifest to a TOML file with pretty formatting.
1529 ///
1530 /// This method serializes the manifest to TOML format and writes it to the
1531 /// specified file path. The output is pretty-printed for human readability
1532 /// and follows TOML best practices.
1533 ///
1534 /// # Formatting
1535 ///
1536 /// The generated TOML file will:
1537 /// - Use consistent indentation and spacing
1538 /// - Omit empty sections for cleaner output
1539 /// - Order sections logically (sources, target, agents, snippets)
1540 /// - Include inline tables for detailed dependencies
1541 ///
1542 /// # Atomic Operation
1543 ///
1544 /// The save operation is atomic - the file is either completely written
1545 /// or left unchanged. This prevents corruption if the operation fails
1546 /// partway through.
1547 ///
1548 /// # Error Handling
1549 ///
1550 /// Returns detailed errors for common problems:
1551 /// - **Permission denied**: Insufficient write permissions
1552 /// - **Directory doesn't exist**: Parent directory missing
1553 /// - **Disk full**: Insufficient storage space
1554 /// - **File locked**: Another process has the file open
1555 ///
1556 /// # Examples
1557 ///
1558 /// ```rust,no_run
1559 /// use agpm_cli::manifest::Manifest;
1560 /// use std::path::Path;
1561 ///
1562 /// let mut manifest = Manifest::new();
1563 /// manifest.add_source(
1564 /// "official".to_string(),
1565 /// "https://github.com/claude-org/resources.git".to_string()
1566 /// );
1567 ///
1568 /// // Save to file
1569 /// # use tempfile::tempdir;
1570 /// # let temp_dir = tempdir()?;
1571 /// # let manifest_path = temp_dir.path().join("agpm.toml");
1572 /// manifest.save(&manifest_path)?;
1573 /// # Ok::<(), anyhow::Error>(())
1574 /// ```
1575 ///
1576 /// # Output Format
1577 ///
1578 /// The generated file will follow this structure:
1579 ///
1580 /// ```toml
1581 /// [sources]
1582 /// official = "https://github.com/claude-org/resources.git"
1583 ///
1584 /// [target]
1585 /// agents = ".claude/agents"
1586 /// snippets = ".claude/agpm/snippets"
1587 ///
1588 /// [agents]
1589 /// helper = { source = "official", path = "agents/helper.md", version = "v1.0.0" }
1590 ///
1591 /// [snippets]
1592 /// utils = { source = "official", path = "snippets/utils.md", version = "v1.0.0" }
1593 /// ```
1594 pub fn save(&self, path: &Path) -> Result<()> {
1595 let content = toml::to_string_pretty(self)
1596 .with_context(|| "Failed to serialize manifest data to TOML format")?;
1597
1598 std::fs::write(path, content).with_context(|| {
1599 format!(
1600 "Cannot write manifest file: {}\n\n\
1601 Possible causes:\n\
1602 - Permission denied (try running with elevated permissions)\n\
1603 - Directory doesn't exist\n\
1604 - Disk is full or read-only\n\
1605 - File is locked by another process",
1606 path.display()
1607 )
1608 })?;
1609
1610 Ok(())
1611 }
1612
1613 /// Validate the manifest structure and enforce business rules.
1614 ///
1615 /// This method performs comprehensive validation of the manifest to ensure
1616 /// logical consistency, security best practices, and correct dependency
1617 /// relationships. It's automatically called during [`Self::load`] but can
1618 /// also be used independently to validate programmatically constructed manifests.
1619 ///
1620 /// # Validation Rules
1621 ///
1622 /// ## Source Validation
1623 /// - All source URLs must use supported protocols (HTTPS, SSH, git://, file://)
1624 /// - No plain directory paths allowed as sources (must use file:// URLs)
1625 /// - No authentication tokens embedded in URLs (security check)
1626 /// - Environment variable expansion is validated for syntax
1627 ///
1628 /// ## Dependency Validation
1629 /// - All dependency paths must be non-empty
1630 /// - Remote dependencies must reference existing sources
1631 /// - Remote dependencies must specify version constraints
1632 /// - Local dependencies cannot have version constraints
1633 /// - No version conflicts between dependencies with the same name
1634 ///
1635 /// ## Path Validation
1636 /// - Local dependency paths are checked for proper format
1637 /// - Remote dependency paths are validated as repository-relative
1638 /// - Path traversal attempts are detected and rejected
1639 ///
1640 /// # Error Types
1641 ///
1642 /// Returns specific error types for different validation failures:
1643 /// - [`crate::core::AgpmError::SourceNotFound`]: Referenced source doesn't exist
1644 /// - [`crate::core::AgpmError::ManifestValidationError`]: General validation failures
1645 /// - Context errors for specific issues with actionable suggestions
1646 ///
1647 /// # Examples
1648 ///
1649 /// ```rust,no_run
1650 /// use agpm_cli::manifest::{Manifest, ResourceDependency, DetailedDependency};
1651 ///
1652 /// let mut manifest = Manifest::new();
1653 ///
1654 /// // This will pass validation (local dependency)
1655 /// manifest.add_dependency(
1656 /// "local".to_string(),
1657 /// ResourceDependency::Simple("../local/helper.md".to_string()),
1658 /// true
1659 /// );
1660 /// assert!(manifest.validate().is_ok());
1661 ///
1662 /// // This will fail validation (missing source)
1663 /// manifest.add_dependency(
1664 /// "remote".to_string(),
1665 /// ResourceDependency::Detailed(Box::new(DetailedDependency {
1666 /// source: Some("missing".to_string()),
1667 /// path: "agent.md".to_string(),
1668 /// version: Some("v1.0.0".to_string()),
1669 /// branch: None,
1670 /// rev: None,
1671 /// command: None,
1672 /// args: None,
1673 /// target: None,
1674 /// filename: None,
1675 /// dependencies: None,
1676 /// tool: "claude-code".to_string(),
1677 /// })),
1678 /// true
1679 /// );
1680 /// assert!(manifest.validate().is_err());
1681 /// ```
1682 ///
1683 /// # Security Considerations
1684 ///
1685 /// This method enforces critical security rules:
1686 /// - Prevents credential leakage in version-controlled files
1687 /// - Blocks path traversal attacks in local dependencies
1688 /// - Validates URL schemes to prevent protocol confusion
1689 /// - Checks for malicious patterns in dependency specifications
1690 ///
1691 /// # Performance
1692 ///
1693 /// Validation is designed to be fast and is safe to call frequently.
1694 /// Complex validations (like network connectivity) are not performed
1695 /// here - those are handled during dependency resolution.
1696 pub fn validate(&self) -> Result<()> {
1697 // Validate artifact type names
1698 for artifact_type in self.get_tools_config().types.keys() {
1699 if artifact_type.contains('/') || artifact_type.contains('\\') {
1700 return Err(crate::core::AgpmError::ManifestValidationError {
1701 reason: format!(
1702 "Artifact type name '{artifact_type}' cannot contain path separators ('/' or '\\\\'). \n\
1703 Artifact type names must be simple identifiers without special characters."
1704 ),
1705 }
1706 .into());
1707 }
1708
1709 // Also check for other potentially problematic characters
1710 if artifact_type.contains("..") {
1711 return Err(crate::core::AgpmError::ManifestValidationError {
1712 reason: format!(
1713 "Artifact type name '{artifact_type}' cannot contain '..' (path traversal). \n\
1714 Artifact type names must be simple identifiers."
1715 ),
1716 }
1717 .into());
1718 }
1719 }
1720
1721 // Check that all referenced sources exist and dependencies have required fields
1722 for (name, dep) in self.all_dependencies() {
1723 // Check for empty path
1724 if dep.get_path().is_empty() {
1725 return Err(crate::core::AgpmError::ManifestValidationError {
1726 reason: format!("Missing required field 'path' for dependency '{name}'"),
1727 }
1728 .into());
1729 }
1730
1731 // Validate pattern safety if it's a pattern dependency
1732 if dep.is_pattern() {
1733 crate::pattern::validate_pattern_safety(dep.get_path()).map_err(|e| {
1734 crate::core::AgpmError::ManifestValidationError {
1735 reason: format!("Invalid pattern in dependency '{name}': {e}"),
1736 }
1737 })?;
1738 }
1739
1740 // Check for version when source is specified (non-local dependencies)
1741 if let Some(source) = dep.get_source() {
1742 if !self.sources.contains_key(source) {
1743 return Err(crate::core::AgpmError::SourceNotFound {
1744 name: source.to_string(),
1745 }
1746 .into());
1747 }
1748
1749 // Check if the source URL is a local path
1750 let source_url = self.sources.get(source).unwrap();
1751 let _is_local_source = source_url.starts_with('/')
1752 || source_url.starts_with("./")
1753 || source_url.starts_with("../");
1754
1755 // Git dependencies can optionally have a version (defaults to 'main' if not specified)
1756 // Local path sources don't need versions
1757 // We no longer require versions for Git dependencies - they'll default to 'main'
1758 } else {
1759 // For local path dependencies (no source), version is not allowed
1760 // Skip directory check for pattern dependencies
1761 if !dep.is_pattern() {
1762 let path = dep.get_path();
1763 let is_plain_dir =
1764 path.starts_with('/') || path.starts_with("./") || path.starts_with("../");
1765
1766 if is_plain_dir && dep.get_version().is_some() {
1767 return Err(crate::core::AgpmError::ManifestValidationError {
1768 reason: format!(
1769 "Version specified for plain directory dependency '{name}' with path '{path}'. \n\
1770 Plain directory dependencies do not support versions. \n\
1771 Remove the 'version' field or use a git source instead."
1772 ),
1773 }
1774 .into());
1775 }
1776 }
1777 }
1778 }
1779
1780 // Check for version conflicts (same dependency name with different versions)
1781 let mut seen_deps: std::collections::HashMap<String, String> =
1782 std::collections::HashMap::new();
1783 for (name, dep) in self.all_dependencies() {
1784 if let Some(version) = dep.get_version() {
1785 if let Some(existing_version) = seen_deps.get(name) {
1786 if existing_version != version {
1787 return Err(crate::core::AgpmError::ManifestValidationError {
1788 reason: format!(
1789 "Version conflict for dependency '{name}': found versions '{existing_version}' and '{version}'"
1790 ),
1791 }
1792 .into());
1793 }
1794 } else {
1795 seen_deps.insert(name.to_string(), version.to_string());
1796 }
1797 }
1798 }
1799
1800 // Validate URLs in sources
1801 for (name, url) in &self.sources {
1802 // Expand environment variables and home directory in URL
1803 let expanded_url = expand_url(url)?;
1804
1805 if !expanded_url.starts_with("http://")
1806 && !expanded_url.starts_with("https://")
1807 && !expanded_url.starts_with("git@")
1808 && !expanded_url.starts_with("file://")
1809 // Plain directory paths not allowed as sources
1810 && !expanded_url.starts_with('/')
1811 && !expanded_url.starts_with("./")
1812 && !expanded_url.starts_with("../")
1813 {
1814 return Err(crate::core::AgpmError::ManifestValidationError {
1815 reason: format!("Source '{name}' has invalid URL: '{url}'. Must be HTTP(S), SSH (git@...), or file:// URL"),
1816 }
1817 .into());
1818 }
1819
1820 // Check if plain directory path is used as a source
1821 if expanded_url.starts_with('/')
1822 || expanded_url.starts_with("./")
1823 || expanded_url.starts_with("../")
1824 {
1825 return Err(crate::core::AgpmError::ManifestValidationError {
1826 reason: format!(
1827 "Plain directory path '{url}' cannot be used as source '{name}'. \n\
1828 Sources must be git repositories. Use one of:\n\
1829 - Remote URL: https://github.com/owner/repo.git\n\
1830 - Local git repo: file:///absolute/path/to/repo\n\
1831 - Or use direct path dependencies without a source"
1832 ),
1833 }
1834 .into());
1835 }
1836 }
1837
1838 // Check for case-insensitive conflicts on all platforms
1839 // This ensures manifests are portable across different filesystems
1840 // Even though Linux supports case-sensitive files, we reject conflicts
1841 // to ensure the manifest works on Windows and macOS too
1842 let mut normalized_names: std::collections::HashSet<String> =
1843 std::collections::HashSet::new();
1844
1845 for (name, _) in self.all_dependencies() {
1846 let normalized = name.to_lowercase();
1847 if !normalized_names.insert(normalized.clone()) {
1848 // Find the original conflicting name
1849 for (other_name, _) in self.all_dependencies() {
1850 if other_name != name && other_name.to_lowercase() == normalized {
1851 return Err(crate::core::AgpmError::ManifestValidationError {
1852 reason: format!(
1853 "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."
1854 ),
1855 }
1856 .into());
1857 }
1858 }
1859 }
1860 }
1861
1862 // Validate artifact types and resource type support
1863 for resource_type in crate::core::ResourceType::all() {
1864 if let Some(deps) = self.get_dependencies(*resource_type) {
1865 for (name, dep) in deps {
1866 // Get tool from dependency (defaults to "claude-code")
1867 let tool = match dep {
1868 ResourceDependency::Detailed(d) => &d.tool,
1869 ResourceDependency::Simple(_) => "claude-code", // Default for simple deps
1870 };
1871
1872 // Check if tool is configured
1873 if self.get_tool_config(tool).is_none() {
1874 return Err(crate::core::AgpmError::ManifestValidationError {
1875 reason: format!(
1876 "Unknown tool '{tool}' for dependency '{name}'.\n\
1877 Available types: {}\n\
1878 Configure custom types in [tools] section or use a standard type.",
1879 self.get_tools_config()
1880 .types
1881 .keys()
1882 .map(|s| format!("'{s}'"))
1883 .collect::<Vec<_>>()
1884 .join(", ")
1885 ),
1886 }
1887 .into());
1888 }
1889
1890 // Check if resource type is supported by this tool
1891 if !self.is_resource_supported(tool, *resource_type) {
1892 let artifact_config = self.get_tool_config(tool).unwrap();
1893 let supported_types: Vec<String> =
1894 artifact_config.resources.keys().map(|s| s.to_string()).collect();
1895
1896 // Build resource-type-specific suggestions
1897 let mut suggestions = Vec::new();
1898
1899 match resource_type {
1900 crate::core::ResourceType::Snippet => {
1901 suggestions.push("Snippets work best with the 'agpm' tool (shared infrastructure)".to_string());
1902 suggestions.push(
1903 "Add tool='agpm' to this dependency to use shared snippets"
1904 .to_string(),
1905 );
1906 }
1907 _ => {
1908 // Find which tool types DO support this resource type
1909 let default_config = ToolsConfig::default();
1910 let tools_config = self.tools.as_ref().unwrap_or(&default_config);
1911 let resource_plural = resource_type.to_plural();
1912 let supporting_types: Vec<String> = tools_config
1913 .types
1914 .iter()
1915 .filter(|(_, config)| {
1916 config.resources.contains_key(resource_plural)
1917 })
1918 .map(|(type_name, _)| format!("'{}'", type_name))
1919 .collect();
1920
1921 if !supporting_types.is_empty() {
1922 suggestions.push(format!(
1923 "This resource type is supported by tools: {}",
1924 supporting_types.join(", ")
1925 ));
1926 }
1927 }
1928 }
1929
1930 let mut reason = format!(
1931 "Resource type '{}' is not supported by tool '{}' for dependency '{}'.\n\n",
1932 resource_type.to_plural(),
1933 tool,
1934 name
1935 );
1936
1937 reason.push_str(&format!(
1938 "Tool '{}' supports: {}\n\n",
1939 tool,
1940 supported_types.join(", ")
1941 ));
1942
1943 if !suggestions.is_empty() {
1944 reason.push_str("💡 Suggestions:\n");
1945 for suggestion in &suggestions {
1946 reason.push_str(&format!(" • {}\n", suggestion));
1947 }
1948 reason.push('\n');
1949 }
1950
1951 reason.push_str(
1952 "You can fix this by:\n\
1953 1. Changing the 'tool' field to a supported tool\n\
1954 2. Using a different resource type\n\
1955 3. Removing this dependency from your manifest",
1956 );
1957
1958 return Err(crate::core::AgpmError::ManifestValidationError {
1959 reason,
1960 }
1961 .into());
1962 }
1963 }
1964 }
1965 }
1966
1967 Ok(())
1968 }
1969
1970 /// Get all dependencies from both agents and snippets sections.
1971 ///
1972 /// Returns a vector of tuples containing dependency names and their
1973 /// specifications. This is useful for iteration over all dependencies
1974 /// without needing to handle agents and snippets separately.
1975 ///
1976 /// # Return Value
1977 ///
1978 /// Each tuple contains:
1979 /// - `&str`: The dependency name (key from TOML)
1980 /// - `&ResourceDependency`: The dependency specification
1981 ///
1982 /// # Examples
1983 ///
1984 /// ```rust,no_run
1985 /// use agpm_cli::manifest::Manifest;
1986 ///
1987 /// let manifest = Manifest::new();
1988 /// // ... add some dependencies
1989 ///
1990 /// for (name, dep) in manifest.all_dependencies() {
1991 /// println!("Dependency: {} -> {}", name, dep.get_path());
1992 /// if let Some(source) = dep.get_source() {
1993 /// println!(" Source: {}", source);
1994 /// }
1995 /// }
1996 /// ```
1997 ///
1998 /// # Order
1999 ///
2000 /// Dependencies are returned in the order they appear in the underlying
2001 /// `HashMaps` (agents first, then snippets, then commands), which means the order is not
2002 /// guaranteed to be stable across runs.
2003 /// Get dependencies for a specific resource type
2004 ///
2005 /// Returns the `HashMap` of dependencies for the specified resource type.
2006 /// Note: MCP servers return None as they use a different dependency type.
2007 pub const fn get_dependencies(
2008 &self,
2009 resource_type: crate::core::ResourceType,
2010 ) -> Option<&HashMap<String, ResourceDependency>> {
2011 use crate::core::ResourceType;
2012 match resource_type {
2013 ResourceType::Agent => Some(&self.agents),
2014 ResourceType::Snippet => Some(&self.snippets),
2015 ResourceType::Command => Some(&self.commands),
2016 ResourceType::Script => Some(&self.scripts),
2017 ResourceType::Hook => Some(&self.hooks),
2018 ResourceType::McpServer => Some(&self.mcp_servers),
2019 }
2020 }
2021
2022 /// Get the target directory for a specific resource type
2023 #[allow(deprecated)]
2024 pub fn get_target_dir(&self, resource_type: crate::core::ResourceType) -> &str {
2025 use crate::core::ResourceType;
2026 match resource_type {
2027 ResourceType::Agent => &self.target.agents,
2028 ResourceType::Snippet => &self.target.snippets,
2029 ResourceType::Command => &self.target.commands,
2030 ResourceType::Script => &self.target.scripts,
2031 ResourceType::Hook => &self.target.hooks,
2032 ResourceType::McpServer => &self.target.mcp_servers,
2033 }
2034 }
2035
2036 /// Get the tools configuration, returning default if not specified.
2037 ///
2038 /// This method provides access to the tool configurations which define
2039 /// where resources are installed for different tools (claude-code, opencode, agpm).
2040 ///
2041 /// Returns the configured tools or the default configuration if not specified.
2042 pub fn get_tools_config(&self) -> &ToolsConfig {
2043 self.tools.as_ref().unwrap_or_else(|| {
2044 // Return a static default - this is safe because ToolsConfig::default() is deterministic
2045 static DEFAULT: std::sync::OnceLock<ToolsConfig> = std::sync::OnceLock::new();
2046 DEFAULT.get_or_init(ToolsConfig::default)
2047 })
2048 }
2049
2050 /// Get configuration for a specific tool type.
2051 ///
2052 /// Returns None if the tool is not configured.
2053 pub fn get_tool_config(&self, tool: &str) -> Option<&ArtifactTypeConfig> {
2054 self.get_tools_config().types.get(tool)
2055 }
2056
2057 /// Get the installation path for a resource within a tool.
2058 ///
2059 /// Returns the full installation directory path by combining:
2060 /// - Tool's base directory (e.g., ".claude", ".opencode")
2061 /// - Resource type's subdirectory (e.g., "agents", "command")
2062 ///
2063 /// Returns None if:
2064 /// - The tool is not configured
2065 /// - The resource type is not supported by this tool
2066 /// - The resource has no configured path (special handling like MCP merge)
2067 pub fn get_artifact_resource_path(
2068 &self,
2069 tool: &str,
2070 resource_type: crate::core::ResourceType,
2071 ) -> Option<std::path::PathBuf> {
2072 let artifact_config = self.get_tool_config(tool)?;
2073 let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
2074
2075 resource_config.path.as_ref().map(|subdir| artifact_config.path.join(subdir))
2076 }
2077
2078 /// Check if a resource type is supported by a tool.
2079 ///
2080 /// Returns true if the tool has configuration for the given resource type.
2081 pub fn is_resource_supported(
2082 &self,
2083 tool: &str,
2084 resource_type: crate::core::ResourceType,
2085 ) -> bool {
2086 self.get_tool_config(tool)
2087 .and_then(|config| config.resources.get(resource_type.to_plural()))
2088 .is_some()
2089 }
2090
2091 /// Returns all dependencies from all resource types.
2092 ///
2093 /// This method collects dependencies from agents, snippets, commands,
2094 /// scripts, hooks, and MCP servers into a single vector. It's commonly used for:
2095 /// - Manifest validation across all dependency types
2096 /// - Dependency resolution operations
2097 /// - Generating reports of all configured dependencies
2098 /// - Bulk operations on all dependencies
2099 ///
2100 /// # Returns
2101 ///
2102 /// A vector of tuples containing the dependency name and its configuration.
2103 /// Each tuple is `(name, dependency)` where:
2104 /// - `name`: The dependency name as specified in the manifest
2105 /// - `dependency`: Reference to the [`ResourceDependency`] configuration
2106 ///
2107 /// The order follows the resource type order defined in [`crate::core::ResourceType::all()`].
2108 ///
2109 /// # Examples
2110 ///
2111 /// ```rust,no_run
2112 /// # use agpm_cli::manifest::Manifest;
2113 /// # let manifest = Manifest::new();
2114 /// for (name, dep) in manifest.all_dependencies() {
2115 /// println!("Dependency: {} -> {}", name, dep.get_path());
2116 /// if let Some(source) = dep.get_source() {
2117 /// println!(" Source: {}", source);
2118 /// }
2119 /// }
2120 /// ```
2121 #[must_use]
2122 pub fn all_dependencies(&self) -> Vec<(&str, &ResourceDependency)> {
2123 let mut deps = Vec::new();
2124
2125 // Use ResourceType::all() to iterate through all resource types
2126 for resource_type in crate::core::ResourceType::all() {
2127 if let Some(type_deps) = self.get_dependencies(*resource_type) {
2128 for (name, dep) in type_deps {
2129 deps.push((name.as_str(), dep));
2130 }
2131 }
2132 }
2133
2134 deps
2135 }
2136
2137 /// Get all dependencies including MCP servers.
2138 ///
2139 /// All resource types now use standard `ResourceDependency`, so no conversion needed.
2140 #[must_use]
2141 pub fn all_dependencies_with_mcp(
2142 &self,
2143 ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>)> {
2144 let mut deps = Vec::new();
2145
2146 // Use ResourceType::all() to iterate through all resource types
2147 for resource_type in crate::core::ResourceType::all() {
2148 if let Some(type_deps) = self.get_dependencies(*resource_type) {
2149 for (name, dep) in type_deps {
2150 deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep)));
2151 }
2152 }
2153 }
2154
2155 deps
2156 }
2157
2158 /// Check if a dependency with the given name exists in any section.
2159 ///
2160 /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
2161 /// with the specified name. This is useful for avoiding duplicate names
2162 /// across different resource types.
2163 ///
2164 /// # Examples
2165 ///
2166 /// ```rust,no_run
2167 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2168 ///
2169 /// let mut manifest = Manifest::new();
2170 /// manifest.add_dependency(
2171 /// "helper".to_string(),
2172 /// ResourceDependency::Simple("../helper.md".to_string()),
2173 /// true // is_agent
2174 /// );
2175 ///
2176 /// assert!(manifest.has_dependency("helper"));
2177 /// assert!(!manifest.has_dependency("nonexistent"));
2178 /// ```
2179 ///
2180 /// # Performance
2181 ///
2182 /// This method performs two `HashMap` lookups, so it's O(1) on average.
2183 #[must_use]
2184 pub fn has_dependency(&self, name: &str) -> bool {
2185 self.agents.contains_key(name)
2186 || self.snippets.contains_key(name)
2187 || self.commands.contains_key(name)
2188 }
2189
2190 /// Get a dependency by name from any section.
2191 ///
2192 /// Searches both the `[agents]` and `[snippets]` sections for a dependency
2193 /// with the specified name, returning the first match found. Agents are
2194 /// searched before snippets.
2195 ///
2196 /// # Examples
2197 ///
2198 /// ```rust,no_run
2199 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2200 ///
2201 /// let mut manifest = Manifest::new();
2202 /// manifest.add_dependency(
2203 /// "helper".to_string(),
2204 /// ResourceDependency::Simple("../helper.md".to_string()),
2205 /// true // is_agent
2206 /// );
2207 ///
2208 /// if let Some(dep) = manifest.get_dependency("helper") {
2209 /// println!("Found dependency: {}", dep.get_path());
2210 /// }
2211 /// ```
2212 ///
2213 /// # Search Order
2214 ///
2215 /// Dependencies are searched in this order:
2216 /// 1. `[agents]` section
2217 /// 2. `[snippets]` section
2218 /// 3. `[commands]` section
2219 ///
2220 /// If the same name exists in multiple sections, the first match is returned.
2221 #[must_use]
2222 pub fn get_dependency(&self, name: &str) -> Option<&ResourceDependency> {
2223 self.agents
2224 .get(name)
2225 .or_else(|| self.snippets.get(name))
2226 .or_else(|| self.commands.get(name))
2227 }
2228
2229 /// Find a dependency by name from any section (alias for `get_dependency`).
2230 ///
2231 /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
2232 /// with the specified name, returning the first match found.
2233 ///
2234 /// # Examples
2235 ///
2236 /// ```rust,no_run
2237 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2238 ///
2239 /// let mut manifest = Manifest::new();
2240 /// manifest.add_dependency(
2241 /// "helper".to_string(),
2242 /// ResourceDependency::Simple("../helper.md".to_string()),
2243 /// true // is_agent
2244 /// );
2245 ///
2246 /// if let Some(dep) = manifest.find_dependency("helper") {
2247 /// println!("Found dependency: {}", dep.get_path());
2248 /// }
2249 /// ```
2250 pub fn find_dependency(&self, name: &str) -> Option<&ResourceDependency> {
2251 self.get_dependency(name)
2252 }
2253
2254 /// Add or update a source repository in the `[sources]` section.
2255 ///
2256 /// Sources map convenient names to Git repository URLs. These names can
2257 /// then be referenced in dependency specifications to avoid repeating
2258 /// long URLs throughout the manifest.
2259 ///
2260 /// # Parameters
2261 ///
2262 /// - `name`: Short, convenient name for the source (e.g., "official", "community")
2263 /// - `url`: Git repository URL (HTTPS, SSH, or file:// protocol)
2264 ///
2265 /// # URL Validation
2266 ///
2267 /// The URL is not validated when added - validation occurs during
2268 /// [`Self::validate`]. Supported URL formats:
2269 /// - `https://github.com/owner/repo.git`
2270 /// - `git@github.com:owner/repo.git`
2271 /// - `file:///absolute/path/to/repo`
2272 /// - `file:///path/to/local/repo`
2273 ///
2274 /// # Examples
2275 ///
2276 /// ```rust,no_run
2277 /// use agpm_cli::manifest::Manifest;
2278 ///
2279 /// let mut manifest = Manifest::new();
2280 ///
2281 /// // Add public repository
2282 /// manifest.add_source(
2283 /// "community".to_string(),
2284 /// "https://github.com/claude-community/resources.git".to_string()
2285 /// );
2286 ///
2287 /// // Add private repository (SSH)
2288 /// manifest.add_source(
2289 /// "private".to_string(),
2290 /// "git@github.com:company/private-resources.git".to_string()
2291 /// );
2292 ///
2293 /// // Add local repository
2294 /// manifest.add_source(
2295 /// "local".to_string(),
2296 /// "file:///home/user/my-resources".to_string()
2297 /// );
2298 /// ```
2299 ///
2300 /// # Security Note
2301 ///
2302 /// Never include authentication tokens in the URL. Use SSH keys or
2303 /// configure authentication globally in `~/.agpm/config.toml`.
2304 pub fn add_source(&mut self, name: String, url: String) {
2305 self.sources.insert(name, url);
2306 }
2307
2308 /// Add or update a dependency in the appropriate section.
2309 ///
2310 /// Adds the dependency to either the `[agents]`, `[snippets]`, or `[commands]` section
2311 /// based on the `is_agent` parameter. If a dependency with the same name
2312 /// already exists in the target section, it will be replaced.
2313 ///
2314 /// **Note**: This method is deprecated in favor of [`Self::add_typed_dependency`]
2315 /// which provides explicit control over resource types.
2316 ///
2317 /// # Parameters
2318 ///
2319 /// - `name`: Unique name for the dependency within its section
2320 /// - `dep`: The dependency specification (Simple or Detailed)
2321 /// - `is_agent`: If true, adds to `[agents]`; if false, adds to `[snippets]`
2322 /// (Note: Use [`Self::add_typed_dependency`] for commands and other resource types)
2323 ///
2324 /// # Validation
2325 ///
2326 /// The dependency is not validated when added - validation occurs during
2327 /// [`Self::validate`]. This allows for building manifests incrementally
2328 /// before all sources are defined.
2329 ///
2330 /// # Examples
2331 ///
2332 /// ```rust,no_run
2333 /// use agpm_cli::manifest::{Manifest, ResourceDependency, DetailedDependency};
2334 ///
2335 /// let mut manifest = Manifest::new();
2336 ///
2337 /// // Add local agent dependency
2338 /// manifest.add_dependency(
2339 /// "helper".to_string(),
2340 /// ResourceDependency::Simple("../local/helper.md".to_string()),
2341 /// true // is_agent = true
2342 /// );
2343 ///
2344 /// // Add remote snippet dependency
2345 /// manifest.add_dependency(
2346 /// "utils".to_string(),
2347 /// ResourceDependency::Detailed(Box::new(DetailedDependency {
2348 /// source: Some("community".to_string()),
2349 /// path: "snippets/utils.md".to_string(),
2350 /// version: Some("v1.0.0".to_string()),
2351 /// branch: None,
2352 /// rev: None,
2353 /// command: None,
2354 /// args: None,
2355 /// target: None,
2356 /// filename: None,
2357 /// dependencies: None,
2358 /// tool: "claude-code".to_string(),
2359 /// })),
2360 /// false // is_agent = false (snippet)
2361 /// );
2362 /// ```
2363 ///
2364 /// # Name Conflicts
2365 ///
2366 /// This method allows the same dependency name to exist in both the
2367 /// `[agents]` and `[snippets]` sections. However, some operations like
2368 /// [`Self::get_dependency`] will prefer agents over snippets when
2369 /// searching by name.
2370 pub fn add_dependency(&mut self, name: String, dep: ResourceDependency, is_agent: bool) {
2371 if is_agent {
2372 self.agents.insert(name, dep);
2373 } else {
2374 self.snippets.insert(name, dep);
2375 }
2376 }
2377
2378 /// Add or update a dependency with specific resource type.
2379 ///
2380 /// This is the preferred method for adding dependencies as it explicitly
2381 /// specifies the resource type using the `ResourceType` enum.
2382 ///
2383 /// # Examples
2384 ///
2385 /// ```rust,no_run
2386 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2387 /// use agpm_cli::core::ResourceType;
2388 ///
2389 /// let mut manifest = Manifest::new();
2390 ///
2391 /// // Add command dependency
2392 /// manifest.add_typed_dependency(
2393 /// "build".to_string(),
2394 /// ResourceDependency::Simple("../commands/build.md".to_string()),
2395 /// ResourceType::Command
2396 /// );
2397 /// ```
2398 pub fn add_typed_dependency(
2399 &mut self,
2400 name: String,
2401 dep: ResourceDependency,
2402 resource_type: crate::core::ResourceType,
2403 ) {
2404 match resource_type {
2405 crate::core::ResourceType::Agent => {
2406 self.agents.insert(name, dep);
2407 }
2408 crate::core::ResourceType::Snippet => {
2409 self.snippets.insert(name, dep);
2410 }
2411 crate::core::ResourceType::Command => {
2412 self.commands.insert(name, dep);
2413 }
2414 crate::core::ResourceType::McpServer => {
2415 // MCP servers don't use ResourceDependency, they have their own type
2416 // This method shouldn't be called for MCP servers
2417 panic!("Use add_mcp_server() for MCP server dependencies");
2418 }
2419 crate::core::ResourceType::Script => {
2420 self.scripts.insert(name, dep);
2421 }
2422 crate::core::ResourceType::Hook => {
2423 self.hooks.insert(name, dep);
2424 }
2425 }
2426 }
2427
2428 /// Add or update an MCP server configuration.
2429 ///
2430 /// MCP servers now use standard `ResourceDependency` format,
2431 /// pointing to JSON configuration files in source repositories.
2432 ///
2433 /// # Examples
2434 ///
2435 /// ```rust,no_run,ignore
2436 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2437 ///
2438 /// let mut manifest = Manifest::new();
2439 ///
2440 /// // Add MCP server from source repository
2441 /// manifest.add_mcp_server(
2442 /// "filesystem".to_string(),
2443 /// ResourceDependency::Simple("../local/mcp-servers/filesystem.json".to_string())
2444 /// );
2445 /// ```
2446 pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
2447 self.mcp_servers.insert(name, dependency);
2448 }
2449}
2450
2451impl ResourceDependency {
2452 /// Get the source repository name if this is a remote dependency.
2453 ///
2454 /// Returns the source name for remote dependencies (those that reference
2455 /// a Git repository), or `None` for local dependencies (those that reference
2456 /// local filesystem paths).
2457 ///
2458 /// # Examples
2459 ///
2460 /// ```rust,no_run
2461 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2462 ///
2463 /// // Local dependency - no source
2464 /// let local = ResourceDependency::Simple("../local/file.md".to_string());
2465 /// assert!(local.get_source().is_none());
2466 ///
2467 /// // Remote dependency - has source
2468 /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
2469 /// source: Some("official".to_string()),
2470 /// path: "agents/tool.md".to_string(),
2471 /// version: Some("v1.0.0".to_string()),
2472 /// branch: None,
2473 /// rev: None,
2474 /// command: None,
2475 /// args: None,
2476 /// target: None,
2477 /// filename: None,
2478 /// dependencies: None,
2479 /// tool: "claude-code".to_string(),
2480 /// }));
2481 /// assert_eq!(remote.get_source(), Some("official"));
2482 /// ```
2483 ///
2484 /// # Use Cases
2485 ///
2486 /// This method is commonly used to:
2487 /// - Determine if dependency resolution should use Git vs filesystem
2488 /// - Validate that referenced sources exist in the manifest
2489 /// - Filter dependencies by type (local vs remote)
2490 /// - Generate dependency graphs and reports
2491 #[must_use]
2492 pub fn get_source(&self) -> Option<&str> {
2493 match self {
2494 Self::Simple(_) => None,
2495 Self::Detailed(d) => d.source.as_deref(),
2496 }
2497 }
2498
2499 /// Get the custom target directory for this dependency.
2500 ///
2501 /// Returns the custom target directory if specified, or `None` if the
2502 /// dependency should use the default installation location for its resource type.
2503 ///
2504 /// # Examples
2505 ///
2506 /// ```rust,no_run
2507 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2508 ///
2509 /// // Dependency with custom target
2510 /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
2511 /// source: Some("official".to_string()),
2512 /// path: "agents/tool.md".to_string(),
2513 /// version: Some("v1.0.0".to_string()),
2514 /// target: Some("custom/tools".to_string()),
2515 /// branch: None,
2516 /// rev: None,
2517 /// command: None,
2518 /// args: None,
2519 /// filename: None,
2520 /// dependencies: None,
2521 /// tool: "claude-code".to_string(),
2522 /// }));
2523 /// assert_eq!(custom.get_target(), Some("custom/tools"));
2524 ///
2525 /// // Dependency without custom target
2526 /// let default = ResourceDependency::Simple("../local/file.md".to_string());
2527 /// assert!(default.get_target().is_none());
2528 /// ```
2529 #[must_use]
2530 pub fn get_target(&self) -> Option<&str> {
2531 match self {
2532 Self::Simple(_) => None,
2533 Self::Detailed(d) => d.target.as_deref(),
2534 }
2535 }
2536
2537 /// Get the custom filename for this dependency.
2538 ///
2539 /// Returns the custom filename if specified, or `None` if the
2540 /// dependency should use the default filename based on the dependency key.
2541 ///
2542 /// # Examples
2543 ///
2544 /// ```rust,no_run
2545 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2546 ///
2547 /// // Dependency with custom filename
2548 /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
2549 /// source: Some("official".to_string()),
2550 /// path: "agents/tool.md".to_string(),
2551 /// version: Some("v1.0.0".to_string()),
2552 /// filename: Some("ai-assistant.md".to_string()),
2553 /// branch: None,
2554 /// rev: None,
2555 /// command: None,
2556 /// args: None,
2557 /// target: None,
2558 /// dependencies: None,
2559 /// tool: "claude-code".to_string(),
2560 /// }));
2561 /// assert_eq!(custom.get_filename(), Some("ai-assistant.md"));
2562 ///
2563 /// // Dependency without custom filename
2564 /// let default = ResourceDependency::Simple("../local/file.md".to_string());
2565 /// assert!(default.get_filename().is_none());
2566 /// ```
2567 #[must_use]
2568 pub fn get_filename(&self) -> Option<&str> {
2569 match self {
2570 Self::Simple(_) => None,
2571 Self::Detailed(d) => d.filename.as_deref(),
2572 }
2573 }
2574
2575 /// Get the path to the resource file.
2576 ///
2577 /// Returns the path component of the dependency, which is interpreted
2578 /// differently based on whether this is a local or remote dependency:
2579 ///
2580 /// - **Local dependencies**: Filesystem path relative to the manifest directory
2581 /// - **Remote dependencies**: Path within the Git repository
2582 ///
2583 /// # Examples
2584 ///
2585 /// ```rust,no_run
2586 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2587 ///
2588 /// // Local dependency - filesystem path
2589 /// let local = ResourceDependency::Simple("../shared/helper.md".to_string());
2590 /// assert_eq!(local.get_path(), "../shared/helper.md");
2591 ///
2592 /// // Remote dependency - repository path
2593 /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
2594 /// source: Some("official".to_string()),
2595 /// path: "agents/code-reviewer.md".to_string(),
2596 /// version: Some("v1.0.0".to_string()),
2597 /// branch: None,
2598 /// rev: None,
2599 /// command: None,
2600 /// args: None,
2601 /// target: None,
2602 /// filename: None,
2603 /// dependencies: None,
2604 /// tool: "claude-code".to_string(),
2605 /// }));
2606 /// assert_eq!(remote.get_path(), "agents/code-reviewer.md");
2607 /// ```
2608 ///
2609 /// # Path Resolution
2610 ///
2611 /// The returned path should be processed appropriately based on the dependency type:
2612 /// - Local paths may need resolution against the manifest directory
2613 /// - Remote paths are used directly within the cloned repository
2614 /// - All paths should use forward slashes (/) for cross-platform compatibility
2615 #[must_use]
2616 pub fn get_path(&self) -> &str {
2617 match self {
2618 Self::Simple(path) => path,
2619 Self::Detailed(d) => &d.path,
2620 }
2621 }
2622
2623 /// Check if this is a pattern-based dependency.
2624 ///
2625 /// Returns `true` if this dependency uses a glob pattern to match
2626 /// multiple resources, `false` if it specifies a single resource path.
2627 ///
2628 /// Patterns are detected by the presence of glob characters (`*`, `?`, `[`)
2629 /// in the path field.
2630 #[must_use]
2631 pub fn is_pattern(&self) -> bool {
2632 let path = self.get_path();
2633 path.contains('*') || path.contains('?') || path.contains('[')
2634 }
2635
2636 /// Get the version constraint for dependency resolution.
2637 ///
2638 /// Returns the version constraint that should be used when resolving this
2639 /// dependency from a Git repository. For local dependencies, always returns `None`.
2640 ///
2641 /// # Priority Rules
2642 ///
2643 /// If both `version` and `git` fields are present in a detailed dependency,
2644 /// the `git` field takes precedence:
2645 ///
2646 /// ```rust,no_run
2647 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2648 ///
2649 /// let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
2650 /// source: Some("repo".to_string()),
2651 /// path: "file.md".to_string(),
2652 /// version: Some("v1.0.0".to_string()), // This is ignored
2653 /// branch: Some("develop".to_string()), // This takes precedence over version
2654 /// rev: None,
2655 /// command: None,
2656 /// args: None,
2657 /// target: None,
2658 /// filename: None,
2659 /// dependencies: None,
2660 /// tool: "claude-code".to_string(),
2661 /// }));
2662 ///
2663 /// assert_eq!(dep.get_version(), Some("develop"));
2664 /// ```
2665 ///
2666 /// # Examples
2667 ///
2668 /// ```rust,no_run
2669 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2670 ///
2671 /// // Local dependency - no version
2672 /// let local = ResourceDependency::Simple("../local/file.md".to_string());
2673 /// assert!(local.get_version().is_none());
2674 ///
2675 /// // Remote dependency with version
2676 /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
2677 /// source: Some("repo".to_string()),
2678 /// path: "file.md".to_string(),
2679 /// version: Some("v1.0.0".to_string()),
2680 /// branch: None,
2681 /// rev: None,
2682 /// command: None,
2683 /// args: None,
2684 /// target: None,
2685 /// filename: None,
2686 /// dependencies: None,
2687 /// tool: "claude-code".to_string(),
2688 /// }));
2689 /// assert_eq!(versioned.get_version(), Some("v1.0.0"));
2690 ///
2691 /// // Remote dependency with branch reference
2692 /// let branch_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
2693 /// source: Some("repo".to_string()),
2694 /// path: "file.md".to_string(),
2695 /// version: None,
2696 /// branch: Some("main".to_string()),
2697 /// rev: None,
2698 /// command: None,
2699 /// args: None,
2700 /// target: None,
2701 /// filename: None,
2702 /// dependencies: None,
2703 /// tool: "claude-code".to_string(),
2704 /// }));
2705 /// assert_eq!(branch_ref.get_version(), Some("main"));
2706 /// ```
2707 ///
2708 /// # Version Formats
2709 ///
2710 /// Supported version constraint formats include:
2711 /// - Semantic versions: `"v1.0.0"`, `"1.2.3"`
2712 /// - Semantic version ranges: `"^1.0.0"`, `"~2.1.0"`
2713 /// - Branch names: `"main"`, `"develop"`, `"latest"`, `"feature/new"`
2714 /// - Git tags: `"release-2023"`, `"stable"`
2715 /// - Commit SHAs: `"a1b2c3d4e5f6..."`
2716 #[must_use]
2717 pub fn get_version(&self) -> Option<&str> {
2718 match self {
2719 Self::Simple(_) => None,
2720 Self::Detailed(d) => {
2721 // Precedence: rev > branch > version
2722 d.rev.as_deref().or(d.branch.as_deref()).or(d.version.as_deref())
2723 }
2724 }
2725 }
2726
2727 /// Check if this is a local filesystem dependency.
2728 ///
2729 /// Returns `true` if this dependency refers to a local file (no Git source),
2730 /// or `false` if it's a remote dependency that will be resolved from a
2731 /// Git repository.
2732 ///
2733 /// This is a convenience method equivalent to `self.get_source().is_none()`.
2734 ///
2735 /// # Examples
2736 ///
2737 /// ```rust,no_run
2738 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2739 ///
2740 /// // Local dependency
2741 /// let local = ResourceDependency::Simple("../local/file.md".to_string());
2742 /// assert!(local.is_local());
2743 ///
2744 /// // Remote dependency
2745 /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
2746 /// source: Some("official".to_string()),
2747 /// path: "agents/tool.md".to_string(),
2748 /// version: Some("v1.0.0".to_string()),
2749 /// branch: None,
2750 /// rev: None,
2751 /// command: None,
2752 /// args: None,
2753 /// target: None,
2754 /// filename: None,
2755 /// dependencies: None,
2756 /// tool: "claude-code".to_string(),
2757 /// }));
2758 /// assert!(!remote.is_local());
2759 ///
2760 /// // Local detailed dependency (no source specified)
2761 /// let local_detailed = ResourceDependency::Detailed(Box::new(DetailedDependency {
2762 /// source: None,
2763 /// path: "../shared/tool.md".to_string(),
2764 /// version: None,
2765 /// branch: None,
2766 /// rev: None,
2767 /// command: None,
2768 /// args: None,
2769 /// target: None,
2770 /// filename: None,
2771 /// dependencies: None,
2772 /// tool: "claude-code".to_string(),
2773 /// }));
2774 /// assert!(local_detailed.is_local());
2775 /// ```
2776 ///
2777 /// # Use Cases
2778 ///
2779 /// This method is useful for:
2780 /// - Choosing between filesystem and Git resolution strategies
2781 /// - Validation logic (local deps can't have versions)
2782 /// - Installation planning (local deps don't need caching)
2783 /// - Progress reporting (different steps for local vs remote)
2784 #[must_use]
2785 pub fn is_local(&self) -> bool {
2786 self.get_source().is_none()
2787 }
2788
2789 /// Get the tool type for this dependency.
2790 ///
2791 /// Returns the target AI coding assistant tool for this resource. This determines where
2792 /// the resource will be installed (e.g., `.claude`, `.opencode`, `.agpm`).
2793 ///
2794 /// For simple dependencies, defaults to "claude-code".
2795 /// For detailed dependencies, returns the configured tool type.
2796 ///
2797 /// # Examples
2798 ///
2799 /// ```rust,no_run
2800 /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2801 ///
2802 /// // Simple dependency - defaults to "claude-code"
2803 /// let simple = ResourceDependency::Simple("../local/file.md".to_string());
2804 /// assert_eq!(simple.get_tool(), "claude-code");
2805 ///
2806 /// // Detailed dependency with explicit tool
2807 /// let detailed = ResourceDependency::Detailed(Box::new(DetailedDependency {
2808 /// source: Some("official".to_string()),
2809 /// path: "agents/tool.md".to_string(),
2810 /// version: Some("v1.0.0".to_string()),
2811 /// branch: None,
2812 /// rev: None,
2813 /// command: None,
2814 /// args: None,
2815 /// target: None,
2816 /// filename: None,
2817 /// dependencies: None,
2818 /// tool: "opencode".to_string(),
2819 /// }));
2820 /// assert_eq!(detailed.get_tool(), "opencode");
2821 /// ```
2822 #[must_use]
2823 pub fn get_tool(&self) -> &str {
2824 match self {
2825 Self::Simple(_) => "claude-code", // Default for simple deps
2826 Self::Detailed(d) => &d.tool,
2827 }
2828 }
2829}
2830
2831impl Default for Manifest {
2832 fn default() -> Self {
2833 Self::new()
2834 }
2835}
2836
2837/// Expand environment variables and home directory in URLs.
2838///
2839/// This function handles URL expansion for source repository specifications,
2840/// supporting environment variable substitution and home directory expansion
2841/// while preserving standard Git URL formats.
2842///
2843/// # Processing Rules
2844///
2845/// 1. **Standard Git URLs** are returned unchanged:
2846/// - `http://` and `https://` URLs
2847/// - SSH URLs starting with `git@`
2848/// - File URLs starting with `file://`
2849///
2850/// 2. **Local paths** with expansion markers are processed:
2851/// - Environment variables: `${VAR_NAME}` or `$VAR_NAME`
2852/// - Home directory: `~` at the start of the path
2853/// - Relative paths: `./` or `../`
2854/// - Absolute paths: starting with `/`
2855///
2856/// 3. **Converted to file:// URLs**: Local paths are converted to file:// URLs
2857/// for consistent handling throughout the system.
2858///
2859/// # Examples
2860///
2861/// ```rust,no_run,ignore
2862/// # use agpm_cli::manifest::expand_url;
2863/// # fn example() -> anyhow::Result<()> {
2864/// // Standard URLs remain unchanged
2865/// assert_eq!(expand_url("https://github.com/user/repo.git")?,
2866/// "https://github.com/user/repo.git");
2867/// assert_eq!(expand_url("git@github.com:user/repo.git")?,
2868/// "git@github.com:user/repo.git");
2869///
2870/// // Environment variable expansion (if HOME=/home/user)
2871/// std::env::set_var("REPOS_DIR", "/home/user/repositories");
2872/// assert_eq!(expand_url("${REPOS_DIR}/my-repo")?,
2873/// "file:///home/user/repositories/my-repo");
2874///
2875/// // Home directory expansion
2876/// assert_eq!(expand_url("~/git/my-repo")?,
2877/// "file:///home/user/git/my-repo");
2878/// # Ok(())
2879/// # }
2880/// ```
2881///
2882/// # Error Handling
2883///
2884/// - Returns the original URL if expansion fails
2885/// - Never panics, even with malformed input
2886/// - Allows validation to catch invalid URLs with proper error messages
2887///
2888/// # Security Considerations
2889///
2890/// - Environment variable expansion is limited to safe patterns
2891/// - Path traversal attempts in expanded paths are detected later in validation
2892/// - No execution of shell commands or arbitrary code
2893///
2894/// # Use Cases
2895///
2896/// This function enables flexible source specifications in manifests:
2897/// - CI/CD systems can inject repository URLs via environment variables
2898/// - Users can reference repositories relative to their home directory
2899/// - Docker containers can use mounted paths with consistent URLs
2900/// - Development teams can share manifests without hardcoded paths
2901/// - Multi-platform projects can use consistent path references
2902///
2903/// # Thread Safety
2904///
2905/// This function is thread-safe and does not modify global state.
2906/// Environment variable access is read-only and atomic.
2907fn expand_url(url: &str) -> Result<String> {
2908 // If it looks like a standard protocol URL (http, https, git@, file://), don't expand
2909 if url.starts_with("http://")
2910 || url.starts_with("https://")
2911 || url.starts_with("git@")
2912 || url.starts_with("file://")
2913 {
2914 return Ok(url.to_string());
2915 }
2916
2917 // Only try to expand if it looks like a local path (contains path separators, starts with ~, or contains env vars)
2918 if url.contains('/') || url.contains('\\') || url.starts_with('~') || url.contains('$') {
2919 // For cases that look like local paths, try to expand as a local path and convert to file:// URL
2920 match crate::utils::platform::resolve_path(url) {
2921 Ok(expanded_path) => {
2922 // Convert to file:// URL
2923 let path_str = expanded_path.to_string_lossy();
2924 if expanded_path.is_absolute() {
2925 Ok(format!("file://{path_str}"))
2926 } else {
2927 Ok(format!(
2928 "file://{}",
2929 std::env::current_dir()?.join(expanded_path).to_string_lossy()
2930 ))
2931 }
2932 }
2933 Err(_) => {
2934 // If path expansion fails, return the original URL
2935 // This allows the validation to catch the error with a proper message
2936 Ok(url.to_string())
2937 }
2938 }
2939 } else {
2940 // For strings that don't look like paths, return as-is to let validation catch the error
2941 Ok(url.to_string())
2942 }
2943}
2944
2945/// Find the manifest file by searching up the directory tree from the current directory.
2946///
2947/// This function implements the standard AGPM behavior of searching for a `agpm.toml`
2948/// file starting from the current working directory and walking up the directory
2949/// tree until one is found or the filesystem root is reached.
2950///
2951/// This behavior mirrors tools like Cargo, Git, and NPM that search for project
2952/// configuration files in parent directories.
2953///
2954/// # Search Algorithm
2955///
2956/// 1. Start from the current working directory
2957/// 2. Look for `agpm.toml` in the current directory
2958/// 3. If not found, move to the parent directory
2959/// 4. Repeat until found or reach the filesystem root
2960/// 5. Return error if no manifest is found
2961///
2962/// # Examples
2963///
2964/// ```rust,no_run
2965/// use agpm_cli::manifest::find_manifest;
2966///
2967/// // Find manifest from current directory
2968/// match find_manifest() {
2969/// Ok(path) => println!("Found manifest at: {}", path.display()),
2970/// Err(e) => println!("No manifest found: {}", e),
2971/// }
2972/// ```
2973///
2974/// # Directory Structure Example
2975///
2976/// ```text
2977/// /home/user/project/
2978/// ├── agpm.toml ← Found here
2979/// └── subdir/
2980/// └── deep/
2981/// └── nested/ ← Search started here, walks up
2982/// ```
2983///
2984/// If called from `/home/user/project/subdir/deep/nested/`, this function
2985/// will find and return `/home/user/project/agpm.toml`.
2986///
2987/// # Error Conditions
2988///
2989/// - **No manifest found**: Searched to filesystem root without finding `agpm.toml`
2990/// - **Permission denied**: Cannot read current directory or traverse up
2991/// - **Filesystem corruption**: Cannot determine current working directory
2992///
2993/// # Use Cases
2994///
2995/// This function is typically called by CLI commands that need to locate the
2996/// project configuration, allowing users to run AGPM commands from any
2997/// subdirectory within their project.
2998pub fn find_manifest() -> Result<PathBuf> {
2999 let current = std::env::current_dir()
3000 .context("Cannot determine current working directory. This may indicate a permission issue or corrupted filesystem")?;
3001 find_manifest_from(current)
3002}
3003
3004/// Find the manifest file, using an explicit path if provided.
3005///
3006/// This function provides a consistent way to locate the manifest file,
3007/// either using an explicitly provided path or by searching from the
3008/// current directory.
3009///
3010/// # Arguments
3011///
3012/// * `explicit_path` - Optional path to a manifest file. If provided and the file exists,
3013/// this path is returned. If provided but the file doesn't exist, an error is returned.
3014///
3015/// # Returns
3016///
3017/// The path to the manifest file.
3018///
3019/// # Errors
3020///
3021/// Returns an error if:
3022/// - An explicit path is provided but the file doesn't exist
3023/// - No explicit path is provided and no manifest is found via search
3024///
3025/// # Examples
3026///
3027/// ```rust,no_run
3028/// use agpm_cli::manifest::find_manifest_with_optional;
3029/// use std::path::PathBuf;
3030///
3031/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3032/// // Use explicit path
3033/// let explicit = Some(PathBuf::from("/path/to/agpm.toml"));
3034/// let manifest = find_manifest_with_optional(explicit)?;
3035///
3036/// // Search from current directory
3037/// let manifest = find_manifest_with_optional(None)?;
3038/// # Ok(())
3039/// # }
3040/// ```
3041pub fn find_manifest_with_optional(explicit_path: Option<PathBuf>) -> Result<PathBuf> {
3042 match explicit_path {
3043 Some(path) => {
3044 if path.exists() {
3045 Ok(path)
3046 } else {
3047 Err(crate::core::AgpmError::ManifestNotFound.into())
3048 }
3049 }
3050 None => find_manifest(),
3051 }
3052}
3053
3054/// Find the manifest file by searching up from a specific starting directory.
3055///
3056/// This is the core manifest discovery function that implements the directory
3057/// traversal logic. It's used internally by [`find_manifest`] and can also
3058/// be used when you need to search from a specific directory rather than
3059/// the current working directory.
3060///
3061/// # Algorithm
3062///
3063/// 1. Check if `agpm.toml` exists in the starting directory
3064/// 2. If found, return the full path to the manifest
3065/// 3. If not found, move to the parent directory
3066/// 4. Repeat until manifest is found or filesystem root is reached
3067/// 5. Return [`crate::core::AgpmError::ManifestNotFound`] if no manifest is found
3068///
3069/// # Parameters
3070///
3071/// - `current`: The starting directory for the search (consumed by the function)
3072///
3073/// # Examples
3074///
3075/// ```rust,no_run
3076/// use agpm_cli::manifest::find_manifest_from;
3077/// use std::path::PathBuf;
3078///
3079/// // Search from a specific directory
3080/// let start_dir = PathBuf::from("/home/user/project/subdir");
3081/// match find_manifest_from(start_dir) {
3082/// Ok(manifest_path) => {
3083/// println!("Found manifest: {}", manifest_path.display());
3084/// }
3085/// Err(e) => {
3086/// println!("No manifest found: {}", e);
3087/// }
3088/// }
3089/// ```
3090///
3091/// # Performance Considerations
3092///
3093/// - Each directory check involves a filesystem stat operation
3094/// - Search depth is limited by filesystem hierarchy (typically < 20 levels)
3095/// - Function returns immediately upon finding the first manifest
3096/// - No filesystem locks are held during the search
3097///
3098/// # Cross-Platform Behavior
3099///
3100/// - Works correctly on Windows, macOS, and Linux
3101/// - Handles filesystem roots appropriately (`/` on Unix, `C:\` on Windows)
3102/// - Respects platform-specific path separators and conventions
3103/// - Works with network filesystems and mounted volumes
3104///
3105/// # Error Handling
3106///
3107/// Returns [`crate::core::AgpmError::ManifestNotFound`] wrapped in an [`anyhow::Error`]
3108/// if no manifest file is found after searching to the filesystem root.
3109pub fn find_manifest_from(mut current: PathBuf) -> Result<PathBuf> {
3110 loop {
3111 let manifest_path = current.join("agpm.toml");
3112 if manifest_path.exists() {
3113 return Ok(manifest_path);
3114 }
3115
3116 if !current.pop() {
3117 return Err(crate::core::AgpmError::ManifestNotFound.into());
3118 }
3119 }
3120}
3121
3122#[cfg(test)]
3123mod tests {
3124 use super::*;
3125 use tempfile::tempdir;
3126
3127 #[test]
3128 fn test_manifest_new() {
3129 let manifest = Manifest::new();
3130 assert!(manifest.sources.is_empty());
3131 assert!(manifest.agents.is_empty());
3132 assert!(manifest.snippets.is_empty());
3133 assert!(manifest.commands.is_empty());
3134 assert!(manifest.mcp_servers.is_empty());
3135 }
3136
3137 #[test]
3138 fn test_manifest_load_save() {
3139 let temp = tempdir().unwrap();
3140 let manifest_path = temp.path().join("agpm.toml");
3141
3142 let mut manifest = Manifest::new();
3143 manifest.add_source(
3144 "official".to_string(),
3145 "https://github.com/example-org/agpm-official.git".to_string(),
3146 );
3147 manifest.add_dependency(
3148 "test-agent".to_string(),
3149 ResourceDependency::Detailed(Box::new(DetailedDependency {
3150 source: Some("official".to_string()),
3151 path: "agents/test.md".to_string(),
3152 version: Some("v1.0.0".to_string()),
3153 branch: None,
3154 rev: None,
3155 command: None,
3156 args: None,
3157 target: None,
3158 filename: None,
3159 dependencies: None,
3160 tool: "claude-code".to_string(),
3161 })),
3162 true,
3163 );
3164
3165 manifest.save(&manifest_path).unwrap();
3166
3167 let loaded = Manifest::load(&manifest_path).unwrap();
3168 assert_eq!(loaded.sources.len(), 1);
3169 assert_eq!(loaded.agents.len(), 1);
3170 assert!(loaded.has_dependency("test-agent"));
3171 }
3172
3173 #[test]
3174 fn test_manifest_validation() {
3175 let mut manifest = Manifest::new();
3176
3177 // Add dependency without source - should be valid (local dependency)
3178 manifest.add_dependency(
3179 "local-agent".to_string(),
3180 ResourceDependency::Simple("../local/agent.md".to_string()),
3181 true,
3182 );
3183 assert!(manifest.validate().is_ok());
3184
3185 // Add dependency with undefined source - should fail validation
3186 manifest.add_dependency(
3187 "remote-agent".to_string(),
3188 ResourceDependency::Detailed(Box::new(DetailedDependency {
3189 source: Some("undefined".to_string()),
3190 path: "agent.md".to_string(),
3191 version: Some("v1.0.0".to_string()),
3192 branch: None,
3193 rev: None,
3194 command: None,
3195 args: None,
3196 target: None,
3197 filename: None,
3198 dependencies: None,
3199 tool: "claude-code".to_string(),
3200 })),
3201 true,
3202 );
3203 assert!(manifest.validate().is_err());
3204
3205 // Add the source - should now be valid
3206 manifest
3207 .add_source("undefined".to_string(), "https://github.com/test/repo.git".to_string());
3208 assert!(manifest.validate().is_ok());
3209 }
3210
3211 #[test]
3212 fn test_dependency_helpers() {
3213 let simple_dep = ResourceDependency::Simple("path/to/file.md".to_string());
3214 assert_eq!(simple_dep.get_path(), "path/to/file.md");
3215 assert!(simple_dep.get_source().is_none());
3216 assert!(simple_dep.get_version().is_none());
3217 assert!(simple_dep.is_local());
3218
3219 let detailed_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3220 source: Some("official".to_string()),
3221 path: "agents/test.md".to_string(),
3222 version: Some("v1.0.0".to_string()),
3223 branch: None,
3224 rev: None,
3225 command: None,
3226 args: None,
3227 target: None,
3228 filename: None,
3229 dependencies: None,
3230 tool: "claude-code".to_string(),
3231 }));
3232 assert_eq!(detailed_dep.get_path(), "agents/test.md");
3233 assert_eq!(detailed_dep.get_source(), Some("official"));
3234 assert_eq!(detailed_dep.get_version(), Some("v1.0.0"));
3235 assert!(!detailed_dep.is_local());
3236 }
3237
3238 #[test]
3239 fn test_all_dependencies() {
3240 let mut manifest = Manifest::new();
3241
3242 manifest.add_dependency(
3243 "agent1".to_string(),
3244 ResourceDependency::Simple("a1.md".to_string()),
3245 true,
3246 );
3247 manifest.add_dependency(
3248 "snippet1".to_string(),
3249 ResourceDependency::Simple("s1.md".to_string()),
3250 false,
3251 );
3252
3253 let all_deps = manifest.all_dependencies();
3254 assert_eq!(all_deps.len(), 2);
3255 }
3256
3257 #[test]
3258 fn test_source_url_validation() {
3259 let mut manifest = Manifest::new();
3260
3261 // Valid URLs
3262 manifest.add_source("http".to_string(), "http://github.com/test/repo.git".to_string());
3263 manifest.add_source("https".to_string(), "https://github.com/test/repo.git".to_string());
3264 manifest.add_source("ssh".to_string(), "git@github.com:test/repo.git".to_string());
3265 assert!(manifest.validate().is_ok());
3266
3267 // Invalid URL
3268 manifest.add_source("invalid".to_string(), "not-a-url".to_string());
3269 let result = manifest.validate();
3270 assert!(result.is_err());
3271 assert!(result.unwrap_err().to_string().contains("invalid URL"));
3272 }
3273
3274 #[test]
3275 fn test_manifest_commands() {
3276 let mut manifest = Manifest::new();
3277
3278 // Add a command dependency
3279 manifest.add_typed_dependency(
3280 "build-command".to_string(),
3281 ResourceDependency::Simple("commands/build.md".to_string()),
3282 crate::core::ResourceType::Command,
3283 );
3284
3285 assert!(manifest.commands.contains_key("build-command"));
3286 assert_eq!(manifest.commands.len(), 1);
3287 assert!(manifest.has_dependency("build-command"));
3288
3289 // Test get_dependency returns command
3290 let dep = manifest.get_dependency("build-command");
3291 assert!(dep.is_some());
3292 assert_eq!(dep.unwrap().get_path(), "commands/build.md");
3293 }
3294
3295 #[test]
3296 fn test_manifest_all_dependencies_with_commands() {
3297 let mut manifest = Manifest::new();
3298
3299 manifest.add_typed_dependency(
3300 "agent1".to_string(),
3301 ResourceDependency::Simple("a1.md".to_string()),
3302 crate::core::ResourceType::Agent,
3303 );
3304 manifest.add_typed_dependency(
3305 "snippet1".to_string(),
3306 ResourceDependency::Simple("s1.md".to_string()),
3307 crate::core::ResourceType::Snippet,
3308 );
3309 manifest.add_typed_dependency(
3310 "command1".to_string(),
3311 ResourceDependency::Simple("c1.md".to_string()),
3312 crate::core::ResourceType::Command,
3313 );
3314
3315 let all_deps = manifest.all_dependencies();
3316 assert_eq!(all_deps.len(), 3);
3317
3318 // Verify all three types are present
3319 assert!(manifest.agents.contains_key("agent1"));
3320 assert!(manifest.snippets.contains_key("snippet1"));
3321 assert!(manifest.commands.contains_key("command1"));
3322 }
3323
3324 #[test]
3325 fn test_manifest_save_load_commands() {
3326 let temp = tempdir().unwrap();
3327 let manifest_path = temp.path().join("agpm.toml");
3328
3329 let mut manifest = Manifest::new();
3330 manifest.add_source(
3331 "community".to_string(),
3332 "https://github.com/example/community.git".to_string(),
3333 );
3334 manifest.add_typed_dependency(
3335 "deploy".to_string(),
3336 ResourceDependency::Detailed(Box::new(DetailedDependency {
3337 source: Some("community".to_string()),
3338 path: "commands/deploy.md".to_string(),
3339 version: Some("v2.0.0".to_string()),
3340 branch: None,
3341 rev: None,
3342 command: None,
3343 args: None,
3344 target: None,
3345 filename: None,
3346 dependencies: None,
3347 tool: "claude-code".to_string(),
3348 })),
3349 crate::core::ResourceType::Command,
3350 );
3351
3352 // Save and reload
3353 manifest.save(&manifest_path).unwrap();
3354 let loaded = Manifest::load(&manifest_path).unwrap();
3355
3356 assert_eq!(loaded.commands.len(), 1);
3357 assert!(loaded.commands.contains_key("deploy"));
3358 assert!(loaded.has_dependency("deploy"));
3359
3360 let dep = loaded.get_dependency("deploy").unwrap();
3361 assert_eq!(dep.get_path(), "commands/deploy.md");
3362 assert_eq!(dep.get_version(), Some("v2.0.0"));
3363 }
3364
3365 #[test]
3366 #[allow(deprecated)]
3367 fn test_target_config_commands_dir() {
3368 let config = TargetConfig::default();
3369 assert_eq!(config.commands, ".claude/commands");
3370
3371 // Test custom config
3372 let mut manifest = Manifest::new();
3373 manifest.target.commands = "custom/commands".to_string();
3374 assert_eq!(manifest.target.commands, "custom/commands");
3375 }
3376
3377 #[test]
3378 fn test_mcp_servers() {
3379 let mut manifest = Manifest::new();
3380
3381 // Add an MCP server (now using standard ResourceDependency)
3382 manifest.add_mcp_server(
3383 "test-server".to_string(),
3384 ResourceDependency::Detailed(Box::new(DetailedDependency {
3385 source: Some("npm".to_string()),
3386 path: "mcp-servers/test-server.json".to_string(),
3387 version: Some("latest".to_string()),
3388 branch: None,
3389 rev: None,
3390 command: None,
3391 args: None,
3392 target: None,
3393 filename: None,
3394 dependencies: None,
3395 tool: "claude-code".to_string(),
3396 })),
3397 );
3398
3399 assert_eq!(manifest.mcp_servers.len(), 1);
3400 assert!(manifest.mcp_servers.contains_key("test-server"));
3401
3402 let server = &manifest.mcp_servers["test-server"];
3403 assert_eq!(server.get_source(), Some("npm"));
3404 assert_eq!(server.get_path(), "mcp-servers/test-server.json");
3405 assert_eq!(server.get_version(), Some("latest"));
3406 }
3407
3408 #[test]
3409 fn test_manifest_save_load_mcp_servers() {
3410 let temp = tempdir().unwrap();
3411 let manifest_path = temp.path().join("agpm.toml");
3412
3413 let mut manifest = Manifest::new();
3414 manifest.add_source("npm".to_string(), "https://registry.npmjs.org".to_string());
3415 manifest.add_mcp_server(
3416 "postgres".to_string(),
3417 ResourceDependency::Simple("../local/mcp-servers/postgres.json".to_string()),
3418 );
3419
3420 // Save and reload
3421 manifest.save(&manifest_path).unwrap();
3422 let loaded = Manifest::load(&manifest_path).unwrap();
3423
3424 assert_eq!(loaded.mcp_servers.len(), 1);
3425 assert!(loaded.mcp_servers.contains_key("postgres"));
3426
3427 let server = &loaded.mcp_servers["postgres"];
3428 assert_eq!(server.get_path(), "../local/mcp-servers/postgres.json");
3429 }
3430
3431 #[test]
3432 #[allow(deprecated)]
3433 fn test_target_config_mcp_servers_dir() {
3434 let config = TargetConfig::default();
3435 assert_eq!(config.mcp_servers, ".claude/agpm/mcp-servers");
3436
3437 // Test custom config
3438 let mut manifest = Manifest::new();
3439 manifest.target.mcp_servers = "custom/mcp".to_string();
3440 assert_eq!(manifest.target.mcp_servers, "custom/mcp");
3441 }
3442
3443 #[test]
3444 fn test_dependency_with_custom_target() {
3445 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3446 source: Some("official".to_string()),
3447 path: "agents/tool.md".to_string(),
3448 version: Some("v1.0.0".to_string()),
3449 branch: None,
3450 rev: None,
3451 command: None,
3452 args: None,
3453 target: Some("custom/tools".to_string()),
3454 filename: None,
3455 dependencies: None,
3456 tool: "claude-code".to_string(),
3457 }));
3458
3459 assert_eq!(dep.get_target(), Some("custom/tools"));
3460 assert_eq!(dep.get_source(), Some("official"));
3461 assert_eq!(dep.get_path(), "agents/tool.md");
3462 }
3463
3464 #[test]
3465 fn test_dependency_without_custom_target() {
3466 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3467 source: Some("official".to_string()),
3468 path: "agents/tool.md".to_string(),
3469 version: Some("v1.0.0".to_string()),
3470 branch: None,
3471 rev: None,
3472 command: None,
3473 args: None,
3474 target: None,
3475 filename: None,
3476 dependencies: None,
3477 tool: "claude-code".to_string(),
3478 }));
3479
3480 assert!(dep.get_target().is_none());
3481 }
3482
3483 #[test]
3484 fn test_simple_dependency_no_custom_target() {
3485 let dep = ResourceDependency::Simple("../local/file.md".to_string());
3486 assert!(dep.get_target().is_none());
3487 }
3488
3489 #[test]
3490 fn test_save_load_dependency_with_custom_target() {
3491 let temp = tempdir().unwrap();
3492 let manifest_path = temp.path().join("agpm.toml");
3493
3494 let mut manifest = Manifest::new();
3495 manifest.add_source(
3496 "official".to_string(),
3497 "https://github.com/example/official.git".to_string(),
3498 );
3499
3500 // Add dependency with custom target
3501 manifest.add_typed_dependency(
3502 "special-agent".to_string(),
3503 ResourceDependency::Detailed(Box::new(DetailedDependency {
3504 source: Some("official".to_string()),
3505 path: "agents/special.md".to_string(),
3506 version: Some("v1.0.0".to_string()),
3507 target: Some("integrations/ai".to_string()),
3508 branch: None,
3509 rev: None,
3510 command: None,
3511 args: None,
3512 filename: None,
3513 dependencies: None,
3514 tool: "claude-code".to_string(),
3515 })),
3516 crate::core::ResourceType::Agent,
3517 );
3518
3519 // Save and reload
3520 manifest.save(&manifest_path).unwrap();
3521 let loaded = Manifest::load(&manifest_path).unwrap();
3522
3523 assert_eq!(loaded.agents.len(), 1);
3524 assert!(loaded.agents.contains_key("special-agent"));
3525
3526 let dep = loaded.get_dependency("special-agent").unwrap();
3527 assert_eq!(dep.get_target(), Some("integrations/ai"));
3528 assert_eq!(dep.get_path(), "agents/special.md");
3529 }
3530
3531 #[test]
3532 fn test_dependency_with_custom_filename() {
3533 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3534 source: Some("official".to_string()),
3535 path: "agents/tool.md".to_string(),
3536 version: Some("v1.0.0".to_string()),
3537 branch: None,
3538 rev: None,
3539 command: None,
3540 args: None,
3541 target: None,
3542 filename: Some("ai-assistant.md".to_string()),
3543 dependencies: None,
3544 tool: "claude-code".to_string(),
3545 }));
3546
3547 assert_eq!(dep.get_filename(), Some("ai-assistant.md"));
3548 assert_eq!(dep.get_source(), Some("official"));
3549 assert_eq!(dep.get_path(), "agents/tool.md");
3550 }
3551
3552 #[test]
3553 fn test_dependency_without_custom_filename() {
3554 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3555 source: Some("official".to_string()),
3556 path: "agents/tool.md".to_string(),
3557 version: Some("v1.0.0".to_string()),
3558 branch: None,
3559 rev: None,
3560 command: None,
3561 args: None,
3562 target: None,
3563 filename: None,
3564 dependencies: None,
3565 tool: "claude-code".to_string(),
3566 }));
3567
3568 assert!(dep.get_filename().is_none());
3569 }
3570
3571 #[test]
3572 fn test_simple_dependency_no_custom_filename() {
3573 let dep = ResourceDependency::Simple("../local/file.md".to_string());
3574 assert!(dep.get_filename().is_none());
3575 }
3576
3577 #[test]
3578 fn test_save_load_dependency_with_custom_filename() {
3579 let temp = tempdir().unwrap();
3580 let manifest_path = temp.path().join("agpm.toml");
3581
3582 let mut manifest = Manifest::new();
3583 manifest.add_source(
3584 "official".to_string(),
3585 "https://github.com/example/official.git".to_string(),
3586 );
3587
3588 // Add dependency with custom filename
3589 manifest.add_typed_dependency(
3590 "my-agent".to_string(),
3591 ResourceDependency::Detailed(Box::new(DetailedDependency {
3592 source: Some("official".to_string()),
3593 path: "agents/complex-name.md".to_string(),
3594 version: Some("v1.0.0".to_string()),
3595 target: None,
3596 filename: Some("simple-name.txt".to_string()),
3597 branch: None,
3598 rev: None,
3599 command: None,
3600 args: None,
3601 dependencies: None,
3602 tool: "claude-code".to_string(),
3603 })),
3604 crate::core::ResourceType::Agent,
3605 );
3606
3607 // Save and reload
3608 manifest.save(&manifest_path).unwrap();
3609 let loaded = Manifest::load(&manifest_path).unwrap();
3610
3611 assert_eq!(loaded.agents.len(), 1);
3612 assert!(loaded.agents.contains_key("my-agent"));
3613
3614 let dep = loaded.get_dependency("my-agent").unwrap();
3615 assert_eq!(dep.get_filename(), Some("simple-name.txt"));
3616 assert_eq!(dep.get_path(), "agents/complex-name.md");
3617 }
3618
3619 #[test]
3620 fn test_pattern_dependency() {
3621 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3622 source: Some("repo".to_string()),
3623 path: "agents/**/*.md".to_string(),
3624 version: Some("v1.0.0".to_string()),
3625 branch: None,
3626 rev: None,
3627 command: None,
3628 args: None,
3629 target: None,
3630 filename: None,
3631 dependencies: None,
3632 tool: "claude-code".to_string(),
3633 }));
3634
3635 assert!(dep.is_pattern());
3636 assert_eq!(dep.get_path(), "agents/**/*.md");
3637 assert!(!dep.is_local());
3638 }
3639
3640 #[test]
3641 fn test_pattern_dependency_validation() {
3642 let mut manifest = Manifest::new();
3643 manifest
3644 .sources
3645 .insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
3646
3647 // Valid pattern dependency (uses glob characters in path)
3648 manifest.agents.insert(
3649 "ai-agents".to_string(),
3650 ResourceDependency::Detailed(Box::new(DetailedDependency {
3651 source: Some("repo".to_string()),
3652 path: "agents/ai/*.md".to_string(),
3653 version: Some("v1.0.0".to_string()),
3654 branch: None,
3655 rev: None,
3656 command: None,
3657 args: None,
3658 target: None,
3659 filename: None,
3660 dependencies: None,
3661 tool: "claude-code".to_string(),
3662 })),
3663 );
3664
3665 assert!(manifest.validate().is_ok());
3666
3667 // Valid: regular dependency (no glob characters)
3668 manifest.agents.insert(
3669 "regular".to_string(),
3670 ResourceDependency::Detailed(Box::new(DetailedDependency {
3671 source: Some("repo".to_string()),
3672 path: "agents/test.md".to_string(),
3673 version: Some("v1.0.0".to_string()),
3674 branch: None,
3675 rev: None,
3676 command: None,
3677 args: None,
3678 target: None,
3679 filename: None,
3680 dependencies: None,
3681 tool: "claude-code".to_string(),
3682 })),
3683 );
3684
3685 let result = manifest.validate();
3686 assert!(result.is_ok());
3687 }
3688
3689 #[test]
3690 fn test_pattern_dependency_with_path_traversal() {
3691 let mut manifest = Manifest::new();
3692 manifest
3693 .sources
3694 .insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
3695
3696 // Pattern with path traversal (using path field now)
3697 manifest.agents.insert(
3698 "unsafe".to_string(),
3699 ResourceDependency::Detailed(Box::new(DetailedDependency {
3700 source: Some("repo".to_string()),
3701 path: "../../../etc/*.conf".to_string(),
3702 version: Some("v1.0.0".to_string()),
3703 branch: None,
3704 rev: None,
3705 command: None,
3706 args: None,
3707 target: None,
3708 filename: None,
3709 dependencies: None,
3710 tool: "claude-code".to_string(),
3711 })),
3712 );
3713
3714 let result = manifest.validate();
3715 assert!(result.is_err());
3716 assert!(result.unwrap_err().to_string().contains("Invalid pattern"));
3717 }
3718
3719 #[test]
3720 fn test_dependency_with_both_target_and_filename() {
3721 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3722 source: Some("official".to_string()),
3723 path: "agents/tool.md".to_string(),
3724 version: Some("v1.0.0".to_string()),
3725 branch: None,
3726 rev: None,
3727 command: None,
3728 args: None,
3729 target: Some("tools/ai".to_string()),
3730 filename: Some("assistant.markdown".to_string()),
3731 dependencies: None,
3732 tool: "claude-code".to_string(),
3733 }));
3734
3735 assert_eq!(dep.get_target(), Some("tools/ai"));
3736 assert_eq!(dep.get_filename(), Some("assistant.markdown"));
3737 }
3738}
3739
3740#[cfg(test)]
3741mod tool_tests {
3742 use super::*;
3743
3744 #[test]
3745 fn test_detailed_dependency_tool_parsing() {
3746 let toml_str = r#"
3747[agents]
3748opencode-helper = { source = "test_repo", path = "agents/helper.md", version = "v1.0.0", tool = "opencode" }
3749"#;
3750
3751 let manifest: Manifest = toml::from_str(toml_str).unwrap();
3752
3753 let helper = manifest.agents.get("opencode-helper").unwrap();
3754
3755 match helper {
3756 ResourceDependency::Detailed(d) => {
3757 assert_eq!(d.tool, "opencode", "tool should be 'opencode'");
3758 }
3759 _ => panic!("Expected Detailed dependency"),
3760 }
3761 }
3762
3763 #[test]
3764 fn test_tool_name_validation() {
3765 // Test that artifact type names with path separators are rejected
3766 let toml_with_slash = r#"
3767[sources]
3768test = "https://example.com/repo.git"
3769
3770[tools."bad/name"]
3771path = ".claude"
3772
3773[tools."bad/name".resources.agents]
3774path = "agents"
3775
3776[agents]
3777test = { source = "test", path = "agents/test.md", type = "bad/name" }
3778"#;
3779
3780 let manifest: Result<Manifest, _> = toml::from_str(toml_with_slash);
3781 assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
3782 let manifest = manifest.unwrap();
3783 let result = manifest.validate();
3784 assert!(result.is_err(), "Validation should fail for artifact type with forward slash");
3785 let err = result.unwrap_err();
3786 assert!(
3787 err.to_string().contains("cannot contain path separators"),
3788 "Error should mention path separators, got: {}",
3789 err
3790 );
3791
3792 // Test backslash
3793 let toml_with_backslash = r#"
3794[sources]
3795test = "https://example.com/repo.git"
3796
3797[tools."bad\\name"]
3798path = ".claude"
3799
3800[tools."bad\\name".resources.agents]
3801path = "agents"
3802
3803[agents]
3804test = { source = "test", path = "agents/test.md", type = "bad\\name" }
3805"#;
3806
3807 let manifest: Result<Manifest, _> = toml::from_str(toml_with_backslash);
3808 assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
3809 let manifest = manifest.unwrap();
3810 let result = manifest.validate();
3811 assert!(result.is_err(), "Validation should fail for artifact type with backslash");
3812
3813 // Test path traversal (..)
3814 let toml_with_dotdot = r#"
3815[sources]
3816test = "https://example.com/repo.git"
3817
3818[tools."bad..name"]
3819path = ".claude"
3820
3821[tools."bad..name".resources.agents]
3822path = "agents"
3823
3824[agents]
3825test = { source = "test", path = "agents/test.md", type = "bad..name" }
3826"#;
3827
3828 let manifest: Result<Manifest, _> = toml::from_str(toml_with_dotdot);
3829 assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
3830 let manifest = manifest.unwrap();
3831 let result = manifest.validate();
3832 assert!(result.is_err(), "Validation should fail for artifact type with ..");
3833 let err = result.unwrap_err();
3834 assert!(
3835 err.to_string().contains("cannot contain '..'"),
3836 "Error should mention path traversal, got: {}",
3837 err
3838 );
3839
3840 // Test valid tool type names work
3841 let toml_valid = r#"
3842[sources]
3843test = "https://example.com/repo.git"
3844
3845[tools."my-custom-type"]
3846path = ".custom"
3847
3848[tools."my-custom-type".resources.agents]
3849path = "agents"
3850
3851[agents]
3852test = { source = "test", path = "agents/test.md", version = "v1.0.0", tool = "my-custom-type" }
3853"#;
3854
3855 let manifest: Result<Manifest, _> = toml::from_str(toml_valid);
3856 assert!(manifest.is_ok(), "Valid manifest should parse");
3857 let manifest = manifest.unwrap();
3858 let result = manifest.validate();
3859 assert!(result.is_ok(), "Valid artifact type name should pass validation");
3860 }
3861}