diff --git a/CLAUDE.md b/CLAUDE.md
index bd8ebc0..dea3f9e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,6 +43,12 @@ mcs export
--global # Export global scope (~/.claude/)
mcs export --identifier id # Set pack identifier (prompted if omitted)
mcs export --non-interactive # Include everything without prompts
mcs export --dry-run # Preview what would be exported
+mcs check-updates # Check for pack and CLI updates
+mcs check-updates --hook # Run as SessionStart hook (7-day cooldown, respects config)
+mcs check-updates --json # Machine-readable JSON output
+mcs config list # Show all settings with current values
+mcs config get # Get a specific setting value
+mcs config set # Set a configuration value (true/false)
```
## Architecture
@@ -71,6 +77,8 @@ mcs export --dry-run # Preview what would be exported
- `ProjectState.swift` — per-project `.claude/.mcs-project` JSON state (configured packs, per-pack `PackArtifactRecord` with ownership tracking, version)
- `ProjectIndex.swift` — cross-project index (`~/.mcs/projects.yaml`) mapping project paths to pack IDs for reference counting
- `MCSError.swift` — error types for the CLI
+- `MCSConfig.swift` — user preferences (`~/.mcs/config.yaml`) with update-check-packs and update-check-cli keys
+- `UpdateChecker.swift` — pack freshness checks (`git ls-remote`), CLI version checks (`git ls-remote --tags`), cooldown management
### TechPack System (`Sources/mcs/TechPack/`)
- `TechPack.swift` — protocol for tech packs (components, templates, hooks, doctor checks, project configuration)
@@ -104,6 +112,8 @@ mcs export --dry-run # Preview what would be exported
- `CleanupCommand.swift` — backup file management with --force flag
- `PackCommand.swift` — `mcs pack add/remove/list/update` subcommands; uses `PackSourceResolver` for 3-tier input detection (URL schemes → filesystem paths → GitHub shorthand)
- `ExportCommand.swift` — export wizard: reads live configuration and generates a reusable tech pack directory; supports `--global`, `--identifier`, `--non-interactive`, `--dry-run`
+- `CheckUpdatesCommand.swift` — lightweight update checker for packs (`git ls-remote`) and CLI version (`git ls-remote --tags`); respects config keys and 7-day cooldown
+- `ConfigCommand.swift` — `mcs config list/get/set` for managing user preferences; `set` immediately syncs the SessionStart hook in `~/.claude/settings.json`
### Export (`Sources/mcs/Export/`)
- `ConfigurationDiscovery.swift` — reads live config sources (settings, MCP servers, hooks, skills, CLAUDE.md, gitignore), produces `DiscoveredConfiguration` model
diff --git a/README.md b/README.md
index 322ebb1..db82f8e 100644
--- a/README.md
+++ b/README.md
@@ -160,7 +160,7 @@ The perfect complement to `mcs`: configure your environment with `mcs`, then use
| Document | Description |
|----------|-------------|
-| 📖 [CLI Reference](docs/cli.md) | Complete command reference (`sync`, `pack`, `doctor`, `export`, `cleanup`) |
+| 📖 [CLI Reference](docs/cli.md) | Complete command reference (`sync`, `pack`, `doctor`, `export`, `cleanup`, `check-updates`, `config`) |
| 📖 [Creating Tech Packs](docs/creating-tech-packs.md) | Step-by-step guide to building your first pack |
| 📋 [Tech Pack Schema](docs/techpack-schema.md) | Complete `techpack.yaml` field reference |
| 🏗️ [Architecture](docs/architecture.md) | Internal design, sync flow, safety guarantees, and extension points |
diff --git a/Sources/mcs/CLI.swift b/Sources/mcs/CLI.swift
index 007f694..6a1d09d 100644
--- a/Sources/mcs/CLI.swift
+++ b/Sources/mcs/CLI.swift
@@ -18,6 +18,8 @@ struct MCS: ParsableCommand {
CleanupCommand.self,
PackCommand.self,
ExportCommand.self,
+ CheckUpdatesCommand.self,
+ ConfigCommand.self,
],
defaultSubcommand: SyncCommand.self
)
diff --git a/Sources/mcs/Commands/CheckUpdatesCommand.swift b/Sources/mcs/Commands/CheckUpdatesCommand.swift
new file mode 100644
index 0000000..0e33804
--- /dev/null
+++ b/Sources/mcs/Commands/CheckUpdatesCommand.swift
@@ -0,0 +1,96 @@
+import ArgumentParser
+import Foundation
+
+struct CheckUpdatesCommand: ParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "check-updates",
+ abstract: "Check for tech pack and CLI updates"
+ )
+
+ @Flag(name: .long, help: "Run as a Claude Code SessionStart hook (respects 7-day cooldown and config)")
+ var hook: Bool = false
+
+ @Flag(name: .long, help: "Output results as JSON")
+ var json: Bool = false
+
+ func run() throws {
+ let env = Environment()
+ let output = CLIOutput()
+ let shell = ShellRunner(environment: env)
+
+ let registry = PackRegistryFile(path: env.packsRegistry)
+ let registryData: PackRegistryFile.RegistryData
+ do {
+ registryData = try registry.load()
+ } catch {
+ if !hook {
+ output.warn("Could not read pack registry: \(error.localizedDescription)")
+ }
+ registryData = PackRegistryFile.RegistryData()
+ }
+
+ let checkPacks: Bool
+ let checkCLI: Bool
+ if hook {
+ // Hook mode: respect config keys
+ let config = MCSConfig.load(from: env.mcsConfigFile)
+ checkPacks = config.updateCheckPacks ?? false
+ checkCLI = config.updateCheckCLI ?? false
+ } else {
+ // User-invoked: always check both
+ checkPacks = true
+ checkCLI = true
+ }
+
+ let relevantEntries = UpdateChecker.filterEntries(registryData.packs, environment: env)
+
+ let checker = UpdateChecker(environment: env, shell: shell)
+ let result = checker.performCheck(
+ entries: relevantEntries,
+ isHook: hook,
+ checkPacks: checkPacks,
+ checkCLI: checkCLI
+ )
+
+ if json {
+ printJSON(result)
+ } else {
+ UpdateChecker.printResult(result, output: output, isHook: hook)
+ }
+ }
+
+ /// Codable DTO for the `--json` output format.
+ private struct JSONOutput: Codable {
+ let cli: CLIStatus
+ let packs: [UpdateChecker.PackUpdate]
+
+ struct CLIStatus: Codable {
+ let current: String
+ let updateAvailable: Bool
+ let latest: String?
+ }
+
+ init(result: UpdateChecker.CheckResult) {
+ cli = CLIStatus(
+ current: MCSVersion.current,
+ updateAvailable: result.cliUpdate != nil,
+ latest: result.cliUpdate?.latestVersion
+ )
+ packs = result.packUpdates
+ }
+ }
+
+ private func printJSON(_ result: UpdateChecker.CheckResult) {
+ let jsonOutput = JSONOutput(result: result)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ do {
+ let data = try encoder.encode(jsonOutput)
+ if let string = String(data: data, encoding: .utf8) {
+ print(string)
+ }
+ } catch {
+ CLIOutput().error("JSON encoding failed: \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/Sources/mcs/Commands/ConfigCommand.swift b/Sources/mcs/Commands/ConfigCommand.swift
new file mode 100644
index 0000000..16991f8
--- /dev/null
+++ b/Sources/mcs/Commands/ConfigCommand.swift
@@ -0,0 +1,104 @@
+import ArgumentParser
+import Foundation
+
+struct ConfigCommand: ParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "config",
+ abstract: "Manage mcs preferences",
+ subcommands: [ListConfig.self, GetConfig.self, SetConfig.self]
+ )
+}
+
+// MARK: - List
+
+struct ListConfig: ParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "list",
+ abstract: "Show all settings with current values"
+ )
+
+ func run() throws {
+ let env = Environment()
+ let output = CLIOutput()
+ let config = MCSConfig.load(from: env.mcsConfigFile, output: output)
+
+ output.header("Configuration")
+ output.plain("")
+
+ for known in MCSConfig.knownKeys {
+ let current = config.value(forKey: known.key)
+ let displayValue = current.map { String($0) } ?? "(not set, default: \(known.defaultValue))"
+ output.plain(" \(known.key): \(displayValue)")
+ output.dimmed(" \(known.description)")
+ }
+
+ output.plain("")
+ output.dimmed("Config file: \(env.mcsConfigFile.path)")
+ }
+}
+
+// MARK: - Get
+
+struct GetConfig: ParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "get",
+ abstract: "Get a configuration value"
+ )
+
+ @Argument(help: "The configuration key")
+ var key: String
+
+ func run() throws {
+ let env = Environment()
+ let output = CLIOutput()
+ let config = MCSConfig.load(from: env.mcsConfigFile, output: output)
+
+ guard MCSConfig.knownKeys.contains(where: { $0.key == key }) else {
+ output.error("Unknown config key '\(key)'")
+ output.dimmed("Known keys: \(MCSConfig.knownKeys.map(\.key).joined(separator: ", "))")
+ throw ExitCode.failure
+ }
+
+ if let value = config.value(forKey: key) {
+ print(value)
+ } else {
+ output.dimmed("(not set)")
+ }
+ }
+}
+
+// MARK: - Set
+
+struct SetConfig: ParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "set",
+ abstract: "Set a configuration value"
+ )
+
+ @Argument(help: "The configuration key")
+ var key: String
+
+ @Argument(help: "The value to set (true/false)")
+ var value: Bool
+
+ func run() throws {
+ let env = Environment()
+ let output = CLIOutput()
+ var config = MCSConfig.load(from: env.mcsConfigFile, output: output)
+
+ guard MCSConfig.knownKeys.contains(where: { $0.key == key }) else {
+ output.error("Unknown config key '\(key)'")
+ output.dimmed("Known keys: \(MCSConfig.knownKeys.map(\.key).joined(separator: ", "))")
+ throw ExitCode.failure
+ }
+
+ let changed = config.setValue(value, forKey: key)
+ guard changed else { throw ExitCode.failure }
+
+ try config.save(to: env.mcsConfigFile)
+ output.success("Updated: \(key) = \(value)")
+
+ // Immediately manage the SessionStart hook in ~/.claude/settings.json
+ UpdateChecker.syncHook(config: config, env: env, output: output)
+ }
+}
diff --git a/Sources/mcs/Commands/DoctorCommand.swift b/Sources/mcs/Commands/DoctorCommand.swift
index c8a39d5..f40dbeb 100644
--- a/Sources/mcs/Commands/DoctorCommand.swift
+++ b/Sources/mcs/Commands/DoctorCommand.swift
@@ -26,6 +26,7 @@ struct DoctorCommand: LockedCommand {
func perform() throws {
let env = Environment()
let output = CLIOutput()
+ let shell = ShellRunner(environment: env)
let registry = TechPackRegistry.loadWithExternalPacks(
environment: env,
output: output
@@ -39,5 +40,8 @@ struct DoctorCommand: LockedCommand {
environment: env
)
try runner.run()
+
+ // Always check for updates in doctor — it's a diagnostic tool
+ UpdateChecker.checkAndPrint(env: env, shell: shell, output: output)
}
}
diff --git a/Sources/mcs/Commands/ExportCommand.swift b/Sources/mcs/Commands/ExportCommand.swift
index d4434da..6df67b6 100644
--- a/Sources/mcs/Commands/ExportCommand.swift
+++ b/Sources/mcs/Commands/ExportCommand.swift
@@ -219,7 +219,7 @@ struct ExportCommand: ParsableCommand {
// Hook files
if !config.hookFiles.isEmpty {
let items = appendItems(config.hookFiles.map { hook in
- let eventInfo = hook.hookRegistration.map { " → \($0.event)" } ?? " (unknown event)"
+ let eventInfo = hook.hookRegistration.map { " → \($0.event.rawValue)" } ?? " (unknown event)"
return (name: hook.filename, description: "Hook script\(eventInfo)")
}, category: .hooks)
groups.append(SelectableGroup(title: "Hooks", items: items, requiredItems: []))
diff --git a/Sources/mcs/Commands/PackCommand.swift b/Sources/mcs/Commands/PackCommand.swift
index a1ea5cf..6fd8f60 100644
--- a/Sources/mcs/Commands/PackCommand.swift
+++ b/Sources/mcs/Commands/PackCommand.swift
@@ -688,6 +688,10 @@ struct UpdatePack: LockedCommand {
ctx.output.error("Failed to save registry: \(error.localizedDescription)")
throw ExitCode.failure
}
+ // Invalidate update check cache so the next hook re-checks
+ if !UpdateChecker.invalidateCache(environment: ctx.env) {
+ ctx.output.warn("Could not clear update check cache — next session may show stale update info")
+ }
ctx.output.plain("")
ctx.output.info("Run 'mcs sync' to apply updated pack components.")
}
diff --git a/Sources/mcs/Commands/SyncCommand.swift b/Sources/mcs/Commands/SyncCommand.swift
index e165009..759a674 100644
--- a/Sources/mcs/Commands/SyncCommand.swift
+++ b/Sources/mcs/Commands/SyncCommand.swift
@@ -44,6 +44,9 @@ struct SyncCommand: LockedCommand {
throw ExitCode.failure
}
+ // First-run: prompt for update notification preference
+ promptForUpdateCheckIfNeeded(env: env, output: output)
+
// Handle --update: fetch latest for all packs before loading
if update {
let lockOps = LockfileOperations(environment: env, output: output, shell: shell)
@@ -60,6 +63,11 @@ struct SyncCommand: LockedCommand {
} else {
try performProject(env: env, output: output, shell: shell, registry: registry)
}
+
+ // Always check for updates after sync — user explicitly ran the command
+ if !dryRun {
+ UpdateChecker.checkAndPrint(env: env, shell: shell, output: output)
+ }
}
// MARK: - Global Scope
@@ -171,6 +179,27 @@ struct SyncCommand: LockedCommand {
// MARK: - Shared Helpers
+ /// Prompt for update notification preference on first interactive sync.
+ @discardableResult
+ private func promptForUpdateCheckIfNeeded(env: Environment, output: CLIOutput) -> MCSConfig {
+ var config = MCSConfig.load(from: env.mcsConfigFile, output: output)
+
+ // Only prompt in interactive mode (no --pack, --all, or --dry-run) and if never configured
+ let isInteractive = pack.isEmpty && !all && !dryRun
+ guard isInteractive, config.isUnconfigured else { return config }
+
+ let enabled = output.askYesNo("Enable update notifications on session start?")
+ config.updateCheckPacks = enabled
+ config.updateCheckCLI = enabled
+ do {
+ try config.save(to: env.mcsConfigFile)
+ } catch {
+ output.warn("Could not save config: \(error.localizedDescription)")
+ }
+ UpdateChecker.syncHook(config: config, env: env, output: output)
+ return config
+ }
+
private func resolvePacks(
from registry: TechPackRegistry,
output: CLIOutput
diff --git a/Sources/mcs/Core/CLIOutput.swift b/Sources/mcs/Core/CLIOutput.swift
index 2078216..f8a99c1 100644
--- a/Sources/mcs/Core/CLIOutput.swift
+++ b/Sources/mcs/Core/CLIOutput.swift
@@ -157,7 +157,9 @@ struct CLIOutput {
func doctorSummary(passed: Int, fixed: Int, warnings: Int, issues: Int) {
var parts: [String] = []
parts.append("\(blue)\(passed) passed\(reset)")
- parts.append("\(green)\(fixed) fixed\(reset)")
+ if fixed > 0 {
+ parts.append("\(green)\(fixed) fixed\(reset)")
+ }
parts.append("\(yellow)\(warnings) warnings\(reset)")
parts.append("\(red)\(issues) issues\(reset)")
write(parts.joined(separator: " ") + "\n")
diff --git a/Sources/mcs/Core/Constants.swift b/Sources/mcs/Core/Constants.swift
index 49b485d..8810f4a 100644
--- a/Sources/mcs/Core/Constants.swift
+++ b/Sources/mcs/Core/Constants.swift
@@ -27,6 +27,12 @@ enum Constants {
/// The process lock file preventing concurrent mcs execution.
static let mcsLock = "lock"
+
+ /// The update check cache file (timestamp + results).
+ static let updateCheckCache = "update-check.json"
+
+ /// The user preferences file.
+ static let mcsConfig = "config.yaml"
}
// MARK: - CLI
@@ -75,21 +81,34 @@ enum Constants {
// MARK: - Hooks
- enum Hooks {
- /// All Claude Code hook event names (PascalCase).
- /// Source: https://docs.anthropic.com/en/docs/claude-code/hooks
- static let validEvents: Set = [
- "SessionStart", "UserPromptSubmit",
- "PreToolUse", "PermissionRequest", "PostToolUse", "PostToolUseFailure",
- "Notification",
- "SubagentStart", "SubagentStop",
- "Stop", "StopFailure",
- "TeammateIdle", "TaskCompleted",
- "ConfigChange", "InstructionsLoaded",
- "WorktreeCreate", "WorktreeRemove",
- "PreCompact", "PostCompact", "SessionEnd",
- "Elicitation", "ElicitationResult",
- ]
+ /// All Claude Code hook event types.
+ /// Source: https://docs.anthropic.com/en/docs/claude-code/hooks
+ enum HookEvent: String, CaseIterable, Codable {
+ case sessionStart = "SessionStart"
+ case userPromptSubmit = "UserPromptSubmit"
+ case preToolUse = "PreToolUse"
+ case permissionRequest = "PermissionRequest"
+ case postToolUse = "PostToolUse"
+ case postToolUseFailure = "PostToolUseFailure"
+ case notification = "Notification"
+ case subagentStart = "SubagentStart"
+ case subagentStop = "SubagentStop"
+ case stop = "Stop"
+ case stopFailure = "StopFailure"
+ case teammateIdle = "TeammateIdle"
+ case taskCompleted = "TaskCompleted"
+ case configChange = "ConfigChange"
+ case instructionsLoaded = "InstructionsLoaded"
+ case worktreeCreate = "WorktreeCreate"
+ case worktreeRemove = "WorktreeRemove"
+ case preCompact = "PreCompact"
+ case postCompact = "PostCompact"
+ case sessionEnd = "SessionEnd"
+ case elicitation = "Elicitation"
+ case elicitationResult = "ElicitationResult"
+
+ /// Set of all valid event raw values (for string-based validation).
+ static let validRawValues: Set = Set(allCases.map(\.rawValue))
}
// MARK: - MCP Scopes
@@ -102,6 +121,16 @@ enum Constants {
static let user = "user"
}
+ // MARK: - MCS Repository
+
+ enum MCSRepo {
+ /// The HTTPS URL for the mcs git repository (used for version checks).
+ static let url = "https://github.com/bguidolim/mcs.git"
+
+ /// The Homebrew formula name for mcs.
+ static let brewFormula = "bguidolim/tap/managed-claude-stack"
+ }
+
// MARK: - Plugins
enum Plugins {
diff --git a/Sources/mcs/Core/Environment.swift b/Sources/mcs/Core/Environment.swift
index bcadaa9..83a390a 100644
--- a/Sources/mcs/Core/Environment.swift
+++ b/Sources/mcs/Core/Environment.swift
@@ -97,6 +97,16 @@ struct Environment {
mcsDirectory.appendingPathComponent(Constants.FileNames.mcsLock)
}
+ /// Update check cache file (`~/.mcs/update-check.json`).
+ var updateCheckCacheFile: URL {
+ mcsDirectory.appendingPathComponent(Constants.FileNames.updateCheckCache)
+ }
+
+ /// User preferences file (`~/.mcs/config.yaml`).
+ var mcsConfigFile: URL {
+ mcsDirectory.appendingPathComponent(Constants.FileNames.mcsConfig)
+ }
+
/// PATH string that includes the Homebrew bin directory.
var pathWithBrew: String {
let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin"
diff --git a/Sources/mcs/Core/MCSConfig.swift b/Sources/mcs/Core/MCSConfig.swift
new file mode 100644
index 0000000..ce6ede4
--- /dev/null
+++ b/Sources/mcs/Core/MCSConfig.swift
@@ -0,0 +1,109 @@
+import Foundation
+import Yams
+
+/// User preferences stored at `~/.mcs/config.yaml`.
+/// All fields are optional — `nil` means "never configured".
+struct MCSConfig: Codable {
+ var updateCheckPacks: Bool?
+ var updateCheckCLI: Bool?
+
+ enum CodingKeys: String, CodingKey, CaseIterable {
+ case updateCheckPacks = "update-check-packs"
+ case updateCheckCLI = "update-check-cli"
+ }
+
+ /// Whether any update check is enabled (at least one key is true).
+ var isUpdateCheckEnabled: Bool {
+ (updateCheckPacks ?? false) || (updateCheckCLI ?? false)
+ }
+
+ /// Whether neither key has been configured yet (first-run state).
+ var isUnconfigured: Bool {
+ updateCheckPacks == nil && updateCheckCLI == nil
+ }
+
+ // MARK: - Known Keys
+
+ struct ConfigKey {
+ let key: String
+ let description: String
+ let defaultValue: String
+ }
+
+ static let knownKeys: [ConfigKey] = [
+ ConfigKey(
+ key: CodingKeys.updateCheckPacks.rawValue,
+ description: "Automatically check for tech pack updates on Claude Code session start",
+ defaultValue: "false"
+ ),
+ ConfigKey(
+ key: CodingKeys.updateCheckCLI.rawValue,
+ description: "Automatically check for new mcs versions on Claude Code session start",
+ defaultValue: "false"
+ ),
+ ]
+
+ // MARK: - Persistence
+
+ /// Load config from disk. Returns empty config if file is missing.
+ /// Warns via `output` if the file exists but is corrupt.
+ static func load(from path: URL, output: CLIOutput? = nil) -> MCSConfig {
+ let fm = FileManager.default
+ guard fm.fileExists(atPath: path.path) else { return MCSConfig() }
+
+ let content: String
+ do {
+ content = try String(contentsOf: path, encoding: .utf8)
+ } catch {
+ output?.warn("Could not read config file: \(error.localizedDescription)")
+ return MCSConfig()
+ }
+
+ guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return MCSConfig()
+ }
+
+ do {
+ return try YAMLDecoder().decode(MCSConfig.self, from: content)
+ } catch {
+ output?.warn("Config file is corrupt (\(path.lastPathComponent)): \(error.localizedDescription)")
+ return MCSConfig()
+ }
+ }
+
+ /// Save config to disk, creating parent directories if needed.
+ func save(to path: URL) throws {
+ let fm = FileManager.default
+ let dir = path.deletingLastPathComponent()
+ if !fm.fileExists(atPath: dir.path) {
+ try fm.createDirectory(at: dir, withIntermediateDirectories: true)
+ }
+ let yaml = try YAMLEncoder().encode(self)
+ try yaml.write(to: path, atomically: true, encoding: .utf8)
+ }
+
+ // MARK: - Key Access
+
+ /// Get a config value by key name. Returns nil if the key is unknown or unset.
+ func value(forKey key: String) -> Bool? {
+ switch key {
+ case CodingKeys.updateCheckPacks.rawValue: updateCheckPacks
+ case CodingKeys.updateCheckCLI.rawValue: updateCheckCLI
+ default: nil
+ }
+ }
+
+ /// Set a config value by key name. Returns false if the key is unknown.
+ mutating func setValue(_ value: Bool, forKey key: String) -> Bool {
+ switch key {
+ case CodingKeys.updateCheckPacks.rawValue:
+ updateCheckPacks = value
+ return true
+ case CodingKeys.updateCheckCLI.rawValue:
+ updateCheckCLI = value
+ return true
+ default:
+ return false
+ }
+ }
+}
diff --git a/Sources/mcs/Core/Settings.swift b/Sources/mcs/Core/Settings.swift
index d40bd57..17a0156 100644
--- a/Sources/mcs/Core/Settings.swift
+++ b/Sources/mcs/Core/Settings.swift
@@ -110,6 +110,43 @@ struct Settings: Codable {
return true
}
+ /// Type-safe overload accepting `HookEvent`.
+ @discardableResult
+ mutating func addHookEntry(
+ event: Constants.HookEvent,
+ command: String,
+ timeout: Int? = nil,
+ isAsync: Bool? = nil,
+ statusMessage: String? = nil
+ ) -> Bool {
+ addHookEntry(
+ event: event.rawValue, command: command,
+ timeout: timeout, isAsync: isAsync, statusMessage: statusMessage
+ )
+ }
+
+ /// Remove a hook entry by command string from the given event.
+ /// Returns `true` if the entry was found and removed.
+ @discardableResult
+ mutating func removeHookEntry(event: String, command: String) -> Bool {
+ guard var groups = hooks?[event] else { return false }
+ let before = groups.count
+ groups.removeAll { $0.hooks?.first?.command == command }
+ if groups.isEmpty {
+ hooks?.removeValue(forKey: event)
+ } else {
+ hooks?[event] = groups
+ }
+ if hooks?.isEmpty == true { hooks = nil }
+ return groups.count < before
+ }
+
+ /// Type-safe overload accepting `HookEvent`.
+ @discardableResult
+ mutating func removeHookEntry(event: Constants.HookEvent, command: String) -> Bool {
+ removeHookEntry(event: event.rawValue, command: command)
+ }
+
// MARK: - Deep Merge
/// Merge `other` into `self`, preserving existing user values.
diff --git a/Sources/mcs/Core/UpdateChecker.swift b/Sources/mcs/Core/UpdateChecker.swift
new file mode 100644
index 0000000..600728a
--- /dev/null
+++ b/Sources/mcs/Core/UpdateChecker.swift
@@ -0,0 +1,402 @@
+import Foundation
+
+/// Checks for available updates to tech packs (via `git ls-remote`)
+/// and the mcs CLI itself (via `git ls-remote --tags` on the mcs repo).
+///
+/// All network operations fail silently — offline or unreachable
+/// remotes produce no output, matching the design goal of non-intrusive checks.
+struct UpdateChecker {
+ let environment: Environment
+ let shell: ShellRunner
+
+ /// Default cooldown interval: 7 days.
+ static let cooldownInterval: TimeInterval = 604_800
+
+ // MARK: - SessionStart Hook (single source of truth)
+
+ static let hookCommand = "mcs check-updates --hook"
+ static let hookTimeout: Int = 30
+ static let hookStatusMessage = "Checking for updates..."
+
+ @discardableResult
+ static func addHook(to settings: inout Settings) -> Bool {
+ settings.addHookEntry(
+ event: Constants.HookEvent.sessionStart,
+ command: hookCommand,
+ timeout: hookTimeout,
+ statusMessage: hookStatusMessage
+ )
+ }
+
+ @discardableResult
+ static func removeHook(from settings: inout Settings) -> Bool {
+ settings.removeHookEntry(event: Constants.HookEvent.sessionStart, command: hookCommand)
+ }
+
+ /// Ensure the SessionStart hook in `~/.claude/settings.json` matches the config.
+ static func syncHook(config: MCSConfig, env: Environment, output: CLIOutput) {
+ do {
+ var settings = try Settings.load(from: env.claudeSettings)
+ if config.isUpdateCheckEnabled {
+ if addHook(to: &settings) {
+ try settings.save(to: env.claudeSettings)
+ }
+ } else {
+ if removeHook(from: &settings) {
+ try settings.save(to: env.claudeSettings)
+ }
+ }
+ } catch {
+ output.warn("Could not update hook in settings: \(error.localizedDescription)")
+ }
+ }
+
+ /// Run an update check and print results. Used by sync and doctor (user-invoked, no cooldown).
+ static func checkAndPrint(env: Environment, shell: ShellRunner, output: CLIOutput) {
+ let packRegistry = PackRegistryFile(path: env.packsRegistry)
+ let allEntries: [PackRegistryFile.PackEntry]
+ do {
+ allEntries = try packRegistry.load().packs
+ } catch {
+ output.warn("Could not load pack registry: \(error.localizedDescription)")
+ allEntries = []
+ }
+ let relevantEntries = filterEntries(allEntries, environment: env)
+ let checker = UpdateChecker(environment: env, shell: shell)
+ let result = checker.performCheck(
+ entries: relevantEntries,
+ checkPacks: true,
+ checkCLI: true
+ )
+ if !result.isEmpty {
+ output.plain("")
+ printResult(result, output: output)
+ }
+ }
+
+ // MARK: - Result Types
+
+ struct PackUpdate: Codable {
+ let identifier: String
+ let displayName: String
+ let localSHA: String
+ let remoteSHA: String
+ }
+
+ struct CLIUpdate: Codable {
+ let currentVersion: String
+ let latestVersion: String
+ }
+
+ struct CheckResult: Codable {
+ let packUpdates: [PackUpdate]
+ let cliUpdate: CLIUpdate?
+
+ var isEmpty: Bool {
+ packUpdates.isEmpty && cliUpdate == nil
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case packUpdates = "packs"
+ case cliUpdate = "cli"
+ }
+ }
+
+ /// On-disk cache: check results + timestamp in a single JSON file.
+ struct CachedResult: Codable {
+ let timestamp: String
+ let result: CheckResult
+ }
+
+ // MARK: - Cache
+
+ /// Load the cached check result. Returns nil if missing, corrupt, or CLI version changed.
+ func loadCache() -> CachedResult? {
+ guard let data = try? Data(contentsOf: environment.updateCheckCacheFile),
+ let cached = try? JSONDecoder().decode(CachedResult.self, from: data)
+ else {
+ return nil
+ }
+ // Invalidate if the CLI version changed (user upgraded mcs)
+ if let cli = cached.result.cliUpdate,
+ cli.currentVersion != MCSVersion.current {
+ return nil
+ }
+ return cached
+ }
+
+ /// Save check results to the cache file.
+ func saveCache(_ result: CheckResult) {
+ let cached = CachedResult(
+ timestamp: ISO8601DateFormatter().string(from: Date()),
+ result: result
+ )
+ do {
+ let dir = environment.updateCheckCacheFile.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(cached)
+ try data.write(to: environment.updateCheckCacheFile, options: .atomic)
+ } catch {
+ // Cache write failure is non-fatal — next check will just redo network calls
+ }
+ }
+
+ /// Delete the cache file (e.g., after `mcs pack update`).
+ /// Returns true if deleted or already absent; false on permission error.
+ @discardableResult
+ static func invalidateCache(environment: Environment) -> Bool {
+ let fm = FileManager.default
+ guard fm.fileExists(atPath: environment.updateCheckCacheFile.path) else { return true }
+ do {
+ try fm.removeItem(at: environment.updateCheckCacheFile)
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ // MARK: - Pack Checks
+
+ /// Check each git pack for remote updates via `git ls-remote`.
+ /// Local packs are skipped. Network failures are silently ignored per-pack.
+ func checkPackUpdates(entries: [PackRegistryFile.PackEntry]) -> [PackUpdate] {
+ var updates: [PackUpdate] = []
+
+ for entry in entries {
+ if entry.isLocalPack { continue }
+
+ let ref = entry.ref ?? "HEAD"
+ let result = shell.run(
+ environment.gitPath,
+ arguments: ["ls-remote", entry.sourceURL, ref]
+ )
+
+ guard result.succeeded,
+ let remoteSHA = Self.parseRemoteSHA(from: result.stdout)
+ else {
+ continue
+ }
+
+ if remoteSHA != entry.commitSHA {
+ updates.append(PackUpdate(
+ identifier: entry.identifier,
+ displayName: entry.displayName,
+ localSHA: entry.commitSHA,
+ remoteSHA: remoteSHA
+ ))
+ }
+ }
+
+ return updates
+ }
+
+ // MARK: - CLI Version Check
+
+ /// Check if a newer mcs version is available via `git ls-remote --tags`.
+ /// Returns nil if the repo is unreachable or no newer version exists.
+ func checkCLIVersion(currentVersion: String) -> CLIUpdate? {
+ let result = shell.run(
+ environment.gitPath,
+ arguments: ["ls-remote", "--tags", "--refs", Constants.MCSRepo.url]
+ )
+
+ guard result.succeeded,
+ let latestTag = Self.parseLatestTag(from: result.stdout)
+ else {
+ return nil
+ }
+
+ guard VersionCompare.isNewer(candidate: latestTag, than: currentVersion) else {
+ return nil
+ }
+
+ return CLIUpdate(currentVersion: currentVersion, latestVersion: latestTag)
+ }
+
+ // MARK: - Combined Check
+
+ /// Run all enabled checks.
+ /// - `isHook: true` — SessionStart hook: returns cached results if fresh, otherwise checks + caches
+ /// - `isHook: false` (default) — user-invoked: always does network checks + updates cache
+ func performCheck(
+ entries: [PackRegistryFile.PackEntry],
+ isHook: Bool = false,
+ checkPacks: Bool,
+ checkCLI: Bool
+ ) -> CheckResult {
+ // Hook mode: serve cached results if still fresh (single disk read)
+ if isHook, let cached = loadCache(),
+ let lastCheck = ISO8601DateFormatter().date(from: cached.timestamp),
+ Date().timeIntervalSince(lastCheck) < Self.cooldownInterval {
+ return cached.result
+ }
+
+ let packUpdates = checkPacks ? checkPackUpdates(entries: entries) : []
+ let cliUpdate = checkCLI ? checkCLIVersion(currentVersion: MCSVersion.current) : nil
+ let result = CheckResult(packUpdates: packUpdates, cliUpdate: cliUpdate)
+
+ // Always save to cache so the hook can serve fresh data between network checks
+ saveCache(result)
+
+ return result
+ }
+
+ // MARK: - Output Formatting
+
+ /// Print update notices. Returns true if anything was printed.
+ /// In hook mode, outputs structured JSON with `additionalContext` for Claude Code.
+ /// In user mode, outputs colored terminal text.
+ @discardableResult
+ static func printResult(_ result: CheckResult, output: CLIOutput, isHook: Bool = false) -> Bool {
+ guard !result.isEmpty else { return false }
+
+ if isHook {
+ // Structured JSON for Claude Code SessionStart hook
+ let context = buildContextString(result)
+ let hookOutput: [String: Any] = [
+ "hookSpecificOutput": [
+ "hookEventName": Constants.HookEvent.sessionStart.rawValue,
+ "additionalContext": context,
+ ] as [String: String],
+ ]
+ do {
+ let data = try JSONSerialization.data(withJSONObject: hookOutput, options: [.sortedKeys])
+ if let json = String(data: data, encoding: .utf8) {
+ print(json)
+ }
+ } catch {
+ // Fallback to plain text if JSON serialization fails
+ print(context)
+ }
+ } else {
+ // User-invoked: colored output for terminal readability
+ if let cli = result.cliUpdate {
+ output.warn(
+ "mcs \(cli.latestVersion) available (current: \(cli.currentVersion)). "
+ + "Run 'brew upgrade \(Constants.MCSRepo.brewFormula)' to update."
+ )
+ }
+ if !result.packUpdates.isEmpty {
+ let noun = result.packUpdates.count == 1 ? "pack has" : "packs have"
+ output.info("\(result.packUpdates.count) \(noun) updates available. Run 'mcs pack update' to update.")
+ }
+ }
+
+ return true
+ }
+
+ /// Build a context string for Claude from check results.
+ /// Uses strong directive language to ensure Claude relays the notification.
+ private static func buildContextString(_ result: CheckResult) -> String {
+ var lines: [String] = []
+ lines.append(
+ "STOP. Before doing ANY work, you MUST relay the following update notifications to the user."
+ + " This is non-negotiable even if the user's message is a task request."
+ )
+ lines.append("")
+ if let cli = result.cliUpdate {
+ lines.append(
+ "- New mcs version \(cli.latestVersion) is available (installed: \(cli.currentVersion)). "
+ + "The user should run: brew upgrade \(Constants.MCSRepo.brewFormula)"
+ )
+ }
+ if !result.packUpdates.isEmpty {
+ let noun = result.packUpdates.count == 1 ? "tech pack has" : "tech packs have"
+ lines.append("- \(result.packUpdates.count) \(noun) updates available. The user should run: mcs pack update")
+ }
+ return lines.joined(separator: "\n")
+ }
+
+ // MARK: - Pack Filtering
+
+ /// Resolve which pack IDs are relevant for update checks in the current context.
+ /// Always includes globally-configured packs. If a project root is detected,
+ /// also includes packs configured for that project.
+ static func relevantPackIDs(environment: Environment) -> Set {
+ var ids = Set()
+
+ // Global packs — only load if the state file exists
+ let fm = FileManager.default
+ if fm.fileExists(atPath: environment.globalStateFile.path) {
+ do {
+ let globalState = try ProjectState(stateFile: environment.globalStateFile)
+ ids.formUnion(globalState.configuredPacks)
+ } catch {
+ // Corrupt state — fall through to check all packs (safe fallback)
+ }
+ }
+
+ // Project packs (detect from CWD)
+ if let projectRoot = ProjectDetector.findProjectRoot() {
+ let statePath = projectRoot
+ .appendingPathComponent(Constants.FileNames.claudeDirectory)
+ .appendingPathComponent(Constants.FileNames.mcsProject)
+ if fm.fileExists(atPath: statePath.path) {
+ do {
+ let projectState = try ProjectState(projectRoot: projectRoot)
+ ids.formUnion(projectState.configuredPacks)
+ } catch {
+ // Corrupt state — fall through to check all packs (safe fallback)
+ }
+ }
+ }
+
+ return ids
+ }
+
+ /// Filter registry entries to only those relevant in the current context.
+ static func filterEntries(
+ _ entries: [PackRegistryFile.PackEntry],
+ environment: Environment
+ ) -> [PackRegistryFile.PackEntry] {
+ let ids = relevantPackIDs(environment: environment)
+ guard !ids.isEmpty else { return entries } // No state files → check all (first run)
+ return entries.filter { ids.contains($0.identifier) }
+ }
+
+ // MARK: - Parsing Helpers
+
+ /// Extract the SHA from the first line of `git ls-remote` output.
+ /// Format: `\t[\n`
+ static func parseRemoteSHA(from output: String) -> String? {
+ let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ let firstLine = trimmed.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? trimmed
+ let sha = firstLine.split(separator: "\t", maxSplits: 1).first.map(String.init)
+ guard let sha, !sha.isEmpty else { return nil }
+ return sha
+ }
+
+ /// Find the latest CalVer tag from `git ls-remote --tags --refs` output.
+ /// Each line: `\trefs/tags/\n`
+ static func parseLatestTag(from output: String) -> String? {
+ let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ var bestTag: String?
+
+ for line in trimmed.split(separator: "\n") {
+ let parts = line.split(separator: "\t", maxSplits: 1)
+ guard parts.count == 2 else { continue }
+ let refPath = String(parts[1])
+
+ let prefix = "refs/tags/"
+ guard refPath.hasPrefix(prefix) else { continue }
+ let tag = String(refPath.dropFirst(prefix.count))
+
+ guard VersionCompare.parse(tag) != nil else { continue }
+
+ if let current = bestTag {
+ if VersionCompare.isNewer(candidate: tag, than: current) {
+ bestTag = tag
+ }
+ } else {
+ bestTag = tag
+ }
+ }
+
+ return bestTag
+ }
+}
diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift
index 33f8994..c46cf89 100644
--- a/Sources/mcs/Doctor/DoctorRunner.swift
+++ b/Sources/mcs/Doctor/DoctorRunner.swift
@@ -219,19 +219,23 @@ struct DoctorRunner {
runChecks(checks)
}
- // Phase 2: Confirm and execute pending fixes
- if fixMode {
- executePendingFixes()
- }
-
- // Summary
+ // Summary (before fixes, so the user sees the full picture first)
output.header("Summary")
output.doctorSummary(
passed: passCount,
- fixed: fixedCount,
+ fixed: 0,
warnings: warnCount,
issues: failCount
)
+
+ // Phase 2: Confirm and execute pending fixes (after summary)
+ if fixMode {
+ executePendingFixes()
+ if fixedCount > 0 {
+ output.plain("")
+ output.success("Applied \(fixedCount) fix\(fixedCount == 1 ? "" : "es").")
+ }
+ }
}
// MARK: - Scope resolution
@@ -542,26 +546,29 @@ struct DoctorRunner {
for check in unfixable {
let result = check.fix()
if case let .notFixable(msg) = result {
- output.warn(" ↳ \(check.name): \(msg)")
+ output.warn(" ↳ \(check.name): \(msg)")
}
}
guard !fixable.isEmpty else { return }
+ output.plain("")
output.sectionHeader("Available fixes")
for check in fixable {
- output.plain(" • \(check.name): \(check.fixCommandPreview!)")
+ output.plain(" • \(check.name): \(check.fixCommandPreview!)")
}
+ output.plain("")
let fixLabel = fixable.count == 1 ? "fix" : "fixes"
if !skipConfirmation {
guard output.askYesNo("Apply \(fixable.count) \(fixLabel)?", default: false) else {
- output.dimmed("Skipped all fixes.")
+ output.dimmed(" Skipped all fixes.")
return
}
}
+ output.plain("")
for check in fixable {
switch check.fix() {
case let .fixed(msg):
@@ -569,7 +576,7 @@ struct DoctorRunner {
case let .failed(msg):
docFixFailed(check.name, msg)
case let .notFixable(msg):
- output.warn(" ↳ \(check.name): \(msg)")
+ output.warn(" ↳ \(check.name): \(msg)")
}
}
}
@@ -597,11 +604,10 @@ struct DoctorRunner {
private mutating func docFixed(_ name: String, _ msg: String) {
fixedCount += 1
- failCount -= 1 // Convert fail to fixed
- output.success(" ✓ \(name): \(msg)")
+ output.success(" ✓ \(name): \(msg)")
}
private mutating func docFixFailed(_ name: String, _ msg: String) {
- output.error(" ✗ \(name): \(msg)")
+ output.error(" ✗ \(name): \(msg)")
}
}
diff --git a/Sources/mcs/Export/ConfigurationDiscovery.swift b/Sources/mcs/Export/ConfigurationDiscovery.swift
index db8dc2e..4329957 100644
--- a/Sources/mcs/Export/ConfigurationDiscovery.swift
+++ b/Sources/mcs/Export/ConfigurationDiscovery.swift
@@ -237,13 +237,16 @@ struct ConfigurationDiscovery {
for (event, groups) in hooks {
for group in groups {
for entry in group.hooks ?? [] {
- if let command = entry.command {
+ guard let command = entry.command else { continue }
+ if let hookEvent = Constants.HookEvent(rawValue: event) {
commandToReg[command] = HookRegistration(
- event: event,
+ event: hookEvent,
timeout: entry.timeout,
isAsync: entry.isAsync,
statusMessage: entry.statusMessage
)
+ } else {
+ output.warn("Skipping hook with unknown event '\(event)' — mcs may need to be updated")
}
}
}
diff --git a/Sources/mcs/Export/ManifestBuilder.swift b/Sources/mcs/Export/ManifestBuilder.swift
index 675655b..654997e 100644
--- a/Sources/mcs/Export/ManifestBuilder.swift
+++ b/Sources/mcs/Export/ManifestBuilder.swift
@@ -181,7 +181,7 @@ struct ManifestBuilder {
CopyFileSpec(
files: config.hookFiles, selected: options.selectedHookFiles,
idPrefix: "hook", componentType: .hookFile, fileType: .hook,
- descriptionFor: { "Hook script for \($0.hookRegistration?.event ?? "unknown event")" }
+ descriptionFor: { "Hook script for \($0.hookRegistration?.event.rawValue ?? "unknown event")" }
),
CopyFileSpec(
files: config.skillFiles, selected: options.selectedSkillFiles,
@@ -459,7 +459,7 @@ struct ManifestBuilder {
// hookRegistration fields
if let reg = comp.hookRegistration {
- yaml.line(" hookEvent: \(yamlQuote(reg.event))")
+ yaml.line(" hookEvent: \(yamlQuote(reg.event.rawValue))")
if let timeout = reg.timeout {
yaml.line(" hookTimeout: \(timeout)")
}
diff --git a/Sources/mcs/ExternalPack/ExternalPackLoader.swift b/Sources/mcs/ExternalPack/ExternalPackLoader.swift
index 6c1d6cc..e67f557 100644
--- a/Sources/mcs/ExternalPack/ExternalPackLoader.swift
+++ b/Sources/mcs/ExternalPack/ExternalPackLoader.swift
@@ -265,6 +265,14 @@ enum VersionCompare {
return currentParts.patch >= requiredParts.patch
}
+ /// Check if `candidate` is strictly newer than `current`.
+ static func isNewer(candidate: String, than current: String) -> Bool {
+ guard let a = parse(candidate), let b = parse(current) else { return false }
+ if a.major != b.major { return a.major > b.major }
+ if a.minor != b.minor { return a.minor > b.minor }
+ return a.patch > b.patch
+ }
+
/// Parse a version string into (major, minor, patch) components.
/// Strips pre-release suffixes (e.g., "2.1.0-alpha" → 2.1.0).
/// Returns nil if the string does not contain at least three numeric components.
diff --git a/Sources/mcs/ExternalPack/ExternalPackManifest.swift b/Sources/mcs/ExternalPack/ExternalPackManifest.swift
index 9e63d40..8d3b3b0 100644
--- a/Sources/mcs/ExternalPack/ExternalPackManifest.swift
+++ b/Sources/mcs/ExternalPack/ExternalPackManifest.swift
@@ -66,14 +66,8 @@ extension ExternalPackManifest {
}
seenComponentIDs.insert(component.id)
- // Validate hook registration
+ // Validate hook registration metadata
if let reg = component.hookRegistration {
- guard Constants.Hooks.validEvents.contains(reg.event) else {
- throw ManifestError.invalidHookEvent(
- componentID: component.id,
- hookEvent: reg.event
- )
- }
if let timeout = reg.timeout, timeout <= 0 {
throw ManifestError.invalidHookMetadata(
componentID: component.id,
@@ -169,7 +163,7 @@ extension ExternalPackManifest {
guard let event = check.event, !event.isEmpty else {
throw ManifestError.invalidDoctorCheck(name: check.name, reason: "hookEventExists requires non-empty 'event'")
}
- guard Constants.Hooks.validEvents.contains(event) else {
+ guard Constants.HookEvent.validRawValues.contains(event) else {
throw ManifestError.invalidDoctorCheck(name: check.name, reason: "hookEventExists has unknown event '\(event)'")
}
case .settingsKeyEquals:
@@ -247,7 +241,6 @@ enum ManifestError: Error, Equatable, LocalizedError {
case dotInRawID(String)
case templateDependencyMismatch(sectionIdentifier: String, componentID: String)
case unresolvedDependency(componentID: String, dependency: String)
- case invalidHookEvent(componentID: String, hookEvent: String)
case invalidHookMetadata(componentID: String, reason: String)
var errorDescription: String? {
@@ -274,8 +267,6 @@ enum ManifestError: Error, Equatable, LocalizedError {
"ID '\(id)' must not contain dots — use a short name and the pack prefix will be added automatically"
case let .unresolvedDependency(componentID, dependency):
"Component '\(componentID)' depends on '\(dependency)' which does not exist in the pack"
- case let .invalidHookEvent(componentID, hookEvent):
- "Component '\(componentID)' has unknown hookEvent '\(hookEvent)'"
case let .invalidHookMetadata(componentID, reason):
"Component '\(componentID)': \(reason)"
}
@@ -380,11 +371,19 @@ 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)
- let hookEvent = try container.decodeIfPresent(String.self, forKey: .hookEvent)
+ let hookEventRaw = 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 {
+ if let hookEventRaw {
+ guard let hookEvent = Constants.HookEvent(rawValue: hookEventRaw) else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: container.codingPath,
+ debugDescription: "Component '\(id)': unknown hookEvent '\(hookEventRaw)'"
+ )
+ )
+ }
hookRegistration = HookRegistration(
event: hookEvent, timeout: hookTimeout, isAsync: hookAsync, statusMessage: hookStatusMessage
)
@@ -423,7 +422,7 @@ struct ExternalComponentDefinition: Codable {
try container.encode(type, forKey: .type)
try container.encodeIfPresent(dependencies, forKey: .dependencies)
try container.encodeIfPresent(isRequired, forKey: .isRequired)
- try container.encodeIfPresent(hookRegistration?.event, forKey: .hookEvent)
+ try container.encodeIfPresent(hookRegistration?.event.rawValue, forKey: .hookEvent)
try container.encodeIfPresent(hookRegistration?.timeout, forKey: .hookTimeout)
try container.encodeIfPresent(hookRegistration?.isAsync, forKey: .hookAsync)
try container.encodeIfPresent(hookRegistration?.statusMessage, forKey: .hookStatusMessage)
diff --git a/Sources/mcs/Install/GlobalSyncStrategy.swift b/Sources/mcs/Install/GlobalSyncStrategy.swift
index 94d6a8f..56f3e0a 100644
--- a/Sources/mcs/Install/GlobalSyncStrategy.swift
+++ b/Sources/mcs/Install/GlobalSyncStrategy.swift
@@ -168,17 +168,24 @@ struct GlobalSyncStrategy: SyncStrategy {
)
}
- // Strip mcs-managed hook entries before re-composing
+ // Strip mcs-managed hook entries before re-composing.
+ // Track whether stripping removed anything so we persist the removal.
+ let groupCountBefore = settings.hooks?.values.reduce(0) { $0 + $1.count } ?? 0
if var hooks = settings.hooks {
for (event, groups) in hooks {
hooks[event] = groups.filter { group in
guard let cmd = group.hooks?.first?.command else { return true }
- return !cmd.hasPrefix(scope.hookCommandPrefix)
+ // Strip pack-managed hooks and the first-party update check hook
+ if cmd.hasPrefix(scope.hookCommandPrefix) { return false }
+ if cmd == UpdateChecker.hookCommand { return false }
+ return true
}
}
hooks = hooks.filter { !$0.value.isEmpty }
settings.hooks = hooks.isEmpty ? nil : hooks
}
+ let groupCountAfter = settings.hooks?.values.reduce(0) { $0 + $1.count } ?? 0
+ let strippedContent = groupCountAfter < groupCountBefore
// Strip previously-tracked settings keys (enabledPlugins + settingsMerge extraJSON)
// before re-composing, so removed components don't leave stale entries.
@@ -190,7 +197,7 @@ struct GlobalSyncStrategy: SyncStrategy {
// Collect top-level keys to pass as dropKeys, preventing Layer 3 re-injection
let dropKeys = Set(allPreviousKeys.filter { !$0.contains(".") })
- let (hasContent, contributedKeys) = ConfiguratorSupport.mergePackComponentsIntoSettings(
+ var (hasContent, contributedKeys) = ConfiguratorSupport.mergePackComponentsIntoSettings(
packs: packs,
excludedComponents: excludedComponents,
settings: &settings,
@@ -199,10 +206,18 @@ struct GlobalSyncStrategy: SyncStrategy {
output: output
)
- if hasContent {
+ // Re-inject first-party update check hook if enabled
+ let config = MCSConfig.load(from: environment.mcsConfigFile, output: output)
+ if config.isUpdateCheckEnabled {
+ if UpdateChecker.addHook(to: &settings) { hasContent = true }
+ }
+
+ if hasContent || strippedContent {
do {
try settings.save(to: scope.settingsPath, dropKeys: dropKeys)
- output.success("Composed settings.json (global)")
+ if hasContent {
+ output.success("Composed settings.json (global)")
+ }
} catch {
output.error("Could not write settings.json: \(error.localizedDescription)")
output.error("Hooks and plugins will not be active. Re-run '\(scope.syncHint)' after fixing the issue.")
diff --git a/Sources/mcs/Install/ProjectSyncStrategy.swift b/Sources/mcs/Install/ProjectSyncStrategy.swift
index 5d8c45c..2a85b0f 100644
--- a/Sources/mcs/Install/ProjectSyncStrategy.swift
+++ b/Sources/mcs/Install/ProjectSyncStrategy.swift
@@ -134,7 +134,7 @@ struct ProjectSyncStrategy: SyncStrategy {
let allPreviousKeys = previousSettingsKeys.values.flatMap(\.self)
let dropKeys = Set(allPreviousKeys.filter { !$0.contains(".") })
- let (hasContent, contributedKeys) = ConfiguratorSupport.mergePackComponentsIntoSettings(
+ var (hasContent, contributedKeys) = ConfiguratorSupport.mergePackComponentsIntoSettings(
packs: packs,
excludedComponents: excludedComponents,
settings: &settings,
@@ -143,6 +143,12 @@ struct ProjectSyncStrategy: SyncStrategy {
output: output
)
+ // Inject first-party update check hook if enabled
+ let config = MCSConfig.load(from: environment.mcsConfigFile, output: output)
+ if config.isUpdateCheckEnabled {
+ if UpdateChecker.addHook(to: &settings) { hasContent = true }
+ }
+
if hasContent {
do {
try settings.save(to: scope.settingsPath, dropKeys: dropKeys)
diff --git a/Sources/mcs/TechPack/Component.swift b/Sources/mcs/TechPack/Component.swift
index d5d8491..741305c 100644
--- a/Sources/mcs/TechPack/Component.swift
+++ b/Sources/mcs/TechPack/Component.swift
@@ -29,12 +29,12 @@ extension ComponentType {
/// When a component has a `HookRegistration`, the engine auto-registers the hook
/// in `settings.local.json` with the specified handler fields.
struct HookRegistration: Equatable {
- let event: String
+ let event: Constants.HookEvent
let timeout: Int?
let isAsync: Bool?
let statusMessage: String?
- init(event: String, timeout: Int? = nil, isAsync: Bool? = nil, statusMessage: String? = nil) {
+ init(event: Constants.HookEvent, timeout: Int? = nil, isAsync: Bool? = nil, statusMessage: String? = nil) {
self.event = event
self.timeout = timeout
self.isAsync = isAsync
diff --git a/Tests/MCSTests/ExternalPackManifestTests.swift b/Tests/MCSTests/ExternalPackManifestTests.swift
index ed592a8..8cb4eda 100644
--- a/Tests/MCSTests/ExternalPackManifestTests.swift
+++ b/Tests/MCSTests/ExternalPackManifestTests.swift
@@ -1898,7 +1898,7 @@ struct ExternalPackManifestTests {
// MARK: - hookEvent validation
- @Test("Validation rejects unknown hookEvent on component")
+ @Test("Decode rejects unknown hookEvent on component")
func rejectUnknownHookEvent() throws {
let yaml = """
schemaVersion: 1
@@ -1921,20 +1921,14 @@ struct ExternalPackManifestTests {
let file = tmpDir.appendingPathComponent("techpack.yaml")
try yaml.write(to: file, atomically: true, encoding: .utf8)
- let raw = try ExternalPackManifest.load(from: file)
- let manifest = try raw.normalized()
-
- #expect(throws: ManifestError.invalidHookEvent(
- componentID: "my-pack.hook",
- hookEvent: "BogusEvent"
- )) {
- try manifest.validate()
+ #expect(throws: (any Error).self) {
+ try ExternalPackManifest.load(from: file)
}
}
- @Test("Validation accepts all known hookEvent values")
+ @Test("Decode accepts all known hookEvent values")
func acceptKnownHookEvents() throws {
- for event in Constants.Hooks.validEvents.sorted() {
+ for event in Constants.HookEvent.allCases.map(\.rawValue) {
let yaml = """
schemaVersion: 1
identifier: my-pack
@@ -2306,7 +2300,7 @@ struct ExternalPackManifestTests {
let comp = try #require(manifest.components?.first)
#expect(comp.type == .hookFile)
- #expect(comp.hookRegistration?.event == "SessionStart")
+ #expect(comp.hookRegistration?.event == .sessionStart)
guard case let .copyPackFile(config) = comp.installAction else {
Issue.record("Expected copyPackFile"); return
}
@@ -2344,7 +2338,7 @@ struct ExternalPackManifestTests {
let manifest = try ExternalPackManifest.load(from: file)
let comp = try #require(manifest.components?.first)
- #expect(comp.hookRegistration?.event == "PostToolUse")
+ #expect(comp.hookRegistration?.event == .postToolUse)
#expect(comp.hookRegistration?.timeout == 30)
#expect(comp.hookRegistration?.isAsync == true)
#expect(comp.hookRegistration?.statusMessage == "Running lint...")
@@ -2813,7 +2807,7 @@ struct ExternalPackManifestTests {
#expect(comp.type == .hookFile)
#expect(comp.dependencies == ["my-pack.jq"])
#expect(comp.isRequired == true)
- #expect(comp.hookRegistration?.event == "SessionStart")
+ #expect(comp.hookRegistration?.event == .sessionStart)
#expect(comp.doctorChecks?.count == 1)
}
}
diff --git a/Tests/MCSTests/GlobalConfiguratorTests.swift b/Tests/MCSTests/GlobalConfiguratorTests.swift
index 5badbc3..408a106 100644
--- a/Tests/MCSTests/GlobalConfiguratorTests.swift
+++ b/Tests/MCSTests/GlobalConfiguratorTests.swift
@@ -205,7 +205,7 @@ struct GlobalSettingsCompositionTests {
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
- hookRegistration: HookRegistration(event: "SessionStart"),
+ hookRegistration: HookRegistration(event: .sessionStart),
installAction: .copyPackFile(
source: hookSource,
destination: "start.sh",
@@ -251,7 +251,7 @@ struct GlobalSettingsCompositionTests {
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
- hookRegistration: HookRegistration(event: "SessionStart"),
+ hookRegistration: HookRegistration(event: .sessionStart),
installAction: .copyPackFile(
source: hookSource,
destination: "start.sh",
@@ -292,7 +292,7 @@ struct GlobalSettingsCompositionTests {
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
- hookRegistration: HookRegistration(event: "SessionStart"),
+ hookRegistration: HookRegistration(event: .sessionStart),
installAction: .copyPackFile(
source: settingsPath, // dummy, won't be reached
destination: "hook.sh",
@@ -351,7 +351,7 @@ struct GlobalSettingsCompositionTests {
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
- hookRegistration: HookRegistration(event: "SessionStart"),
+ hookRegistration: HookRegistration(event: .sessionStart),
installAction: .copyPackFile(
source: hookSource,
destination: "start.sh",
@@ -812,7 +812,7 @@ struct GlobalUnconfigurePackTests {
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
- hookRegistration: HookRegistration(event: "SessionStart"),
+ hookRegistration: HookRegistration(event: .sessionStart),
installAction: .copyPackFile(
source: hookSource,
destination: "hook.sh",
@@ -1146,3 +1146,84 @@ struct GlobalStateAndIndexTests {
#expect(globalEntry != nil)
}
}
+
+// MARK: - Global Hook Injection Tests
+
+struct GlobalHookInjectionTests {
+ @Test("Global sync injects update check hook when config enabled")
+ func globalSyncInjectsHook() throws {
+ let tmpDir = try makeGlobalTmpDir(label: "hook-inject")
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ try config.save(to: env.mcsConfigFile)
+
+ // Create empty settings.json so the strategy can load it
+ try "{}".write(to: env.claudeSettings, atomically: true, encoding: .utf8)
+
+ let configurator = makeGlobalConfigurator(home: tmpDir)
+ let pack = MockTechPack(identifier: "test-pack", displayName: "Test", components: [])
+ try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])
+
+ let settings = try Settings.load(from: env.claudeSettings)
+ let groups = settings.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? []
+ let commands = groups.flatMap { $0.hooks ?? [] }.compactMap(\.command)
+ #expect(commands.contains(UpdateChecker.hookCommand))
+ }
+
+ @Test("Global sync strips old hook and re-injects current version")
+ func globalSyncConvergesHook() throws {
+ let tmpDir = try makeGlobalTmpDir(label: "hook-converge")
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ try config.save(to: env.mcsConfigFile)
+
+ // Pre-populate settings with the hook
+ var initial = Settings()
+ UpdateChecker.addHook(to: &initial)
+ try initial.save(to: env.claudeSettings)
+
+ // Sync should strip and re-inject (idempotent)
+ let configurator = makeGlobalConfigurator(home: tmpDir)
+ let pack = MockTechPack(identifier: "test-pack", displayName: "Test", components: [])
+ try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])
+
+ let settings = try Settings.load(from: env.claudeSettings)
+ let groups = settings.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? []
+ let commands = groups.flatMap { $0.hooks ?? [] }.compactMap(\.command)
+ #expect(commands.contains(UpdateChecker.hookCommand))
+ // Verify no duplicates
+ #expect(commands.count(where: { $0 == UpdateChecker.hookCommand }) == 1)
+ }
+
+ @Test("Global sync removes hook when config disabled")
+ func globalSyncRemovesHookWhenDisabled() throws {
+ let tmpDir = try makeGlobalTmpDir(label: "hook-remove")
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ var config = MCSConfig()
+ config.updateCheckPacks = false
+ config.updateCheckCLI = false
+ try config.save(to: env.mcsConfigFile)
+
+ // Pre-populate settings with the hook
+ var initial = Settings()
+ UpdateChecker.addHook(to: &initial)
+ try initial.save(to: env.claudeSettings)
+
+ let configurator = makeGlobalConfigurator(home: tmpDir)
+ let pack = MockTechPack(identifier: "test-pack", displayName: "Test", components: [])
+ try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])
+
+ let settings = try Settings.load(from: env.claudeSettings)
+ let groups = settings.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? []
+ let commands = groups.flatMap { $0.hooks ?? [] }.compactMap(\.command)
+ #expect(!commands.contains(UpdateChecker.hookCommand))
+ }
+}
diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift
index b1d0e80..d919ee2 100644
--- a/Tests/MCSTests/LifecycleIntegrationTests.swift
+++ b/Tests/MCSTests/LifecycleIntegrationTests.swift
@@ -212,7 +212,7 @@ struct SinglePackLifecycleTests {
identifier: "test-pack",
displayName: "Test Pack",
components: [
- bed.hookComponent(pack: "test-pack", id: "lint-hook", source: hookSource, destination: "lint.sh", hookRegistration: HookRegistration(event: "PostToolUse")),
+ bed.hookComponent(pack: "test-pack", id: "lint-hook", source: hookSource, destination: "lint.sh", hookRegistration: HookRegistration(event: .postToolUse)),
bed.mcpComponent(pack: "test-pack", id: "mcp-server", name: "test-mcp", args: ["-y", "test-server"], env: ["API_KEY": "test-key"]),
bed.settingsComponent(pack: "test-pack", id: "settings", source: settingsSource),
],
@@ -768,7 +768,7 @@ struct HookMetadataLifecycleTests {
pack: "meta-pack", id: "lint",
source: hookSource, destination: "lint.sh",
hookRegistration: HookRegistration(
- event: "PostToolUse", timeout: 30,
+ event: .postToolUse, timeout: 30,
isAsync: true, statusMessage: "Running lint..."
)
),
@@ -812,7 +812,7 @@ struct HookMetadataLifecycleTests {
bed.hookComponent(
pack: "plain-pack", id: "guard",
source: hookSource, destination: "guard.sh",
- hookRegistration: HookRegistration(event: "PreToolUse")
+ hookRegistration: HookRegistration(event: .preToolUse)
),
]
)
@@ -828,4 +828,93 @@ struct HookMetadataLifecycleTests {
#expect(!rawJSON.contains("\"async\""))
#expect(!rawJSON.contains("\"statusMessage\""))
}
+
+ // MARK: - Update check hook injection
+
+ @Test("Project sync injects update check hook when config enabled")
+ func projectSyncInjectsUpdateHook() throws {
+ let bed = try LifecycleTestBed()
+ defer { bed.cleanup() }
+
+ // Enable update checks in config
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ try config.save(to: bed.env.mcsConfigFile)
+
+ // Sync with a minimal pack
+ let pack = MockTechPack(identifier: "test-pack", displayName: "Test Pack", components: [])
+ let registry = TechPackRegistry(packs: [pack])
+ let configurator = bed.makeConfigurator(registry: registry)
+ try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])
+
+ // Verify the hook is in settings.local.json
+ let settings = try Settings.load(from: bed.settingsLocalPath)
+ let sessionStartGroups = settings.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? []
+ let commands = sessionStartGroups.flatMap { $0.hooks ?? [] }.compactMap(\.command)
+ #expect(commands.contains(UpdateChecker.hookCommand))
+ }
+
+ @Test("Project sync omits update check hook when config disabled")
+ func projectSyncOmitsHookWhenDisabled() throws {
+ let bed = try LifecycleTestBed()
+ defer { bed.cleanup() }
+
+ // Disable update checks in config
+ var config = MCSConfig()
+ config.updateCheckPacks = false
+ config.updateCheckCLI = false
+ try config.save(to: bed.env.mcsConfigFile)
+
+ let pack = MockTechPack(identifier: "test-pack", displayName: "Test Pack", components: [])
+ let registry = TechPackRegistry(packs: [pack])
+ let configurator = bed.makeConfigurator(registry: registry)
+ try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])
+
+ // Verify no update check hook in settings
+ let fm = FileManager.default
+ if fm.fileExists(atPath: bed.settingsLocalPath.path) {
+ let settings = try Settings.load(from: bed.settingsLocalPath)
+ let sessionStartGroups = settings.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? []
+ let commands = sessionStartGroups.flatMap { $0.hooks ?? [] }.compactMap(\.command)
+ #expect(!commands.contains(UpdateChecker.hookCommand))
+ }
+ }
+
+ @Test("Project sync converges hook on re-sync: enable then disable")
+ func projectSyncConvergesHook() throws {
+ let bed = try LifecycleTestBed()
+ defer { bed.cleanup() }
+
+ let pack = MockTechPack(identifier: "test-pack", displayName: "Test Pack", components: [])
+ let registry = TechPackRegistry(packs: [pack])
+
+ // First sync: enabled
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ try config.save(to: bed.env.mcsConfigFile)
+
+ var configurator = bed.makeConfigurator(registry: registry)
+ try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])
+
+ let settings1 = try Settings.load(from: bed.settingsLocalPath)
+ let commands1 = (settings1.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? [])
+ .flatMap { $0.hooks ?? [] }.compactMap(\.command)
+ #expect(commands1.contains(UpdateChecker.hookCommand))
+
+ // Second sync: disabled
+ config.updateCheckPacks = false
+ config.updateCheckCLI = false
+ try config.save(to: bed.env.mcsConfigFile)
+
+ configurator = bed.makeConfigurator(registry: registry)
+ try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])
+
+ // Project strategy rebuilds from scratch — hook should be absent
+ if FileManager.default.fileExists(atPath: bed.settingsLocalPath.path) {
+ let settings2 = try Settings.load(from: bed.settingsLocalPath)
+ let commands2 = (settings2.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? [])
+ .flatMap { $0.hooks ?? [] }.compactMap(\.command)
+ #expect(!commands2.contains(UpdateChecker.hookCommand))
+ }
+ }
}
diff --git a/Tests/MCSTests/MCSConfigTests.swift b/Tests/MCSTests/MCSConfigTests.swift
new file mode 100644
index 0000000..aa22f6c
--- /dev/null
+++ b/Tests/MCSTests/MCSConfigTests.swift
@@ -0,0 +1,180 @@
+import Foundation
+@testable import mcs
+import Testing
+
+struct MCSConfigTests {
+ private func makeTmpDir() throws -> URL {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("mcs-config-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ // MARK: - Load
+
+ @Test("Load returns empty config when file does not exist")
+ func loadMissingFile() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let path = tmpDir.appendingPathComponent("config.yaml")
+ let config = MCSConfig.load(from: path)
+ #expect(config.updateCheckPacks == nil)
+ #expect(config.updateCheckCLI == nil)
+ }
+
+ @Test("Load parses valid YAML")
+ func loadValidYAML() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let path = tmpDir.appendingPathComponent("config.yaml")
+ let yaml = """
+ update-check-packs: true
+ update-check-cli: false
+ """
+ try yaml.write(to: path, atomically: true, encoding: .utf8)
+
+ let config = MCSConfig.load(from: path)
+ #expect(config.updateCheckPacks == true)
+ #expect(config.updateCheckCLI == false)
+ }
+
+ @Test("Load returns empty config for corrupt YAML")
+ func loadCorruptYAML() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let path = tmpDir.appendingPathComponent("config.yaml")
+ try ":::invalid:::yaml:::".write(to: path, atomically: true, encoding: .utf8)
+
+ let config = MCSConfig.load(from: path)
+ #expect(config.updateCheckPacks == nil)
+ #expect(config.updateCheckCLI == nil)
+ }
+
+ @Test("Load returns empty config for empty file")
+ func loadEmptyFile() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let path = tmpDir.appendingPathComponent("config.yaml")
+ try "".write(to: path, atomically: true, encoding: .utf8)
+
+ let config = MCSConfig.load(from: path)
+ #expect(config.updateCheckPacks == nil)
+ }
+
+ // MARK: - Save + Roundtrip
+
+ @Test("Save and reload produces the same values")
+ func saveRoundtrip() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let path = tmpDir.appendingPathComponent("config.yaml")
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ config.updateCheckCLI = false
+ try config.save(to: path)
+
+ let reloaded = MCSConfig.load(from: path)
+ #expect(reloaded.updateCheckPacks == true)
+ #expect(reloaded.updateCheckCLI == false)
+ }
+
+ @Test("Save creates parent directories")
+ func saveCreatesDirectories() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let path = tmpDir.appendingPathComponent("nested/dir/config.yaml")
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ try config.save(to: path)
+
+ let reloaded = MCSConfig.load(from: path)
+ #expect(reloaded.updateCheckPacks == true)
+ }
+
+ // MARK: - Computed Properties
+
+ @Test("isUpdateCheckEnabled returns false when both nil")
+ func isUpdateCheckEnabledBothNil() {
+ let config = MCSConfig()
+ #expect(!config.isUpdateCheckEnabled)
+ }
+
+ @Test("isUpdateCheckEnabled returns true when packs enabled")
+ func isUpdateCheckEnabledPacksOnly() {
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ #expect(config.isUpdateCheckEnabled)
+ }
+
+ @Test("isUpdateCheckEnabled returns true when CLI enabled")
+ func isUpdateCheckEnabledCLIOnly() {
+ var config = MCSConfig()
+ config.updateCheckCLI = true
+ #expect(config.isUpdateCheckEnabled)
+ }
+
+ @Test("isUpdateCheckEnabled returns false when both explicitly false")
+ func isUpdateCheckEnabledBothFalse() {
+ var config = MCSConfig()
+ config.updateCheckPacks = false
+ config.updateCheckCLI = false
+ #expect(!config.isUpdateCheckEnabled)
+ }
+
+ @Test("isUnconfigured returns true when both nil")
+ func isUnconfiguredBothNil() {
+ let config = MCSConfig()
+ #expect(config.isUnconfigured)
+ }
+
+ @Test("isUnconfigured returns false when one is set")
+ func isUnconfiguredOneSet() {
+ var config = MCSConfig()
+ config.updateCheckPacks = false
+ #expect(!config.isUnconfigured)
+ }
+
+ // MARK: - Key Access
+
+ @Test("value(forKey:) returns correct values")
+ func valueForKey() {
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ config.updateCheckCLI = false
+
+ #expect(config.value(forKey: "update-check-packs") == true)
+ #expect(config.value(forKey: "update-check-cli") == false)
+ #expect(config.value(forKey: "unknown-key") == nil)
+ }
+
+ @Test("setValue(_:forKey:) sets correct values")
+ func setValueForKey() {
+ var config = MCSConfig()
+
+ let packsSet = config.setValue(true, forKey: "update-check-packs")
+ #expect(packsSet)
+ #expect(config.updateCheckPacks == true)
+
+ let cliSet = config.setValue(false, forKey: "update-check-cli")
+ #expect(cliSet)
+ #expect(config.updateCheckCLI == false)
+
+ let unknownSet = config.setValue(true, forKey: "unknown-key")
+ #expect(!unknownSet)
+ }
+
+ // MARK: - Known Keys
+
+ @Test("knownKeys covers all CodingKeys")
+ func knownKeysComplete() {
+ let codingKeyValues = Set(MCSConfig.CodingKeys.allCases.map(\.rawValue))
+ let knownKeyValues = Set(MCSConfig.knownKeys.map(\.key))
+ #expect(codingKeyValues == knownKeyValues)
+ }
+}
diff --git a/Tests/MCSTests/ManifestBuilderTests.swift b/Tests/MCSTests/ManifestBuilderTests.swift
index a273ad0..650769c 100644
--- a/Tests/MCSTests/ManifestBuilderTests.swift
+++ b/Tests/MCSTests/ManifestBuilderTests.swift
@@ -48,7 +48,7 @@ struct ManifestBuilderTests {
ConfigurationDiscovery.DiscoveredFile(
filename: "pre_tool_use.sh",
absolutePath: hookURL,
- hookRegistration: HookRegistration(event: "PreToolUse")
+ hookRegistration: HookRegistration(event: .preToolUse)
),
]
@@ -162,7 +162,7 @@ struct ManifestBuilderTests {
// 5. Verify hook with hookEvent
let hookComp = try #require(components.first { $0.type == .hookFile })
- #expect(hookComp.hookRegistration?.event == "PreToolUse")
+ #expect(hookComp.hookRegistration?.event == .preToolUse)
guard case let .copyPackFile(hookFile) = hookComp.installAction else {
Issue.record("Expected copyPackFile for hook")
return
diff --git a/Tests/MCSTests/ProjectConfiguratorTests.swift b/Tests/MCSTests/ProjectConfiguratorTests.swift
index 2de3e8a..8dc1c6b 100644
--- a/Tests/MCSTests/ProjectConfiguratorTests.swift
+++ b/Tests/MCSTests/ProjectConfiguratorTests.swift
@@ -590,7 +590,7 @@ struct AutoDerivedSettingsTests {
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
- hookRegistration: HookRegistration(event: "SessionStart"),
+ hookRegistration: HookRegistration(event: .sessionStart),
installAction: .copyPackFile(
source: hookSource,
destination: "session_start.sh",
@@ -743,7 +743,7 @@ struct AutoDerivedSettingsTests {
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
- hookRegistration: HookRegistration(event: "SessionStart"),
+ hookRegistration: HookRegistration(event: .sessionStart),
installAction: .copyPackFile(
source: hookSource,
destination: "session_start.sh",
diff --git a/Tests/MCSTests/SettingsMergeTests.swift b/Tests/MCSTests/SettingsMergeTests.swift
index 9e14901..fbc9ea6 100644
--- a/Tests/MCSTests/SettingsMergeTests.swift
+++ b/Tests/MCSTests/SettingsMergeTests.swift
@@ -768,4 +768,54 @@ struct SettingsMergeTests {
#expect(entry.isAsync == true)
#expect(entry.statusMessage == "Initializing...")
}
+
+ // MARK: - removeHookEntry
+
+ @Test("removeHookEntry removes matching command and returns true")
+ func removeHookEntryRemovesMatch() {
+ var settings = Settings()
+ settings.addHookEntry(event: "SessionStart", command: "mcs check-updates --hook", timeout: 30)
+ settings.addHookEntry(event: "SessionStart", command: "bash .claude/hooks/startup.sh")
+
+ let removed = settings.removeHookEntry(event: "SessionStart", command: "mcs check-updates --hook")
+ #expect(removed)
+ #expect(settings.hooks?["SessionStart"]?.count == 1)
+ #expect(settings.hooks?["SessionStart"]?.first?.hooks?.first?.command == "bash .claude/hooks/startup.sh")
+ }
+
+ @Test("removeHookEntry returns false when event does not exist")
+ func removeHookEntryMissingEvent() {
+ var settings = Settings()
+ let removed = settings.removeHookEntry(event: "SessionStart", command: "mcs check-updates --hook")
+ #expect(!removed)
+ }
+
+ @Test("removeHookEntry returns false when command does not match")
+ func removeHookEntryNoMatch() {
+ var settings = Settings()
+ settings.addHookEntry(event: "SessionStart", command: "bash .claude/hooks/startup.sh")
+
+ let removed = settings.removeHookEntry(event: "SessionStart", command: "nonexistent")
+ #expect(!removed)
+ #expect(settings.hooks?["SessionStart"]?.count == 1)
+ }
+
+ @Test("removeHookEntry cleans up event key when last hook removed")
+ func removeHookEntryCleanupEvent() {
+ var settings = Settings()
+ settings.addHookEntry(event: "SessionStart", command: "mcs check-updates --hook")
+
+ let removed = settings.removeHookEntry(event: "SessionStart", command: "mcs check-updates --hook")
+ #expect(removed)
+ #expect(settings.hooks?["SessionStart"] == nil)
+ }
+
+ @Test("removeHookEntry nils hooks when last event removed")
+ func removeHookEntryNilsHooks() {
+ var settings = Settings()
+ settings.addHookEntry(event: "SessionStart", command: "mcs check-updates --hook")
+
+ settings.removeHookEntry(event: "SessionStart", command: "mcs check-updates --hook")
+ #expect(settings.hooks == nil)
+ }
}
diff --git a/Tests/MCSTests/UpdateCheckerTests.swift b/Tests/MCSTests/UpdateCheckerTests.swift
new file mode 100644
index 0000000..e345fec
--- /dev/null
+++ b/Tests/MCSTests/UpdateCheckerTests.swift
@@ -0,0 +1,443 @@
+import Foundation
+@testable import mcs
+import Testing
+
+// MARK: - Cache Tests
+
+struct UpdateCheckerCacheTests {
+ private func makeTmpDir() throws -> URL {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("mcs-updatechecker-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ private func makeChecker(home: URL) -> UpdateChecker {
+ let env = Environment(home: home)
+ let shell = ShellRunner(environment: env)
+ return UpdateChecker(environment: env, shell: shell)
+ }
+
+ private func writeCacheFile(at env: Environment, timestamp: Date, result: UpdateChecker.CheckResult) throws {
+ let cached = UpdateChecker.CachedResult(
+ timestamp: ISO8601DateFormatter().string(from: timestamp),
+ result: result
+ )
+ let dir = env.updateCheckCacheFile.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let data = try JSONEncoder().encode(cached)
+ try data.write(to: env.updateCheckCacheFile, options: .atomic)
+ }
+
+ private var emptyResult: UpdateChecker.CheckResult {
+ UpdateChecker.CheckResult(packUpdates: [], cliUpdate: nil)
+ }
+
+ @Test("loadCache returns nil when file does not exist")
+ func cacheNilWhenMissing() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let checker = makeChecker(home: tmpDir)
+ #expect(checker.loadCache() == nil)
+ }
+
+ @Test("loadCache returns cached result when file is valid")
+ func cacheLoadsValidFile() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ try writeCacheFile(at: env, timestamp: Date().addingTimeInterval(-3600), result: emptyResult)
+
+ let checker = makeChecker(home: tmpDir)
+ let cached = checker.loadCache()
+ #expect(cached != nil)
+ #expect(cached?.result.isEmpty == true)
+ }
+
+ @Test("loadCache returns nil when file content is corrupt")
+ func cacheNilWhenCorrupt() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ let mcsDir = tmpDir.appendingPathComponent(".mcs")
+ try FileManager.default.createDirectory(at: mcsDir, withIntermediateDirectories: true)
+ try "not-json".write(to: env.updateCheckCacheFile, atomically: true, encoding: .utf8)
+
+ let checker = makeChecker(home: tmpDir)
+ #expect(checker.loadCache() == nil)
+ }
+
+ @Test("saveCache writes a decodable cache file")
+ func saveCacheRoundtrip() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let checker = makeChecker(home: tmpDir)
+ let result = UpdateChecker.CheckResult(
+ packUpdates: [UpdateChecker.PackUpdate(
+ identifier: "test", displayName: "Test", localSHA: "aaa", remoteSHA: "bbb"
+ )],
+ cliUpdate: nil
+ )
+ checker.saveCache(result)
+
+ let cached = checker.loadCache()
+ #expect(cached != nil)
+ #expect(cached?.result.packUpdates.count == 1)
+ #expect(cached?.result.packUpdates.first?.identifier == "test")
+ }
+
+ @Test("loadCache returns nil when CLI version changed")
+ func cacheInvalidatedOnCLIUpgrade() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ let staleResult = UpdateChecker.CheckResult(
+ packUpdates: [],
+ cliUpdate: UpdateChecker.CLIUpdate(currentVersion: "1999.1.1", latestVersion: "2000.1.1")
+ )
+ try writeCacheFile(at: env, timestamp: Date(), result: staleResult)
+
+ let checker = makeChecker(home: tmpDir)
+ #expect(checker.loadCache() == nil)
+ }
+
+ @Test("invalidateCache deletes the cache file")
+ func invalidateCache() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let checker = makeChecker(home: tmpDir)
+ checker.saveCache(emptyResult)
+
+ let env = Environment(home: tmpDir)
+ #expect(FileManager.default.fileExists(atPath: env.updateCheckCacheFile.path))
+
+ UpdateChecker.invalidateCache(environment: env)
+ #expect(!FileManager.default.fileExists(atPath: env.updateCheckCacheFile.path))
+ }
+}
+
+// MARK: - performCheck Tests
+
+struct UpdateCheckerPerformCheckTests {
+ private func makeTmpDir() throws -> URL {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("mcs-performcheck-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ private func makeChecker(home: URL) -> UpdateChecker {
+ let env = Environment(home: home)
+ let shell = ShellRunner(environment: env)
+ return UpdateChecker(environment: env, shell: shell)
+ }
+
+ private func writeFreshCache(at env: Environment, result: UpdateChecker.CheckResult) throws {
+ let cached = UpdateChecker.CachedResult(
+ timestamp: ISO8601DateFormatter().string(from: Date()),
+ result: result
+ )
+ let dir = env.updateCheckCacheFile.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let data = try JSONEncoder().encode(cached)
+ try data.write(to: env.updateCheckCacheFile, options: .atomic)
+ }
+
+ @Test("Hook mode returns cached result when cache is fresh")
+ func hookModeReturnsCachedResult() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ let cachedResult = UpdateChecker.CheckResult(
+ packUpdates: [UpdateChecker.PackUpdate(
+ identifier: "cached-pack", displayName: "Cached", localSHA: "aaa", remoteSHA: "bbb"
+ )],
+ cliUpdate: nil
+ )
+ try writeFreshCache(at: env, result: cachedResult)
+
+ let checker = makeChecker(home: tmpDir)
+ let result = checker.performCheck(entries: [], isHook: true, checkPacks: true, checkCLI: false)
+
+ // Should return cached result, not do network calls (entries is empty so network would return nothing)
+ #expect(result.packUpdates.count == 1)
+ #expect(result.packUpdates.first?.identifier == "cached-pack")
+ }
+
+ @Test("User mode always does fresh check regardless of cache")
+ func userModeIgnoresCache() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ let cachedResult = UpdateChecker.CheckResult(
+ packUpdates: [UpdateChecker.PackUpdate(
+ identifier: "stale", displayName: "Stale", localSHA: "aaa", remoteSHA: "bbb"
+ )],
+ cliUpdate: nil
+ )
+ try writeFreshCache(at: env, result: cachedResult)
+
+ let checker = makeChecker(home: tmpDir)
+ // User mode (isHook: false, default) with no entries — should do fresh check, returning empty
+ let result = checker.performCheck(entries: [], checkPacks: true, checkCLI: false)
+
+ // Should NOT return cached "stale" pack — should do fresh check with empty entries
+ #expect(result.packUpdates.isEmpty)
+ }
+
+ @Test("performCheck saves cache after network check")
+ func savesCache() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ #expect(!FileManager.default.fileExists(atPath: env.updateCheckCacheFile.path))
+
+ let checker = makeChecker(home: tmpDir)
+ _ = checker.performCheck(entries: [], checkPacks: true, checkCLI: false)
+
+ #expect(FileManager.default.fileExists(atPath: env.updateCheckCacheFile.path))
+ }
+}
+
+// MARK: - Parsing Tests
+
+struct UpdateCheckerParsingTests {
+ @Test("parseRemoteSHA extracts SHA from valid ls-remote output")
+ func parseValidSHA() {
+ let output = "abc123def456789\tHEAD\n"
+ let sha = UpdateChecker.parseRemoteSHA(from: output)
+ #expect(sha == "abc123def456789")
+ }
+
+ @Test("parseRemoteSHA returns nil for empty output")
+ func parseEmptyOutput() {
+ #expect(UpdateChecker.parseRemoteSHA(from: "") == nil)
+ #expect(UpdateChecker.parseRemoteSHA(from: " ") == nil)
+ }
+
+ @Test("parseRemoteSHA handles multi-line output (takes first line)")
+ func parseMultiLine() {
+ let output = """
+ abc123\trefs/heads/main
+ def456\trefs/heads/develop
+ """
+ let sha = UpdateChecker.parseRemoteSHA(from: output)
+ #expect(sha == "abc123")
+ }
+
+ @Test("parseLatestTag finds the highest CalVer tag")
+ func parseLatestTagMultiple() {
+ let output = """
+ aaa\trefs/tags/2026.1.1
+ bbb\trefs/tags/2026.3.22
+ ccc\trefs/tags/2026.2.15
+ ddd\trefs/tags/2025.12.1
+ """
+ let latest = UpdateChecker.parseLatestTag(from: output)
+ #expect(latest == "2026.3.22")
+ }
+
+ @Test("parseLatestTag returns nil for empty output")
+ func parseLatestTagEmpty() {
+ #expect(UpdateChecker.parseLatestTag(from: "") == nil)
+ }
+
+ @Test("parseLatestTag skips non-CalVer tags")
+ func parseLatestTagSkipsNonCalVer() {
+ let output = """
+ aaa\trefs/tags/v1.0
+ bbb\trefs/tags/2026.3.22
+ ccc\trefs/tags/beta
+ """
+ let latest = UpdateChecker.parseLatestTag(from: output)
+ #expect(latest == "2026.3.22")
+ }
+
+ @Test("parseLatestTag returns nil when no CalVer tags exist")
+ func parseLatestTagNoCalVer() {
+ let output = """
+ aaa\trefs/tags/v1.0
+ bbb\trefs/tags/release-candidate
+ """
+ #expect(UpdateChecker.parseLatestTag(from: output) == nil)
+ }
+}
+
+// MARK: - Version Comparison Tests
+
+struct UpdateCheckerVersionTests {
+ @Test("isNewer detects newer version")
+ func newerVersion() {
+ #expect(VersionCompare.isNewer(candidate: "2026.4.1", than: "2026.3.22"))
+ #expect(VersionCompare.isNewer(candidate: "2027.1.1", than: "2026.12.31"))
+ #expect(VersionCompare.isNewer(candidate: "2026.3.23", than: "2026.3.22"))
+ }
+
+ @Test("isNewer returns false for same version")
+ func sameVersion() {
+ #expect(!VersionCompare.isNewer(candidate: "2026.3.22", than: "2026.3.22"))
+ }
+
+ @Test("isNewer returns false for older version")
+ func olderVersion() {
+ #expect(!VersionCompare.isNewer(candidate: "2026.3.21", than: "2026.3.22"))
+ #expect(!VersionCompare.isNewer(candidate: "2025.12.31", than: "2026.1.1"))
+ }
+
+ @Test("isNewer returns false for unparseable versions")
+ func unparseable() {
+ #expect(!VersionCompare.isNewer(candidate: "invalid", than: "2026.3.22"))
+ #expect(!VersionCompare.isNewer(candidate: "2026.3.22", than: "invalid"))
+ }
+}
+
+// MARK: - Hook Management Tests
+
+struct UpdateCheckerHookTests {
+ private func makeTmpDir() throws -> URL {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("mcs-hook-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ @Test("addHook creates SessionStart entry with correct command and metadata")
+ func addHookCreatesEntry() {
+ var settings = Settings()
+ let added = UpdateChecker.addHook(to: &settings)
+ #expect(added)
+
+ let groups = settings.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? []
+ #expect(groups.count == 1)
+ let entry = groups.first?.hooks?.first
+ #expect(entry?.command == "mcs check-updates --hook")
+ #expect(entry?.timeout == 30)
+ #expect(entry?.statusMessage == "Checking for updates...")
+ #expect(entry?.type == "command")
+ }
+
+ @Test("addHook is idempotent")
+ func addHookIdempotent() {
+ var settings = Settings()
+ UpdateChecker.addHook(to: &settings)
+ let secondAdd = UpdateChecker.addHook(to: &settings)
+ #expect(!secondAdd) // no-op, already present
+ #expect(settings.hooks?[Constants.HookEvent.sessionStart.rawValue]?.count == 1)
+ }
+
+ @Test("removeHook removes the update check entry")
+ func removeHookRemovesEntry() {
+ var settings = Settings()
+ UpdateChecker.addHook(to: &settings)
+ let removed = UpdateChecker.removeHook(from: &settings)
+ #expect(removed)
+ #expect(settings.hooks == nil)
+ }
+
+ @Test("removeHook preserves other SessionStart hooks")
+ func removeHookPreservesOthers() {
+ var settings = Settings()
+ settings.addHookEntry(event: "SessionStart", command: "bash .claude/hooks/startup.sh")
+ UpdateChecker.addHook(to: &settings)
+
+ UpdateChecker.removeHook(from: &settings)
+ let groups = settings.hooks?["SessionStart"] ?? []
+ #expect(groups.count == 1)
+ #expect(groups.first?.hooks?.first?.command == "bash .claude/hooks/startup.sh")
+ }
+
+ @Test("addHook + removeHook round-trip leaves settings clean")
+ func hookRoundTrip() {
+ var settings = Settings()
+ UpdateChecker.addHook(to: &settings)
+ UpdateChecker.removeHook(from: &settings)
+ #expect(settings.hooks == nil)
+ }
+
+ @Test("syncHook adds hook when config enabled")
+ func syncHookAdds() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ // Create empty settings.json
+ let claudeDir = tmpDir.appendingPathComponent(".claude")
+ try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true)
+ try "{}".write(to: env.claudeSettings, atomically: true, encoding: .utf8)
+
+ var config = MCSConfig()
+ config.updateCheckPacks = true
+ let output = CLIOutput()
+
+ UpdateChecker.syncHook(config: config, env: env, output: output)
+
+ let settings = try Settings.load(from: env.claudeSettings)
+ let groups = settings.hooks?[Constants.HookEvent.sessionStart.rawValue] ?? []
+ #expect(groups.count == 1)
+ #expect(groups.first?.hooks?.first?.command == UpdateChecker.hookCommand)
+ }
+
+ @Test("syncHook removes hook when config disabled")
+ func syncHookRemoves() throws {
+ let tmpDir = try makeTmpDir()
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
+
+ let env = Environment(home: tmpDir)
+ let claudeDir = tmpDir.appendingPathComponent(".claude")
+ try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true)
+
+ // Pre-populate with the hook
+ var initial = Settings()
+ UpdateChecker.addHook(to: &initial)
+ try initial.save(to: env.claudeSettings)
+
+ var config = MCSConfig()
+ config.updateCheckPacks = false
+ config.updateCheckCLI = false
+ let output = CLIOutput()
+
+ UpdateChecker.syncHook(config: config, env: env, output: output)
+
+ let settings = try Settings.load(from: env.claudeSettings)
+ #expect(settings.hooks == nil)
+ }
+}
+
+// MARK: - CheckResult Tests
+
+struct UpdateCheckerResultTests {
+ @Test("isEmpty returns true when no updates")
+ func emptyResult() {
+ let result = UpdateChecker.CheckResult(packUpdates: [], cliUpdate: nil)
+ #expect(result.isEmpty)
+ }
+
+ @Test("isEmpty returns false with pack updates")
+ func nonEmptyPackResult() {
+ let result = UpdateChecker.CheckResult(
+ packUpdates: [UpdateChecker.PackUpdate(
+ identifier: "test", displayName: "Test", localSHA: "aaa", remoteSHA: "bbb"
+ )],
+ cliUpdate: nil
+ )
+ #expect(!result.isEmpty)
+ }
+
+ @Test("isEmpty returns false with CLI update")
+ func nonEmptyCLIResult() {
+ let result = UpdateChecker.CheckResult(
+ packUpdates: [],
+ cliUpdate: UpdateChecker.CLIUpdate(currentVersion: "1.0.0", latestVersion: "2.0.0")
+ )
+ #expect(!result.isEmpty)
+ }
+}
diff --git a/docs/cli.md b/docs/cli.md
index a5f196f..ae23ed4 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -129,6 +129,57 @@ mcs export --dry-run # Preview what would be exported
The export wizard discovers MCP servers, hooks, skills, commands, agents, plugins, `CLAUDE.md` sections, gitignore entries (global only), and settings. Sensitive env vars are replaced with `__PLACEHOLDER__` tokens and corresponding `prompts:` entries are generated.
+## `mcs check-updates`
+
+Check for available tech pack and CLI updates. Designed to be lightweight and non-intrusive.
+
+```bash
+mcs check-updates # Check for updates (always runs)
+mcs check-updates --hook # Run as SessionStart hook (respects 7-day cooldown and config)
+mcs check-updates --json # Machine-readable JSON output
+```
+
+| Flag | Description |
+|------|-------------|
+| `--hook` | Run as a Claude Code SessionStart hook. Respects the 7-day cooldown and config keys. Without this flag, checks always run. |
+| `--json` | Output results as JSON instead of human-readable text. |
+
+**How it works:**
+- **Pack checks**: Runs `git ls-remote` per pack to compare the remote HEAD against the local commit SHA. Local packs are skipped.
+- **CLI version check**: Queries `git ls-remote --tags` on the mcs repository and compares the latest CalVer tag against the installed version.
+- **Cache**: Results are cached in `~/.mcs/update-check.json` (timestamp + results). In `--hook` mode, the hook serves cached results if the cache is less than 7 days old (no network request). When the cache is stale, a fresh network check runs and the results are cached for subsequent sessions. User-invoked checks always refresh the cache.
+- **Cache invalidation**: `mcs pack update` deletes the cache so the next hook re-checks. CLI version cache self-invalidates when the user upgrades mcs.
+- **Scope**: Checks global packs plus packs configured in the current project (detected via project root). Packs not relevant to the current context are skipped.
+- **Offline resilience**: Network failures are silently ignored — the command never errors on connectivity issues.
+
+**Note:** `mcs sync` and `mcs doctor` always check for updates regardless of config — they are user-initiated commands. The config keys below only control the **automatic** `SessionStart` hook that runs in the background when you start a Claude Code session.
+
+## `mcs config`
+
+Manage mcs user preferences stored at `~/.mcs/config.yaml`.
+
+```bash
+mcs config list # Show all settings with current values
+mcs config get # Get a specific value
+mcs config set # Set a value (true/false)
+```
+
+### Available Keys
+
+| Key | Description | Default |
+|-----|-------------|---------|
+| `update-check-packs` | Automatically check for tech pack updates on session start | `false` |
+| `update-check-cli` | Automatically check for new mcs versions on session start | `false` |
+
+These keys control a `SessionStart` hook in `~/.claude/settings.json` that runs `mcs check-updates` when you start a Claude Code session. The hook's output is injected into Claude's context so Claude can inform you about available updates.
+
+- **Enabled (either key `true`)**: A synchronous `SessionStart` hook is registered. It respects the 7-day cooldown.
+- **Disabled (both keys `false`)**: No hook is registered. You can still check manually with `mcs check-updates` or rely on `mcs sync` / `mcs doctor` which always check.
+
+When either key changes, `mcs config set` immediately adds or removes the hook from `~/.claude/settings.json` — no re-sync needed. The same hook is also converged during `mcs sync`.
+
+On first interactive sync, `mcs` prompts whether to enable automatic update notifications (sets both keys at once). Fine-tune later with `mcs config set`.
+
---
**Next**: Learn to build packs from scratch in [Creating Tech Packs](creating-tech-packs.md).
]