Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dfabacb
[#251] Add check-updates command, config system, and HookEvent enum
bguidolim Mar 22, 2026
b8cb74d
Replace --force with --hook flag for check-updates
bguidolim Mar 22, 2026
bd0e735
Add Claude instruction prefix when running in --hook mode
bguidolim Mar 22, 2026
1fcd6f7
Replace timestamp file with JSON cache for update checks
bguidolim Mar 22, 2026
c88737b
Strengthen Claude instruction for hook update notifications
bguidolim Mar 22, 2026
dfe307b
Use plain text output in hook mode (no ANSI escape codes)
bguidolim Mar 22, 2026
d8a73d8
Update docs: replace timestamp file reference with cache docs
bguidolim Mar 22, 2026
cf236be
Use structured JSON output for SessionStart hook
bguidolim Mar 22, 2026
889646e
Strengthen hook notification instruction for Claude
bguidolim Mar 23, 2026
1049f70
Fix simplify review findings
bguidolim Mar 23, 2026
3166511
Move isNewer to VersionCompare, eliminate duplicate comparison logic
bguidolim Mar 23, 2026
caa3eba
Fix review findings: try? violations, dead code, doctor summary
bguidolim Mar 23, 2026
98d38c9
Hide 'fixed' from doctor summary when no fixes applied yet
bguidolim Mar 23, 2026
0ff7d17
Add tests for removeHookEntry, hook management, and syncHook
bguidolim Mar 23, 2026
41b887f
Add typed HookEvent overloads, Codable JSON DTO, and integration tests
bguidolim Mar 23, 2026
d4d8ea3
Merge remote-tracking branch 'origin/main' into bruno/251-check-updat…
bguidolim Mar 23, 2026
515501a
Fix all PR review findings
bguidolim Mar 23, 2026
3dfac97
Revert removeHookEntry doc comment to original
bguidolim Mar 23, 2026
91793ab
Only save global settings.json when content changed
bguidolim Mar 23, 2026
3403e15
Fix minor review findings: docs wording and CodingKeys drift test
bguidolim Mar 23, 2026
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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ mcs export <dir> --global # Export global scope (~/.claude/)
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 --json # Machine-readable JSON output
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)
```

## Architecture
Expand Down Expand Up @@ -71,6 +77,8 @@ mcs export <dir> --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)
Expand Down Expand Up @@ -104,6 +112,8 @@ mcs export <dir> --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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions Sources/mcs/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ struct MCS: ParsableCommand {
CleanupCommand.self,
PackCommand.self,
ExportCommand.self,
CheckUpdatesCommand.self,
ConfigCommand.self,
],
defaultSubcommand: SyncCommand.self
)
Expand Down
96 changes: 96 additions & 0 deletions Sources/mcs/Commands/CheckUpdatesCommand.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
104 changes: 104 additions & 0 deletions Sources/mcs/Commands/ConfigCommand.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions Sources/mcs/Commands/DoctorCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion Sources/mcs/Commands/ExportCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: []))
Expand Down
4 changes: 4 additions & 0 deletions Sources/mcs/Commands/PackCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand Down
29 changes: 29 additions & 0 deletions Sources/mcs/Commands/SyncCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Sources/mcs/Core/CLIOutput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading