agpm_cli/manifest/
tool_config.rs

1//! Tool configuration types for multi-tool support.
2//!
3//! This module defines the types and structures used to configure different AI coding
4//! assistant tools (Claude Code, OpenCode, AGPM, and custom tools) in AGPM manifests.
5//!
6//! # Overview
7//!
8//! AGPM supports multiple AI coding tools through a flexible configuration system:
9//! - **Claude Code**: The primary AI coding assistant (enabled by default)
10//! - **OpenCode**: Alternative AI coding assistant (enabled by default for consistency)
11//! - **AGPM**: Internal tool for shared infrastructure like snippets (enabled by default)
12//! - **Custom Tools**: User-defined tools with custom configurations (enabled by default)
13//!
14//! # Tool Configuration
15//!
16//! Each tool defines:
17//! - A base directory (e.g., `.claude`, `.opencode`, `.agpm`)
18//! - Resource type mappings (agents, commands, snippets, etc.)
19//! - Installation paths or merge targets for each resource type
20//! - Default flatten behavior for directory structure preservation
21//! - An enabled/disabled state
22//!
23//! # Key Types
24//!
25//! - [`WellKnownTool`]: Enum representing officially supported tools
26//! - [`ResourceConfig`]: Configuration for a specific resource type within a tool
27//! - [`ArtifactTypeConfig`]: Complete configuration for a tool
28//! - [`ToolsConfig`]: Top-level configuration mapping tool names to their configs
29//!
30//! # Examples
31//!
32//! ```toml
33//! [tools.claude-code]
34//! path = ".claude"
35//! enabled = true
36//!
37//! [tools.claude-code.resources.agents]
38//! path = "agents/agpm"  # agpm subdirectory for easy gitignore management
39//! flatten = true
40//!
41//! [tools.opencode]
42//! path = ".opencode"
43//! enabled = true   # Enabled by default
44//!
45//! [tools.opencode.resources.agents]
46//! path = "agent/agpm"  # Singular in OpenCode + agpm subdirectory
47//! flatten = true
48//! ```
49
50use serde::{Deserialize, Serialize};
51use std::collections::HashMap;
52use std::path::PathBuf;
53use std::sync::OnceLock;
54
55// Cached default configuration to avoid repeated allocations
56static DEFAULT_TOOLS_CONFIG: OnceLock<ToolsConfig> = OnceLock::new();
57
58/// Resource configuration within a tool.
59///
60/// Defines the installation path for a specific resource type within a tool.
61/// Resources can either:
62/// - Install to a subdirectory (via `path`)
63/// - Merge into a configuration file (via `merge_target`)
64///
65/// At least one of `path` or `merge_target` should be set for a resource type
66/// to be considered supported by a tool.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct ResourceConfig {
69    /// Subdirectory path for this resource type relative to the tool's base directory.
70    ///
71    /// Used for resources that install as separate files (agents, snippets, commands, scripts).
72    /// When None, this resource type either uses merge_target or is not supported.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub path: Option<String>,
75
76    /// Target configuration file for merging this resource type.
77    ///
78    /// Used for resources that merge into configuration files (hooks, MCP servers).
79    /// The path is relative to the project root.
80    ///
81    /// # Examples
82    ///
83    /// - Hooks: `.claude/settings.local.json`
84    /// - MCP servers: `.mcp.json` or `.opencode/opencode.json`
85    #[serde(skip_serializing_if = "Option::is_none", rename = "merge-target")]
86    pub merge_target: Option<String>,
87
88    /// Default flatten behavior for this resource type.
89    ///
90    /// When `true`: Only the filename is used for installation (e.g., `nested/dir/file.md` → `file.md`)
91    /// When `false`: Full relative path is preserved (e.g., `nested/dir/file.md` → `nested/dir/file.md`)
92    ///
93    /// This default can be overridden per-dependency using the `flatten` field.
94    /// If not specified, defaults to `false` (preserve directory structure).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub flatten: Option<bool>,
97}
98
99/// Well-known tool types with specific default behaviors.
100///
101/// This enum represents the officially supported tools and their
102/// specific default configurations, particularly for the `enabled` field.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum WellKnownTool {
105    /// Claude Code - the primary AI coding assistant tool.
106    /// Enabled by default since most users rely on Claude Code.
107    ClaudeCode,
108
109    /// OpenCode - an alternative AI coding assistant tool.
110    /// Enabled by default for consistency with other tools.
111    OpenCode,
112
113    /// AGPM - internal tool for shared infrastructure (snippets).
114    /// Enabled by default for backward compatibility and shared resources.
115    Agpm,
116
117    /// Generic/custom tools not in the well-known set.
118    /// Enabled by default for backward compatibility.
119    Generic,
120}
121
122impl WellKnownTool {
123    /// Identifies a well-known tool from its string name.
124    ///
125    /// # Arguments
126    ///
127    /// * `tool_name` - The name of the tool (e.g., "claude-code", "opencode", "agpm")
128    ///
129    /// # Returns
130    ///
131    /// The corresponding `WellKnownTool` variant, or `Generic` for custom tools.
132    pub fn from_name(tool_name: &str) -> Self {
133        match tool_name {
134            "claude-code" => WellKnownTool::ClaudeCode,
135            "opencode" => WellKnownTool::OpenCode,
136            "agpm" => WellKnownTool::Agpm,
137            _ => WellKnownTool::Generic,
138        }
139    }
140
141    /// Returns the default `enabled` value for this tool.
142    ///
143    /// # Default Values
144    ///
145    /// - **Claude Code**: `true` (most users rely on it)
146    /// - **OpenCode**: `true` (enabled by default for consistency)
147    /// - **AGPM**: `true` (shared infrastructure)
148    /// - **Generic**: `true` (backward compatibility)
149    pub const fn default_enabled(self) -> bool {
150        match self {
151            WellKnownTool::ClaudeCode => true,
152            WellKnownTool::OpenCode => true,
153            WellKnownTool::Agpm => true,
154            WellKnownTool::Generic => true,
155        }
156    }
157}
158
159/// Tool configuration (internal deserialization structure).
160///
161/// This is used during deserialization to capture optional fields.
162/// The public API uses `ArtifactTypeConfig` with required `enabled` field.
163#[derive(Debug, Clone, Deserialize)]
164struct ArtifactTypeConfigRaw {
165    /// Base directory for this tool (e.g., ".claude", ".opencode", ".agpm")
166    path: PathBuf,
167
168    /// Map of resource type -> configuration
169    #[serde(default)]
170    resources: HashMap<String, ResourceConfig>,
171
172    /// Whether this tool is enabled (optional during deserialization)
173    ///
174    /// When None, the tool-specific default will be applied based on the tool name.
175    #[serde(default)]
176    enabled: Option<bool>,
177}
178
179/// Tool configuration.
180///
181/// Defines how a specific tool (e.g., claude-code, opencode, agpm)
182/// organizes its resources. Each tool has a base directory and
183/// a map of resource types to their subdirectory configurations.
184#[derive(Debug, Clone, Serialize)]
185pub struct ArtifactTypeConfig {
186    /// Base directory for this tool (e.g., ".claude", ".opencode", ".agpm")
187    pub path: PathBuf,
188
189    /// Map of resource type -> configuration
190    pub resources: HashMap<String, ResourceConfig>,
191
192    /// Whether this tool is enabled.
193    ///
194    /// When disabled, dependencies for this tool will not be resolved,
195    /// installed, or included in the lockfile.
196    ///
197    /// # Defaults
198    ///
199    /// - **claude-code**: `true` (most users rely on it)
200    /// - **opencode**: `true` (enabled by default for consistency)
201    /// - **agpm**: `true` (shared infrastructure)
202    /// - **custom tools**: `true` (backward compatibility)
203    pub enabled: bool,
204}
205
206/// Top-level tools configuration.
207///
208/// Maps tool type names to their configurations. This replaces the old
209/// `[target]` section and enables multi-tool support.
210#[derive(Debug, Clone, Serialize)]
211pub struct ToolsConfig {
212    /// Map of tool type name -> configuration
213    #[serde(flatten)]
214    pub types: HashMap<String, ArtifactTypeConfig>,
215}
216
217/// Custom deserializer that merges user configuration with built-in defaults.
218///
219/// # Merging Behavior
220///
221/// For **well-known tools** (claude-code, opencode, agpm):
222/// - Starts with built-in default resource configurations
223/// - User-provided resources override defaults on a per-resource-type basis
224/// - Missing resource types automatically use defaults
225///
226/// For **custom tools**:
227/// - No default merging occurs (user config used as-is)
228/// - User must provide complete configuration
229///
230/// # Example
231///
232/// ```toml
233/// [tools.opencode]
234/// path = ".opencode"
235/// resources = { commands = { path = "command", flatten = true } }
236/// # Snippets not specified - will use default from built-in config
237/// ```
238///
239/// After deserialization, opencode will have:
240/// - `commands`: User config (overrides default)
241/// - `snippets`: Default config (auto-merged)
242impl<'de> serde::Deserialize<'de> for ToolsConfig {
243    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
244    where
245        D: serde::Deserializer<'de>,
246    {
247        // First deserialize into the raw structure with Option<bool> for enabled
248        let raw_types: HashMap<String, ArtifactTypeConfigRaw> = HashMap::deserialize(deserializer)?;
249
250        // Get default configurations for merging (cached)
251        let defaults = DEFAULT_TOOLS_CONFIG.get_or_init(ToolsConfig::default);
252
253        // Convert to the final structure, applying tool-specific defaults
254        let types = raw_types
255            .into_iter()
256            .map(|(tool_name, raw_config)| {
257                // Determine the enabled value:
258                // - If explicitly set in TOML, use that value
259                // - Otherwise, use the tool-specific default
260                let well_known_tool = WellKnownTool::from_name(&tool_name);
261                let enabled =
262                    raw_config.enabled.unwrap_or_else(|| well_known_tool.default_enabled());
263
264                // Merge resources: start with defaults, then overlay user config
265                let merged_resources = if let Some(default_config) = defaults.types.get(&tool_name)
266                {
267                    let mut resources = default_config.resources.clone();
268                    // User-provided resources override defaults
269                    resources.extend(raw_config.resources);
270                    resources
271                } else {
272                    // No defaults for this tool (custom tool), use as-is
273                    raw_config.resources
274                };
275
276                let config = ArtifactTypeConfig {
277                    path: raw_config.path,
278                    resources: merged_resources,
279                    enabled,
280                };
281
282                (tool_name, config)
283            })
284            .collect();
285
286        Ok(ToolsConfig {
287            types,
288        })
289    }
290}
291
292impl Default for ToolsConfig {
293    fn default() -> Self {
294        use crate::core::ResourceType;
295        let mut types = HashMap::new();
296
297        // Claude Code configuration
298        // Resources install to agpm/ subdirectory for easy gitignore management
299        let mut claude_resources = HashMap::new();
300        claude_resources.insert(
301            ResourceType::Agent.to_plural().to_string(),
302            ResourceConfig {
303                path: Some("agents/agpm".to_string()),
304                merge_target: None,
305                flatten: Some(true), // Agents flatten by default
306            },
307        );
308        claude_resources.insert(
309            ResourceType::Snippet.to_plural().to_string(),
310            ResourceConfig {
311                path: Some("snippets/agpm".to_string()),
312                merge_target: None,
313                flatten: Some(false), // Snippets preserve directory structure
314            },
315        );
316        claude_resources.insert(
317            ResourceType::Command.to_plural().to_string(),
318            ResourceConfig {
319                path: Some("commands/agpm".to_string()),
320                merge_target: None,
321                flatten: Some(true), // Commands flatten by default
322            },
323        );
324        claude_resources.insert(
325            ResourceType::Script.to_plural().to_string(),
326            ResourceConfig {
327                path: Some("scripts/agpm".to_string()),
328                merge_target: None,
329                flatten: Some(false), // Scripts preserve directory structure
330            },
331        );
332        claude_resources.insert(
333            ResourceType::Hook.to_plural().to_string(),
334            ResourceConfig {
335                path: None, // Hooks are merged into configuration file
336                merge_target: Some(".claude/settings.local.json".to_string()),
337                flatten: None, // N/A for merge targets
338            },
339        );
340        claude_resources.insert(
341            ResourceType::McpServer.to_plural().to_string(),
342            ResourceConfig {
343                path: None, // MCP servers are merged into configuration file
344                merge_target: Some(".mcp.json".to_string()),
345                flatten: None, // N/A for merge targets
346            },
347        );
348        claude_resources.insert(
349            ResourceType::Skill.to_plural().to_string(),
350            ResourceConfig {
351                path: Some("skills/agpm".to_string()),
352                merge_target: None,
353                flatten: Some(false), // Skills are directories, preserve structure
354            },
355        );
356
357        types.insert(
358            "claude-code".to_string(),
359            ArtifactTypeConfig {
360                path: PathBuf::from(".claude"),
361                resources: claude_resources,
362                enabled: WellKnownTool::ClaudeCode.default_enabled(),
363            },
364        );
365
366        // OpenCode configuration
367        // Resources install to agpm/ subdirectory for easy gitignore management
368        let mut opencode_resources = HashMap::new();
369        opencode_resources.insert(
370            ResourceType::Agent.to_plural().to_string(),
371            ResourceConfig {
372                path: Some("agent/agpm".to_string()), // Singular + agpm subdirectory
373                merge_target: None,
374                flatten: Some(true), // Agents flatten by default
375            },
376        );
377        opencode_resources.insert(
378            ResourceType::Snippet.to_plural().to_string(),
379            ResourceConfig {
380                path: Some("snippet/agpm".to_string()), // Singular + agpm subdirectory
381                merge_target: None,
382                flatten: Some(false), // Snippets preserve directory structure
383            },
384        );
385        opencode_resources.insert(
386            ResourceType::Command.to_plural().to_string(),
387            ResourceConfig {
388                path: Some("command/agpm".to_string()), // Singular + agpm subdirectory
389                merge_target: None,
390                flatten: Some(true), // Commands flatten by default
391            },
392        );
393        opencode_resources.insert(
394            ResourceType::McpServer.to_plural().to_string(),
395            ResourceConfig {
396                path: None, // MCP servers are merged into configuration file
397                merge_target: Some(".opencode/opencode.json".to_string()),
398                flatten: None, // N/A for merge targets
399            },
400        );
401
402        types.insert(
403            "opencode".to_string(),
404            ArtifactTypeConfig {
405                path: PathBuf::from(".opencode"),
406                resources: opencode_resources,
407                enabled: WellKnownTool::OpenCode.default_enabled(),
408            },
409        );
410
411        // AGPM configuration (snippets only)
412        // .agpm/ directory is already AGPM-specific, no need for /agpm subdirectory
413        let mut agpm_resources = HashMap::new();
414        agpm_resources.insert(
415            ResourceType::Snippet.to_plural().to_string(),
416            ResourceConfig {
417                path: Some("snippets".to_string()),
418                merge_target: None,
419                flatten: Some(false), // Snippets preserve directory structure
420            },
421        );
422
423        types.insert(
424            "agpm".to_string(),
425            ArtifactTypeConfig {
426                path: PathBuf::from(".agpm"),
427                resources: agpm_resources,
428                enabled: WellKnownTool::Agpm.default_enabled(),
429            },
430        );
431
432        Self {
433            types,
434        }
435    }
436}