Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ mcs export <dir> --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 <key> # Get a specific setting value
mcs config set <key> <value> # Set a configuration value (true/false)
Expand Down
23 changes: 21 additions & 2 deletions Sources/mcs/Commands/CheckUpdatesCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/mcs/Core/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
5 changes: 5 additions & 0 deletions Sources/mcs/Core/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 30 additions & 7 deletions Sources/mcs/Core/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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."
Expand All @@ -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")
}
Expand Down
45 changes: 41 additions & 4 deletions Tests/MCSTests/UpdateCheckerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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]))
Expand All @@ -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."))
}
}
Expand Down Expand Up @@ -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))
}
}
3 changes: 2 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`.

---

Expand Down
Loading