agpm-cli 0.4.14

AGent Package Manager - A Git-based package manager for coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! Tool configuration types for multi-tool support.
//!
//! This module defines the types and structures used to configure different AI coding
//! assistant tools (Claude Code, OpenCode, AGPM, and custom tools) in AGPM manifests.
//!
//! # Overview
//!
//! AGPM supports multiple AI coding tools through a flexible configuration system:
//! - **Claude Code**: The primary AI coding assistant (enabled by default)
//! - **OpenCode**: Alternative AI coding assistant (enabled by default for consistency)
//! - **AGPM**: Internal tool for shared infrastructure like snippets (enabled by default)
//! - **Custom Tools**: User-defined tools with custom configurations (enabled by default)
//!
//! # Tool Configuration
//!
//! Each tool defines:
//! - A base directory (e.g., `.claude`, `.opencode`, `.agpm`)
//! - Resource type mappings (agents, commands, snippets, etc.)
//! - Installation paths or merge targets for each resource type
//! - Default flatten behavior for directory structure preservation
//! - An enabled/disabled state
//!
//! # Key Types
//!
//! - [`WellKnownTool`]: Enum representing officially supported tools
//! - [`ResourceConfig`]: Configuration for a specific resource type within a tool
//! - [`ArtifactTypeConfig`]: Complete configuration for a tool
//! - [`ToolsConfig`]: Top-level configuration mapping tool names to their configs
//!
//! # Examples
//!
//! ```toml
//! [tools.claude-code]
//! path = ".claude"
//! enabled = true
//!
//! [tools.claude-code.resources.agents]
//! path = "agents/agpm"  # agpm subdirectory for easy gitignore management
//! flatten = true
//!
//! [tools.opencode]
//! path = ".opencode"
//! enabled = true   # Enabled by default
//!
//! [tools.opencode.resources.agents]
//! path = "agent/agpm"  # Singular in OpenCode + agpm subdirectory
//! flatten = true
//! ```

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;

// Cached default configuration to avoid repeated allocations
static DEFAULT_TOOLS_CONFIG: OnceLock<ToolsConfig> = OnceLock::new();

/// Resource configuration within a tool.
///
/// Defines the installation path for a specific resource type within a tool.
/// Resources can either:
/// - Install to a subdirectory (via `path`)
/// - Merge into a configuration file (via `merge_target`)
///
/// At least one of `path` or `merge_target` should be set for a resource type
/// to be considered supported by a tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResourceConfig {
    /// Subdirectory path for this resource type relative to the tool's base directory.
    ///
    /// Used for resources that install as separate files (agents, snippets, commands, scripts).
    /// When None, this resource type either uses merge_target or is not supported.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,

    /// Target configuration file for merging this resource type.
    ///
    /// Used for resources that merge into configuration files (hooks, MCP servers).
    /// The path is relative to the project root.
    ///
    /// # Examples
    ///
    /// - Hooks: `.claude/settings.local.json`
    /// - MCP servers: `.mcp.json` or `.opencode/opencode.json`
    #[serde(skip_serializing_if = "Option::is_none", rename = "merge-target")]
    pub merge_target: Option<String>,

    /// Default flatten behavior for this resource type.
    ///
    /// When `true`: Only the filename is used for installation (e.g., `nested/dir/file.md` → `file.md`)
    /// When `false`: Full relative path is preserved (e.g., `nested/dir/file.md` → `nested/dir/file.md`)
    ///
    /// This default can be overridden per-dependency using the `flatten` field.
    /// If not specified, defaults to `false` (preserve directory structure).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub flatten: Option<bool>,
}

/// Well-known tool types with specific default behaviors.
///
/// This enum represents the officially supported tools and their
/// specific default configurations, particularly for the `enabled` field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WellKnownTool {
    /// Claude Code - the primary AI coding assistant tool.
    /// Enabled by default since most users rely on Claude Code.
    ClaudeCode,

    /// OpenCode - an alternative AI coding assistant tool.
    /// Enabled by default for consistency with other tools.
    OpenCode,

    /// AGPM - internal tool for shared infrastructure (snippets).
    /// Enabled by default for backward compatibility and shared resources.
    Agpm,

    /// Generic/custom tools not in the well-known set.
    /// Enabled by default for backward compatibility.
    Generic,
}

impl WellKnownTool {
    /// Identifies a well-known tool from its string name.
    ///
    /// # Arguments
    ///
    /// * `tool_name` - The name of the tool (e.g., "claude-code", "opencode", "agpm")
    ///
    /// # Returns
    ///
    /// The corresponding `WellKnownTool` variant, or `Generic` for custom tools.
    pub fn from_name(tool_name: &str) -> Self {
        match tool_name {
            "claude-code" => WellKnownTool::ClaudeCode,
            "opencode" => WellKnownTool::OpenCode,
            "agpm" => WellKnownTool::Agpm,
            _ => WellKnownTool::Generic,
        }
    }

    /// Returns the default `enabled` value for this tool.
    ///
    /// # Default Values
    ///
    /// - **Claude Code**: `true` (most users rely on it)
    /// - **OpenCode**: `true` (enabled by default for consistency)
    /// - **AGPM**: `true` (shared infrastructure)
    /// - **Generic**: `true` (backward compatibility)
    pub const fn default_enabled(self) -> bool {
        match self {
            WellKnownTool::ClaudeCode => true,
            WellKnownTool::OpenCode => true,
            WellKnownTool::Agpm => true,
            WellKnownTool::Generic => true,
        }
    }
}

/// Tool configuration (internal deserialization structure).
///
/// This is used during deserialization to capture optional fields.
/// The public API uses `ArtifactTypeConfig` with required `enabled` field.
#[derive(Debug, Clone, Deserialize)]
struct ArtifactTypeConfigRaw {
    /// Base directory for this tool (e.g., ".claude", ".opencode", ".agpm")
    path: PathBuf,

    /// Map of resource type -> configuration
    #[serde(default)]
    resources: HashMap<String, ResourceConfig>,

    /// Whether this tool is enabled (optional during deserialization)
    ///
    /// When None, the tool-specific default will be applied based on the tool name.
    #[serde(default)]
    enabled: Option<bool>,
}

/// Tool configuration.
///
/// Defines how a specific tool (e.g., claude-code, opencode, agpm)
/// organizes its resources. Each tool has a base directory and
/// a map of resource types to their subdirectory configurations.
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactTypeConfig {
    /// Base directory for this tool (e.g., ".claude", ".opencode", ".agpm")
    pub path: PathBuf,

    /// Map of resource type -> configuration
    pub resources: HashMap<String, ResourceConfig>,

    /// Whether this tool is enabled.
    ///
    /// When disabled, dependencies for this tool will not be resolved,
    /// installed, or included in the lockfile.
    ///
    /// # Defaults
    ///
    /// - **claude-code**: `true` (most users rely on it)
    /// - **opencode**: `true` (enabled by default for consistency)
    /// - **agpm**: `true` (shared infrastructure)
    /// - **custom tools**: `true` (backward compatibility)
    pub enabled: bool,
}

/// Top-level tools configuration.
///
/// Maps tool type names to their configurations. This replaces the old
/// `[target]` section and enables multi-tool support.
#[derive(Debug, Clone, Serialize)]
pub struct ToolsConfig {
    /// Map of tool type name -> configuration
    #[serde(flatten)]
    pub types: HashMap<String, ArtifactTypeConfig>,
}

/// Custom deserializer that merges user configuration with built-in defaults.
///
/// # Merging Behavior
///
/// For **well-known tools** (claude-code, opencode, agpm):
/// - Starts with built-in default resource configurations
/// - User-provided resources override defaults on a per-resource-type basis
/// - Missing resource types automatically use defaults
///
/// For **custom tools**:
/// - No default merging occurs (user config used as-is)
/// - User must provide complete configuration
///
/// # Example
///
/// ```toml
/// [tools.opencode]
/// path = ".opencode"
/// resources = { commands = { path = "command", flatten = true } }
/// # Snippets not specified - will use default from built-in config
/// ```
///
/// After deserialization, opencode will have:
/// - `commands`: User config (overrides default)
/// - `snippets`: Default config (auto-merged)
impl<'de> serde::Deserialize<'de> for ToolsConfig {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        // First deserialize into the raw structure with Option<bool> for enabled
        let raw_types: HashMap<String, ArtifactTypeConfigRaw> = HashMap::deserialize(deserializer)?;

        // Get default configurations for merging (cached)
        let defaults = DEFAULT_TOOLS_CONFIG.get_or_init(ToolsConfig::default);

        // Convert to the final structure, applying tool-specific defaults
        let types = raw_types
            .into_iter()
            .map(|(tool_name, raw_config)| {
                // Determine the enabled value:
                // - If explicitly set in TOML, use that value
                // - Otherwise, use the tool-specific default
                let well_known_tool = WellKnownTool::from_name(&tool_name);
                let enabled =
                    raw_config.enabled.unwrap_or_else(|| well_known_tool.default_enabled());

                // Merge resources: start with defaults, then overlay user config
                let merged_resources = if let Some(default_config) = defaults.types.get(&tool_name)
                {
                    let mut resources = default_config.resources.clone();
                    // User-provided resources override defaults
                    resources.extend(raw_config.resources);
                    resources
                } else {
                    // No defaults for this tool (custom tool), use as-is
                    raw_config.resources
                };

                let config = ArtifactTypeConfig {
                    path: raw_config.path,
                    resources: merged_resources,
                    enabled,
                };

                (tool_name, config)
            })
            .collect();

        Ok(ToolsConfig {
            types,
        })
    }
}

impl Default for ToolsConfig {
    fn default() -> Self {
        use crate::core::ResourceType;
        let mut types = HashMap::new();

        // Claude Code configuration
        // Resources install to agpm/ subdirectory for easy gitignore management
        let mut claude_resources = HashMap::new();
        claude_resources.insert(
            ResourceType::Agent.to_plural().to_string(),
            ResourceConfig {
                path: Some("agents/agpm".to_string()),
                merge_target: None,
                flatten: Some(true), // Agents flatten by default
            },
        );
        claude_resources.insert(
            ResourceType::Snippet.to_plural().to_string(),
            ResourceConfig {
                path: Some("snippets/agpm".to_string()),
                merge_target: None,
                flatten: Some(false), // Snippets preserve directory structure
            },
        );
        claude_resources.insert(
            ResourceType::Command.to_plural().to_string(),
            ResourceConfig {
                path: Some("commands/agpm".to_string()),
                merge_target: None,
                flatten: Some(true), // Commands flatten by default
            },
        );
        claude_resources.insert(
            ResourceType::Script.to_plural().to_string(),
            ResourceConfig {
                path: Some("scripts/agpm".to_string()),
                merge_target: None,
                flatten: Some(false), // Scripts preserve directory structure
            },
        );
        claude_resources.insert(
            ResourceType::Hook.to_plural().to_string(),
            ResourceConfig {
                path: None, // Hooks are merged into configuration file
                merge_target: Some(".claude/settings.local.json".to_string()),
                flatten: None, // N/A for merge targets
            },
        );
        claude_resources.insert(
            ResourceType::McpServer.to_plural().to_string(),
            ResourceConfig {
                path: None, // MCP servers are merged into configuration file
                merge_target: Some(".mcp.json".to_string()),
                flatten: None, // N/A for merge targets
            },
        );
        claude_resources.insert(
            ResourceType::Skill.to_plural().to_string(),
            ResourceConfig {
                path: Some("skills/agpm".to_string()),
                merge_target: None,
                flatten: Some(false), // Skills are directories, preserve structure
            },
        );

        types.insert(
            "claude-code".to_string(),
            ArtifactTypeConfig {
                path: PathBuf::from(".claude"),
                resources: claude_resources,
                enabled: WellKnownTool::ClaudeCode.default_enabled(),
            },
        );

        // OpenCode configuration
        // Resources install to agpm/ subdirectory for easy gitignore management
        let mut opencode_resources = HashMap::new();
        opencode_resources.insert(
            ResourceType::Agent.to_plural().to_string(),
            ResourceConfig {
                path: Some("agent/agpm".to_string()), // Singular + agpm subdirectory
                merge_target: None,
                flatten: Some(true), // Agents flatten by default
            },
        );
        opencode_resources.insert(
            ResourceType::Snippet.to_plural().to_string(),
            ResourceConfig {
                path: Some("snippet/agpm".to_string()), // Singular + agpm subdirectory
                merge_target: None,
                flatten: Some(false), // Snippets preserve directory structure
            },
        );
        opencode_resources.insert(
            ResourceType::Command.to_plural().to_string(),
            ResourceConfig {
                path: Some("command/agpm".to_string()), // Singular + agpm subdirectory
                merge_target: None,
                flatten: Some(true), // Commands flatten by default
            },
        );
        opencode_resources.insert(
            ResourceType::McpServer.to_plural().to_string(),
            ResourceConfig {
                path: None, // MCP servers are merged into configuration file
                merge_target: Some(".opencode/opencode.json".to_string()),
                flatten: None, // N/A for merge targets
            },
        );

        types.insert(
            "opencode".to_string(),
            ArtifactTypeConfig {
                path: PathBuf::from(".opencode"),
                resources: opencode_resources,
                enabled: WellKnownTool::OpenCode.default_enabled(),
            },
        );

        // AGPM configuration (snippets only)
        // .agpm/ directory is already AGPM-specific, no need for /agpm subdirectory
        let mut agpm_resources = HashMap::new();
        agpm_resources.insert(
            ResourceType::Snippet.to_plural().to_string(),
            ResourceConfig {
                path: Some("snippets".to_string()),
                merge_target: None,
                flatten: Some(false), // Snippets preserve directory structure
            },
        );

        types.insert(
            "agpm".to_string(),
            ArtifactTypeConfig {
                path: PathBuf::from(".agpm"),
                resources: agpm_resources,
                enabled: WellKnownTool::Agpm.default_enabled(),
            },
        );

        Self {
            types,
        }
    }
}