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
15 changes: 8 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `Settings.swift` — Codable model for `settings.json` and `settings.local.json`, deep-merge
- `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`
- `ClaudeIntegration.swift` — `claude mcp add/remove` (with scope support), `claude plugin install/uninstall/list` (scope + working directory, like MCP) and `marketplace add/list`
- `ClaudePrerequisite.swift` — Claude Code CLI availability check with optional Homebrew auto-install
- `Homebrew.swift` — brew detection, package install/uninstall, and `provides(_:)` — the one availability predicate shared by `ComponentExecutor` and `BrewPackageCheck` (PATH under `bareName(of:)`, falling back to `brew list`)
- `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
- `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
- `PluginRef.swift` — parsed `name@repo` plugin references; resolves the `name@<marketplace-name>` id Claude Code keys plugins by (`@org/repo` needs a `marketplace list` lookup), plus the `plugin list` / `marketplace list` JSON models
- `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 @@ -148,13 +148,13 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `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 artifact base); `removeFileArtifact` is a shared default that logs each actual deletion
- `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")
- `GlobalSyncStrategy.swift` — global-scope strategy (settings.json preservation, brew ownership, plugins and MCP servers at "user" scope)
- `ComponentExecutor.swift` — dispatches install actions (brew, MCP servers, plugins, gitignore, project-scoped file copy)
- `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
- `ScopeReapplier.swift` — converges one scope onto its already-configured pack set (whole-scope skip when any configured pack cannot be produced); shared by `mcs update` and `mcs doctor --fix`
- `PackUpdater.swift` — shared fetch → validate → trust cycle for updating a single git pack (used by `UpdatePack` and `UpdateCommand`)
- `ResourceRefCounter.swift` — two-tier reference counting (global artifacts + project index manifests) for safe removal of brew packages, plugins and gitignore entries; decoded state is cached per instance so one removal pass reads each state file once
- `ResourceRefCounter.swift` — two-tier reference counting (global artifacts + project index manifests) for safe removal of brew packages and gitignore entries; decoded state is cached per instance so one removal pass reads each state file once
- `SyncDeltaSummary.swift` — computes add/remove/keep deltas between previous and selected pack sets and renders the review-changes summary shown before destructive sync operations

### Templates (`Sources/mcs/Templates/`)
Expand Down Expand Up @@ -210,8 +210,9 @@ swiftlint --fix
- **`mcs sync` is the primary command**: per-project multi-select of registered packs, fully idempotent convergence (add/remove/update), per-project artifact placement. `--global` flag handles global-scope install. `--pack` follows bootstrap's additive-by-default model: named packs are added/updated and every other configured pack is kept (a configured pack missing from the registry aborts the run); `--prune` opts into the authoritative set with a removal confirmation, `--yes` skips it
- **`mcs bootstrap` is project-scoped, additive-by-default, opt-in-authoritative**: reads `./mcs.yaml` (fixed filename, cwd only) and drives the current project's pack set. Like `mcs sync`, its non-dry-run tail refreshes the shared user-level update-check hook in `~/.claude/settings.json` — that hook is a per-user preference, not per-project. Default is additive — declared packs are installed / updated, and packs already configured but absent from the file are preserved (a footer reports them so divergence stays visible). `--prune` makes `mcs.yaml` the exact desired set — extras get unconfigured through the same `Configurator.configure(confirmRemovals:)` gate `mcs sync --pack` uses, with `--yes` to skip the confirmation, and `--prune` also bypasses the unloadable-scope guard so a broken configured pack can be pruned rather than blocking the run. Composes existing primitives (`PackAdder`, `PackUpdater`) rather than reimplementing them. `PackRef.scope` in the schema is reserved for a future release; v1 rejects any value other than `"project"`. The additive default matches the pattern that Homebrew Bundle, Kubernetes, and npm settled on for declarative-file + external-state workflows: safe default, explicit opt-in for destructive action.
- **Non-interactive prompt resolution**: when stdin is not a TTY, every prompt resolves as seeded/stored value → declared default (a `select` default must be an option; a `fileDetect` default must be a match, or with zero matches any non-empty value; otherwise an explicit `""` counts) → failure. `CrossPackPromptResolver.planValues` builds the key set and reuse partition that both sync (`resolveAllValues`) and the preflight (`resolveNonInteractively`) start from, and the preflight replays sync's stages in order — reused priors (per `Configurator.priorReuse`), shared prompts, then each pack for the keys still open, since a pack skips keys an earlier pack produced — so the two cannot disagree. `Configurator.configure` runs it against the post-removal priors before any removal or install and throws `PromptResolutionError` (every `pack: key`, never values, with a scope-specific remedy), otherwise merges its answers so no prompt reader runs. `--dry-run` only warns; `mcs update` skips a failing scope, finishes the rest, then exits non-zero. Two older prompts still read stdin off-TTY (tracked in #417)
- **Packs install whole**: a selected pack installs every component it declares, in declaration order (project sync still installs brew packages and plugins first). There is no per-component selection — `mcs.yaml` could not express one, so the same file would install different sets on different machines. `dependencies` / `isRequired` in a manifest are decoded only into `deprecatedKeys` for a `mcs pack validate` warning. Exclusions left in state by the removed `--customize` are announced by sync and dry-run, then installed and cleared (emptied, not removed — older releases require the key) by the first real sync; until a global sync clears them, `ScopeDuplicationCheck` refuses to remove a project copy
- **Per-project artifacts**: skills, hooks, commands, and `settings.local.json` go to `<project>/.claude/`; only brew packages and plugins are global
- **Packs install whole**: a selected pack installs every component it declares, in declaration order (project sync still installs brew packages first). There is no per-component selection — `mcs.yaml` could not express one, so the same file would install different sets on different machines. `dependencies` / `isRequired` in a manifest are decoded only into `deprecatedKeys` for a `mcs pack validate` warning. Exclusions left in state by the removed `--customize` are announced by sync and dry-run, then installed and cleared (emptied, not removed — older releases require the key) by the first real sync; until a global sync clears them, `ScopeDuplicationCheck` refuses to remove a project copy
- **Per-project artifacts**: skills, hooks, commands, and `settings.local.json` go to `<project>/.claude/`; only brew packages are global
- **Plugins install per scope, and the Claude CLI owns `enabledPlugins`**: project sync runs `claude plugin install <name>@<marketplace> -s local` from the project directory, global sync `-s user`; mcs never writes `enabledPlugins` (Claude Code ignores anything but the `name@marketplace` key) and project composition preserves what the CLI wrote to `settings.local.json`. A plugin is recorded only on the pack whose install put it there, carried over only while that pack still declares it, and uninstalled from its own scope unless another pack in that scope claims it — which then takes over the record. `PluginCheck` reads `claude plugin list --json`
- **MCP scope defaults to `local`**: per-user, per-project isolation via `claude mcp add -s local` (stored in `~/.claude.json` keyed by project path)
- **Convergent sync**: `ProjectState` records per-pack `PackArtifactRecord` (MCP servers, files, template sections, hook commands, settings keys, settings hash, brew packages, plugins, gitignore entries, file hashes); re-running converges to desired state by diffing previous vs. selected packs. `mcs pack remove` discovers all scopes via `ProjectIndex` and runs the same `unconfigurePack()` convergence for each
- **External pack protocol**: `TechPack` protocol with `ExternalPackAdapter` bridging YAML manifests (`techpack.yaml`) to the same install/doctor/sync flows
Expand All @@ -223,5 +224,5 @@ swiftlint --fix
- **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
- **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, plugins or gitignore entries. 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. Gitignore entries need it because `GitignoreManager` resolves one file for the whole machine, so a pack in two scopes holds two claims on one line; `GitignoreManager.coreEntries` are owned by no pack and are protected from removal outright
- **Cross-project reference counting**: `ProjectIndex` (`~/.mcs/projects.yaml`) tracks which projects use which packs; `ResourceRefCounter` checks all scopes before removing shared brew packages or gitignore entries. Conservative by default — if state is unreadable, assume resource is still needed. MCP servers and plugins install per scope (`-s local` in a project) and skip cross-scope ref counting. Gitignore entries need it because `GitignoreManager` resolves one file for the whole machine, so a pack in two scopes holds two claims on one line; `GitignoreManager.coreEntries` are owned by no pack and are protected from removal outright
- **Conditional copyPackFile namespacing**: `copyPackFile` destinations are installed flat by default — except `fileType: hook`, which `DestinationCollisionResolver` phase 0 namespaces unconditionally (whenever a filesystem context is present) so a pack can never overwrite a user's hand-written hook at a flat path. When two+ packs define the same `(destination, fileType)`, the resolver auto-namespaces: subdirectory prefix (`<pack-id>/`) for hooks/commands/agents/generic, or directory name suffix (`-<pack-id>`) for skills (which require flat one-level directories). First pack keeps the clean name; subsequent packs get namespaced. Skill renames emit a warning
44 changes: 33 additions & 11 deletions Sources/mcs/Core/ClaudeIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ protocol ClaudeCLI: Sendable {
func mcpRemove(name: String, scope: String, workingDirectory: URL?) -> ShellResult
@discardableResult
func pluginMarketplaceAdd(repo: String) -> ShellResult
func pluginMarketplaceList() -> ShellResult
func pluginList(workingDirectory: URL?) -> ShellResult
@discardableResult
func pluginInstall(ref: PluginRef) -> ShellResult
func pluginInstall(id: String, scope: String, workingDirectory: URL?) -> ShellResult
@discardableResult
func pluginRemove(ref: PluginRef) -> ShellResult
func pluginRemove(id: String, scope: String, workingDirectory: URL?) -> ShellResult
}

/// Wrapper for the `claude` CLI to manage MCP servers and plugins.
Expand Down Expand Up @@ -78,24 +80,44 @@ struct ClaudeIntegration: ClaudeCLI {
)
}

/// Install a plugin (registers marketplace first).
@discardableResult
func pluginInstall(ref: PluginRef) -> ShellResult {
pluginMarketplaceAdd(repo: ref.marketplaceRepo)
/// `claude plugin marketplace list --json`.
func pluginMarketplaceList() -> ShellResult {
shell.run(
Constants.CLI.env,
arguments: [Constants.CLI.claudeCommand, "plugin", "marketplace", "list", "--json"],
additionalEnvironment: claudeEnv
)
}

return shell.run(
/// `claude plugin list --json`. Run in a project, `enabled` reflects that project's settings.
func pluginList(workingDirectory: URL?) -> ShellResult {
shell.run(
Constants.CLI.env,
arguments: [Constants.CLI.claudeCommand, "plugin", "list", "--json"],
workingDirectory: workingDirectory?.path,
additionalEnvironment: claudeEnv
)
}

/// Install a plugin by its `name@marketplace` id. A `local` install belongs to the project
/// `workingDirectory` names, as with `mcpAdd`.
@discardableResult
func pluginInstall(id: String, scope: String, workingDirectory: URL?) -> ShellResult {
shell.run(
Constants.CLI.env,
arguments: [Constants.CLI.claudeCommand, "plugin", "install", ref.bareName],
arguments: [Constants.CLI.claudeCommand, "plugin", "install", id, "-s", scope],
workingDirectory: workingDirectory?.path,
additionalEnvironment: claudeEnv
)
}

/// Remove a plugin.
/// Uninstall a plugin from one scope. See `pluginInstall` for `workingDirectory`.
@discardableResult
func pluginRemove(ref: PluginRef) -> ShellResult {
func pluginRemove(id: String, scope: String, workingDirectory: URL?) -> ShellResult {
shell.run(
Constants.CLI.env,
arguments: [Constants.CLI.claudeCommand, "plugin", "remove", ref.bareName],
arguments: [Constants.CLI.claudeCommand, "plugin", "uninstall", id, "-s", scope],
workingDirectory: workingDirectory?.path,
additionalEnvironment: claudeEnv
)
}
Expand Down
11 changes: 10 additions & 1 deletion Sources/mcs/Core/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ enum Constants {
/// Stderr text `claude mcp remove` prints when the server is not registered in that scope.
static let mcpServerNotFound = "No MCP server named"

/// Stderr text `claude plugin remove` prints when the plugin is not installed.
/// Stderr text `claude plugin uninstall` prints when the plugin is not installed.
static let pluginNotFound = "not found in installed plugins"
}

Expand Down Expand Up @@ -164,6 +164,15 @@ enum Constants {

// MARK: - Plugins

/// Claude Code plugin install scopes.
enum PluginScope {
/// Enabled only in the project the install ran in (`settings.local.json` there).
static let local = "local"

/// Enabled in every project (`~/.claude/settings.json`).
static let user = "user"
}

enum Plugins {
/// The official Anthropic plugin marketplace identifier.
static let officialMarketplace = "claude-plugins-official"
Expand Down
Loading
Loading