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