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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ mcs export <dir> --identifier id # Set pack identifier (prompted if omitted)
mcs export <dir> --non-interactive # Include everything without prompts
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 (7-day cooldown, respects config)
mcs check-updates --hook # Run as SessionStart hook (24-hour cooldown, respects config)
mcs check-updates --json # Machine-readable JSON output
mcs config list # Show all settings with current values
mcs config get <key> # Get a specific setting value
Expand Down Expand Up @@ -112,7 +112,7 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `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
- `CheckUpdatesCommand.swift` — lightweight update checker for packs (`git ls-remote`) and CLI version (`git ls-remote --tags`); respects config keys and 24-hour 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/`)
Expand Down
8 changes: 4 additions & 4 deletions Sources/mcs/Commands/CheckUpdatesCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct CheckUpdatesCommand: ParsableCommand {
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)")
@Flag(name: .long, help: "Run as a Claude Code SessionStart hook (respects 24-hour cooldown and config)")
var hook: Bool = false

@Flag(name: .long, help: "Output results as JSON")
Expand Down Expand Up @@ -47,15 +47,15 @@ struct CheckUpdatesCommand: ParsableCommand {
let checker = UpdateChecker(environment: env, shell: shell)
let result = checker.performCheck(
entries: relevantEntries,
isHook: hook,
forceRefresh: !hook,
checkPacks: checkPacks,
checkCLI: checkCLI
)

if json {
printJSON(result)
} else {
UpdateChecker.printResult(result, output: output, isHook: hook)
} else if !UpdateChecker.printResult(result, output: output, isHook: hook), !hook {
output.success("Everything is up to date.")
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/mcs/Commands/DoctorCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct DoctorCommand: LockedCommand {
)
try runner.run()

// Always check for updates in doctor — it's a diagnostic tool
// Check for updates (respects 24-hour cache)
UpdateChecker.checkAndPrint(env: env, shell: shell, output: output)
}
}
2 changes: 1 addition & 1 deletion Sources/mcs/Commands/SyncCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ struct SyncCommand: LockedCommand {
try performProject(env: env, output: output, shell: shell, registry: registry)
}

// Always check for updates after sync — user explicitly ran the command
// Check for updates after sync (respects 24-hour cache)
if !dryRun {
UpdateChecker.checkAndPrint(env: env, shell: shell, output: output)
}
Expand Down
60 changes: 40 additions & 20 deletions Sources/mcs/Core/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ struct UpdateChecker {
let environment: Environment
let shell: ShellRunner

/// Default cooldown interval: 7 days.
static let cooldownInterval: TimeInterval = 604_800
/// Default cooldown interval: 24 hours.
static let cooldownInterval: TimeInterval = 86400

/// Environment variables to suppress credential prompts during read-only git checks.
/// GIT_TERMINAL_PROMPT=0 prevents terminal-based prompts; GIT_ASKPASS="" disables GUI credential helpers.
private static let gitNoPromptEnv: [String: String] = [
"GIT_TERMINAL_PROMPT": "0",
"GIT_ASKPASS": "",
]

// MARK: - SessionStart Hook (single source of truth)

Expand Down Expand Up @@ -51,7 +58,8 @@ struct UpdateChecker {
}
}

/// Run an update check and print results. Used by sync and doctor (user-invoked, no cooldown).
/// Run an update check and print results. Used by sync and doctor.
/// Respects the 24-hour cache cooldown — only does network checks when cache is stale.
static func checkAndPrint(env: Environment, shell: ShellRunner, output: CLIOutput) {
let packRegistry = PackRegistryFile(path: env.packsRegistry)
let allEntries: [PackRegistryFile.PackEntry]
Expand Down Expand Up @@ -160,36 +168,41 @@ struct UpdateChecker {
// 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.
/// Local packs are skipped. Checks run in parallel. Network failures are silently ignored per-pack.
func checkPackUpdates(entries: [PackRegistryFile.PackEntry]) -> [PackUpdate] {
var updates: [PackUpdate] = []
let gitEntries = entries.filter { !$0.isLocalPack }
guard !gitEntries.isEmpty else { return [] }

for entry in entries {
if entry.isLocalPack { continue }
// Each index is written by exactly one iteration — no data race.
// nonisolated(unsafe) is needed because concurrentPerform's closure is @Sendable.
nonisolated(unsafe) var results = [PackUpdate?](repeating: nil, count: gitEntries.count)

DispatchQueue.concurrentPerform(iterations: gitEntries.count) { index in
let entry = gitEntries[index]
let ref = entry.ref ?? "HEAD"
let result = shell.run(
environment.gitPath,
arguments: ["ls-remote", entry.sourceURL, ref]
arguments: ["ls-remote", entry.sourceURL, ref],
additionalEnvironment: Self.gitNoPromptEnv
)

guard result.succeeded,
let remoteSHA = Self.parseRemoteSHA(from: result.stdout)
else {
continue
return
}

if remoteSHA != entry.commitSHA {
updates.append(PackUpdate(
results[index] = PackUpdate(
identifier: entry.identifier,
displayName: entry.displayName,
localSHA: entry.commitSHA,
remoteSHA: remoteSHA
))
)
}
}

return updates
return results.compactMap(\.self)
}

// MARK: - CLI Version Check
Expand All @@ -199,7 +212,8 @@ struct UpdateChecker {
func checkCLIVersion(currentVersion: String) -> CLIUpdate? {
let result = shell.run(
environment.gitPath,
arguments: ["ls-remote", "--tags", "--refs", Constants.MCSRepo.url]
arguments: ["ls-remote", "--tags", "--refs", Constants.MCSRepo.url],
additionalEnvironment: Self.gitNoPromptEnv
)

guard result.succeeded,
Expand All @@ -218,16 +232,16 @@ struct UpdateChecker {
// 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
/// - `forceRefresh: false` (default) — returns cached results if fresh (within 24h), otherwise network + cache
/// - `forceRefresh: true` — always does network checks + updates cache (for explicit `mcs check-updates`)
func performCheck(
entries: [PackRegistryFile.PackEntry],
isHook: Bool = false,
forceRefresh: Bool = false,
checkPacks: Bool,
checkCLI: Bool
) -> CheckResult {
// Hook mode: serve cached results if still fresh (single disk read)
if isHook, let cached = loadCache(),
// Serve cached results if still fresh (single disk read), unless explicitly forced
if !forceRefresh, let cached = loadCache(),
let lastCheck = ISO8601DateFormatter().date(from: cached.timestamp),
Date().timeIntervalSince(lastCheck) < Self.cooldownInterval {
return cached.result
Expand Down Expand Up @@ -279,8 +293,14 @@ struct UpdateChecker {
)
}
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.")
let noun = result.packUpdates.count == 1 ? "pack update" : "pack updates"
output.info("\(result.packUpdates.count) \(noun) available:")
for pack in result.packUpdates {
let local = String(pack.localSHA.prefix(7))
let remote = String(pack.remoteSHA.prefix(7))
output.plain(" \u{2022} \(pack.displayName) (\(local) \u{2192} \(remote))")
}
output.plain(" Run 'mcs pack update' to update.")
}
}

Expand Down
43 changes: 36 additions & 7 deletions Tests/MCSTests/UpdateCheckerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ struct UpdateCheckerPerformCheckTests {
try data.write(to: env.updateCheckCacheFile, options: .atomic)
}

@Test("Hook mode returns cached result when cache is fresh")
func hookModeReturnsCachedResult() throws {
@Test("Returns cached result when cache is fresh")
func cachedResultReturnedByDefault() throws {
let tmpDir = try makeTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

Expand All @@ -164,15 +164,15 @@ struct UpdateCheckerPerformCheckTests {
try writeFreshCache(at: env, result: cachedResult)

let checker = makeChecker(home: tmpDir)
let result = checker.performCheck(entries: [], isHook: true, checkPacks: true, checkCLI: false)
let result = checker.performCheck(entries: [], 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 {
@Test("forceRefresh bypasses cache and does fresh check")
func forceRefreshBypassesCache() throws {
let tmpDir = try makeTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

Expand All @@ -186,13 +186,42 @@ struct UpdateCheckerPerformCheckTests {
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)
let result = checker.performCheck(entries: [], forceRefresh: true, checkPacks: true, checkCLI: false)

// Should NOT return cached "stale" pack — should do fresh check with empty entries
#expect(result.packUpdates.isEmpty)
}

@Test("Stale cache triggers fresh check")
func staleCacheTriggersFreshCheck() throws {
let tmpDir = try makeTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

let env = Environment(home: tmpDir)
let staleResult = UpdateChecker.CheckResult(
packUpdates: [UpdateChecker.PackUpdate(
identifier: "old-pack", displayName: "Old", localSHA: "aaa", remoteSHA: "bbb"
)],
cliUpdate: nil
)
// Write cache with timestamp older than 24 hours
let staleTimestamp = Date().addingTimeInterval(-90000)
let cached = UpdateChecker.CachedResult(
timestamp: ISO8601DateFormatter().string(from: staleTimestamp),
result: staleResult
)
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)

let checker = makeChecker(home: tmpDir)
let result = checker.performCheck(entries: [], checkPacks: true, checkCLI: false)

// Cache is stale (>24h), so should do fresh check with empty entries → empty result
#expect(result.packUpdates.isEmpty)
}

@Test("performCheck saves cache after network check")
func savesCache() throws {
let tmpDir = try makeTmpDir()
Expand Down
14 changes: 7 additions & 7 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,25 +134,25 @@ The export wizard discovers MCP servers, hooks, skills, commands, agents, plugin
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 # 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
```

| Flag | Description |
|------|-------------|
| `--hook` | Run as a Claude Code SessionStart hook. Respects the 7-day cooldown and config keys. Without this flag, checks always run. |
| `--hook` | Run as a Claude Code SessionStart hook. Respects the 24-hour cooldown and config keys. Without this flag, always fetches from remote. |
| `--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**: Results are cached in `~/.mcs/update-check.json` (timestamp + results). Cached results are served if less than 24 hours old (no network request). When the cache is stale, a fresh network check runs and the results are cached. `mcs check-updates` (without `--hook`) always forces a fresh check; `mcs sync` and `mcs doctor` respect 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.
**Note:** `mcs sync` and `mcs doctor` check for updates at the end of execution, respecting the 24-hour cache. The config keys below only control the **automatic** `SessionStart` hook that runs when you start a Claude Code session.

## `mcs config`

Expand All @@ -173,8 +173,8 @@ mcs config set <key> <value> # Set a value (true/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.
- **Enabled (either key `true`)**: A synchronous `SessionStart` hook is registered. It respects the 24-hour 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 check using the 24-hour cache.

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

Expand Down
Loading