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
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,13 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `Backup.swift` — timestamped backups for mixed-ownership files (CLAUDE.local.md), backup discovery and deletion
- `GitignoreManager.swift` — global gitignore management, core entry list
- `ClaudeIntegration.swift` — `claude mcp add/remove` (with scope support), `claude plugin install/remove`
- `ClaudePrerequisite.swift` — Claude Code CLI availability check with optional Homebrew auto-install
- `Homebrew.swift` — brew detection, package install/uninstall
- `FileHasher.swift` — SHA-256 file and directory hashing via CryptoKit (used by `PackTrustManager` and `ComponentExecutor`)
- `FileLock.swift` — POSIX `flock()` process lock and `LockedCommand` protocol for mutually exclusive CLI commands
- `Lockfile.swift` — `mcs.lock.yaml` model for pinning pack commits
- `PathContainment.swift` — centralized path-boundary checks and relative-path utilities (symlink-safe containment, traversal prevention)
- `PluginRef.swift` — parsed `name@repo` plugin references with marketplace resolution
- `ProjectDetector.swift` — walk-up project root detection (`.git/` or `CLAUDE.local.md`)
- `SettingsHasher.swift` — deterministic SHA-256 hashing of per-pack settings key-value pairs for drift detection
- `ProjectState.swift` — per-project `.claude/.mcs-project` JSON state (configured packs, per-pack `PackArtifactRecord` with ownership tracking, version)
Expand Down Expand Up @@ -120,13 +125,16 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `ManifestBuilder.swift` — converts selected artifacts into YAML using custom renderer (ordered metadata, section comments, proper quoting)
- `PackWriter.swift` — writes output directory with symlink resolution for copied files

### Install (`Sources/mcs/Install/`)
### Sync (`Sources/mcs/Sync/`)
- `Configurator.swift` — unified multi-pack convergence engine parameterized by `SyncStrategy` (artifact tracking, settings composition, CLAUDE file writing, gitignore). `unconfigurePack()` handles removal for both `mcs sync` (deselection) and `mcs pack remove` (federated across all affected scopes)
- `ConfiguratorSupport.swift` — shared utilities for `Configurator` and `SyncStrategy` implementations (executor factory, gitignore setup, dry-run summary, settings composition helpers)
- `SyncScope.swift` — pure data struct capturing path-level differences between project and global scopes
- `SyncStrategy.swift` — protocol isolating scope-specific behavior (artifact installation, settings/CLAUDE composition, file removal)
- `ProjectSyncStrategy.swift` — project-scope strategy (settings.local.json, CLAUDE.local.md, repo name resolution)
- `GlobalSyncStrategy.swift` — global-scope strategy (settings.json preservation, brew/plugin ownership, MCP scope override to "user")
- `ComponentExecutor.swift` — dispatches install actions (brew, MCP servers, plugins, gitignore, project-scoped file copy/removal)
- `CrossPackPromptResolver.swift` — deduplicates shared prompt keys across multiple packs (groups by key, executes once for shared `input`/`select` prompts)
- `DestinationCollisionResolver.swift` — auto-namespaces `copyPackFile` destinations when multiple packs target the same `(destination, fileType)` pair
- `PackInstaller.swift` — auto-installs missing pack components
- `PackUpdater.swift` — shared fetch → validate → trust cycle for updating a single git pack (used by `UpdatePack` and `LockfileOperations`)
- `ResourceRefCounter.swift` — two-tier reference counting (global artifacts + project index manifests) for safe brew/plugin removal
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct GlobalMCPScopeTests {
defer { try? FileManager.default.removeItem(at: tmpDir) }

let mockCLI = MockClaudeCLI()
let configurator = makeGlobalConfigurator(home: tmpDir, mockCLI: mockCLI)
let configurator = makeGlobalSyncConfigurator(home: tmpDir, mockCLI: mockCLI)

let pack = MockTechPack(
identifier: "test-pack",
Expand Down Expand Up @@ -42,7 +42,7 @@ struct GlobalMCPScopeTests {
defer { try? FileManager.default.removeItem(at: tmpDir) }

let mockCLI = MockClaudeCLI()
let configurator = makeGlobalConfigurator(home: tmpDir, mockCLI: mockCLI)
let configurator = makeGlobalSyncConfigurator(home: tmpDir, mockCLI: mockCLI)

let pack = MockTechPack(
identifier: "test-pack",
Expand Down Expand Up @@ -72,7 +72,7 @@ struct GlobalMCPScopeTests {
let tmpDir = try makeGlobalTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)

let pack = MockTechPack(
identifier: "test-pack",
Expand Down Expand Up @@ -106,7 +106,7 @@ struct GlobalMCPScopeTests {
defer { try? FileManager.default.removeItem(at: tmpDir) }

let mockCLI = MockClaudeCLI()
let configurator = makeGlobalConfigurator(home: tmpDir, mockCLI: mockCLI)
let configurator = makeGlobalSyncConfigurator(home: tmpDir, mockCLI: mockCLI)

// Pack v1: two MCP servers
let packV1 = MockTechPack(
Expand Down Expand Up @@ -186,7 +186,7 @@ struct GlobalSettingsCompositionTests {
"""
try userSettings.write(to: settingsPath, atomically: true, encoding: .utf8)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)

// Pack with a hook file that will add hooks to settings
let packDir = tmpDir.appendingPathComponent("pack/hooks")
Expand Down Expand Up @@ -233,7 +233,7 @@ struct GlobalSettingsCompositionTests {
let tmpDir = try makeGlobalTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)

let packDir = tmpDir.appendingPathComponent("pack/hooks")
try FileManager.default.createDirectory(at: packDir, withIntermediateDirectories: true)
Expand Down Expand Up @@ -280,7 +280,7 @@ struct GlobalSettingsCompositionTests {
let settingsPath = tmpDir.appendingPathComponent(".claude/settings.json")
try "{ invalid json".write(to: settingsPath, atomically: true, encoding: .utf8)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
let pack = MockTechPack(
identifier: "test-pack",
displayName: "Test Pack",
Expand Down Expand Up @@ -317,7 +317,7 @@ struct GlobalSettingsCompositionTests {
let settingsPath = tmpDir.appendingPathComponent(".claude/settings.json")
try "{}".write(to: settingsPath, atomically: true, encoding: .utf8)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
let pack = MockTechPack(
identifier: "test-pack",
displayName: "Test Pack"
Expand All @@ -333,7 +333,7 @@ struct GlobalSettingsCompositionTests {
let tmpDir = try makeGlobalTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)

let packDir = tmpDir.appendingPathComponent("pack/hooks")
try FileManager.default.createDirectory(at: packDir, withIntermediateDirectories: true)
Expand Down Expand Up @@ -389,7 +389,7 @@ struct GlobalClaudeCompositionTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let claudePath = tmpDir.appendingPathComponent(".claude/CLAUDE.md")
Expand All @@ -409,7 +409,7 @@ struct GlobalClaudeCompositionTests {
displayName: "Test Pack"
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let claudePath = tmpDir.appendingPathComponent(".claude/CLAUDE.md")
Expand All @@ -432,7 +432,7 @@ struct GlobalClaudeCompositionTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

// Simulate user appending custom content outside section markers
Expand Down Expand Up @@ -465,7 +465,7 @@ struct GlobalClaudeCompositionTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let env = Environment(home: tmpDir)
Expand Down Expand Up @@ -503,7 +503,7 @@ struct GlobalFileInstallationTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let dest = tmpDir.appendingPathComponent(".claude/skills/my-skill.md")
Expand Down Expand Up @@ -535,7 +535,7 @@ struct GlobalFileInstallationTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let dest = tmpDir.appendingPathComponent(".claude/hooks/test-pack/start.sh")
Expand Down Expand Up @@ -567,7 +567,7 @@ struct GlobalFileInstallationTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let env = Environment(home: tmpDir)
Expand Down Expand Up @@ -615,7 +615,7 @@ struct GlobalFileInstallationTests {
]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [packV1], confirmRemovals: false)

let destB = tmpDir.appendingPathComponent(".claude/skills/skill-b.md")
Expand Down Expand Up @@ -675,7 +675,7 @@ struct GlobalConvergenceFlowTests {
displayName: "Test Pack"
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

#expect(pack.configureProjectCallCount == 0)
Expand All @@ -691,7 +691,7 @@ struct GlobalUnconfigurePackTests {
defer { try? FileManager.default.removeItem(at: tmpDir) }

let mockCLI = MockClaudeCLI()
let configurator = makeGlobalConfigurator(home: tmpDir, mockCLI: mockCLI)
let configurator = makeGlobalSyncConfigurator(home: tmpDir, mockCLI: mockCLI)

let pack = MockTechPack(
identifier: "test-pack",
Expand Down Expand Up @@ -747,7 +747,7 @@ struct GlobalUnconfigurePackTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let dest = tmpDir.appendingPathComponent(".claude/skills/my-skill.md")
Expand All @@ -774,7 +774,7 @@ struct GlobalUnconfigurePackTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let claudePath = tmpDir.appendingPathComponent(".claude/CLAUDE.md")
Expand All @@ -794,7 +794,7 @@ struct GlobalUnconfigurePackTests {
let tmpDir = try makeGlobalTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)

let packDir = tmpDir.appendingPathComponent("pack/hooks")
try FileManager.default.createDirectory(at: packDir, withIntermediateDirectories: true)
Expand Down Expand Up @@ -842,7 +842,7 @@ struct GlobalUnconfigurePackTests {
defer { try? FileManager.default.removeItem(at: tmpDir) }

let mockCLI = MockClaudeCLI()
let configurator = makeGlobalConfigurator(home: tmpDir, mockCLI: mockCLI)
let configurator = makeGlobalSyncConfigurator(home: tmpDir, mockCLI: mockCLI)

let packA = MockTechPack(
identifier: "pack-a",
Expand Down Expand Up @@ -920,7 +920,7 @@ struct GlobalUnconfigurePackTests {
state.setArtifacts(record, for: "orphan-pack")
try state.save()

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [], confirmRemovals: false)

let after = try ProjectState(stateFile: env.globalStateFile)
Expand Down Expand Up @@ -959,7 +959,7 @@ struct GlobalDryRunTests {
)]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.dryRun(packs: [pack])

// No state file should be created
Expand All @@ -976,7 +976,7 @@ struct GlobalDryRunTests {
let tmpDir = try makeGlobalTmpDir()
defer { try? FileManager.default.removeItem(at: tmpDir) }

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)

// Install a pack first
let packA = MockTechPack(
Expand Down Expand Up @@ -1024,7 +1024,7 @@ struct GlobalExcludedComponentsTests {
defer { try? FileManager.default.removeItem(at: tmpDir) }

let mockCLI = MockClaudeCLI()
let configurator = makeGlobalConfigurator(home: tmpDir, mockCLI: mockCLI)
let configurator = makeGlobalSyncConfigurator(home: tmpDir, mockCLI: mockCLI)

let pack = MockTechPack(
identifier: "test-pack",
Expand Down Expand Up @@ -1112,7 +1112,7 @@ struct GlobalExcludedComponentsTests {
]
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)

// First sync: both installed
try configurator.configure(packs: [pack], confirmRemovals: false)
Expand Down Expand Up @@ -1146,7 +1146,7 @@ struct GlobalStateAndIndexTests {
displayName: "Test Pack"
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let env = Environment(home: tmpDir)
Expand All @@ -1163,7 +1163,7 @@ struct GlobalStateAndIndexTests {
displayName: "Test Pack"
)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
try configurator.configure(packs: [pack], confirmRemovals: false)

let env = Environment(home: tmpDir)
Expand Down Expand Up @@ -1191,7 +1191,7 @@ struct GlobalHookInjectionTests {
// Create empty settings.json so the strategy can load it
try "{}".write(to: env.claudeSettings, atomically: true, encoding: .utf8)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
let pack = MockTechPack(identifier: "test-pack", displayName: "Test", components: [])
try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])

Expand All @@ -1217,7 +1217,7 @@ struct GlobalHookInjectionTests {
try initial.save(to: env.claudeSettings)

// Sync should strip and re-inject (idempotent)
let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
let pack = MockTechPack(identifier: "test-pack", displayName: "Test", components: [])
try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])

Expand Down Expand Up @@ -1245,7 +1245,7 @@ struct GlobalHookInjectionTests {
UpdateChecker.addHook(to: &initial)
try initial.save(to: env.claudeSettings)

let configurator = makeGlobalConfigurator(home: tmpDir)
let configurator = makeGlobalSyncConfigurator(home: tmpDir)
let pack = MockTechPack(identifier: "test-pack", displayName: "Test", components: [])
try configurator.configure(packs: [pack], confirmRemovals: false, excludedComponents: [:])

Expand Down
6 changes: 3 additions & 3 deletions Tests/MCSTests/LifecycleIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ private struct LifecycleTestBed {
)
}

func makeGlobalConfigurator(registry: TechPackRegistry = TechPackRegistry()) -> Configurator {
func makeGlobalSyncConfigurator(registry: TechPackRegistry = TechPackRegistry()) -> Configurator {
Configurator(
environment: env,
output: CLIOutput(colorsEnabled: false),
Expand Down Expand Up @@ -803,7 +803,7 @@ struct GlobalScopeLifecycleTests {
let registry = TechPackRegistry(packs: [pack])

// === Configure global scope ===
let configurator = bed.makeGlobalConfigurator(registry: registry)
let configurator = bed.makeGlobalSyncConfigurator(registry: registry)
try configurator.configure(packs: [pack], confirmRemovals: false)

// Verify hook installed in ~/.claude/hooks/
Expand Down Expand Up @@ -976,7 +976,7 @@ struct GlobalScopeExclusionTests {
let registry = TechPackRegistry(packs: [pack])

// === Step 1: Configure global with both ===
let configurator = bed.makeGlobalConfigurator(registry: registry)
let configurator = bed.makeGlobalSyncConfigurator(registry: registry)
try configurator.configure(packs: [pack], confirmRemovals: false)

let hookAPath = bed.env.hooksDirectory.appendingPathComponent("global-pack/globalA.sh")
Expand Down
2 changes: 1 addition & 1 deletion Tests/MCSTests/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ func makeSandboxProject(label: String = "project") throws -> (home: URL, project
}

/// Create a `Configurator` configured for global-scope sync.
func makeGlobalConfigurator(
func makeGlobalSyncConfigurator(
home: URL,
mockCLI: MockClaudeCLI = MockClaudeCLI(),
shell: (any ShellRunning)? = nil
Expand Down
Loading