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).