diff --git a/CLAUDE.md b/CLAUDE.md index a665246..84230d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,8 +70,13 @@ mcs config set # 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) @@ -120,13 +125,16 @@ mcs config set # 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 diff --git a/Sources/mcs/Install/ComponentExecutor.swift b/Sources/mcs/Sync/ComponentExecutor.swift similarity index 100% rename from Sources/mcs/Install/ComponentExecutor.swift rename to Sources/mcs/Sync/ComponentExecutor.swift diff --git a/Sources/mcs/Install/Configurator.swift b/Sources/mcs/Sync/Configurator.swift similarity index 100% rename from Sources/mcs/Install/Configurator.swift rename to Sources/mcs/Sync/Configurator.swift diff --git a/Sources/mcs/Install/ConfiguratorSupport.swift b/Sources/mcs/Sync/ConfiguratorSupport.swift similarity index 100% rename from Sources/mcs/Install/ConfiguratorSupport.swift rename to Sources/mcs/Sync/ConfiguratorSupport.swift diff --git a/Sources/mcs/Install/CrossPackPromptResolver.swift b/Sources/mcs/Sync/CrossPackPromptResolver.swift similarity index 100% rename from Sources/mcs/Install/CrossPackPromptResolver.swift rename to Sources/mcs/Sync/CrossPackPromptResolver.swift diff --git a/Sources/mcs/Install/DestinationCollisionResolver.swift b/Sources/mcs/Sync/DestinationCollisionResolver.swift similarity index 100% rename from Sources/mcs/Install/DestinationCollisionResolver.swift rename to Sources/mcs/Sync/DestinationCollisionResolver.swift diff --git a/Sources/mcs/Install/GlobalSyncStrategy.swift b/Sources/mcs/Sync/GlobalSyncStrategy.swift similarity index 100% rename from Sources/mcs/Install/GlobalSyncStrategy.swift rename to Sources/mcs/Sync/GlobalSyncStrategy.swift diff --git a/Sources/mcs/Install/LockfileOperations.swift b/Sources/mcs/Sync/LockfileOperations.swift similarity index 100% rename from Sources/mcs/Install/LockfileOperations.swift rename to Sources/mcs/Sync/LockfileOperations.swift diff --git a/Sources/mcs/Install/PackInstaller.swift b/Sources/mcs/Sync/PackInstaller.swift similarity index 100% rename from Sources/mcs/Install/PackInstaller.swift rename to Sources/mcs/Sync/PackInstaller.swift diff --git a/Sources/mcs/Install/PackUpdater.swift b/Sources/mcs/Sync/PackUpdater.swift similarity index 100% rename from Sources/mcs/Install/PackUpdater.swift rename to Sources/mcs/Sync/PackUpdater.swift diff --git a/Sources/mcs/Install/ProjectSyncStrategy.swift b/Sources/mcs/Sync/ProjectSyncStrategy.swift similarity index 100% rename from Sources/mcs/Install/ProjectSyncStrategy.swift rename to Sources/mcs/Sync/ProjectSyncStrategy.swift diff --git a/Sources/mcs/Install/ResourceRefCounter.swift b/Sources/mcs/Sync/ResourceRefCounter.swift similarity index 100% rename from Sources/mcs/Install/ResourceRefCounter.swift rename to Sources/mcs/Sync/ResourceRefCounter.swift diff --git a/Sources/mcs/Install/SyncScope.swift b/Sources/mcs/Sync/SyncScope.swift similarity index 100% rename from Sources/mcs/Install/SyncScope.swift rename to Sources/mcs/Sync/SyncScope.swift diff --git a/Sources/mcs/Install/SyncStrategy.swift b/Sources/mcs/Sync/SyncStrategy.swift similarity index 100% rename from Sources/mcs/Install/SyncStrategy.swift rename to Sources/mcs/Sync/SyncStrategy.swift diff --git a/Tests/MCSTests/GlobalConfiguratorTests.swift b/Tests/MCSTests/GlobalSyncTests.swift similarity index 95% rename from Tests/MCSTests/GlobalConfiguratorTests.swift rename to Tests/MCSTests/GlobalSyncTests.swift index b013122..45e553f 100644 --- a/Tests/MCSTests/GlobalConfiguratorTests.swift +++ b/Tests/MCSTests/GlobalSyncTests.swift @@ -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", @@ -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", @@ -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", @@ -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( @@ -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") @@ -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) @@ -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", @@ -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" @@ -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) @@ -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") @@ -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") @@ -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 @@ -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) @@ -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") @@ -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") @@ -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) @@ -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") @@ -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) @@ -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", @@ -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") @@ -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") @@ -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) @@ -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", @@ -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) @@ -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 @@ -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( @@ -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", @@ -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) @@ -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) @@ -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) @@ -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: [:]) @@ -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: [:]) @@ -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: [:]) diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index 4b9405b..f5c62b8 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -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), @@ -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/ @@ -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") diff --git a/Tests/MCSTests/PackSourceTests.swift b/Tests/MCSTests/PackSourceResolverTests.swift similarity index 100% rename from Tests/MCSTests/PackSourceTests.swift rename to Tests/MCSTests/PackSourceResolverTests.swift diff --git a/Tests/MCSTests/ProjectConfiguratorTests.swift b/Tests/MCSTests/ProjectSyncTests.swift similarity index 100% rename from Tests/MCSTests/ProjectConfiguratorTests.swift rename to Tests/MCSTests/ProjectSyncTests.swift diff --git a/Tests/MCSTests/TestHelpers.swift b/Tests/MCSTests/TestHelpers.swift index a38c736..c29c940 100644 --- a/Tests/MCSTests/TestHelpers.swift +++ b/Tests/MCSTests/TestHelpers.swift @@ -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