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
4 changes: 2 additions & 2 deletions Sources/mcs/Commands/ExportCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ struct ExportCommand: ParsableCommand {
// Hook files
if !config.hookFiles.isEmpty {
let items = appendItems(config.hookFiles.map { hook in
let eventInfo = hook.hookEvent.map { " → \($0)" } ?? " (unknown event)"
let eventInfo = hook.hookRegistration.map { " → \($0.event)" } ?? " (unknown event)"
return (name: hook.filename, description: "Hook script\(eventInfo)")
}, category: .hooks)
groups.append(SelectableGroup(title: "Hooks", items: items, requiredItems: []))
Expand Down Expand Up @@ -342,7 +342,7 @@ struct ExportCommand: ParsableCommand {
}

// Check for hooks without matched events
let unmatchedHooks = config.hookFiles.filter { $0.hookEvent == nil }
let unmatchedHooks = config.hookFiles.filter { $0.hookRegistration == nil }
if !unmatchedHooks.isEmpty {
hints.append("Hook files without matched events: \(unmatchedHooks.map(\.filename).joined(separator: ", "))")
hints.append("Add `hookEvent:` to these components in techpack.yaml")
Expand Down
7 changes: 4 additions & 3 deletions Sources/mcs/Core/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,12 @@ enum Constants {
"PreToolUse", "PermissionRequest", "PostToolUse", "PostToolUseFailure",
"Notification",
"SubagentStart", "SubagentStop",
"Stop",
"Stop", "StopFailure",
"TeammateIdle", "TaskCompleted",
"ConfigChange",
"ConfigChange", "InstructionsLoaded",
"WorktreeCreate", "WorktreeRemove",
"PreCompact", "SessionEnd",
"PreCompact", "PostCompact", "SessionEnd",
"Elicitation", "ElicitationResult",
]
}

Expand Down
44 changes: 12 additions & 32 deletions Sources/mcs/Export/ConfigurationDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,6 @@ struct ConfigurationDiscovery {
let environment: Environment
let output: CLIOutput

/// Hook metadata extracted from a settings hook entry for export correlation.
struct HookInfo {
let event: String
let timeout: Int?
let isAsync: Bool?
let statusMessage: String?
}

// MARK: - Discovered Artifact Models

struct DiscoveredConfiguration {
Expand Down Expand Up @@ -56,21 +48,12 @@ struct ConfigurationDiscovery {
struct DiscoveredFile {
let filename: String
let absolutePath: URL
let hookEvent: String?
let hookTimeout: Int?
let hookAsync: Bool?
let hookStatusMessage: String?

init(
filename: String, absolutePath: URL, hookEvent: String? = nil,
hookTimeout: Int? = nil, hookAsync: Bool? = nil, hookStatusMessage: String? = nil
) {
let hookRegistration: HookRegistration?

init(filename: String, absolutePath: URL, hookRegistration: HookRegistration? = nil) {
self.filename = filename
self.absolutePath = absolutePath
self.hookEvent = hookEvent
self.hookTimeout = hookTimeout
self.hookAsync = hookAsync
self.hookStatusMessage = hookStatusMessage
self.hookRegistration = hookRegistration
}
}

Expand Down Expand Up @@ -213,7 +196,7 @@ struct ConfigurationDiscovery {

/// Discovers settings and returns hook command → metadata mappings for file correlation.
@discardableResult
private func discoverSettings(at settingsPath: URL, into config: inout DiscoveredConfiguration) -> [String: HookInfo]? {
private func discoverSettings(at settingsPath: URL, into config: inout DiscoveredConfiguration) -> [String: HookRegistration]? {
let settings: Settings
do {
settings = try Settings.load(from: settingsPath)
Expand Down Expand Up @@ -250,12 +233,12 @@ struct ConfigurationDiscovery {
// Extract hook command → event/metadata mappings for file correlation
guard let hooks = settings.hooks else { return nil }

var commandToHookInfo: [String: HookInfo] = [:]
var commandToReg: [String: HookRegistration] = [:]
for (event, groups) in hooks {
for group in groups {
for entry in group.hooks ?? [] {
if let command = entry.command {
commandToHookInfo[command] = HookInfo(
commandToReg[command] = HookRegistration(
event: event,
timeout: entry.timeout,
isAsync: entry.isAsync,
Expand All @@ -265,12 +248,12 @@ struct ConfigurationDiscovery {
}
}
}
return commandToHookInfo.isEmpty ? nil : commandToHookInfo
return commandToReg.isEmpty ? nil : commandToReg
}

// MARK: - File Discovery

private func discoverFiles(in hooksDir: URL, hookCommands: [String: HookInfo]?, into config: inout DiscoveredConfiguration) {
private func discoverFiles(in hooksDir: URL, hookCommands: [String: HookRegistration]?, into config: inout DiscoveredConfiguration) {
let fm = FileManager.default
guard fm.fileExists(atPath: hooksDir.path) else { return }

Expand All @@ -282,7 +265,7 @@ struct ConfigurationDiscovery {
return
}

let commandToHookInfo = hookCommands ?? [:]
let commandToReg = hookCommands ?? [:]

for file in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
let filename = file.lastPathComponent
Expand All @@ -297,17 +280,14 @@ struct ConfigurationDiscovery {
}

// Try to match this file to a hook event via settings commands
let matchedInfo = commandToHookInfo.first { command, _ in
let matchedReg = commandToReg.first { command, _ in
command.contains(filename)
}?.value

config.hookFiles.append(DiscoveredFile(
filename: filename,
absolutePath: file,
hookEvent: matchedInfo?.event,
hookTimeout: matchedInfo?.timeout,
hookAsync: matchedInfo?.isAsync,
hookStatusMessage: matchedInfo?.statusMessage
hookRegistration: matchedReg
))
}
}
Expand Down
31 changes: 14 additions & 17 deletions Sources/mcs/Export/ManifestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ struct ManifestBuilder {
CopyFileSpec(
files: config.hookFiles, selected: options.selectedHookFiles,
idPrefix: "hook", componentType: .hookFile, fileType: .hook,
descriptionFor: { "Hook script for \($0.hookEvent ?? "unknown event")" }
descriptionFor: { "Hook script for \($0.hookRegistration?.event ?? "unknown event")" }
),
CopyFileSpec(
files: config.skillFiles, selected: options.selectedSkillFiles,
Expand Down Expand Up @@ -211,10 +211,7 @@ struct ManifestBuilder {
displayName: id,
description: spec.descriptionFor(file),
type: spec.componentType,
hookEvent: file.hookEvent,
hookTimeout: file.hookTimeout,
hookAsync: file.hookAsync,
hookStatusMessage: file.hookStatusMessage,
hookRegistration: file.hookRegistration,
installAction: .copyPackFile(ExternalCopyPackFileConfig(
source: "\(directory)/\(file.filename)",
destination: file.filename,
Expand Down Expand Up @@ -460,21 +457,21 @@ struct ManifestBuilder {
yaml.line(" isRequired: true")
}

// hookEvent and hook handler fields
if let hookEvent = comp.hookEvent {
yaml.line(" hookEvent: \(yamlQuote(hookEvent))")
// hookRegistration fields
if let reg = comp.hookRegistration {
yaml.line(" hookEvent: \(yamlQuote(reg.event))")
if let timeout = reg.timeout {
yaml.line(" hookTimeout: \(timeout)")
}
if let hookAsync = reg.isAsync {
yaml.line(" hookAsync: \(hookAsync)")
}
if let statusMessage = reg.statusMessage {
yaml.line(" hookStatusMessage: \(yamlQuote(statusMessage))")
}
} else if comp.type == .hookFile {
yaml.comment(" TODO: Add hookEvent (e.g. SessionStart, PreToolUse, Stop)", indent: 4)
}
if let timeout = comp.hookTimeout {
yaml.line(" hookTimeout: \(timeout)")
}
if let hookAsync = comp.hookAsync {
yaml.line(" hookAsync: \(hookAsync)")
}
if let statusMessage = comp.hookStatusMessage {
yaml.line(" hookStatusMessage: \(yamlQuote(statusMessage))")
}

// Install action → shorthand key (exhaustive switch = compile-time safety)
switch comp.installAction {
Expand Down
5 changes: 1 addition & 4 deletions Sources/mcs/ExternalPack/ExternalPackAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,7 @@ struct ExternalPackAdapter: TechPack {
packIdentifier: manifest.identifier,
dependencies: ext.dependencies ?? [],
isRequired: ext.isRequired ?? false,
hookEvent: ext.hookEvent,
hookTimeout: ext.hookTimeout,
hookAsync: ext.hookAsync,
hookStatusMessage: ext.hookStatusMessage,
hookRegistration: ext.hookRegistration,
installAction: action,
supplementaryChecks: supplementary
)
Expand Down
86 changes: 41 additions & 45 deletions Sources/mcs/ExternalPack/ExternalPackManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,31 +66,20 @@ extension ExternalPackManifest {
}
seenComponentIDs.insert(component.id)

// Validate hookEvent against known Claude Code hook events
if let hookEvent = component.hookEvent {
guard Constants.Hooks.validEvents.contains(hookEvent) else {
// Validate hook registration
if let reg = component.hookRegistration {
guard Constants.Hooks.validEvents.contains(reg.event) else {
throw ManifestError.invalidHookEvent(
componentID: component.id,
hookEvent: hookEvent
hookEvent: reg.event
)
}
if let timeout = reg.timeout, timeout <= 0 {
throw ManifestError.invalidHookMetadata(
componentID: component.id,
reason: "hookTimeout must be positive (got \(timeout))"
)
}
}

// Validate hook handler metadata
if let timeout = component.hookTimeout, timeout <= 0 {
throw ManifestError.invalidHookMetadata(
componentID: component.id,
reason: "hookTimeout must be positive (got \(timeout))"
)
}
let hasHookMetadata = component.hookTimeout != nil
|| component.hookAsync != nil
|| component.hookStatusMessage != nil
if hasHookMetadata, component.hookEvent == nil {
throw ManifestError.invalidHookMetadata(
componentID: component.id,
reason: "hookTimeout/hookAsync/hookStatusMessage require hookEvent to be set"
)
}
}

Expand Down Expand Up @@ -326,14 +315,11 @@ struct ExternalComponentDefinition: Codable {
let type: ExternalComponentType
var dependencies: [String]?
let isRequired: Bool?
/// Claude Code hook event name (e.g. "SessionStart", "PreToolUse") for `hookFile` components.
/// When set, the engine auto-registers this hook in `settings.local.json`.
/// The `hookTimeout`, `hookAsync`, and `hookStatusMessage` fields map to
/// the corresponding Claude Code hook handler fields on the emitted entry.
let hookEvent: String?
let hookTimeout: Int?
let hookAsync: Bool?
let hookStatusMessage: String?
/// Hook registration metadata. When set, the engine auto-registers this hook
/// in `settings.local.json` with the specified handler fields.
/// YAML keys remain flat (`hookEvent`, `hookTimeout`, `hookAsync`, `hookStatusMessage`)
/// for pack author ergonomics; the custom Codable implementation maps them to this struct.
let hookRegistration: HookRegistration?
let installAction: ExternalInstallAction
let doctorChecks: [ExternalDoctorCheckDefinition]?

Expand Down Expand Up @@ -369,10 +355,7 @@ struct ExternalComponentDefinition: Codable {
type: ExternalComponentType,
dependencies: [String]? = nil,
isRequired: Bool? = nil,
hookEvent: String? = nil,
hookTimeout: Int? = nil,
hookAsync: Bool? = nil,
hookStatusMessage: String? = nil,
hookRegistration: HookRegistration? = nil,
installAction: ExternalInstallAction,
doctorChecks: [ExternalDoctorCheckDefinition]? = nil
) {
Expand All @@ -382,10 +365,7 @@ struct ExternalComponentDefinition: Codable {
self.type = type
self.dependencies = dependencies
self.isRequired = isRequired
self.hookEvent = hookEvent
self.hookTimeout = hookTimeout
self.hookAsync = hookAsync
self.hookStatusMessage = hookStatusMessage
self.hookRegistration = hookRegistration
self.installAction = installAction
self.doctorChecks = doctorChecks
}
Expand All @@ -400,10 +380,26 @@ struct ExternalComponentDefinition: Codable {
description = try container.decode(String.self, forKey: .description)
dependencies = try container.decodeIfPresent([String].self, forKey: .dependencies)
isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired)
hookEvent = try container.decodeIfPresent(String.self, forKey: .hookEvent)
hookTimeout = try container.decodeIfPresent(Int.self, forKey: .hookTimeout)
hookAsync = try container.decodeIfPresent(Bool.self, forKey: .hookAsync)
hookStatusMessage = try container.decodeIfPresent(String.self, forKey: .hookStatusMessage)
let hookEvent = try container.decodeIfPresent(String.self, forKey: .hookEvent)
let hookTimeout = try container.decodeIfPresent(Int.self, forKey: .hookTimeout)
let hookAsync = try container.decodeIfPresent(Bool.self, forKey: .hookAsync)
let hookStatusMessage = try container.decodeIfPresent(String.self, forKey: .hookStatusMessage)
if let hookEvent {
hookRegistration = HookRegistration(
event: hookEvent, timeout: hookTimeout, isAsync: hookAsync, statusMessage: hookStatusMessage
)
} else {
hookRegistration = nil
// Reject orphaned hook metadata (timeout/async/statusMessage without hookEvent)
if hookTimeout != nil || hookAsync != nil || hookStatusMessage != nil {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: container.codingPath,
debugDescription: "Component '\(id)': hookTimeout/hookAsync/hookStatusMessage require hookEvent to be set"
)
)
}
}
doctorChecks = try container.decodeIfPresent([ExternalDoctorCheckDefinition].self, forKey: .doctorChecks)

if let resolved = try Self.resolveShorthand(shorthand, componentId: id) {
Expand All @@ -427,10 +423,10 @@ struct ExternalComponentDefinition: Codable {
try container.encode(type, forKey: .type)
try container.encodeIfPresent(dependencies, forKey: .dependencies)
try container.encodeIfPresent(isRequired, forKey: .isRequired)
try container.encodeIfPresent(hookEvent, forKey: .hookEvent)
try container.encodeIfPresent(hookTimeout, forKey: .hookTimeout)
try container.encodeIfPresent(hookAsync, forKey: .hookAsync)
try container.encodeIfPresent(hookStatusMessage, forKey: .hookStatusMessage)
try container.encodeIfPresent(hookRegistration?.event, forKey: .hookEvent)
try container.encodeIfPresent(hookRegistration?.timeout, forKey: .hookTimeout)
try container.encodeIfPresent(hookRegistration?.isAsync, forKey: .hookAsync)
try container.encodeIfPresent(hookRegistration?.statusMessage, forKey: .hookStatusMessage)
try container.encode(installAction, forKey: .installAction)
try container.encodeIfPresent(doctorChecks, forKey: .doctorChecks)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/mcs/Install/Configurator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ struct Configurator {
output.warn(" Could not remove '\(relativePath)' — will retry on next sync")
}
if component.type == .hookFile,
component.hookEvent != nil,
component.hookRegistration != nil,
fileType == .hook {
let hookCmd = "\(scope.hookCommandPrefix)\(destination)"
artifacts.hookCommands.removeAll { $0 == hookCmd }
Expand Down
10 changes: 5 additions & 5 deletions Sources/mcs/Install/ConfiguratorSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,15 @@ enum ConfiguratorSupport {
guard !excluded.contains(component.id) else { continue }

if component.type == .hookFile,
let hookEvent = component.hookEvent,
let reg = component.hookRegistration,
case let .copyPackFile(_, destination, .hook) = component.installAction {
let command = "\(hookCommandPrefix)\(destination)"
if settings.addHookEntry(
event: hookEvent,
event: reg.event,
command: command,
timeout: component.hookTimeout,
isAsync: component.hookAsync,
statusMessage: component.hookStatusMessage
timeout: reg.timeout,
isAsync: reg.isAsync,
statusMessage: reg.statusMessage
) {
hasContent = true
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/mcs/Install/GlobalSyncStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ struct GlobalSyncStrategy: SyncStrategy {
artifacts.files.append(relativePath)
artifacts.fileHashes.merge(result.hashes) { _, new in new }
if component.type == .hookFile,
component.hookEvent != nil,
component.hookRegistration != nil,
fileType == .hook {
artifacts.hookCommands.append("\(scope.hookCommandPrefix)\(destination)")
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/mcs/Install/ProjectSyncStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ struct ProjectSyncStrategy: SyncStrategy {
artifacts.files.append(contentsOf: result.paths)
artifacts.fileHashes.merge(result.hashes) { _, new in new }
if component.type == .hookFile,
component.hookEvent != nil,
component.hookRegistration != nil,
fileType == .hook {
artifacts.hookCommands.append("\(scope.hookCommandPrefix)\(destination)")
}
Expand Down
Loading
Loading