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 |