diff --git a/CLAUDE.md b/CLAUDE.md
index 5f6696d..f7fd3c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -78,7 +78,7 @@ mcs export
--dry-run # Preview what would be exported
- `DependencyResolver.swift` — topological sort of component dependencies with cycle detection
### External Pack System (`Sources/mcs/ExternalPack/`)
-- `ExternalPackManifest.swift` — YAML `techpack.yaml` schema (Codable models for components, templates, hooks, doctor checks, prompts, configure scripts). Supports **shorthand syntax** (`brew:`, `mcp:`, `plugin:`, `hook:`, `command:`, `skill:`, `settingsFile:`, `gitignore:`, `shell:`) that infers `type` + `installAction` from a single key
+- `ExternalPackManifest.swift` — YAML `techpack.yaml` schema (Codable models for components, templates, hooks, doctor checks, prompts, configure scripts). Supports **shorthand syntax** (`brew:`, `mcp:`, `plugin:`, `hook:`, `command:`, `skill:`, `agent:`, `settingsFile:`, `gitignore:`, `shell:`) that infers `type` + `installAction` from a single key
- `ExternalPackAdapter.swift` — bridges `ExternalPackManifest` to the `TechPack` protocol
- `ExternalPackLoader.swift` — discovers and loads packs from `~/.mcs/packs/` (git) or absolute paths (local)
- `PackFetcher.swift` — Git clone/pull for pack repositories
diff --git a/Sources/mcs/Commands/ExportCommand.swift b/Sources/mcs/Commands/ExportCommand.swift
index 44b3340..7c5bbd6 100644
--- a/Sources/mcs/Commands/ExportCommand.swift
+++ b/Sources/mcs/Commands/ExportCommand.swift
@@ -84,6 +84,7 @@ struct ExportCommand: ParsableCommand {
selectedHookFiles: selection.hookFiles,
selectedSkillFiles: selection.skillFiles,
selectedCommandFiles: selection.commandFiles,
+ selectedAgentFiles: selection.agentFiles,
selectedPlugins: selection.plugins,
selectedSections: selection.sections,
includeUserContent: selection.includeUserContent,
@@ -126,6 +127,9 @@ struct ExportCommand: ParsableCommand {
if !config.commandFiles.isEmpty {
output.plain(" Commands: \(config.commandFiles.map(\.filename).joined(separator: ", "))")
}
+ if !config.agentFiles.isEmpty {
+ output.plain(" Agents: \(config.agentFiles.map(\.filename).joined(separator: ", "))")
+ }
if !config.plugins.isEmpty {
output.plain(" Plugins: \(config.plugins.joined(separator: ", "))")
}
@@ -151,6 +155,7 @@ struct ExportCommand: ParsableCommand {
var hookFiles: Set
var skillFiles: Set
var commandFiles: Set
+ var agentFiles: Set
var plugins: Set
var sections: Set
var includeUserContent: Bool
@@ -164,6 +169,7 @@ struct ExportCommand: ParsableCommand {
hookFiles: Set(config.hookFiles.map(\.filename)),
skillFiles: Set(config.skillFiles.map(\.filename)),
commandFiles: Set(config.commandFiles.map(\.filename)),
+ agentFiles: Set(config.agentFiles.map(\.filename)),
plugins: Set(config.plugins),
sections: Set(config.claudeSections.map(\.sectionIdentifier)),
includeUserContent: config.claudeUserContent != nil,
@@ -172,7 +178,7 @@ struct ExportCommand: ParsableCommand {
)
}
- private enum ItemCategory { case mcp, hooks, skills, commands, plugins, sections }
+ private enum ItemCategory { case mcp, hooks, skills, commands, agents, plugins, sections }
private enum SentinelKey { case userContent, gitignore, settings }
private func interactiveSelect(
@@ -233,6 +239,12 @@ struct ExportCommand: ParsableCommand {
groups.append(SelectableGroup(title: "Commands", items: items, requiredItems: []))
}
+ // Agents
+ if !config.agentFiles.isEmpty {
+ let items = appendItems(config.agentFiles.map { (name: $0.filename, description: "Subagent") }, category: .agents)
+ groups.append(SelectableGroup(title: "Agents", items: items, requiredItems: []))
+ }
+
// Plugins
if !config.plugins.isEmpty {
let items = appendItems(config.plugins.map { (name: $0, description: "Plugin") }, category: .plugins)
@@ -276,6 +288,7 @@ struct ExportCommand: ParsableCommand {
hookFiles: selectedNames(.hooks),
skillFiles: selectedNames(.skills),
commandFiles: selectedNames(.commands),
+ agentFiles: selectedNames(.agents),
plugins: selectedNames(.plugins),
sections: selectedNames(.sections),
includeUserContent: sentinels[.userContent].map { selected.contains($0) } ?? false,
diff --git a/Sources/mcs/Core/Environment.swift b/Sources/mcs/Core/Environment.swift
index 0518c11..83bf8c1 100644
--- a/Sources/mcs/Core/Environment.swift
+++ b/Sources/mcs/Core/Environment.swift
@@ -9,6 +9,7 @@ struct Environment: Sendable {
let hooksDirectory: URL
let skillsDirectory: URL
let commandsDirectory: URL
+ let agentsDirectory: URL
/// mcs-internal state directory (`~/.mcs/`).
/// Stores pack checkouts, registry, global state, and lock file.
@@ -39,6 +40,7 @@ struct Environment: Sendable {
self.hooksDirectory = claudeDir.appendingPathComponent("hooks")
self.skillsDirectory = claudeDir.appendingPathComponent("skills")
self.commandsDirectory = claudeDir.appendingPathComponent("commands")
+ self.agentsDirectory = claudeDir.appendingPathComponent("agents")
self.mcsDirectory = home.appendingPathComponent(".mcs")
diff --git a/Sources/mcs/Export/ConfigurationDiscovery.swift b/Sources/mcs/Export/ConfigurationDiscovery.swift
index 4286541..35f58fd 100644
--- a/Sources/mcs/Export/ConfigurationDiscovery.swift
+++ b/Sources/mcs/Export/ConfigurationDiscovery.swift
@@ -15,6 +15,7 @@ struct ConfigurationDiscovery: Sendable {
var hookFiles: [DiscoveredFile] = []
var skillFiles: [DiscoveredFile] = []
var commandFiles: [DiscoveredFile] = []
+ var agentFiles: [DiscoveredFile] = []
var plugins: [String] = []
var claudeSections: [DiscoveredClaudeSection] = []
var claudeUserContent: String?
@@ -25,7 +26,7 @@ struct ConfigurationDiscovery: Sendable {
var isEmpty: Bool {
mcpServers.isEmpty && hookFiles.isEmpty && skillFiles.isEmpty
- && commandFiles.isEmpty && plugins.isEmpty && claudeSections.isEmpty
+ && commandFiles.isEmpty && agentFiles.isEmpty && plugins.isEmpty && claudeSections.isEmpty
&& claudeUserContent == nil && gitignoreEntries.isEmpty
&& remainingSettingsData == nil
}
@@ -78,6 +79,7 @@ struct ConfigurationDiscovery: Sendable {
let hooksDir: URL
let skillsDir: URL
let commandsDir: URL
+ let agentsDir: URL
switch scope {
case .global:
@@ -86,6 +88,7 @@ struct ConfigurationDiscovery: Sendable {
hooksDir = environment.hooksDirectory
skillsDir = environment.skillsDirectory
commandsDir = environment.commandsDirectory
+ agentsDir = environment.agentsDirectory
case .project(let projectRoot):
let claudeDir = projectRoot.appendingPathComponent(Constants.FileNames.claudeDirectory)
settingsPath = claudeDir.appendingPathComponent("settings.local.json")
@@ -93,6 +96,7 @@ struct ConfigurationDiscovery: Sendable {
hooksDir = claudeDir.appendingPathComponent("hooks")
skillsDir = claudeDir.appendingPathComponent("skills")
commandsDir = claudeDir.appendingPathComponent("commands")
+ agentsDir = claudeDir.appendingPathComponent("agents")
}
// 1. Discover MCP servers from ~/.claude.json
@@ -105,6 +109,7 @@ struct ConfigurationDiscovery: Sendable {
discoverFiles(in: hooksDir, hookCommands: hookCommands, into: &config)
config.skillFiles = listFiles(in: skillsDir)
config.commandFiles = listFiles(in: commandsDir)
+ config.agentFiles = listFiles(in: agentsDir)
// 4. Discover CLAUDE.md content
discoverClaudeContent(at: claudeFilePath, into: &config)
diff --git a/Sources/mcs/Export/ManifestBuilder.swift b/Sources/mcs/Export/ManifestBuilder.swift
index 723123c..7fbbc29 100644
--- a/Sources/mcs/Export/ManifestBuilder.swift
+++ b/Sources/mcs/Export/ManifestBuilder.swift
@@ -45,6 +45,7 @@ struct ManifestBuilder {
let selectedHookFiles: Set
let selectedSkillFiles: Set
let selectedCommandFiles: Set
+ let selectedAgentFiles: Set
let selectedPlugins: Set
let selectedSections: Set
let includeUserContent: Bool
@@ -182,6 +183,8 @@ struct ManifestBuilder {
{ "\($0.filename) skill" }),
(config.commandFiles, options.selectedCommandFiles, "cmd", .command, .command,
{ "/\($0.filename.hasSuffix(".md") ? String($0.filename.dropLast(3)) : $0.filename) command" }),
+ (config.agentFiles, options.selectedAgentFiles, "agent", .agent, .agent,
+ { "\($0.filename) subagent" }),
]
for spec in copyFileSpecs {
@@ -332,6 +335,7 @@ struct ManifestBuilder {
("Hooks", { $0.type == .hookFile }),
("Skills", { $0.type == .skill }),
("Commands", { $0.type == .command }),
+ ("Agents", { $0.type == .agent }),
("Plugins", { $0.type == .plugin }),
("Configuration", { $0.type == .configuration }),
]
@@ -383,6 +387,7 @@ struct ManifestBuilder {
yaml.comment(" hook: → checks if hook file exists")
yaml.comment(" skill: → checks if skill directory exists")
yaml.comment(" command: → checks if command file exists")
+ yaml.comment(" agent: → checks if agent file exists")
yaml.comment(" shell: → NO auto-check (add doctorChecks: manually)")
yaml.comment("")
yaml.comment("Add pack-level checks for prerequisites not tied to a component:")
@@ -484,6 +489,7 @@ struct ManifestBuilder {
case .hook: key = "hook"
case .skill: key = "skill"
case .command: key = "command"
+ case .agent: key = "agent"
case .generic, .none:
preconditionFailure("Export does not produce .generic file components")
}
diff --git a/Sources/mcs/ExternalPack/ExternalPackManifest.swift b/Sources/mcs/ExternalPack/ExternalPackManifest.swift
index 92246f2..2dabc10 100644
--- a/Sources/mcs/ExternalPack/ExternalPackManifest.swift
+++ b/Sources/mcs/ExternalPack/ExternalPackManifest.swift
@@ -313,6 +313,7 @@ struct ExternalComponentDefinition: Codable, Sendable {
case hook // Map — CopyFileShorthand (fileType: .hook)
case command // Map — CopyFileShorthand (fileType: .command)
case skill // Map — CopyFileShorthand (fileType: .skill)
+ case agent // Map — CopyFileShorthand (fileType: .agent)
case settingsFile // String — source path
case gitignore // [String] — gitignore entries
}
@@ -420,6 +421,10 @@ struct ExternalComponentDefinition: Codable, Sendable {
let config = try shorthand.decode(CopyFileShorthand.self, forKey: .skill)
return ResolvedShorthand(type: .skill, action: .copyPackFile(config.toExternalConfig(fileType: .skill)))
}
+ if shorthand.contains(.agent) {
+ let config = try shorthand.decode(CopyFileShorthand.self, forKey: .agent)
+ return ResolvedShorthand(type: .agent, action: .copyPackFile(config.toExternalConfig(fileType: .agent)))
+ }
if shorthand.contains(.settingsFile) {
let source = try shorthand.decode(String.self, forKey: .settingsFile)
return ResolvedShorthand(type: .configuration, action: .settingsFile(source: source))
@@ -478,6 +483,7 @@ enum ExternalComponentType: String, Codable, Sendable {
case skill
case hookFile
case command
+ case agent
case brewPackage
case configuration
@@ -489,6 +495,7 @@ enum ExternalComponentType: String, Codable, Sendable {
case .skill: return .skill
case .hookFile: return .hookFile
case .command: return .command
+ case .agent: return .agent
case .brewPackage: return .brewPackage
case .configuration: return .configuration
}
@@ -649,6 +656,7 @@ enum ExternalCopyFileType: String, Codable, Sendable {
case skill
case hook
case command
+ case agent
case generic
}
diff --git a/Sources/mcs/TechPack/Component.swift b/Sources/mcs/TechPack/Component.swift
index d3b9fa6..e0ceebc 100644
--- a/Sources/mcs/TechPack/Component.swift
+++ b/Sources/mcs/TechPack/Component.swift
@@ -7,6 +7,7 @@ enum ComponentType: String, Sendable, CaseIterable {
case skill = "Skills"
case hookFile = "Hooks"
case command = "Commands"
+ case agent = "Agents"
case brewPackage = "Dependencies"
case configuration = "Configurations"
}
@@ -75,6 +76,7 @@ enum CopyFileType: String, Sendable {
case skill
case hook
case command
+ case agent
case generic
}
@@ -86,6 +88,7 @@ extension CopyFileType {
case .skill: return "skills/"
case .hook: return "hooks/"
case .command: return "commands/"
+ case .agent: return "agents/"
case .generic: return ""
}
}
@@ -95,6 +98,7 @@ extension CopyFileType {
case .skill: return environment.skillsDirectory
case .hook: return environment.hooksDirectory
case .command: return environment.commandsDirectory
+ case .agent: return environment.agentsDirectory
case .generic: return environment.claudeDirectory
}
}
@@ -110,6 +114,7 @@ extension CopyFileType {
case .skill: return claudeDir.appendingPathComponent("skills")
case .hook: return claudeDir.appendingPathComponent("hooks")
case .command: return claudeDir.appendingPathComponent("commands")
+ case .agent: return claudeDir.appendingPathComponent("agents")
case .generic: return claudeDir
}
}
diff --git a/Tests/MCSTests/ComponentExecutorTests.swift b/Tests/MCSTests/ComponentExecutorTests.swift
index 8a5f74d..c2df6f8 100644
--- a/Tests/MCSTests/ComponentExecutorTests.swift
+++ b/Tests/MCSTests/ComponentExecutorTests.swift
@@ -115,4 +115,35 @@ struct ComponentExecutorTests {
#expect(!content.contains("__PROJECT_DIR_NAME__"))
#expect(!content.contains("__REPO_NAME__"))
}
+
+ @Test("installProjectFile with agent fileType installs to .claude/agents/")
+ func installProjectFileAgentType() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let projectPath = tmpDir.appendingPathComponent("project")
+ try FileManager.default.createDirectory(at: projectPath, withIntermediateDirectories: true)
+
+ let agentFile = tmpDir.appendingPathComponent("code-reviewer.md")
+ try "---\nname: Code Reviewer\n---\nReview code".write(
+ to: agentFile,
+ atomically: true, encoding: .utf8
+ )
+
+ var exec = makeExecutor()
+ let paths = exec.installProjectFile(
+ source: agentFile,
+ destination: "code-reviewer.md",
+ fileType: .agent,
+ projectPath: projectPath,
+ resolvedValues: [:]
+ )
+
+ #expect(!paths.isEmpty)
+
+ let installed = projectPath.appendingPathComponent(".claude/agents/code-reviewer.md")
+ #expect(FileManager.default.fileExists(atPath: installed.path))
+ let content = try String(contentsOf: installed, encoding: .utf8)
+ #expect(content.contains("Code Reviewer"))
+ }
}
diff --git a/Tests/MCSTests/DerivedDoctorCheckTests.swift b/Tests/MCSTests/DerivedDoctorCheckTests.swift
index 7738225..ae0d091 100644
--- a/Tests/MCSTests/DerivedDoctorCheckTests.swift
+++ b/Tests/MCSTests/DerivedDoctorCheckTests.swift
@@ -152,6 +152,7 @@ struct DerivedDoctorCheckTests {
(.skill, "/tmp/proj/.claude/skills/test.md"),
(.hook, "/tmp/proj/.claude/hooks/test.md"),
(.command, "/tmp/proj/.claude/commands/test.md"),
+ (.agent, "/tmp/proj/.claude/agents/test.md"),
(.generic, "/tmp/proj/.claude/test.md"),
]
for (fileType, expectedPath) in cases {
diff --git a/Tests/MCSTests/ExternalPackAdapterTests.swift b/Tests/MCSTests/ExternalPackAdapterTests.swift
index 126cf00..bf610cc 100644
--- a/Tests/MCSTests/ExternalPackAdapterTests.swift
+++ b/Tests/MCSTests/ExternalPackAdapterTests.swift
@@ -171,6 +171,37 @@ struct ExternalPackAdapterTests {
}
}
+ @Test("Adapter converts copyPackFile component with agent type")
+ func copyPackFileAgentComponent() throws {
+ let manifest = manifestWithComponents([
+ ExternalComponentDefinition(
+ id: "test-pack.agent",
+ displayName: "Code Reviewer",
+ description: "A subagent",
+ type: .agent,
+ dependencies: nil,
+ isRequired: nil,
+ hookEvent: nil,
+ installAction: .copyPackFile(ExternalCopyPackFileConfig(
+ source: "agents/code-reviewer.md",
+ destination: "code-reviewer.md",
+ fileType: .agent
+ )),
+ doctorChecks: nil
+ ),
+ ])
+ let (adapter, packPath) = try makeAdapter(manifest: manifest)
+ let component = adapter.components[0]
+ #expect(component.type == .agent)
+ if case .copyPackFile(let source, let destination, let fileType) = component.installAction {
+ #expect(source == packPath.appendingPathComponent("agents/code-reviewer.md"))
+ #expect(destination == "code-reviewer.md")
+ #expect(fileType == .agent)
+ } else {
+ Issue.record("Expected .copyPackFile action")
+ }
+ }
+
@Test("Adapter sets packIdentifier on components")
func packIdentifierSet() throws {
let manifest = manifestWithComponents([
diff --git a/Tests/MCSTests/ExternalPackManifestTests.swift b/Tests/MCSTests/ExternalPackManifestTests.swift
index a0c5d88..2d0b1c4 100644
--- a/Tests/MCSTests/ExternalPackManifestTests.swift
+++ b/Tests/MCSTests/ExternalPackManifestTests.swift
@@ -906,6 +906,42 @@ struct ExternalPackManifestTests {
#expect(config.fileType == .hook)
}
+ @Test("Deserialize copyPackFile install action with agent fileType")
+ func copyPackFileAgentAction() throws {
+ let yaml = """
+ schemaVersion: 1
+ identifier: test
+ displayName: Test
+ description: Test
+ version: "1.0.0"
+ components:
+ - id: test.agent
+ displayName: Code Reviewer
+ description: A subagent file
+ type: agent
+ installAction:
+ type: copyPackFile
+ source: agents/code-reviewer.md
+ destination: code-reviewer.md
+ fileType: agent
+ """
+
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let file = tmpDir.appendingPathComponent("techpack.yaml")
+ try yaml.write(to: file, atomically: true, encoding: .utf8)
+
+ let manifest = try ExternalPackManifest.load(from: file)
+ guard case .copyPackFile(let config) = manifest.components?[0].installAction else {
+ Issue.record("Expected copyPackFile install action")
+ return
+ }
+ #expect(config.source == "agents/code-reviewer.md")
+ #expect(config.destination == "code-reviewer.md")
+ #expect(config.fileType == .agent)
+ }
+
// MARK: - Doctor check types
@Test("Deserialize all doctor check types")
@@ -1129,6 +1165,7 @@ struct ExternalPackManifestTests {
#expect(ExternalComponentType.skill.componentType == .skill)
#expect(ExternalComponentType.hookFile.componentType == .hookFile)
#expect(ExternalComponentType.command.componentType == .command)
+ #expect(ExternalComponentType.agent.componentType == .agent)
#expect(ExternalComponentType.brewPackage.componentType == .brewPackage)
#expect(ExternalComponentType.configuration.componentType == .configuration)
}
@@ -2141,6 +2178,41 @@ struct ExternalPackManifestTests {
#expect(config.fileType == .skill)
}
+ // MARK: - Shorthand: agent
+
+ @Test("Shorthand agent: infers agent type and copyPackFile action")
+ func shorthandAgent() throws {
+ let yaml = """
+ schemaVersion: 1
+ identifier: my-pack
+ displayName: My Pack
+ description: Test
+ version: "1.0.0"
+ components:
+ - id: my-pack.code-reviewer
+ description: Expert code reviewer subagent
+ agent:
+ source: agents/code-reviewer.md
+ destination: code-reviewer.md
+ """
+
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let file = tmpDir.appendingPathComponent("techpack.yaml")
+ try yaml.write(to: file, atomically: true, encoding: .utf8)
+
+ let manifest = try ExternalPackManifest.load(from: file)
+ let comp = try #require(manifest.components?.first)
+
+ #expect(comp.type == .agent)
+ guard case .copyPackFile(let config) = comp.installAction else {
+ Issue.record("Expected copyPackFile"); return
+ }
+ #expect(config.source == "agents/code-reviewer.md")
+ #expect(config.fileType == .agent)
+ }
+
// MARK: - Shorthand: settingsFile
@Test("Shorthand settingsFile: infers configuration type and settingsFile action")
diff --git a/Tests/MCSTests/ManifestBuilderTests.swift b/Tests/MCSTests/ManifestBuilderTests.swift
index 3eede34..cb03138 100644
--- a/Tests/MCSTests/ManifestBuilderTests.swift
+++ b/Tests/MCSTests/ManifestBuilderTests.swift
@@ -68,6 +68,15 @@ struct ManifestBuilderTests {
),
]
+ let agentURL = tmpDir.appendingPathComponent("code-reviewer.md")
+ try "---\nname: Code Reviewer\n---\nReview code".write(to: agentURL, atomically: true, encoding: .utf8)
+ config.agentFiles = [
+ ConfigurationDiscovery.DiscoveredFile(
+ filename: "code-reviewer.md",
+ absolutePath: agentURL
+ ),
+ ]
+
config.plugins = ["pr-review-toolkit@claude-plugins-official"]
config.gitignoreEntries = [".env", "*.log"]
@@ -101,6 +110,7 @@ struct ManifestBuilderTests {
selectedHookFiles: Set(config.hookFiles.map(\.filename)),
selectedSkillFiles: Set(config.skillFiles.map(\.filename)),
selectedCommandFiles: Set(config.commandFiles.map(\.filename)),
+ selectedAgentFiles: Set(config.agentFiles.map(\.filename)),
selectedPlugins: Set(config.plugins),
selectedSections: Set(config.claudeSections.map(\.sectionIdentifier)),
includeUserContent: true,
@@ -124,9 +134,9 @@ struct ManifestBuilderTests {
let normalized = try loaded.normalized()
try normalized.validate()
- // 3. Verify component counts — 2 MCP + 1 hook + 1 skill + 1 cmd + 1 plugin + 1 settings + 1 gitignore = 8
+ // 3. Verify component counts — 2 MCP + 1 hook + 1 skill + 1 cmd + 1 agent + 1 plugin + 1 settings + 1 gitignore = 9
let components = try #require(normalized.components)
- #expect(components.count == 8)
+ #expect(components.count == 9)
// 4. Verify MCP servers
let mcpComps = components.filter { $0.type == .mcpServer }
@@ -179,7 +189,16 @@ struct ManifestBuilderTests {
}
#expect(cmdFile.fileType == .command)
- // 8. Verify plugin
+ // 8. Verify agent
+ let agentComp = try #require(components.first { $0.type == .agent })
+ guard case .copyPackFile(let agentFile) = agentComp.installAction else {
+ Issue.record("Expected copyPackFile for agent")
+ return
+ }
+ #expect(agentFile.fileType == .agent)
+ #expect(agentFile.destination == "code-reviewer.md")
+
+ // 9. Verify plugin
let pluginComp = try #require(components.first { $0.type == .plugin })
guard case .plugin(let pluginName) = pluginComp.installAction else {
Issue.record("Expected plugin install action")
@@ -187,7 +206,7 @@ struct ManifestBuilderTests {
}
#expect(pluginName == "pr-review-toolkit@claude-plugins-official")
- // 9. Verify settings and gitignore (both .configuration type)
+ // 10. Verify settings and gitignore (both .configuration type)
let configComps = components.filter { $0.type == .configuration }
#expect(configComps.count == 2)
let settingsComp = configComps.first { $0.id.contains("settings") }
@@ -201,18 +220,18 @@ struct ManifestBuilderTests {
#expect(entries.contains(".env"))
#expect(entries.contains("*.log"))
- // 10. Verify templates (section + user content = 2)
+ // 11. Verify templates (section + user content = 2)
let templates = try #require(normalized.templates)
#expect(templates.count == 2)
- // 11. Verify prompts (auto-generated for API_KEY)
+ // 12. Verify prompts (auto-generated for API_KEY)
let prompts = try #require(normalized.prompts)
#expect(prompts.count == 1)
#expect(prompts[0].key == "API_KEY")
#expect(prompts[0].type == .input)
- // 12. Verify side-channel outputs
- #expect(result.filesToCopy.count == 3) // hook + skill + command
+ // 13. Verify side-channel outputs
+ #expect(result.filesToCopy.count == 4) // hook + skill + command + agent
#expect(result.settingsToWrite != nil)
#expect(result.templateFiles.count == 2)
}
@@ -236,7 +255,7 @@ struct ManifestBuilderTests {
from: config, metadata: metadata,
options: ManifestBuilder.BuildOptions(
selectedMCPServers: [], selectedHookFiles: [], selectedSkillFiles: [],
- selectedCommandFiles: [], selectedPlugins: [], selectedSections: [],
+ selectedCommandFiles: [], selectedAgentFiles: [], selectedPlugins: [], selectedSections: [],
includeUserContent: false, includeGitignore: false, includeSettings: false
)
)
@@ -285,7 +304,7 @@ struct ManifestBuilderTests {
options: ManifestBuilder.BuildOptions(
selectedMCPServers: Set(config.mcpServers.map(\.name)),
selectedHookFiles: [], selectedSkillFiles: [],
- selectedCommandFiles: [],
+ selectedCommandFiles: [], selectedAgentFiles: [],
selectedPlugins: Set(config.plugins),
selectedSections: [],
includeUserContent: false, includeGitignore: false, includeSettings: false
@@ -355,7 +374,7 @@ struct ManifestBuilderTests {
options: ManifestBuilder.BuildOptions(
selectedMCPServers: Set(config.mcpServers.map(\.name)),
selectedHookFiles: [], selectedSkillFiles: [],
- selectedCommandFiles: [], selectedPlugins: [], selectedSections: [],
+ selectedCommandFiles: [], selectedAgentFiles: [], selectedPlugins: [], selectedSections: [],
includeUserContent: false, includeGitignore: false, includeSettings: false
)
)
@@ -405,7 +424,7 @@ struct ManifestBuilderTests {
options: ManifestBuilder.BuildOptions(
selectedMCPServers: selectedMCPServers,
selectedHookFiles: [], selectedSkillFiles: [],
- selectedCommandFiles: [], selectedPlugins: [], selectedSections: [],
+ selectedCommandFiles: [], selectedAgentFiles: [], selectedPlugins: [], selectedSections: [],
includeUserContent: false, includeGitignore: false, includeSettings: false
)
)
diff --git a/docs/architecture.md b/docs/architecture.md
index badd1e0..e63d9a4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -46,6 +46,7 @@ Per-project paths (created by `mcs sync`):
- `/.claude/skills/` — per-project skills
- `/.claude/hooks/` — per-project hook scripts
- `/.claude/commands/` — per-project slash commands
+- `/.claude/agents/` — per-project subagents
- `/.claude/.mcs-project` — per-project state (JSON)
- `/CLAUDE.local.md` — per-project instructions with section markers
- `/mcs.lock.yaml` — lockfile pinning pack commits
@@ -196,7 +197,7 @@ The `--pack` flag bypasses multi-select for CI use: `mcs sync --pack ios --pack
Each installable unit is a `ComponentDefinition` with:
- **id**: unique identifier (e.g., `ios.xcodebuildmcp`)
-- **type**: `mcpServer`, `plugin`, `skill`, `hookFile`, `command`, `brewPackage`, `configuration`
+- **type**: `mcpServer`, `plugin`, `skill`, `hookFile`, `command`, `agent`, `brewPackage`, `configuration`
- **packIdentifier**: pack ID for the owning pack
- **dependencies**: IDs of components this depends on
- **isRequired**: if true, always installed with its pack
diff --git a/docs/creating-tech-packs.md b/docs/creating-tech-packs.md
index 0931186..cac41f7 100644
--- a/docs/creating-tech-packs.md
+++ b/docs/creating-tech-packs.md
@@ -1,6 +1,6 @@
# Creating Tech Packs
-A tech pack is your Claude Code setup — packaged as a Git repo and shareable with anyone. It bundles MCP servers, plugins, hooks, skills, commands, templates, and settings into a single `techpack.yaml` file that `mcs` knows how to sync and maintain.
+A tech pack is your Claude Code setup — packaged as a Git repo and shareable with anyone. It bundles MCP servers, plugins, hooks, skills, commands, agents, templates, and settings into a single `techpack.yaml` file that `mcs` knows how to sync and maintain.
Think of it like a dotfiles repo, but specifically for Claude Code.
@@ -20,7 +20,7 @@ mcs export ./my-pack
mcs export ./my-pack --global --dry-run
```
-The export wizard discovers your MCP servers, hooks, skills, commands, plugins, CLAUDE.md sections, gitignore entries (global export only), and settings — then generates a complete pack directory with `techpack.yaml` and all supporting files.
+The export wizard discovers your MCP servers, hooks, skills, commands, agents, plugins, CLAUDE.md sections, gitignore entries (global export only), and settings — then generates a complete pack directory with `techpack.yaml` and all supporting files.
**What it handles automatically:**
- Sensitive env vars (API keys, tokens) are replaced with `__PLACEHOLDER__` tokens and corresponding `prompts:` entries are generated
@@ -222,6 +222,20 @@ Custom `/command` prompts:
destination: pr.md
```
+### Agents
+
+Custom subagents — Markdown files with YAML frontmatter that Claude Code can invoke as specialized agents:
+
+```yaml
+ - id: code-reviewer
+ description: Code review subagent
+ agent:
+ source: agents/code-reviewer.md
+ destination: code-reviewer.md
+```
+
+This copies the agent Markdown file from your pack repo into `/.claude/agents/`. Agent files follow Claude Code's subagent format (Markdown with `---` frontmatter containing the agent name and configuration).
+
### Settings
Merge Claude Code settings (plan mode, env vars, etc.):
@@ -422,6 +436,7 @@ prompts:
| `hook: {source, destination}` | Does the hook file exist? |
| `skill: {source, destination}` | Does the skill directory exist? |
| `command: {source, destination}` | Does the command file exist? |
+| `agent: {source, destination}` | Does the agent file exist? |
### When you need custom checks
@@ -534,6 +549,7 @@ This tracking lives in `/.claude/.mcs-project`. You don't need to manag
| Skills | `/.claude/skills/` |
| Hooks | `/.claude/hooks/` |
| Commands | `/.claude/commands/` |
+| Agents | `/.claude/agents/` |
| Settings | `/.claude/settings.local.json` |
| Templates | `/CLAUDE.local.md` |
| Brew packages | Global (`brew install`) |
diff --git a/docs/techpack-schema.md b/docs/techpack-schema.md
index 53a14c0..b33ec32 100644
--- a/docs/techpack-schema.md
+++ b/docs/techpack-schema.md
@@ -172,6 +172,25 @@ Infers: `type: skill`, `installAction: copyPackFile(fileType: skill)`
---
+#### `agent:` — Subagent
+
+```yaml
+- id: code-reviewer
+ description: Code review subagent
+ agent:
+ source: agents/code-reviewer.md
+ destination: code-reviewer.md
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `source` | `String` | Yes | Path to agent Markdown file in the pack repo |
+| `destination` | `String` | Yes | Filename in `/.claude/agents/` |
+
+Infers: `type: agent`, `installAction: copyPackFile(fileType: agent)`
+
+---
+
#### `settingsFile:` — Settings
```yaml
@@ -256,7 +275,7 @@ The explicit form with `type` + `installAction` is always supported:
| `settingsFile` | `source` | Merge settings from file |
| `copyPackFile` | `source`, `destination`, `fileType` | Copy file from pack |
-`fileType` values: `skill`, `hook`, `command`, `generic`
+`fileType` values: `skill`, `hook`, `command`, `agent`, `generic`
---
@@ -398,6 +417,7 @@ Most components get free doctor checks from their install action — no need to
| `hook: {source, dest}` | File exists at destination |
| `skill: {source, dest}` | Directory exists at destination |
| `command: {source, dest}` | File exists at destination |
+| `agent: {source, dest}` | File exists at destination |
| `settingsFile: path` | Always re-applied (convergent) |
| `gitignore: [...]` | Always re-applied (convergent) |
| `shell: "..."` | **None** — add `doctorChecks` manually |