diff --git a/CLAUDE.md b/CLAUDE.md index 04a08dc1..e1327d42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ 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 (24-hour cooldown, respects config) mcs check-updates --json # Machine-readable JSON output +mcs check-updates --snooze # Silence the SessionStart update notice until tomorrow (local time) 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) diff --git a/Sources/mcs/Commands/CheckUpdatesCommand.swift b/Sources/mcs/Commands/CheckUpdatesCommand.swift index fdb36c48..1670f06f 100644 --- a/Sources/mcs/Commands/CheckUpdatesCommand.swift +++ b/Sources/mcs/Commands/CheckUpdatesCommand.swift @@ -13,9 +13,28 @@ struct CheckUpdatesCommand: ParsableCommand { @Flag(name: .shortAndLong, help: "Output results as JSON") var json: Bool = false + @Flag(name: .long, help: "Silence the SessionStart update notice until tomorrow (local time)") + var snooze: Bool = false + + func validate() throws { + if snooze, hook || json { + throw ValidationError("--snooze cannot be combined with --hook or --json.") + } + } + func run() throws { let env = Environment() let output = CLIOutput() + if snooze { + do { + try UpdateChecker.snooze(environment: env) + } catch { + output.error("Could not write \(env.updateCheckSnoozeFile.path): \(error.localizedDescription)") + throw ExitCode.failure + } + output.success("Update notifications snoozed until tomorrow.") + return + } guard let result = check(env: env, output: output) else { return } if json { @@ -26,9 +45,9 @@ struct CheckUpdatesCommand: ParsableCommand { } func check(env: Environment, output: CLIOutput) -> UpdateChecker.CheckResult? { - // A hand-edited config can disable checks while the hook entry is still registered; + // A hand-edited config or a snooze silences the hook while its entry is still registered; // bail before `performCheck`, which would otherwise serve a <24h cache written by sync or doctor. - if hook, !MCSConfig.load(from: env.mcsConfigFile).isUpdateCheckEnabled { + if hook, !MCSConfig.load(from: env.mcsConfigFile).isUpdateCheckEnabled || UpdateChecker.isSnoozed(environment: env) { return nil } diff --git a/Sources/mcs/Core/Constants.swift b/Sources/mcs/Core/Constants.swift index a4c8db9a..86955cc7 100644 --- a/Sources/mcs/Core/Constants.swift +++ b/Sources/mcs/Core/Constants.swift @@ -31,6 +31,9 @@ enum Constants { /// The update check cache file (timestamp + results). static let updateCheckCache = "update-check.json" + /// The local date (`yyyy-MM-dd`) until whose end the SessionStart update notice is snoozed. + static let updateCheckSnooze = "update-check-snooze" + /// The per-project settings file written by `mcs sync`. static let settingsLocal = "settings.local.json" diff --git a/Sources/mcs/Core/Environment.swift b/Sources/mcs/Core/Environment.swift index f1b12c37..e57237a5 100644 --- a/Sources/mcs/Core/Environment.swift +++ b/Sources/mcs/Core/Environment.swift @@ -110,6 +110,11 @@ struct Environment { mcsDirectory.appendingPathComponent(Constants.FileNames.updateCheckCache) } + /// Update check snooze file (`~/.mcs/update-check-snooze`). + var updateCheckSnoozeFile: URL { + mcsDirectory.appendingPathComponent(Constants.FileNames.updateCheckSnooze) + } + /// User preferences file (`~/.mcs/config.yaml`). var mcsConfigFile: URL { mcsDirectory.appendingPathComponent(Constants.FileNames.mcsConfig) diff --git a/Sources/mcs/Core/UpdateChecker.swift b/Sources/mcs/Core/UpdateChecker.swift index d8a700f7..e7f37430 100644 --- a/Sources/mcs/Core/UpdateChecker.swift +++ b/Sources/mcs/Core/UpdateChecker.swift @@ -28,6 +28,7 @@ struct UpdateChecker { static let cliUpgradeCommands = ["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"] static let manualPackUpdateCommand = "mcs update --all-projects" static let hookPackUpdateCommand = "mcs update -a --trust-all" + static let snoozeCommand = "mcs check-updates --snooze" @discardableResult static func addHook(to settings: inout Settings) -> Bool { @@ -180,6 +181,24 @@ struct UpdateChecker { } } + // MARK: - Snooze + + /// Silence the SessionStart notice until the local date changes. + static func snooze(environment: Environment, now: Date = Date()) throws { + let file = environment.updateCheckSnoozeFile + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try localDay(now).write(to: file, atomically: true, encoding: .utf8) + } + + static func isSnoozed(environment: Environment, now: Date = Date()) -> Bool { + let day = try? String(contentsOf: environment.updateCheckSnoozeFile, encoding: .utf8) + return day?.trimmingCharacters(in: .whitespacesAndNewlines) == localDay(now) + } + + private static func localDay(_ date: Date) -> String { + date.formatted(Date.ISO8601FormatStyle(timeZone: .current).year().month().day()) + } + // MARK: - Pack Checks /// Classification of an upstream commit-SHA change. Drives whether the noise filter @@ -645,12 +664,12 @@ struct UpdateChecker { lines.append("- \(result.packUpdates.count) \(noun) updates available: \(names).") commands.append(hookPackUpdateCommand) } - lines.append("- To stop these notifications, run: mcs config set update-check false") lines.append("") lines.append( - "Then make the AskUserQuestion tool your first tool call: one yes/no question asking whether to" - + " apply all of these updates now. If AskUserQuestion is unavailable, ask in plain text and" - + " end your turn to wait for the answer. Never run these commands without an explicit yes," + "Then make the AskUserQuestion tool your first tool call: one question asking whether to apply all" + + " of these updates now, with exactly three options: \"Update now\", \"Not now\" and" + + " \"Remind me tomorrow\". If AskUserQuestion is unavailable, ask in plain text and end your" + + " turn to wait for the answer. Never run the update commands without an explicit \"Update now\"," + " and ask at most once per session." ) if hasPackUpdates { @@ -662,7 +681,7 @@ struct UpdateChecker { ) } lines.append("") - lines.append("On yes, run: \(commands.joined(separator: " && "))") + lines.append("On \"Update now\", run: \(commands.joined(separator: " && "))") lines.append( "Report the outcome. Most changes apply immediately; new hooks or MCP servers may need a new" + " Claude Code session." @@ -682,10 +701,14 @@ struct UpdateChecker { + " a separate AskUserQuestion yes/no before running: mcs doctor --fix --yes" ) lines.append( - "On no, suggest running '\(manualPackUpdateCommand)' in a separate terminal to review each" - + " trust prompt." + "On \"Not now\", suggest running '\(manualPackUpdateCommand)' in a separate terminal to review" + + " each trust prompt." ) } + lines.append( + "On \"Remind me tomorrow\", run only: \(snoozeCommand) (silences this notice in every session" + + " until the local date changes)." + ) lines.append("Only after these questions are settled, continue with the user's original request.") return lines.joined(separator: "\n") } diff --git a/Tests/MCSTests/UpdateCheckerTests.swift b/Tests/MCSTests/UpdateCheckerTests.swift index 766ec59e..9f8125a8 100644 --- a/Tests/MCSTests/UpdateCheckerTests.swift +++ b/Tests/MCSTests/UpdateCheckerTests.swift @@ -1218,9 +1218,13 @@ struct UpdateCheckerContextStringTests { let context = UpdateChecker.buildContextString( UpdateChecker.CheckResult(packUpdates: [], cliUpdate: cliUpdate) ) - #expect(context.contains("On yes, run: \(UpdateChecker.cliUpgradeCommands.joined(separator: " && "))\n")) + #expect(context.contains( + "On \"Update now\", run: \(UpdateChecker.cliUpgradeCommands.joined(separator: " && "))\n" + )) #expect(context.contains("AskUserQuestion")) - #expect(context.contains("mcs config set update-check false")) + #expect(context.contains("\"Not now\"")) + #expect(context.contains("On \"Remind me tomorrow\", run only: \(UpdateChecker.snoozeCommand)")) + #expect(!context.contains("mcs config set update-check")) #expect(!context.contains("--trust-all")) #expect(!context.contains("mcs doctor")) #expect(!context.contains("mcs cleanup")) @@ -1232,7 +1236,7 @@ struct UpdateCheckerContextStringTests { let context = UpdateChecker.buildContextString( UpdateChecker.CheckResult(packUpdates: [packUpdate], cliUpdate: nil) ) - #expect(context.contains("On yes, run: \(UpdateChecker.hookPackUpdateCommand)\n")) + #expect(context.contains("On \"Update now\", run: \(UpdateChecker.hookPackUpdateCommand)\n")) #expect(context.contains("without review")) #expect(context.contains(UpdateChecker.manualPackUpdateCommand)) #expect(!context.contains(UpdateChecker.cliUpgradeCommands[1])) @@ -1249,7 +1253,7 @@ struct UpdateCheckerContextStringTests { ) let chain = (UpdateChecker.cliUpgradeCommands + [UpdateChecker.hookPackUpdateCommand]) .joined(separator: " && ") - #expect(context.contains("On yes, run: \(chain)\n")) + #expect(context.contains("On \"Update now\", run: \(chain)\n")) #expect(context.hasSuffix("continue with the user's original request.")) } } @@ -1287,4 +1291,37 @@ struct CheckUpdatesCommandHookGateTests { let result = command.check(env: env, output: CLIOutput(colorsEnabled: false)) #expect(result?.cliUpdate?.latestVersion == "9999.1.1") } + + @Test("Hook mode returns nothing while snoozed today, even with a fresh cache") + func snoozedSkipsCache() throws { + let env = try makeHome(updateCheck: true) + defer { try? FileManager.default.removeItem(at: env.homeDirectory) } + try UpdateChecker.snooze(environment: env) + let command = try CheckUpdatesCommand.parse(["--hook"]) + #expect(command.check(env: env, output: CLIOutput(colorsEnabled: false)) == nil) + } + + @Test("--snooze is rejected alongside --hook or --json") + func snoozeRejectsOtherModes() { + #expect(throws: (any Error).self) { try CheckUpdatesCommand.parse(["--snooze", "--hook"]) } + #expect(throws: (any Error).self) { try CheckUpdatesCommand.parse(["--snooze", "--json"]) } + } +} + +// MARK: - Snooze Tests + +struct UpdateCheckerSnoozeTests { + @Test("A snooze expires at local midnight, not 24 hours later") + func expiresAtMidnight() throws { + let home = try makeTmpDir(label: "snooze") + let env = Environment(home: home) + defer { try? FileManager.default.removeItem(at: home) } + let midnight = Calendar.current.startOfDay(for: Date()) + let lateEvening = midnight.addingTimeInterval(-60) + + try UpdateChecker.snooze(environment: env, now: lateEvening) + + #expect(UpdateChecker.isSnoozed(environment: env, now: lateEvening.addingTimeInterval(30))) + #expect(!UpdateChecker.isSnoozed(environment: env, now: midnight)) + } } diff --git a/docs/cli.md b/docs/cli.md index ea8a0db0..b3c4b5cd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -306,6 +306,7 @@ Check for available tech pack and CLI updates. Designed to be lightweight and no mcs check-updates # Check for updates (always fetches from remote) mcs check-updates --hook # Run as SessionStart hook (respects 24-hour cooldown and config) mcs check-updates --json # Machine-readable JSON output +mcs check-updates --snooze # Silence the SessionStart update notice until tomorrow (local time) ``` | Flag | Description | @@ -351,7 +352,7 @@ When either key changes, `mcs config set` immediately adds or removes the hook f On first interactive sync, `mcs` prompts whether to enable automatic update notifications (sets both keys at once). Fine-tune later with `mcs config set`. -When updates are pending, Claude tells you before it starts on your request and asks once, through a question prompt, whether to apply them; for pack updates that question also warns that `--trust-all` approves the packs' changed executable content without review (see the [`--trust-all` caveat](#mcs-bootstrap)). It runs nothing without your yes, and continues with your request once every question is answered. The commands run in this order: `brew update` and `brew upgrade mcs-cli/tap/mcs` for a CLI update, then `mcs update -a --trust-all` for pack updates. After a pack update Claude runs `mcs doctor` (global scope and current project only). If every check passes it asks, separately, whether to delete all mcs backups across tracked projects with `mcs cleanup -af`; if a check fails it keeps the backups and asks, separately, whether to run `mcs doctor --fix --yes`. If you decline the pack update, run `mcs update --all-projects` in your own terminal to review each trust prompt. The notice also tells you how to turn it off, with `mcs config set update-check false`. +When updates are pending, Claude tells you before it starts on your request and asks once, through a question prompt, whether to apply them; for pack updates that question also warns that `--trust-all` approves the packs' changed executable content without review (see the [`--trust-all` caveat](#mcs-bootstrap)). The question offers three answers: **Update now**, **Not now** (asked again next session) and **Remind me tomorrow**, which runs `mcs check-updates --snooze` so no session asks again until the local date changes. Claude runs no update command without **Update now**, and continues with your request once every question is answered. The commands run in this order: `brew update` and `brew upgrade mcs-cli/tap/mcs` for a CLI update, then `mcs update -a --trust-all` for pack updates. After a pack update Claude runs `mcs doctor` (global scope and current project only). If every check passes it asks, separately, whether to delete all mcs backups across tracked projects with `mcs cleanup -af`; if a check fails it keeps the backups and asks, separately, whether to run `mcs doctor --fix --yes`. If you answer **Not now** to a pack update, run `mcs update --all-projects` in your own terminal to review each trust prompt. To turn the notice off entirely, run `mcs config set update-check false`. ---