Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ mcs export <dir> --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
Expand Down
15 changes: 14 additions & 1 deletion Sources/mcs/Commands/ExportCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: ", "))")
}
Expand All @@ -151,6 +155,7 @@ struct ExportCommand: ParsableCommand {
var hookFiles: Set<String>
var skillFiles: Set<String>
var commandFiles: Set<String>
var agentFiles: Set<String>
var plugins: Set<String>
var sections: Set<String>
var includeUserContent: Bool
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions Sources/mcs/Core/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")

Expand Down
7 changes: 6 additions & 1 deletion Sources/mcs/Export/ConfigurationDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
}
Expand Down Expand Up @@ -78,6 +79,7 @@ struct ConfigurationDiscovery: Sendable {
let hooksDir: URL
let skillsDir: URL
let commandsDir: URL
let agentsDir: URL

switch scope {
case .global:
Expand All @@ -86,13 +88,15 @@ 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")
claudeFilePath = projectRoot.appendingPathComponent(Constants.FileNames.claudeLocalMD)
hooksDir = claudeDir.appendingPathComponent("hooks")
skillsDir = claudeDir.appendingPathComponent("skills")
commandsDir = claudeDir.appendingPathComponent("commands")
agentsDir = claudeDir.appendingPathComponent("agents")
}

// 1. Discover MCP servers from ~/.claude.json
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions Sources/mcs/Export/ManifestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ struct ManifestBuilder {
let selectedHookFiles: Set<String>
let selectedSkillFiles: Set<String>
let selectedCommandFiles: Set<String>
let selectedAgentFiles: Set<String>
let selectedPlugins: Set<String>
let selectedSections: Set<String>
let includeUserContent: Bool
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }),
]
Expand Down Expand Up @@ -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:")
Expand Down Expand Up @@ -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")
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/mcs/ExternalPack/ExternalPackManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -478,6 +483,7 @@ enum ExternalComponentType: String, Codable, Sendable {
case skill
case hookFile
case command
case agent
case brewPackage
case configuration

Expand All @@ -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
}
Expand Down Expand Up @@ -649,6 +656,7 @@ enum ExternalCopyFileType: String, Codable, Sendable {
case skill
case hook
case command
case agent
case generic
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/mcs/TechPack/Component.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -75,6 +76,7 @@ enum CopyFileType: String, Sendable {
case skill
case hook
case command
case agent
case generic
}

Expand All @@ -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 ""
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
31 changes: 31 additions & 0 deletions Tests/MCSTests/ComponentExecutorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}
1 change: 1 addition & 0 deletions Tests/MCSTests/DerivedDoctorCheckTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions Tests/MCSTests/ExternalPackAdapterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading