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
5 changes: 2 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ mcs sync --dry-run # Preview what would change
mcs sync --customize # Per-pack component selection
mcs sync --global # Sync global scope (MCP servers, brew, plugins to ~/.claude/)
mcs sync --lock # Checkout locked versions from mcs.lock.yaml
mcs sync --update # [DEPRECATED — use 'mcs update'] Fetch latest and force-write mcs.lock.yaml
mcs update # Fetch latest pack versions and re-apply across every configured scope
mcs update --global # Refresh only the global scope
mcs update --project # Refresh only the current project's scope
Expand Down Expand Up @@ -119,7 +118,7 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `SectionValidator.swift` — validation of CLAUDE.local.md section markers

### Commands (`Sources/mcs/Commands/`)
- `SyncCommand.swift` — primary command (`mcs sync`), handles both project-scoped and global-scoped sync with `--pack`, `--all`, `--dry-run`, `--customize`, `--global`, `--lock`, `--update` flags. `guardClaudeHomeCwd()` at the top of `perform()` rejects/redirects runs from `~/.claude` or `$HOME` to prevent silent corruption of the global scope
- `SyncCommand.swift` — primary command (`mcs sync`), handles both project-scoped and global-scoped sync with `--pack`, `--all`, `--dry-run`, `--customize`, `--global`, `--lock` flags. `guardClaudeHomeCwd()` at the top of `perform()` rejects/redirects runs from `~/.claude` or `$HOME` to prevent silent corruption of the global scope
- `DoctorCommand.swift` — health checks with optional --fix and --pack filter
- `CleanupCommand.swift` — backup file management with --force flag
- `PackCommand.swift` — `mcs pack add/remove/list/update/validate` subcommands; uses `PackSourceResolver` for 3-tier input detection (URL schemes → filesystem paths → GitHub shorthand)
Expand Down Expand Up @@ -203,7 +202,7 @@ swiftlint --fix
- **Backup for mixed-ownership files**: timestamped backup before modifying files with user content (CLAUDE.local.md); tool-managed files are not backed up since they can be regenerated
- **Component-derived doctor checks**: `ComponentDefinition` is the single source of truth — `deriveDoctorCheck()` auto-generates verification from `installAction`, supplementary checks handle extras
- **Project awareness**: doctor detects project root (walk-up for `.git/`), resolves packs from `.claude/.mcs-project` before falling back to section marker inference, then to global manifest
- **Lockfile support (opt-in)**: `mcs.lock.yaml` pins pack commits for reproducible builds. Generation is opt-in — enable with `mcs config set generate-lockfile true` to write on every sync, or pass `--update` to fetch latest and write once. `--lock` checks out pinned commits from an existing lockfile. Tri-state semantics on `generate-lockfile`: `true` writes, `false` is silent (explicit opt-out), `nil` (never configured) surfaces a one-time drift warning if a stale lockfile exists — the upgrade nudge
- **Lockfile support (opt-in)**: `mcs.lock.yaml` pins pack commits for reproducible builds. Generation is opt-in — enable with `mcs config set generate-lockfile true` to write on every sync. `--lock` checks out pinned commits from an existing lockfile. Tri-state semantics on `generate-lockfile`: `true` writes, `false` is silent (explicit opt-out), `nil` (never configured) surfaces a one-time drift warning if a stale lockfile exists — the upgrade nudge
- **Local packs**: `mcs pack add /path` registers a pack read in-place — no git clone, no `mcs pack update`, no directory deletion on remove. Uses `isLocal: Bool?` on `PackEntry` (backward-compatible) and `commitSHA: "local"` sentinel. Trust verification is skipped since scripts change during development
- **GitHub shorthand**: `mcs pack add user/repo` expands to `https://github.com/user/repo.git`. Filesystem paths are checked before shorthand regex to prevent ambiguity with relative paths like `org/pack`
- **Cross-project reference counting**: `ProjectIndex` (`~/.mcs/projects.yaml`) tracks which projects use which packs; `ResourceRefCounter` checks all scopes before removing shared brew packages or plugins. Conservative by default — if state is unreadable, assume resource is still needed. MCP servers are project-independent (scoped via `-s local`) and skip ref counting
Expand Down
23 changes: 3 additions & 20 deletions Sources/mcs/Commands/SyncCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ struct SyncCommand: LockedCommand {
@Flag(name: .long, help: "Checkout locked pack versions from mcs.lock.yaml before syncing")
var lock = false

@Flag(name: .long, help: "Fetch latest pack versions and update mcs.lock.yaml")
var update = false

@Flag(name: .long, help: "Customize which components to include per pack")
var customize = false

Expand All @@ -51,20 +48,6 @@ struct SyncCommand: LockedCommand {
// First-run: prompt for update notification preference
let config = promptForUpdateCheckIfNeeded(env: env, output: output)

// Handle --update: fetch latest for all packs before loading
if update {
output.warn(
"'mcs sync --update' is deprecated. Use 'mcs update' to fetch and re-apply across all configured scopes; "
+ "combine with 'mcs config set generate-lockfile true' if you want a lockfile."
)
let lockOps = LockfileOperations(environment: env, output: output, shell: shell)
// On a non-interactive hard failure this throws before the sync phase below, so
// healthy packs are fetched (and their SHAs saved) but not re-applied this run —
// unlike `mcs update`, which re-applies first and signals failure last. Accepted
// for this deprecated path; `mcs update` is the supported fetch-and-apply command.
try lockOps.updatePacks()
}

let registry = TechPackRegistry.loadWithExternalPacks(
environment: env,
output: output
Expand Down Expand Up @@ -183,7 +166,7 @@ struct SyncCommand: LockedCommand {
try configurator.interactiveConfigure(dryRun: dryRun, customize: customize)
}

switch Self.lockfileAction(dryRun: dryRun, update: update, config: config) {
switch Self.lockfileAction(dryRun: dryRun, config: config) {
case .write:
try lockOps.writeLockfile(at: projectPath)
case .reportDrift:
Expand All @@ -203,9 +186,9 @@ struct SyncCommand: LockedCommand {
case skip
}

static func lockfileAction(dryRun: Bool, update: Bool, config: MCSConfig) -> LockfileAction {
static func lockfileAction(dryRun: Bool, config: MCSConfig) -> LockfileAction {
guard !dryRun else { return .skip }
if update || config.isLockfileGenerationEnabled { return .write }
if config.isLockfileGenerationEnabled { return .write }
if config.isLockfileGenerationUnset { return .reportDrift }
return .skip
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/mcs/Commands/UpdateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
/// Refresh-only orchestration: fetch latest pack contents (with trust verification),
/// then re-apply the existing configured set in both the global scope and the current
/// project's scope. Does not add or remove packs (use `mcs sync`). Lockfile writes are
/// gated by `generate-lockfile`, unlike `mcs sync --update` which force-writes.
/// gated by `generate-lockfile`.
struct UpdateCommand: LockedCommand {
static let configuration = CommandConfiguration(
commandName: "update",
Expand Down
62 changes: 0 additions & 62 deletions Sources/mcs/Sync/LockfileOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,68 +80,6 @@ struct LockfileOperations {
}
}

/// Fetch latest commits for all registered git packs. Local packs are skipped.
/// Re-validates trust when scripts change (mirrors `mcs pack update` behavior).
func updatePacks() throws {
let registryFile = PackRegistryFile(path: environment.packsRegistry)
let registryData = try registryFile.load()

if registryData.packs.isEmpty {
output.info("No packs registered. Nothing to update.")
return
}

output.info("Fetching latest pack commits...")
let updater = PackUpdater(
fetcher: PackFetcher(shell: shell, output: output, packsDirectory: environment.packsDirectory),
trustManager: PackTrustManager(output: output),
environment: environment,
output: output
)

var updatedData = registryData
var attemptedCount = 0
var failedPacks: [String] = []
for entry in registryData.packs {
if entry.isLocalPack {
output.dimmed(" \(entry.identifier): local pack (skipped)")
continue
}

attemptedCount += 1
guard let packPath = entry.resolvedPath(packsDirectory: environment.packsDirectory) else {
output.warn(" \(entry.identifier): invalid path — skipping")
failedPacks.append(entry.identifier)
continue
}

let result = updater.updateGitPack(entry: entry, packPath: packPath, registry: registryFile)
switch result {
case .alreadyUpToDate:
output.dimmed(" \(entry.identifier): already up to date")
case let .updated(updatedEntry):
registryFile.register(updatedEntry, in: &updatedData)
output.success(" \(entry.identifier): updated (\(updatedEntry.shortSHA))")
case .trustDeclined:
output.info(" \(entry.identifier): \(result.reason ?? "trust not granted") (will re-prompt next run)")
case .fetchFailed, .manifestInvalid, .internalError:
output.warn(" \(entry.identifier): \(result.reason ?? "update failed")")
failedPacks.append(entry.identifier)
}
}

try registryFile.save(updatedData)

if PackUpdater.shouldExitNonZero(
failedCount: failedPacks.count,
attemptedCount: attemptedCount,
isInteractive: output.hasInteractiveStdin
) {
output.error("Failed to update: \(failedPacks.joined(separator: ", "))")
throw ExitCode.failure
}
}

/// Write the lockfile after a successful sync.
func writeLockfile(at projectPath: URL) throws {
let registryFile = PackRegistryFile(path: environment.packsRegistry)
Expand Down
8 changes: 4 additions & 4 deletions Sources/mcs/Sync/PackUpdater.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Handles the fetch → validate → trust cycle for updating a single git pack.
/// Used by both `UpdatePack` (interactive) and `LockfileOperations` (`--update`).
/// Used by both `UpdatePack` (interactive) and `UpdateCommand` (multi-scope refresh).
struct PackUpdater {
let fetcher: PackFetcher
let trustManager: PackTrustManager
Expand Down Expand Up @@ -135,9 +135,9 @@ struct PackUpdater {

extension PackUpdater {
/// Exit-code policy shared by every multi-pack update caller (`mcs pack update`,
/// `mcs sync --update`, `mcs update`): a hard failure exits non-zero when running
/// non-interactively (CI), or when every attempted pack failed. A lone trust-decline is
/// not a failure. Centralized so the three callers can't drift out of contract.
/// `mcs update`): a hard failure exits non-zero when running non-interactively (CI), or
/// when every attempted pack failed. A lone trust-decline is not a failure. Centralized so
/// the callers can't drift out of contract.
static func shouldExitNonZero(failedCount: Int, attemptedCount: Int, isInteractive: Bool) -> Bool {
failedCount > 0 && (!isInteractive || failedCount == attemptedCount)
}
Expand Down
42 changes: 0 additions & 42 deletions Tests/MCSTests/LockfileOperationsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -497,48 +497,6 @@ struct LockfileOperationsTests {
}
}

// MARK: - updatePacks: Empty registry

@Test("updatePacks returns early when no packs registered")
func updatePacksEmptyRegistry() throws {
let home = try makeTmpDir()
defer { try? FileManager.default.removeItem(at: home) }

try writeRegistry([], home: home)

let ops = makeOperations(home: home)
// Should not throw — early return for empty registry
try ops.updatePacks()
}

@Test("updatePacks exits non-zero when a pack fails in a non-interactive process")
func updatePacksThrowsOnFailureNonInteractive() throws {
let home = try makeTmpDir()
defer { try? FileManager.default.removeItem(at: home) }

// A git pack whose checkout doesn't exist on disk — the real fetch fails, producing
// a hard failure. The test process has no TTY, so updatePacks must surface a non-zero
// exit rather than silently warning.
let ghost = PackRegistryFile.PackEntry(
identifier: "ghost",
displayName: "Ghost Pack",
author: nil,
sourceURL: "file:///fake/ghost.git",
ref: nil,
commitSHA: "abcdef0123",
localPath: "ghost",
addedAt: "2026-03-21T00:00:00Z",
trustedScriptHashes: [:],
isLocal: nil
)
try writeRegistry([ghost], home: home)

let ops = makeOperations(home: home)
#expect(throws: ExitCode.self) {
try ops.updatePacks()
}
}

// MARK: - checkoutLockedCommits: Git operations (mock-based)

/// Set up a locked pack with a real pack directory and lockfile, returning
Expand Down
28 changes: 5 additions & 23 deletions Tests/MCSTests/SyncCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ struct SyncCommandTests {
#expect(cmd.all == false)
#expect(cmd.dryRun == false)
#expect(cmd.lock == false)
#expect(cmd.update == false)
#expect(cmd.customize == false)
#expect(cmd.global == false)
}
Expand Down Expand Up @@ -47,12 +46,6 @@ struct SyncCommandTests {
#expect(cmd.lock == true)
}

@Test("Parses --update flag")
func parsesUpdate() throws {
let cmd = try SyncCommand.parse(["--update"])
#expect(cmd.update == true)
}

@Test("skipLock is true when --dry-run is set")
func skipLockWhenDryRun() throws {
let cmd = try SyncCommand.parse(["--dry-run"])
Expand All @@ -78,7 +71,6 @@ struct SyncCommandTests {
#expect(cmd.pack == ["ios"])
#expect(cmd.dryRun == true)
#expect(cmd.lock == true)
#expect(cmd.update == false)
#expect(cmd.all == false)
}

Expand Down Expand Up @@ -123,39 +115,29 @@ struct SyncCommandTests {
var config = MCSConfig()
for flag in [nil, true, false] as [Bool?] {
config.generateLockfile = flag
#expect(SyncCommand.lockfileAction(dryRun: true, update: false, config: config) == .skip)
#expect(SyncCommand.lockfileAction(dryRun: true, update: true, config: config) == .skip)
}
}

@Test("Dispatch: --update forces write regardless of config")
func dispatchUpdateForcesWrite() {
var config = MCSConfig()
for flag in [nil, true, false] as [Bool?] {
config.generateLockfile = flag
#expect(SyncCommand.lockfileAction(dryRun: false, update: true, config: config) == .write)
#expect(SyncCommand.lockfileAction(dryRun: true, config: config) == .skip)
}
}

@Test("Dispatch: generate-lockfile=true writes without --update")
@Test("Dispatch: generate-lockfile=true writes")
func dispatchConfigTrueWrites() {
var config = MCSConfig()
config.generateLockfile = true
#expect(SyncCommand.lockfileAction(dryRun: false, update: false, config: config) == .write)
#expect(SyncCommand.lockfileAction(dryRun: false, config: config) == .write)
}

@Test("Dispatch: generate-lockfile=nil (unset) reports drift — upgrade path")
func dispatchConfigNilReportsDrift() {
let config = MCSConfig()
#expect(config.generateLockfile == nil)
#expect(SyncCommand.lockfileAction(dryRun: false, update: false, config: config) == .reportDrift)
#expect(SyncCommand.lockfileAction(dryRun: false, config: config) == .reportDrift)
}

@Test("Dispatch: generate-lockfile=false stays silent — explicit opt-out")
func dispatchConfigFalseSkips() {
var config = MCSConfig()
config.generateLockfile = false
#expect(SyncCommand.lockfileAction(dryRun: false, update: false, config: config) == .skip)
#expect(SyncCommand.lockfileAction(dryRun: false, config: config) == .skip)
}
}

Expand Down
Loading
Loading